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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -195,6 +196,23 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 —
|
// broj naloga se generiše OVDE, unutar iste transakcije kao insert —
|
||||||
// ne sme se prosleđivati kao unapred generisana vrednost iz handlera,
|
// ne sme se prosleđivati kao unapred generisana vrednost iz handlera,
|
||||||
// jer bi dupliran/ponovljen POST zahtev tada napravio dva zasebna,
|
// 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
|
// insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
|
||||||
rezultat, err := tx.ExecContext(ctx, `
|
rezultat, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
|
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum, idempotency_key)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum,
|
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum, nullString(n.IdempotencyKey),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err)
|
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 {
|
func absF(x float64) float64 {
|
||||||
if x < 0 {
|
if x < 0 {
|
||||||
return -x
|
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" {
|
if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" {
|
||||||
nalog.NacinPlacanja = "gotovina"
|
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[]"]
|
artikalIDovi := r.Form["artikal_id[]"]
|
||||||
kolicine := r.Form["kolicina[]"]
|
kolicine := r.Form["kolicina[]"]
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type ProdajniNalog struct {
|
|||||||
Stornirano bool
|
Stornirano bool
|
||||||
RazlogStorniranja string
|
RazlogStorniranja string
|
||||||
Datum time.Time
|
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
|
// 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;
|
||||||
@@ -177,6 +177,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
primljenoIznos: '',
|
primljenoIznos: '',
|
||||||
prikaziRacun: true,
|
prikaziRacun: true,
|
||||||
saljemSe: false,
|
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,
|
_fiskalniTab: null,
|
||||||
artikliOpcije: [],
|
artikliOpcije: [],
|
||||||
pretragaArtikal: '',
|
pretragaArtikal: '',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
<form method="POST" action="/prodaja/nova" @submit.prevent="posaljiProdaju($event)">
|
<form method="POST" action="/prodaja/nova" @submit.prevent="posaljiProdaju($event)">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}" />
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}" />
|
||||||
|
<input type="hidden" name="idempotency_key" :value="idempotencyKey" />
|
||||||
{{if .Greska}}
|
{{if .Greska}}
|
||||||
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
|
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
<template x-for="(stavka, i) in stavke" :key="i">
|
<template x-for="(stavka, i) in stavke" :key="i">
|
||||||
<tr style="border-bottom: 0.5px solid var(--ivica)">
|
<tr style="border-bottom: 0.5px solid var(--ivica)">
|
||||||
<td style="padding: 8px 10px; position: relative;">
|
<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">
|
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="isMobile">
|
||||||
<template x-if="stavka.artikal_id">
|
<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;">
|
<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>
|
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>
|
||||||
<div style="display: flex; flex-direction: column; gap: 10px">
|
<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;">
|
<div style="position: relative;">
|
||||||
<label class="polje-labela">Artikal</label>
|
<label class="polje-labela">Artikal</label>
|
||||||
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile">
|
<input type="hidden" :name="'artikal_id[]'" :value="stavka.artikal_id" :disabled="!isMobile">
|
||||||
|
|||||||
Reference in New Issue
Block a user