Prodaja: idempotency zaštita od duplog POST-a, ispravljeno dupliranje pdv_stopa[] u formi
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user