diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 96674dc..90ccc60 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -392,6 +392,8 @@ func main() { r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/dodeli-sifre", h.DodeliSifreArtiklima) + r.With(doz("artikal.izmeni")).Get("/magacin/kategorije/uskladi-sifre", h.PregledUskladjivanjaSifri) + r.With(doz("artikal.izmeni")).Post("/magacin/kategorije/uskladi-sifre", h.PotvrdiUskladjivanjeSifri) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) diff --git a/internal/db/repository.go b/internal/db/repository.go index 119c1c9..4e4ec0f 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -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) diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index d6db643..08e5d64 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -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) } diff --git a/internal/db/sqlite/kategorija.go b/internal/db/sqlite/kategorija.go index 8a88ec4..8cc5709 100644 --- a/internal/db/sqlite/kategorija.go +++ b/internal/db/sqlite/kategorija.go @@ -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 diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index e292228..e03da98 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -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) diff --git a/internal/db/sqlite/prodaja_kreiraj_test.go b/internal/db/sqlite/prodaja_kreiraj_test.go index aa9f510..4b857cd 100644 --- a/internal/db/sqlite/prodaja_kreiraj_test.go +++ b/internal/db/sqlite/prodaja_kreiraj_test.go @@ -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 diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 1682eb6..93d211f 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -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 } diff --git a/internal/db/sqlite/trosak.go b/internal/db/sqlite/trosak.go index cbed94f..e7b109c 100644 --- a/internal/db/sqlite/trosak.go +++ b/internal/db/sqlite/trosak.go @@ -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 diff --git a/internal/db/sqlite/usluga.go b/internal/db/sqlite/usluga.go index 8db77c6..2ad7342 100644 --- a/internal/db/sqlite/usluga.go +++ b/internal/db/sqlite/usluga.go @@ -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 diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index e4ac298..10628f3 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -2,7 +2,9 @@ package handler import ( "errors" + "fmt" "net/http" + "sort" "strconv" "strings" @@ -88,6 +90,178 @@ func (h *Handler) DodeliSifreArtiklima(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther) } +// PromenaSifre je jedna stavka plana usklađivanja šifri — artikal čija +// trenutna šifra ne odgovara prefiksu njegove kategorije, sa predloženom +// novom šifrom. +type PromenaSifre struct { + ArtikalID int64 + Naziv string + KategorijaNaziv string + StaraSifra string + NovaSifra string +} + +// izracunajPlanUskladjivanjaSifri prolazi kroz sve artikle (po ID-u, redosled +// unosa) i za svaki proverava da li šifra odgovara PREFIKS-NNNN obrascu +// njegove kategorije (kod kategorije, ili "ART" ako artikal nema kategoriju). +// Artikli iz kategorija bez postavljenog koda se preskaču. Novi broj je prvi +// slobodan (počev od 0001) za taj prefiks — uzima u obzir i šifre koje već +// postoje kod drugih artikala i one dodeljene ranije u istom prolazu. +func izracunajPlanUskladjivanjaSifri(artikli []model.ArtikalSaKategorijom, kategorije []model.Kategorija) []PromenaSifre { + // Lista artikala dolazi poređana po nazivu — kandidati se obrađuju po ID-u + // (redosled unosa), pa se pravi sopstvena poređana kopija. + artikli = append([]model.ArtikalSaKategorijom(nil), artikli...) + sort.Slice(artikli, func(i, j int) bool { return artikli[i].ID < artikli[j].ID }) + + kodPoKategoriji := make(map[int64]string, len(kategorije)) + for _, k := range kategorije { + kodPoKategoriji[k.ID] = k.Kod + } + + // zauzeti[prefiks] = skup već iskorišćenih brojeva (iz postojećih šifri svih artikala) + zauzeti := make(map[string]map[int]bool) + zauzmi := func(prefiks string, broj int) { + if zauzeti[prefiks] == nil { + zauzeti[prefiks] = make(map[int]bool) + } + zauzeti[prefiks][broj] = true + } + parsirajBroj := func(sifra, prefiks string) (int, bool) { + rep := prefiks + "-" + if !strings.HasPrefix(sifra, rep) { + return 0, false + } + n, err := strconv.Atoi(sifra[len(rep):]) + if err != nil || n < 1 { + return 0, false + } + return n, true + } + + prvoSlobodan := func(prefiks string) int { + broj := 1 + for zauzeti[prefiks][broj] { + broj++ + } + return broj + } + + // popiši postojeće brojeve po prefiksu iz svih trenutnih šifri (oblika PREFIKS-NNNN) + for _, a := range artikli { + if a.Sifra == "" { + continue + } + i := strings.LastIndex(a.Sifra, "-") + if i <= 0 { + continue + } + prefiks, rep := a.Sifra[:i], a.Sifra[i+1:] + if n, err := strconv.Atoi(rep); err == nil && n >= 1 { + zauzmi(prefiks, n) + } + } + + var plan []PromenaSifre + for _, a := range artikli { + ocekivaniPrefiks := "ART" + if a.KategorijaID != nil { + kod, ima := kodPoKategoriji[*a.KategorijaID] + if !ima || kod == "" { + continue // kategorija bez koda — preskoči + } + ocekivaniPrefiks = kod + } + + if _, tacna := parsirajBroj(a.Sifra, ocekivaniPrefiks); tacna { + continue // šifra već odgovara obrascu za ovu kategoriju + } + + broj := prvoSlobodan(ocekivaniPrefiks) + zauzmi(ocekivaniPrefiks, broj) + novaSifra := fmt.Sprintf("%s-%04d", ocekivaniPrefiks, broj) + + plan = append(plan, PromenaSifre{ + ArtikalID: a.ID, + Naziv: a.Naziv, + KategorijaNaziv: a.KategorijaNaziv, + StaraSifra: a.Sifra, + NovaSifra: novaSifra, + }) + } + + return plan +} + +// PodaciUskladjivanjaSifri su podaci za stranicu pregleda usklađivanja šifri +type PodaciUskladjivanjaSifri struct { + model.PodaciStranice + Plan []PromenaSifre +} + +// PregledUskladjivanjaSifri prikazuje spisak artikala čija šifra ne odgovara +// prefiksu njihove kategorije, sa predlogom nove šifre — pre potvrde. +func (h *Handler) PregledUskladjivanjaSifri(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok { + return + } + + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + + artikli, err := h.Artikli.Lista(r.Context(), db.ArtikalFilter{}) + if err != nil { + http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError) + return + } + kategorije, err := h.KategorijeRepo.Lista(r.Context()) + if err != nil { + http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "magacin" + ps.NaslovStranice = "Usklađivanje šifri" + + h.renderujTemplate(w, "kategorije_uskladi_sifre", PodaciUskladjivanjaSifri{ + PodaciStranice: ps, + Plan: izracunajPlanUskladjivanjaSifri(artikli, kategorije), + }) +} + +// PotvrdiUskladjivanjeSifri prima POST sa tačnim spiskom promena prikazanim +// na stranici pregleda (artikal_id[] / nova_sifra[]) i upisuje ih. Ne računa +// plan iznova — primenjuje tačno ono što je pregledano i potvrđeno. +func (h *Handler) PotvrdiUskladjivanjeSifri(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + idovi := r.Form["artikal_id"] + sifre := r.Form["nova_sifra"] + + broj := 0 + for i := 0; i < len(idovi) && i < len(sifre); i++ { + id, err := strconv.ParseInt(idovi[i], 10, 64) + if err != nil { + continue + } + if err := h.Artikli.AzurirajSifru(r.Context(), id, sifre[i]); err != nil { + continue + } + broj++ + } + + http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther) +} + // DodajKategoriju prima POST i čuva novu kategoriju func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok { @@ -112,6 +286,11 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { } if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil { + if errors.Is(err, db.ErrKategorijaDuplikat) { + middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.") + http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther) + return + } http.Error(w, "Greška pri čuvanju kategorije", http.StatusInternalServerError) return } @@ -150,6 +329,11 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) { } if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil { + if errors.Is(err, db.ErrKategorijaDuplikat) { + middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.") + http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther) + return + } http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError) return } diff --git a/internal/handler/kategorija_sifre_test.go b/internal/handler/kategorija_sifre_test.go new file mode 100644 index 0000000..312b5c2 --- /dev/null +++ b/internal/handler/kategorija_sifre_test.go @@ -0,0 +1,92 @@ +package handler + +import ( + "testing" + + "ntech/internal/model" +) + +func TestIzracunajPlanUskladjivanjaSifri_PopunjavaPrazninu(t *testing.T) { + kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}} + artikli := []model.ArtikalSaKategorijom{ + {Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"}, + {Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "KOMP-0003"}, KategorijaNaziv: "Kompjuteri"}, + {Artikal: model.Artikal{ID: 3, KategorijaID: p(1), Sifra: "STARA-ŠIFRA"}, KategorijaNaziv: "Kompjuteri"}, + } + + plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije) + + if len(plan) != 1 { + t.Fatalf("plan = %d stavki, očekivano 1", len(plan)) + } + if plan[0].ArtikalID != 3 || plan[0].NovaSifra != "KOMP-0002" { + t.Errorf("plan[0] = %+v, očekivano ArtikalID=3 NovaSifra=KOMP-0002", plan[0]) + } +} + +func TestIzracunajPlanUskladjivanjaSifri_KategorijaBezKodaSePreskace(t *testing.T) { + kategorije := []model.Kategorija{{ID: 1, Naziv: "Ram memorije", Kod: ""}} + artikli := []model.ArtikalSaKategorijom{ + {Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "NASUMICNO"}, KategorijaNaziv: "Ram memorije"}, + } + + plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije) + + if len(plan) != 0 { + t.Fatalf("plan = %d stavki, očekivano 0 (kategorija bez koda)", len(plan)) + } +} + +func TestIzracunajPlanUskladjivanjaSifri_VecIspravnaSifraSePreskace(t *testing.T) { + kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}} + artikli := []model.ArtikalSaKategorijom{ + {Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"}, + } + + plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije) + + if len(plan) != 0 { + t.Fatalf("plan = %d stavki, očekivano 0 (šifra već ispravna)", len(plan)) + } +} + +func TestIzracunajPlanUskladjivanjaSifri_BezKategorijeIdeUnderArt(t *testing.T) { + kategorije := []model.Kategorija{} + artikli := []model.ArtikalSaKategorijom{ + {Artikal: model.Artikal{ID: 1, KategorijaID: nil, Sifra: "ART-0001"}}, + {Artikal: model.Artikal{ID: 2, KategorijaID: nil, Sifra: "NEBITNO"}}, + } + + plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije) + + if len(plan) != 1 { + t.Fatalf("plan = %d stavki, očekivano 1", len(plan)) + } + if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "ART-0002" { + t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=ART-0002", plan[0]) + } +} + +func TestIzracunajPlanUskladjivanjaSifri_RedosledPoID(t *testing.T) { + kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}} + artikli := []model.ArtikalSaKategorijom{ + {Artikal: model.Artikal{ID: 5, KategorijaID: p(1), Sifra: "X"}, KategorijaNaziv: "Kompjuteri"}, + {Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "Y"}, KategorijaNaziv: "Kompjuteri"}, + } + + plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije) + + if len(plan) != 2 { + t.Fatalf("plan = %d stavki, očekivano 2", len(plan)) + } + // ulaz je namerno dat u redosledu ID 5 pa 2 — funkcija sama sortira po ID-u + // pre dodele brojeva, pa 2 (manji ID) mora dobiti prvi (manji) broj + if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "KOMP-0001" { + t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=KOMP-0001", plan[0]) + } + if plan[1].ArtikalID != 5 || plan[1].NovaSifra != "KOMP-0002" { + t.Errorf("plan[1] = %+v, očekivano ArtikalID=5 NovaSifra=KOMP-0002", plan[1]) + } +} + +func p(id int64) *int64 { return &id } diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 515809c..706bba3 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -23,7 +23,7 @@ var saSidebar = []string{ "dashboard", "dobavljaci", "dobavljac_forma", "izvestaji", "prometni_list", "stanje_zaliha", "popis", - "kategorije", + "kategorije", "kategorije_uskladi_sifre", "klijenti", "klijent_forma", "magacin", "magacin_forma", "magacin_kartica", "nabavke", "nabavka_forma", "nabavka_detalji", diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 5c6aaa9..ab5ba20 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "fmt" "log/slog" "net/http" @@ -8,6 +9,7 @@ import ( "strings" "time" + "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -121,7 +123,32 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { id, err := h.Artikli.Kreiraj(r.Context(), &artikal) if err != nil { - http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError) + poruka := "Greška pri čuvanju artikla." + if errors.Is(err, db.ErrArtikalDuplikatSifre) { + poruka = "Šifra ili barkod već postoji kod drugog artikla." + } else { + slog.Error("čuvanje artikla nije uspelo", "error", err) + } + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + kategorije, _ := h.KategorijeRepo.Lista(r.Context()) + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") + katIDStr := "" + if artikal.KategorijaID != nil { + katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10) + } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "magacin" + ps.NaslovStranice = "Novi artikal" + h.renderujFormuArtikla(w, PodaciFormeArtikla{ + PodaciStranice: ps, + Artikal: artikal, + Kategorije: kategorije, + KategorijaIDStr: katIDStr, + Dobavljaci: dobavljaci, + IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)), + Greska: poruka, + Izmena: false, + }) return } artikal.ID = id @@ -262,7 +289,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { artikal.ID = id if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { - http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) + poruka := "Greška pri čuvanju izmene." + if errors.Is(err, db.ErrArtikalDuplikatSifre) { + poruka = "Šifra ili barkod već postoji kod drugog artikla." + } else { + slog.Error("čuvanje izmene artikla nije uspelo", "artikal_id", id, "error", err) + } + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + kategorije, _ := h.KategorijeRepo.Lista(r.Context()) + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") + katIDStr := "" + if artikal.KategorijaID != nil { + katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10) + } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "magacin" + ps.NaslovStranice = "Izmeni artikal" + h.renderujFormuArtikla(w, PodaciFormeArtikla{ + PodaciStranice: ps, + Artikal: artikal, + Kategorije: kategorije, + KategorijaIDStr: katIDStr, + Dobavljaci: dobavljaci, + IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)), + Greska: poruka, + Izmena: true, + }) return } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index eed3d9c..2f758b4 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -276,14 +276,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { } } - brojNaloga, err := h.ProdajaRepo.SledeciBroj(r.Context()) - if err != nil { - slog.Error("greška pri generisanju broja naloga", "error", err) - renderujGresku("Greška pri generisanju broja naloga.") - return - } - - nalog.BrojNaloga = brojNaloga + // broj naloga generiše ProdajaRepo.Kreiraj unutar transakcije upisa nalog.Datum = time.Now() var ukupno float64 diff --git a/internal/handler/troskovi.go b/internal/handler/troskovi.go index fa43168..29c4af6 100644 --- a/internal/handler/troskovi.go +++ b/internal/handler/troskovi.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" "strings" @@ -91,7 +92,11 @@ func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) { return } if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil { - h.renderujFormuTroska(w, r, trosak, false, "Greška pri čuvanju troška. Pokušajte ponovo.") + poruka := "Greška pri čuvanju troška. Pokušajte ponovo." + if errors.Is(err, db.ErrTrosakDuplikatSifre) { + poruka = "Šifra već postoji kod drugog troška." + } + h.renderujFormuTroska(w, r, trosak, false, poruka) return } http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) @@ -114,7 +119,11 @@ func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) { return } if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil { - h.renderujFormuTroska(w, r, trosak, true, "Greška pri čuvanju troška. Pokušajte ponovo.") + poruka := "Greška pri čuvanju troška. Pokušajte ponovo." + if errors.Is(err, db.ErrTrosakDuplikatSifre) { + poruka = "Šifra već postoji kod drugog troška." + } + h.renderujFormuTroska(w, r, trosak, true, poruka) return } http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther) diff --git a/internal/handler/usluge.go b/internal/handler/usluge.go index f9a6cd0..bef475f 100644 --- a/internal/handler/usluge.go +++ b/internal/handler/usluge.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strconv" "strings" @@ -92,7 +93,11 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) { return } if _, err := h.UslugeRepo.Kreiraj(r.Context(), &usluga); err != nil { - h.renderujFormuUsluge(w, r, usluga, false, "Greška pri čuvanju usluge. Pokušajte ponovo.") + poruka := "Greška pri čuvanju usluge. Pokušajte ponovo." + if errors.Is(err, db.ErrUslugaDuplikatSifre) { + poruka = "Šifra već postoji kod druge usluge." + } + h.renderujFormuUsluge(w, r, usluga, false, poruka) return } http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther) @@ -115,7 +120,11 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) { return } if err := h.UslugeRepo.Izmeni(r.Context(), &usluga); err != nil { - h.renderujFormuUsluge(w, r, usluga, true, "Greška pri čuvanju usluge. Pokušajte ponovo.") + poruka := "Greška pri čuvanju usluge. Pokušajte ponovo." + if errors.Is(err, db.ErrUslugaDuplikatSifre) { + poruka = "Šifra već postoji kod druge usluge." + } + h.renderujFormuUsluge(w, r, usluga, true, poruka) return } http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther) diff --git a/migrations/104_kategorije_unique.sql b/migrations/104_kategorije_unique.sql new file mode 100644 index 0000000..1d402ec --- /dev/null +++ b/migrations/104_kategorije_unique.sql @@ -0,0 +1,6 @@ +-- Sprečava dupliranje kategorije: isti naziv (bez obzira na velika/mala slova) +-- ili isti kôd (prefiks šifre artikla) ne sme postojati kod dve kategorije. +-- Kôd je NULL kad nije postavljen (fallback prefiks ART), pa ga izuzimamo iz +-- provere jedinstvenosti — više kategorija sme biti bez koda. +CREATE UNIQUE INDEX IF NOT EXISTS idx_kategorije_naziv ON kategorije(naziv COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS idx_kategorije_kod ON kategorije(kod) WHERE kod IS NOT NULL; diff --git a/migrations/105_usluge_troskovi_sifra_unique.sql b/migrations/105_usluge_troskovi_sifra_unique.sql new file mode 100644 index 0000000..89047b4 --- /dev/null +++ b/migrations/105_usluge_troskovi_sifra_unique.sql @@ -0,0 +1,6 @@ +-- Šifra usluge/troška treba da bude jedinstvena, isti obrazac kao artikal +-- šifra/barkod (migracija 055_artikal_sifra_barkod.sql). Prazna šifra ('') se +-- ne čuva (handler šalje NULL kad je polje prazno), pa je izuzimamo da više +-- usluga/troškova bez šifre ne bi sudarilo proveru. +CREATE UNIQUE INDEX IF NOT EXISTS idx_usluge_sifra ON usluge(sifra) WHERE sifra IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_troskovi_sifra ON troskovi(sifra) WHERE sifra IS NOT NULL; diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 3349eaa..9b57ab3 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -1,3 +1,26 @@ +// generička zaštita od duplog slanja forme: onemogući submit dugme(ad) čim +// forma krene da se šalje (spor internet/dupli klik/Enter+klik ne pravi drugi +// POST). Dodaj data-ne-onemoguci="1" na formu da se isključi (npr. forme sa +// više imenovanih submit dugmadi gde različit klik nosi različitu vrednost). +document.addEventListener('submit', function(e) { + var forma = e.target; + if (!(forma instanceof HTMLFormElement)) return; + if (forma.dataset.neOnemoguci === '1') return; + var dugmad = forma.querySelectorAll('button[type="submit"]:not([name]), input[type="submit"]:not([name])'); + setTimeout(function() { + dugmad.forEach(function(btn) { btn.disabled = true; }); + }, 0); +}, true); + +// ako se stranica vrati iz bfcache-a (dugme nazad/napred), vrati dugmad u +// normalno stanje da forma ostane upotrebljiva +window.addEventListener('pageshow', function(e) { + if (!e.persisted) return; + document.querySelectorAll('button[type="submit"]:disabled, input[type="submit"]:disabled').forEach(function(btn) { + btn.disabled = false; + }); +}); + // otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen // (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni. function ntechTogglePodmeni(btn) { diff --git a/web/templates/stranice/admin_dozvole.html b/web/templates/stranice/admin_dozvole.html index e80b763..70a1267 100644 --- a/web/templates/stranice/admin_dozvole.html +++ b/web/templates/stranice/admin_dozvole.html @@ -32,11 +32,11 @@