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:
2026-07-07 03:50:31 +02:00
parent 1ff572dd77
commit bc60288a0d
28 changed files with 682 additions and 110 deletions
+184
View File
@@ -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
}
+92
View File
@@ -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 }
+1 -1
View File
@@ -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",
+54 -2
View File
@@ -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
}
+1 -8
View File
@@ -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
+11 -2
View File
@@ -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)
+11 -2
View File
@@ -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)