Prodaja: idempotency zaštita od duplog POST-a, ispravljeno dupliranje pdv_stopa[] u formi

This commit is contained in:
2026-07-31 02:39:56 +02:00
parent a735a5121b
commit f7e466f30b
7 changed files with 100 additions and 5 deletions
+21 -3
View File
@@ -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
+3
View File
@@ -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[]"]
+5
View File
@@ -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
@@ -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;
+4
View File
@@ -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: '',
+3 -2
View File
@@ -34,6 +34,7 @@
<form method="POST" action="/prodaja/nova" @submit.prevent="posaljiProdaju($event)">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}" />
<input type="hidden" name="idempotency_key" :value="idempotencyKey" />
{{if .Greska}}
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
{{end}}
@@ -128,7 +129,7 @@
<template x-for="(stavka, i) in stavke" :key="i">
<tr style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding: 8px 10px; position: relative;">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa" :disabled="isMobile">
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="isMobile">
<template x-if="stavka.artikal_id">
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;padding:7px 10px;border:0.5px solid var(--ivica);border-radius:8px;">
@@ -220,7 +221,7 @@
style="background: none; border: 0.5px solid #dc2626; color: #dc2626; cursor: pointer; font-size: 13px; padding: 2px 8px; border-radius: 4px;">Ukloni</button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa">
<input type="hidden" :name="'pdv_stopa[]'" :value="stavka.pdv_stopa" :disabled="!isMobile">
<div style="position: relative;">
<label class="polje-labela">Artikal</label>
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile">