Ispravke: QR proxy šema, race šifra, JM validacija, zaštita zaliha, magacin history flash

- 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)
This commit is contained in:
2026-06-20 21:43:34 +02:00
parent b0250b2917
commit fec84f98d5
8 changed files with 94 additions and 39 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ type ProdajaRepository interface {
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, 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 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) SledeciBroj(ctx context.Context) (string, error)
} }
+22 -4
View File
@@ -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) // 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) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err) 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 { for _, p := range stavke {
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima // usluge i troškovi nemaju stanje — vraćamo samo proizvodima
_, err := tx.ExecContext(ctx, var stanjePre int
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')", var tip string
p.kolicina, p.artikalID, 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 { if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err) 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)
}
} }
} }
+42 -18
View File
@@ -5,6 +5,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
@@ -88,24 +90,23 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
return 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) id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
if err != nil { if err != nil {
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
return return
} }
artikal.ID = id
// 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)
}
}
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla // fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
if r.Header.Get("X-Requested-With") == "fetch" { 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 var staraCena float64
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil { if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
staraCena = stari.ProdajnaCena 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 artikal.ID = id
@@ -257,11 +265,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
artikal.Tip = model.TipProizvod artikal.Tip = model.TipProizvod
} }
// jedinica mere — podrazumevano "kom" // jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera
artikal.JedinicaMere = r.FormValue("jedinica_mere") artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere"))
if artikal.JedinicaMere == "" {
artikal.JedinicaMere = "kom"
}
if k := r.FormValue("kolicina"); k != "" { if k := r.FormValue("kolicina"); k != "" {
v, err := strconv.Atoi(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) { func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
h.renderujTemplate(w, "magacin_forma", podaci) 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()
}
+2 -1
View File
@@ -325,13 +325,14 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
return return
} }
k := middleware.KorisnikIzKonteksta(r.Context())
id, err := parseID(chi.URLParam(r, "id")) id, err := parseID(chi.URLParam(r, "id"))
if err != nil { if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return 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) http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return return
} }
+17 -15
View File
@@ -644,11 +644,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
} }
// QR kod vodi na javnu status stranicu — dostupnu bez prijave // QR kod vodi na javnu status stranicu — dostupnu bez prijave
scheme := "http" nalogURL := qrNalogURL(r, nalog.JavniToken)
if r.TLS != nil {
scheme = "https"
}
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
var qrKod string var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png) qrKod = base64.StdEncoding.EncodeToString(png)
@@ -754,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
} }
} }
nalogURL := "http" nalogURL := qrNalogURL(r, nalog.JavniToken)
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
var qrKodOtpr string var qrKodOtpr string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKodOtpr = base64.StdEncoding.EncodeToString(png) 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) vaziDo := datumIzdavanja.AddDate(0, 0, rok)
// QR kod vodi na javnu status stranicu — dostupnu bez prijave // QR kod vodi na javnu status stranicu — dostupnu bez prijave
nalogURL := "http" nalogURL := qrNalogURL(r, nalog.JavniToken)
if r.TLS != nil {
nalogURL += "s"
}
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
var qrKod string var qrKod string
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil { if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
qrKod = base64.StdEncoding.EncodeToString(png) qrKod = base64.StdEncoding.EncodeToString(png)
@@ -991,3 +979,17 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
SviStatusi: model.SviStatusi, 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
}
@@ -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 != '';
+1
View File
@@ -112,6 +112,7 @@
<input type="text" name="kod" value="{{.Kod}}" maxlength="10" <input type="text" name="kod" value="{{.Kod}}" maxlength="10"
placeholder="npr. MEM" placeholder="npr. MEM"
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;"> style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;">
{{if not .Kod}}<div class="pomocni-tekst" style="margin-top:4px;color:#f59e0b;">Kôd nije postavljen — artikli će koristiti prefiks ART-.</div>{{end}}
</div> </div>
<div> <div>
<label class="polje-labela">Opis</label> <label class="polje-labela">Opis</label>
+6
View File
@@ -242,6 +242,12 @@ document.body.addEventListener('htmx:beforeRequest', function (e) {
if (rez) rez.classList.add('bez-anim'); 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');
});
</script> </script>
{{end}} {{end}}