diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index e03da98..ed3c5b6 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "database/sql" + "errors" "fmt" "time" @@ -195,6 +196,23 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk } defer tx.Rollback() + // idempotency zaštita: ako je pozivalac poslao ključ (frontend ga generiše po + // otvaranju forme) i nalog sa tim ključem već postoji, to znači da je ovo dupliran + // POST (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba) — + // vraćamo ID postojećeg naloga umesto da pravimo drugi, identičan. + if n.IdempotencyKey != "" { + var postojeciID int64 + err := tx.QueryRowContext(ctx, + "SELECT id FROM prodajni_nalozi WHERE idempotency_key = ?", n.IdempotencyKey, + ).Scan(&postojeciID) + if err == nil { + return postojeciID, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: provera idempotency key: %w", err) + } + } + // broj naloga se generiše OVDE, unutar iste transakcije kao insert — // ne sme se prosleđivati kao unapred generisana vrednost iz handlera, // jer bi dupliran/ponovljen POST zahtev tada napravio dva zasebna, @@ -207,9 +225,9 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk // insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin rezultat, err := tx.ExecContext(ctx, ` - INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum) - VALUES (?, ?, ?, ?, ?, ?)`, - nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum, + INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum, idempotency_key) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum, nullString(n.IdempotencyKey), ) if err != nil { return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err) diff --git a/internal/db/sqlite/prodaja_kreiraj_test.go b/internal/db/sqlite/prodaja_kreiraj_test.go index 4b857cd..e97f245 100644 --- a/internal/db/sqlite/prodaja_kreiraj_test.go +++ b/internal/db/sqlite/prodaja_kreiraj_test.go @@ -122,6 +122,60 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) { } } +// TestProdajaKreiraj_IdempotencyKey: dva poziva Kreiraj sa istim IdempotencyKey +// (simulira dupliran POST — dupli klik, "Nazad" pa ponovni submit, mrežni retry) +// vraćaju ISTI nalogID, ne prave drugi nalog i ne skidaju stanje dvaput. +func TestProdajaKreiraj_IdempotencyKey(t *testing.T) { + ctx := context.Background() + baza := testDB(t) + artRepo := NoviArtikalRepo(baza) + prodRepo := NoviProdajaRepo(baza) + + artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{ + Naziv: "Slušalice", Tip: model.TipProizvod, Kolicina: 10, + }) + + nalog := &model.ProdajniNalog{ + Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(), + IdempotencyKey: "test-kljuc-123", + } + stavke := []model.StavkaProdaje{ + {ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 500}, + } + + id1, err := prodRepo.Kreiraj(ctx, nalog, stavke, nil) + if err != nil { + t.Fatalf("prvi Kreiraj: %v", err) + } + + // drugi poziv sa ISTIM ključem (nov nalog/stavke, kao pri ponovljenom POST-u) + id2, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{ + Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(), + IdempotencyKey: "test-kljuc-123", + }, []model.StavkaProdaje{ + {ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 500}, + }, nil) + if err != nil { + t.Fatalf("drugi Kreiraj (dupliran POST): %v", err) + } + + if id1 != id2 { + t.Errorf("drugi poziv sa istim idempotency ključem vratio drugačiji ID: %d != %d — napravljen dupli nalog", id1, id2) + } + + var brNaloga int + baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM prodajni_nalozi WHERE idempotency_key = ?", "test-kljuc-123").Scan(&brNaloga) + if brNaloga != 1 { + t.Errorf("broj naloga sa ovim idempotency ključem = %d, očekivano 1", brNaloga) + } + + // stanje skinuto SAMO jednom (2 kom), ne dvaput (4 kom) + a, _ := artRepo.DohvatiID(ctx, artID) + if a.Kolicina != 8 { + t.Errorf("stanje = %d, očekivano 8 (10-2, skinuto samo jednom)", a.Kolicina) + } +} + func absF(x float64) float64 { if x < 0 { return -x diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 892cc75..a1366b0 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -543,6 +543,9 @@ func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.Pr if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" { nalog.NacinPlacanja = "gotovina" } + // idempotency_key: UUID koji frontend generiše po otvaranju forme (skriveno polje); + // prosleđen dalje do ProdajaRepo.Kreiraj radi zaštite od duplog POST-a + nalog.IdempotencyKey = strings.TrimSpace(r.FormValue("idempotency_key")) artikalIDovi := r.Form["artikal_id[]"] kolicine := r.Form["kolicina[]"] diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go index 621a1a4..a92779c 100644 --- a/internal/model/prodaja.go +++ b/internal/model/prodaja.go @@ -13,6 +13,11 @@ type ProdajniNalog struct { Stornirano bool RazlogStorniranja string Datum time.Time + // IdempotencyKey je UUID koji frontend generiše po otvaranju forme (skriveno polje). + // Ako isti ključ već postoji u bazi, Kreiraj ne pravi novi nalog nego vraća postojeći — + // štiti od duplog POST-a (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva taba). + // Prazan string znači da pozivalac ne koristi zaštitu (npr. testovi, budući pozivaoci). + IdempotencyKey string } // StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje diff --git a/migrations/106_prodaja_idempotency_key.sql b/migrations/106_prodaja_idempotency_key.sql new file mode 100644 index 0000000..d101b78 --- /dev/null +++ b/migrations/106_prodaja_idempotency_key.sql @@ -0,0 +1,10 @@ +-- Idempotency ključ za prodajni nalog: frontend generiše UUID po otvaranju forme +-- (jedan ključ po pokušaju unosa) i šalje ga kao skriveno polje. Ako isti POST +-- stigne na server dva puta (dupli klik koji je promakao JS zaštiti, "Nazad" pa +-- ponovni submit, mrežni retry, dva otvorena taba), drugi zahtev se prepoznaje +-- po već postojećem ključu i vraća VEĆ kreirani nalog umesto da napravi drugi. +-- Isti obrazac kao šifra usluge/troška (migracija 105): NULL dozvoljen i ne ulazi +-- u UNIQUE proveru (stari zapisi, ili budući pozivaoci koji ne šalju ključ). +ALTER TABLE prodajni_nalozi ADD COLUMN idempotency_key TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS idx_prodajni_nalozi_idempotency_key + ON prodajni_nalozi(idempotency_key) WHERE idempotency_key IS NOT NULL; diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index df8134a..fbf5e71 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -177,6 +177,10 @@ document.addEventListener('alpine:init', () => { primljenoIznos: '', prikaziRacun: true, saljemSe: false, + // generisan jednom po otvaranju forme; server ga koristi da prepozna dupli POST + // (dupli klik, "Nazad" pa ponovni submit, mrežni retry, dva otvorena taba) i + // vrati postojeći nalog umesto da napravi drugi — v. ProdajaRepo.Kreiraj + idempotencyKey: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)), _fiskalniTab: null, artikliOpcije: [], pretragaArtikal: '', diff --git a/web/templates/stranice/prodaja_forma.html b/web/templates/stranice/prodaja_forma.html index cf13464..adff751 100644 --- a/web/templates/stranice/prodaja_forma.html +++ b/web/templates/stranice/prodaja_forma.html @@ -34,6 +34,7 @@