From fec84f98d5b9b48497276bbc49433ba2caeb2d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 20 Jun 2026 21:43:34 +0200 Subject: [PATCH] =?UTF-8?q?Ispravke:=20QR=20proxy=20=C5=A1ema,=20race=20?= =?UTF-8?q?=C5=A1ifra,=20JM=20validacija,=20za=C5=A1tita=20zaliha,=20magac?= =?UTF-8?q?in=20history=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - servis.go: qrNalogURL helper čita X-Forwarded-Proto za ispravan HTTPS QR kod iza proxy-ja - magacin_forma.go: šifra se generiše pre INSERT (uklanja race condition); normalizujJM validacija 4 kar.; blokada promene tipa ako postoji stanje na lageru - prodaja.go + repository.go: Obrisi beleži magacinsku promenu (PromenaPovracaj) uz korisnikID; ispravljeni zamenjeni potpisi interfejsa ServisRepository/ProdajaRepository - kategorije.html: UI hint kada kategorija nema kôd (prefiks šifre) - 061_backfill_kategorija_kod.sql: popunjava kod postojećim kategorijama iz naziva - magacin.html: htmx:beforeHistorySave sklanja bez-anim pre snimanja snapshota (fix flash animacije) --- internal/db/repository.go | 2 +- internal/db/sqlite/prodaja.go | 26 ++++++++-- internal/handler/magacin_forma.go | 60 +++++++++++++++------- internal/handler/prodaja.go | 3 +- internal/handler/servis.go | 32 ++++++------ migrations/061_backfill_kategorija_kod.sql | 3 ++ web/templates/stranice/kategorije.html | 1 + web/templates/stranice/magacin.html | 6 +++ 8 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 migrations/061_backfill_kategorija_kod.sql diff --git a/internal/db/repository.go b/internal/db/repository.go index 61f74bb..4722850 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -149,7 +149,7 @@ type ProdajaRepository interface { DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error - Obrisi(ctx context.Context, id int64) error + Obrisi(ctx context.Context, id int64, korisnikID *int64) error SledeciBroj(ctx context.Context) (string, error) } diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 8849585..0c93e96 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -328,7 +328,7 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris } // Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji) -func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error { +func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err) @@ -366,13 +366,31 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error { for _, p := range stavke { // usluge i troškovi nemaju stanje — vraćamo samo proizvodima - _, err := tx.ExecContext(ctx, - "UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')", - p.kolicina, p.artikalID, + var stanjePre int + var tip string + err := tx.QueryRowContext(ctx, + "SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID, + ).Scan(&stanjePre, &tip) + if err != nil { + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stanje: %w", err) + } + if !(tip == model.TipProizvod || tip == "") { + continue + } + + stanjePosle := stanjePre + p.kolicina + _, err = tx.ExecContext(ctx, + "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID, ) if err != nil { return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err) } + + err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj, + p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje prodajnog naloga") + if err != nil { + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: magacin: %w", err) + } } } diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 89bed36..6efd662 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -5,6 +5,8 @@ import ( "log/slog" "net/http" "strconv" + "strings" + "time" "ntech/internal/db/sqlite" "ntech/internal/middleware" @@ -88,24 +90,23 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { return } + // ako korisnik nije uneo šifru, auto-generišemo pre Kreiraj + // (tako je dodela šifre atomična: ako INSERT padne na UNIQUE constraint, + // Kreiraj vraća grešku umesto da šifra ostane NULL bez ikakve poruke) + if artikal.Sifra == "" { + autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID) + if e != nil { + autoSifra = fmt.Sprintf("ART-%04d", time.Now().UnixMilli()%10000) + } + artikal.Sifra = autoSifra + } + id, err := h.Artikli.Kreiraj(r.Context(), &artikal) if err != nil { http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError) return } - - // ako korisnik nije uneo šifru, auto-generišemo po prefiksu kategorije - if artikal.Sifra == "" { - autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID) - if e != nil { - autoSifra = fmt.Sprintf("ART-%04d", id) - } - artikal.ID = id - artikal.Sifra = autoSifra - if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { - slog.Error("greška pri upisu auto-šifre", "id", id, "err", err) - } - } + artikal.ID = id // fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla if r.Header.Get("X-Requested-With") == "fetch" { @@ -207,6 +208,13 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { var staraCena float64 if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil { staraCena = stari.ProdajnaCena + // spreči promenu tipa sa proizvoda na uslugu/trošak ako artikal ima zalihu + if stari.PratiLager() && stari.Kolicina > 0 && !artikal.PratiLager() { + middleware.SetFlash(w, r, h.DB, "greska", + fmt.Sprintf("Artikal ima %d %s na stanju. Prvo koriguj količinu na 0 pre promene tipa.", stari.Kolicina, stari.JedinicaMere)) + http.Redirect(w, r, "/magacin/izmeni/"+idStr, http.StatusSeeOther) + return + } } artikal.ID = id @@ -257,11 +265,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { artikal.Tip = model.TipProizvod } - // jedinica mere — podrazumevano "kom" - artikal.JedinicaMere = r.FormValue("jedinica_mere") - if artikal.JedinicaMere == "" { - artikal.JedinicaMere = "kom" - } + // jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera + artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere")) if k := r.FormValue("kolicina"); k != "" { v, err := strconv.Atoi(k) @@ -341,3 +346,22 @@ func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) { func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) { h.renderujTemplate(w, "magacin_forma", podaci) } + +// normalizujJM čisti jedinicu mere: mala slova, samo slova i brojevi, max 4 karaktera. +// Ako je rezultat prazan, vraća "kom" kao podrazumevanu vrednost. +func normalizujJM(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + if b.Len() >= 4 { + break + } + } + } + if b.Len() == 0 { + return "kom" + } + return b.String() +} diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 9311ffd..9607ec8 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -325,13 +325,14 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok { return } + k := middleware.KorisnikIzKonteksta(r.Context()) id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) return } - if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil { + if err := h.ProdajaRepo.Obrisi(r.Context(), id, &k.ID); err != nil { http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError) return } diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 11986cf..81d3035 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -644,11 +644,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) { } // QR kod vodi na javnu status stranicu — dostupnu bez prijave - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken + nalogURL := qrNalogURL(r, nalog.JavniToken) var qrKod string if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { qrKod = base64.StdEncoding.EncodeToString(png) @@ -754,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) { } } - nalogURL := "http" - if r.TLS != nil { - nalogURL += "s" - } - nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken + nalogURL := qrNalogURL(r, nalog.JavniToken) var qrKodOtpr string if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { qrKodOtpr = base64.StdEncoding.EncodeToString(png) @@ -894,11 +886,7 @@ func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) { vaziDo := datumIzdavanja.AddDate(0, 0, rok) // QR kod vodi na javnu status stranicu — dostupnu bez prijave - nalogURL := "http" - if r.TLS != nil { - nalogURL += "s" - } - nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken + nalogURL := qrNalogURL(r, nalog.JavniToken) var qrKod string if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { qrKod = base64.StdEncoding.EncodeToString(png) @@ -991,3 +979,17 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) { SviStatusi: model.SviStatusi, }) } + +// qrNalogURL konstruiše URL za QR kod vodeći računa o reverse proxy-ju. +// Ako aplikacija radi iza nginx/Caddy/Traefik koji prekida TLS, r.TLS je nil, +// ali X-Forwarded-Proto header sadrži stvarnu šemu. +func qrNalogURL(r *http.Request, token string) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return scheme + "://" + r.Host + "/status/" + token +} diff --git a/migrations/061_backfill_kategorija_kod.sql b/migrations/061_backfill_kategorija_kod.sql new file mode 100644 index 0000000..f05ad40 --- /dev/null +++ b/migrations/061_backfill_kategorija_kod.sql @@ -0,0 +1,3 @@ +UPDATE kategorije +SET kod = UPPER(SUBSTR(REPLACE(naziv, ' ', ''), 1, 4)) +WHERE (kod IS NULL OR kod = '') AND naziv IS NOT NULL AND naziv != ''; diff --git a/web/templates/stranice/kategorije.html b/web/templates/stranice/kategorije.html index 8f61168..437d2c6 100644 --- a/web/templates/stranice/kategorije.html +++ b/web/templates/stranice/kategorije.html @@ -112,6 +112,7 @@ + {{if not .Kod}}
Kôd nije postavljen — artikli će koristiti prefiks ART-.
{{end}}
diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 796a3d8..0f9c285 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -242,6 +242,12 @@ document.body.addEventListener('htmx:beforeRequest', function (e) { if (rez) rez.classList.add('bez-anim'); } }); +// Pre nego što HTMX sačuva snapshot stranice u sessionStorage, skloni bez-anim +// da snapshot ne prikazuje "zamrznutu" tabelu bez animacije pri sledećoj navigaciji. +document.body.addEventListener('htmx:beforeHistorySave', function () { + var rez = document.getElementById('magacin-rezultati'); + if (rez) rez.classList.remove('bez-anim'); +}); {{end}}