Sprečeno dupliranje prodaje/servisa, alat za usklađivanje šifri artikla, ispravke osvežavanja stranica, zaštita od duplikata kategorije/artikla/usluge/troška
- Prodaja i servis: broj naloga se generiše unutar transakcije upisa umesto unapred u handleru, čime se otklanja duplo kreiranje naloga pri dvostrukom slanju forme - Servis izmena: uklonjen sudar generičkog i sopstvenog auto-snimanja forme (dva paralelna POST-a) - Magacin: novi alat 'Uskladi šifre sa kategorijama' za masovnu ispravku šifri artikala posle uvođenja šifarnika po kategoriji - Ispravljeno da forme na 9 stranica (kategorije, admin korisnici/dozvole, profil, podešavanja) posle akcije osveže prikaz umesto da ostanu zastarele do F5 - Kategorija, artikal, usluga i trošak: sprečeno dupliranje naziva/šifre/koda (UNIQUE indeksi + razumljive poruke grešaka)
This commit is contained in:
@@ -16,6 +16,19 @@ var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
|
||||
// referencirana od strane artikla.
|
||||
var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi")
|
||||
|
||||
// ErrKategorijaDuplikat se vraća kad naziv ili kôd kategorije već postoji
|
||||
// kod druge kategorije (vidi migraciju 104_kategorije_unique.sql).
|
||||
var ErrKategorijaDuplikat = errors.New("ntech: kategorija sa tim nazivom ili kodom već postoji")
|
||||
|
||||
// ErrArtikalDuplikatSifre se vraća kad šifra ili barkod artikla već postoji
|
||||
// kod drugog artikla (vidi migraciju 055_artikal_sifra_barkod.sql).
|
||||
var ErrArtikalDuplikatSifre = errors.New("ntech: šifra ili barkod artikla već postoji kod drugog artikla")
|
||||
|
||||
// ErrUslugaDuplikatSifre i ErrTrosakDuplikatSifre se vraćaju kad šifra usluge/
|
||||
// troška već postoji kod drugog reda (vidi migraciju 105_usluge_troskovi_sifra_unique.sql).
|
||||
var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge već postoji")
|
||||
var ErrTrosakDuplikatSifre = errors.New("ntech: šifra troška već postoji")
|
||||
|
||||
// ArtikalRepository definiše operacije nad artiklima
|
||||
type ArtikalRepository interface {
|
||||
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
||||
|
||||
@@ -227,6 +227,9 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
|
||||
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrArtikalDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
|
||||
}
|
||||
|
||||
@@ -259,6 +262,9 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
||||
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrArtikalDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
||||
return rezultat, nil
|
||||
}
|
||||
|
||||
// jeUnique proverava da li greška iz SQLite drajvera potiče od povrede
|
||||
// UNIQUE indeksa (naziv ili kôd kategorije već postoji — vidi migraciju
|
||||
// 104_kategorije_unique.sql).
|
||||
func jeUnique(err error) bool {
|
||||
var sqliteErr *mosqlite.Error
|
||||
return errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 // SQLITE_CONSTRAINT_UNIQUE
|
||||
}
|
||||
|
||||
// Kreiraj dodaje novu kategoriju
|
||||
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
|
||||
var kod any
|
||||
@@ -64,6 +72,9 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
|
||||
k.Naziv, k.Opis, kod, k.Marza,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrKategorijaDuplikat
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
||||
}
|
||||
|
||||
@@ -109,6 +120,9 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error
|
||||
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrKategorijaDuplikat
|
||||
}
|
||||
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -23,18 +23,29 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca
|
||||
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
return sledeciBrojProdaje(ctx, r.db)
|
||||
}
|
||||
|
||||
// sledeciBrojProdaje čita i generiše sledeći broj naloga preko prosleđenog
|
||||
// izvršioca upita (r.db ili tx). Kreiraj je poziva NAD ISTOM transakcijom
|
||||
// u kojoj upisuje nalog — pošto konekcija koristi _txlock=immediate, write
|
||||
// lock se drži od BeginTx, pa je ovo čitanje-pa-upis atomsko i bez race-a
|
||||
// između dva konkurentna/duplirana zahteva.
|
||||
func sledeciBrojProdaje(ctx context.Context, q interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}) (string, error) {
|
||||
sada := time.Now()
|
||||
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
uzorak := prefiks + "%"
|
||||
|
||||
var sledeci int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||
FROM prodajni_nalozi
|
||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
|
||||
return "", fmt.Errorf("ntech: sledeciBrojProdaje: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%04d", prefiks, sledeci), nil
|
||||
@@ -184,6 +195,16 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 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,
|
||||
// validna naloga sa istim stavkama umesto da drugi bude odbijen.
|
||||
brojNaloga, err := sledeciBrojProdaje(ctx, tx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: broj naloga: %w", err)
|
||||
}
|
||||
n.BrojNaloga = brojNaloga
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -98,8 +98,10 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
||||
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20},
|
||||
// CenaBezPdv=0 namerno — treba da se auto-izračuna
|
||||
}
|
||||
_, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
||||
BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
||||
// BrojNaloga se namerno NE prosleđuje — Kreiraj ga uvek sam generiše
|
||||
// (broj_naloga se ne veruje pozivaocu, vidi ProdajaRepo.Kreiraj)
|
||||
nalogID, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
||||
Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
||||
}, stavke, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Kreiraj: %v", err)
|
||||
@@ -107,7 +109,7 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
||||
|
||||
var cenaBezPdv, pdvIznos float64
|
||||
baza.QueryRowContext(ctx,
|
||||
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = (SELECT id FROM prodajni_nalozi WHERE broj_naloga='PR-PDV-001')",
|
||||
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = ?", nalogID,
|
||||
).Scan(&cenaBezPdv, &pdvIznos)
|
||||
|
||||
const ocekivanaNeto = 1000.0
|
||||
|
||||
@@ -31,8 +31,16 @@ func NoviServisRepo(db *sql.DB) *ServisRepo {
|
||||
}
|
||||
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca.
|
||||
// Koristi se samo za PRIKAZ predloga na praznoj formi (NoviNalog) — stvarni
|
||||
// broj koji se upisuje generiše Kreiraj iznova, unutar svoje transakcije.
|
||||
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
return sledeciBrojServisa(ctx, r.db)
|
||||
}
|
||||
|
||||
func sledeciBrojServisa(ctx context.Context, q interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}) (string, error) {
|
||||
sada := time.Now()
|
||||
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
@@ -40,12 +48,12 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
|
||||
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
||||
var sledeci int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||
FROM servisni_nalozi
|
||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: ServisRepo.SledeciBroj: %w", err)
|
||||
return "", fmt.Errorf("ntech: sledeciBrojServisa: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%03d", prefiks, sledeci), nil
|
||||
@@ -120,14 +128,30 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
|
||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token.
|
||||
// Broj naloga se generiše OVDE, unutar transakcije upisa, i ne veruje se
|
||||
// vrednosti iz forme (koja je samo predlog prikazan pri otvaranju prazne
|
||||
// forme) — tako dupliran/ponovljen POST zahtev ne pravi dva zasebna naloga
|
||||
// sa istim brojem/sadržajem.
|
||||
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
|
||||
token, err := generisiJavniToken()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
|
||||
}
|
||||
|
||||
rezultat, err := r.db.ExecContext(ctx, `
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
brojNaloga, err := sledeciBrojServisa(ctx, tx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: broj naloga: %w", err)
|
||||
}
|
||||
n.BrojNaloga = brojNaloga
|
||||
|
||||
rezultat, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO servisni_nalozi
|
||||
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje,
|
||||
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_zavrsetka, predvidjen_datum,
|
||||
@@ -150,6 +174,10 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: last insert id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: commit: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error
|
||||
sifra, t.Naziv, t.Cena, t.Opis,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrTrosakDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err)
|
||||
}
|
||||
id, err := rez.LastInsertId()
|
||||
@@ -117,6 +120,9 @@ func (r *TrosakRepo) Izmeni(ctx context.Context, t *model.Trosak) error {
|
||||
sifra, t.Naziv, t.Cena, t.Opis, t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrTrosakDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -117,6 +117,9 @@ func (r *UslugaRepo) Kreiraj(ctx context.Context, u *model.Usluga) (int64, error
|
||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrUslugaDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err)
|
||||
}
|
||||
id, err := rez.LastInsertId()
|
||||
@@ -138,6 +141,9 @@ func (r *UslugaRepo) Izmeni(ctx context.Context, u *model.Usluga) error {
|
||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis, u.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrUslugaDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user