diff --git a/Readme.md b/Readme.md index f3e27be..9db30aa 100644 --- a/Readme.md +++ b/Readme.md @@ -40,6 +40,12 @@ The goal is simple: everything the repair shop needs to track is located in one - Service orders — intake, status bar, costs, receipt - Sales orders — items, calculation, receipt with company and client details - Procurement — records of purchases from suppliers +- Sales price calculation on procurement — markup (global, per category, per item), landed costs (customs, shipping...) allocated across items, two-way markup↔price computation; respects VAT-payer status +- Price revaluation (nivelacija) — sales price changes with an audit trail (old→new, reason, source, user) +- Company profile and modules — features toggle based on company type and VAT-payer status +- VAT records (KIR/KPR) — books of issued and received invoices, auto-filled from sales and procurement +- VAT calculation per period + mapping to the PP-PDV form; imports (customs declaration) tracked in fields 006/106 +- VAT rate code list - Clients and suppliers — contact database - Reminders — records with deadlines - Reports — revenue overview, inventory status @@ -55,7 +61,8 @@ The goal is simple: everything the repair shop needs to track is located in one ### Planned -- Fiscalization and VAT calculation (specification in Project.md) +- Fiscalization (ESIR/PFR) — specification in Project.md +- KPO book and double-entry bookkeeping (optional, later phase) - PostgreSQL support (for multi-user environments) - WebAuthn / Passkey login (database schema is already prepared) - Notifications (email / WhatsApp) — deferred to a later phase diff --git a/Readme_sr.md b/Readme_sr.md index 8842782..c171953 100644 --- a/Readme_sr.md +++ b/Readme_sr.md @@ -40,6 +40,12 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b - Servisni nalozi — prijem, statusna traka, troškovi, priznanica - Prodajni nalozi — stavke, obračun, priznanica sa podacima firme i klijenta - Nabavke — evidencija nabavki od dobavljača +- Kalkulacija prodajne cene pri nabavci — marža (globalna, po kategoriji i po artiklu), zavisni troškovi (carina, prevoz...) sa raspodelom na stavke, dvosmerni izračun marža↔prodajna; poštuje status PDV obveznika +- Nivelacija — promena prodajne cene uz trag (istorija promena: stara→nova, razlog, izvor, korisnik) +- Profil firme i moduli — funkcije se uključuju prema tipu firme i statusu PDV obveznika +- PDV evidencija (KIR/KPR) — knjige izdatih i primljenih računa, automatsko punjenje iz prodaje i nabavke +- PDV obračun za period + mapiranje na obrazac PP-PDV; uvoz robe (JCI) se vodi u poljima 006/106 +- Šifarnik PDV stopa - Klijenti i dobavljači — baza kontakata - Podsetnici — evidencija sa rokom - Izveštaji — pregled prihoda, stanje magacina @@ -55,7 +61,8 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b ### Planirano -- Fiskalizacija i PDV obračun (specifikacija u Project.md) +- Fiskalizacija (ESIR/PFR) — specifikacija u Project.md +- KPO knjiga i dvojno knjigovodstvo (opciono, kasnija faza) - Podrška za PostgreSQL (za višekorisničko okruženje) - WebAuthn / Passkey prijava (šema baze je pripremljena) - Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index bcbdf77..a1d179d 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -8,9 +8,9 @@ import ( "fmt" "io/fs" "log/slog" + "mime" "net/http" "os" - "mime" "path/filepath" "sort" "strconv" @@ -87,12 +87,14 @@ func main() { db, err := sqlite.OtvoriDB(putanjaBaze) if err != nil { - slog.Error("Greška pri otvaranju baze", "error", err); os.Exit(1) + slog.Error("Greška pri otvaranju baze", "error", err) + os.Exit(1) } defer db.Close() if err := sqlite.PokreniMigracije(db, migrFS); err != nil { - slog.Error("Greška pri migracijama", "error", err); os.Exit(1) + slog.Error("Greška pri migracijama", "error", err) + os.Exit(1) } slog.Info("migracije uspešno izvršene") @@ -111,7 +113,8 @@ func main() { // ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM) totpKljuc, err := ucitajTotpKljuc() if err != nil { - slog.Error("Greška pri učitavanju ključa za TOTP", "error", err); os.Exit(1) + slog.Error("Greška pri učitavanju ključa za TOTP", "error", err) + os.Exit(1) } // jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst @@ -137,7 +140,8 @@ func main() { if os.Getenv("NTECH_ENV") == "production" { kes, err := handler.KreirajKes(templFS) if err != nil { - slog.Error("Greška pri kreiranju keša šablona", "error", err); os.Exit(1) + slog.Error("Greška pri kreiranju keša šablona", "error", err) + os.Exit(1) } h.Templates = kes slog.Info("keš šablona kreiran", "broj", len(kes)) @@ -221,6 +225,20 @@ func main() { return ntechmw.RequireDozvolaMut(h.DozvoleRepo.ImaDozvolu, akcija) } + // modul vraća middleware koji propušta zahtev samo ako je zakonski modul + // uključen za firmu (profil firme). Sloj IZNAD RBAC-a — zahtev mora proći + // i „modul uključen" (ovo) i „korisnik sme" (doz/zahtevajDozvolu). + proveriModul := func(ctx context.Context, m string) bool { + pod, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) + if err != nil { + return false + } + return config.ModulUkljucen(pod, m) + } + modul := func(m string) func(http.Handler) http.Handler { + return ntechmw.RequireModul(proveriModul, m) + } + r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusFound) }) @@ -229,6 +247,10 @@ func main() { r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) + r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu) @@ -237,6 +259,18 @@ func main() { r.Get("/podesavanja/backup", h.BackupBaze) r.With(doz("backup.pokreni")).Post("/podesavanja/backup/vrati", h.VratiBackup) + + // PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul + // „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*. + r.With(modul("pdv")).Get("/pdv/kir", h.PdvKir) + r.With(modul("pdv")).Get("/pdv/kir/nova", h.NoviPdvKir) + r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir) + r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir) + r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr) + r.With(modul("pdv")).Get("/pdv/kpr/nova", h.NoviPdvKpr) + r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kpr/nova", h.SacuvajPdvKpr) + r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr) + r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica) r.Get("/magacin", h.Magacin) r.Get("/magacin/novi", h.NoviArtikal) r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) @@ -244,8 +278,11 @@ func main() { r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) + r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla) + r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije) r.Get("/magacin/kategorije", h.Kategorije) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) + r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) 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) @@ -327,7 +364,8 @@ func main() { slog.Info("NTech pokrenut", "port", port) err = http.ListenAndServe(":"+port, r) if err != nil { - slog.Error("port je zauzet ili nije dostupan", "port", port); os.Exit(1) + slog.Error("port je zauzet ili nije dostupan", "port", port) + os.Exit(1) } } @@ -419,4 +457,3 @@ func ocistiStareBackupe(folder string, max int) { _ = os.Remove(f) } } - diff --git a/internal/config/moduli.go b/internal/config/moduli.go new file mode 100644 index 0000000..db15eca --- /dev/null +++ b/internal/config/moduli.go @@ -0,0 +1,67 @@ +package config + +// Ovaj fajl izvodi iz profila firme (vidi Project.md §2, §4) koji su zakonski +// moduli uključeni za datu firmu. Profil se čuva kao key-value u tabeli +// `podesavanja`, pa helper prima već učitanu mapu (čista funkcija — lako se +// testira bez baze). Ovo je sloj IZNAD RBAC-a: „da li firma uopšte koristi +// modul", nezavisno od „da li korisnik sme". + +// Ključevi profila firme u tabeli `podesavanja`. +const ( + KljucPravniOblik = "firma_pravni_oblik" + KljucPdvObveznik = "firma_pdv_obveznik" + KljucFiskalizacija = "firma_fiskalizacija" + KljucRezim = "firma_rezim" +) + +// Nazivi modula koje program pali/gasi prema profilu firme. +const ( + ModulPdv = "pdv" + ModulFiskalizacija = "fiskalizacija" + ModulKpo = "kpo" + ModulDvojno = "dvojno" +) + +// ModulUkljucen vraća da li je dati zakonski modul aktivan za firmu, na osnovu +// profila firme. Režim „samo evidencija" gasi ceo zakonski sloj — tada nijedan +// modul nije uključen, bez obzira na ostale prekidače. +func ModulUkljucen(podesavanja map[string]string, modul string) bool { + // podrazumevano (ključ još ne postoji) je „samo evidencija" — najbezbednije + // stanje: stara instalacija ne počinje da se ponaša kao poreski obveznik. + rezim := podesavanja[KljucRezim] + if rezim == "" { + rezim = "samo_evidencija" + } + if rezim != "pun" { + return false + } + + switch modul { + case ModulFiskalizacija: + // fiskalizacija je nezavisna od pravnog oblika — zaseban prekidač + // „izdaje li račune građanima" (Project.md §3, napomena *). + return podesavanja[KljucFiskalizacija] == "da" + case ModulPdv: + // PDV evidencija se vodi kad je firma u sistemu PDV-a. + return podesavanja[KljucPdvObveznik] == "da" + case ModulKpo: + // KPO (knjiga o ostvarenom prometu) je samo za paušalce. + return podesavanja[KljucPravniOblik] == "pausalac" + case ModulDvojno: + // dvojno knjigovodstvo je za DOO (za preduzetnike je buduća podela, Project.md §8). + return podesavanja[KljucPravniOblik] == "doo" + default: + return false + } +} + +// SviModuli vraća mapu svih poznatih modula → da li su uključeni za dati profil firme. +// Koristi se da šabloni uslovno prikazuju stavke menija (analogno mapi Dozvole). +func SviModuli(podesavanja map[string]string) map[string]bool { + moduli := []string{ModulPdv, ModulFiskalizacija, ModulKpo, ModulDvojno} + m := make(map[string]bool, len(moduli)) + for _, modul := range moduli { + m[modul] = ModulUkljucen(podesavanja, modul) + } + return m +} diff --git a/internal/config/moduli_test.go b/internal/config/moduli_test.go new file mode 100644 index 0000000..4f89f6c --- /dev/null +++ b/internal/config/moduli_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "maps" + "testing" +) + +func TestModulUkljucen(t *testing.T) { + // pun je profil punog režima sa svim prekidačima upaljenim i pravnim oblikom doo; + // pojedinačni testovi ga prepravljaju po potrebi preko pomoćne funkcije izmeni. + pun := map[string]string{ + KljucRezim: "pun", + KljucPdvObveznik: "da", + KljucFiskalizacija: "da", + KljucPravniOblik: "doo", + } + // izmeni vraća kopiju mape sa promenjenim/dodatim ključem (da testovi ne dele stanje) + izmeni := func(osnova map[string]string, kljuc, vrednost string) map[string]string { + kopija := make(map[string]string, len(osnova)+1) + maps.Copy(kopija, osnova) + kopija[kljuc] = vrednost + return kopija + } + + testovi := []struct { + naziv string + podesavanja map[string]string + modul string + ocekivano bool + }{ + // master prekidač: režim „samo evidencija" gasi sve + {"prazna mapa — pdv ugašen (default samo_evidencija)", map[string]string{}, ModulPdv, false}, + {"prazna mapa — fiskalizacija ugašena", map[string]string{}, ModulFiskalizacija, false}, + {"prazna mapa — kpo ugašen", map[string]string{}, ModulKpo, false}, + {"prazna mapa — dvojno ugašeno", map[string]string{}, ModulDvojno, false}, + {"samo evidencija gasi pdv iako je obveznik", izmeni(pun, KljucRezim, "samo_evidencija"), ModulPdv, false}, + {"samo evidencija gasi fiskalizaciju iako je upaljena", izmeni(pun, KljucRezim, "samo_evidencija"), ModulFiskalizacija, false}, + + // fiskalizacija — zaseban prekidač, nezavisan od oblika + {"pun + fiskalizacija da", pun, ModulFiskalizacija, true}, + {"pun + fiskalizacija ne", izmeni(pun, KljucFiskalizacija, "ne"), ModulFiskalizacija, false}, + + // pdv — vezan isključivo za prekidač obveznika + {"pun + obveznik da", pun, ModulPdv, true}, + {"pun + obveznik ne (čak i doo)", izmeni(pun, KljucPdvObveznik, "ne"), ModulPdv, false}, + + // kpo — samo paušalac + {"pun + paušalac → kpo", izmeni(pun, KljucPravniOblik, "pausalac"), ModulKpo, true}, + {"pun + doo → nema kpo", pun, ModulKpo, false}, + {"pun + preduzetnik → nema kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, false}, + + // dvojno — samo doo + {"pun + doo → dvojno", pun, ModulDvojno, true}, + {"pun + paušalac → nema dvojno", izmeni(pun, KljucPravniOblik, "pausalac"), ModulDvojno, false}, + {"pun + preduzetnik → nema dvojno (buduća podela)", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulDvojno, false}, + + // nepoznat modul + {"nepoznat modul → false", pun, "nepostojeci", false}, + } + + for _, tt := range testovi { + t.Run(tt.naziv, func(t *testing.T) { + if got := ModulUkljucen(tt.podesavanja, tt.modul); got != tt.ocekivano { + t.Errorf("ModulUkljucen(%q) = %v, očekivano %v", tt.modul, got, tt.ocekivano) + } + }) + } +} + +func TestSviModuli(t *testing.T) { + // pun režim, doo, PDV obveznik, fiskalizacija → pdv, fiskalizacija, dvojno uključeni; kpo ne + pod := map[string]string{ + KljucRezim: "pun", + KljucPdvObveznik: "da", + KljucFiskalizacija: "da", + KljucPravniOblik: "doo", + } + m := SviModuli(pod) + if len(m) != 4 { + t.Fatalf("SviModuli vraća %d modula, očekivano 4", len(m)) + } + ocek := map[string]bool{ModulPdv: true, ModulFiskalizacija: true, ModulKpo: false, ModulDvojno: true} + for modul, want := range ocek { + if m[modul] != want { + t.Errorf("SviModuli[%q] = %v, očekivano %v", modul, m[modul], want) + } + } + + // samo evidencija → svi ugašeni + prazna := SviModuli(map[string]string{KljucRezim: "samo_evidencija"}) + for modul, ukljucen := range prazna { + if ukljucen { + t.Errorf("u režimu samo_evidencija modul %q ne sme biti uključen", modul) + } + } +} diff --git a/internal/db/repository.go b/internal/db/repository.go index ab4e689..aba096c 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -13,6 +13,8 @@ type ArtikalRepository interface { DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Izmeni(ctx context.Context, a *model.Artikal) error + // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) + AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error Obrisi(ctx context.Context, id int64) error } @@ -20,7 +22,51 @@ type ArtikalRepository interface { // KategorijaRepository definiše operacije nad kategorijama type KategorijaRepository interface { Lista(ctx context.Context) ([]model.Kategorija, error) + DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) + Izmeni(ctx context.Context, k *model.Kategorija) error +} + +// PdvStopaRepository definiše operacije nad šifarnikom PDV stopa +type PdvStopaRepository interface { + Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) + Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) + Izmeni(ctx context.Context, s *model.PdvStopa) error + PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error +} + +// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR) +type PdvKirRepository interface { + Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) + Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) + Obrisi(ctx context.Context, id int64) error + // ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje) + ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error +} + +// PdvKprRepository definiše operacije nad knjigom primljenih računa (KPR) +type PdvKprRepository interface { + Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) + Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) + Obrisi(ctx context.Context, id int64) error + // ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke) + ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error +} + +// NivelacijaRepository definiše operacije nad evidencijom promene prodajnih cena +type NivelacijaRepository interface { + // PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis; + // vraća kreirani zapis (sa starom i novom cenom). Izvor je "rucno". + PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) + // Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla) + Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error) + // Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice + Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error) + // ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo) + ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error) } // ArtikalFilter definiše parametre za filtriranje liste artikala @@ -35,7 +81,8 @@ type NabavkaRepository interface { Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) DohvatiStavke(ctx context.Context, nabavkaID int64) ([]model.StavkaSaArtiklom, error) - Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke) (int64, error) + DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error) + Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error) Obrisi(ctx context.Context, id int64) error } @@ -117,8 +164,8 @@ type SesijeRepository interface { // PodsetnikFilter definiše parametre za filtriranje liste podsetnika type PodsetnikFilter struct { - SamoAktivni bool // true = samo nezavršeni; false = svi - KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika + SamoAktivni bool // true = samo nezavršeni; false = svi + KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika } // PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index a324a40..6ed7f68 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -25,8 +25,8 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod SELECT a.id, a.kategorija_id, a.naziv, a.opis, a.kolicina, a.kolicina_min, a.lokacija, - a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.napomena, a.datum_unosa, - COALESCE(k.naziv, '') as kategorija_naziv + a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.marza, a.napomena, a.datum_unosa, + COALESCE(k.naziv, '') as kategorija_naziv, k.marza as kategorija_marza FROM artikli a LEFT JOIN kategorije k ON a.kategorija_id = k.id WHERE 1=1` @@ -59,12 +59,13 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod for redovi.Next() { var a model.ArtikalSaKategorijom var kategorijaID sql.NullInt64 + var marza, katMarza sql.NullFloat64 err := redovi.Scan( &a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.Kolicina, &a.KolicinMin, &a.Lokacija, - &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa, - &a.KategorijaNaziv, + &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, + &a.KategorijaNaziv, &katMarza, ) if err != nil { return nil, fmt.Errorf("ntech: ArtikalRepo.Lista: scan: %w", err) @@ -73,6 +74,12 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod if kategorijaID.Valid { a.KategorijaID = &kategorijaID.Int64 } + if marza.Valid { + a.Marza = &marza.Float64 + } + if katMarza.Valid { + a.KategorijaMarza = &katMarza.Float64 + } a.KriticnaZaliha = a.Kolicina <= a.KolicinMin @@ -86,14 +93,15 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { var a model.Artikal var kategorijaID sql.NullInt64 + var marza sql.NullFloat64 err := r.db.QueryRowContext(ctx, ` SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min, - lokacija, nabavna_cena, prodajna_cena, pdv_stopa, napomena, datum_unosa + lokacija, nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena, datum_unosa FROM artikli WHERE id = ?`, id).Scan( &a.ID, &kategorijaID, &a.Naziv, &a.Opis, &a.Kolicina, &a.KolicinMin, &a.Lokacija, - &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa, + &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &marza, &a.Napomena, &a.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) @@ -102,6 +110,9 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, if kategorijaID.Valid { a.KategorijaID = &kategorijaID.Int64 } + if marza.Valid { + a.Marza = &marza.Float64 + } return &a, nil } @@ -111,10 +122,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err rezultat, err := r.db.ExecContext(ctx, ` INSERT INTO artikli (kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija, - nabavna_cena, prodajna_cena, pdv_stopa, napomena) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + nabavna_cena, prodajna_cena, pdv_stopa, marza, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, - a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, + a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, ) if err != nil { return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err) @@ -134,11 +145,11 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { UPDATE artikli SET kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?, kolicina_min = ?, lokacija = ?, - nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, napomena = ? + nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, marza = ?, napomena = ? WHERE id = ?`, a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin, a.Lokacija, - a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.ID, + a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Marza, a.Napomena, a.ID, ) if err != nil { return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err) @@ -147,6 +158,16 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { return nil } +// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe). +func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE artikli SET nabavna_cena = ?, prodajna_cena = ? WHERE id = ?", nabavna, prodajna, id) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.AzurirajCene: %w", err) + } + return nil +} + // PremestiKategoriju menja samo kategoriju artikla (premeštanje u drugu kategoriju). // kategorijaID može biti nil — tada artikal ostaje bez kategorije. func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error { diff --git a/internal/db/sqlite/dobavljac.go b/internal/db/sqlite/dobavljac.go index b3f96d4..48cebfa 100644 --- a/internal/db/sqlite/dobavljac.go +++ b/internal/db/sqlite/dobavljac.go @@ -21,7 +21,7 @@ func NoviDobavljacRepo(db *sql.DB) *DobavljacRepo { // Lista vraća listu dobavljača sa opcionom pretragom po nazivu func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dobavljac, error) { upit := ` - SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa + SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa FROM dobavljaci WHERE 1=1` @@ -43,9 +43,9 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob var rezultat []model.Dobavljac for redovi.Next() { var d model.Dobavljac - var kontaktOsoba, telefon, email, napomena sql.NullString + var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString err := redovi.Scan( - &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, + &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: DobavljacRepo.Lista: scan: %w", err) @@ -53,6 +53,8 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob d.KontaktOsoba = kontaktOsoba.String d.Telefon = telefon.String d.Email = email.String + d.PIB = pib.String + d.Mesto = mesto.String d.Napomena = napomena.String rezultat = append(rezultat, d) } @@ -63,12 +65,12 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob // DohvatiID vraća jednog dobavljača po ID-u func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavljac, error) { var d model.Dobavljac - var kontaktOsoba, telefon, email, napomena sql.NullString + var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa + SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa FROM dobavljaci WHERE id = ?`, id).Scan( - &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, + &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: DobavljacRepo.DohvatiID: %w", err) @@ -77,6 +79,8 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj d.KontaktOsoba = kontaktOsoba.String d.Telefon = telefon.String d.Email = email.String + d.PIB = pib.String + d.Mesto = mesto.String d.Napomena = napomena.String return &d, nil @@ -85,10 +89,10 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj // Kreiraj dodaje novog dobavljača u bazu func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, error) { rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, napomena) - VALUES (?, ?, ?, ?, ?)`, + INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, pib, mesto, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?)`, d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), - nullString(d.Email), nullString(d.Napomena), + nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena), ) if err != nil { return 0, fmt.Errorf("ntech: DobavljacRepo.Kreiraj: %w", err) @@ -106,10 +110,10 @@ func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, func (r *DobavljacRepo) Izmeni(ctx context.Context, d *model.Dobavljac) error { _, err := r.db.ExecContext(ctx, ` UPDATE dobavljaci SET - naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, napomena = ? + naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, pib = ?, mesto = ?, napomena = ? WHERE id = ?`, d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), - nullString(d.Email), nullString(d.Napomena), d.ID, + nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena), d.ID, ) if err != nil { return fmt.Errorf("ntech: DobavljacRepo.Izmeni: %w", err) @@ -127,4 +131,3 @@ func (r *DobavljacRepo) Obrisi(ctx context.Context, id int64) error { return nil } - diff --git a/internal/db/sqlite/kategorija.go b/internal/db/sqlite/kategorija.go index 3a83ce6..928169c 100644 --- a/internal/db/sqlite/kategorija.go +++ b/internal/db/sqlite/kategorija.go @@ -20,7 +20,7 @@ func NovaKategorijaRepo(db *sql.DB) *KategorijaRepo { // Lista vraća sve kategorije func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) { - redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis FROM kategorije ORDER BY naziv ASC") + redovi, err := r.db.QueryContext(ctx, "SELECT id, naziv, opis, marza FROM kategorije ORDER BY naziv ASC") if err != nil { return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: %w", err) } @@ -30,12 +30,16 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) for redovi.Next() { var k model.Kategorija var opis sql.NullString - if err := redovi.Scan(&k.ID, &k.Naziv, &opis); err != nil { + var marza sql.NullFloat64 + if err := redovi.Scan(&k.ID, &k.Naziv, &opis, &marza); err != nil { return nil, fmt.Errorf("ntech: KategorijaRepo.Lista: scan: %w", err) } if opis.Valid { k.Opis = opis.String } + if marza.Valid { + k.Marza = &marza.Float64 + } rezultat = append(rezultat, k) } @@ -45,8 +49,8 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error) // Kreiraj dodaje novu kategoriju func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) { rezultat, err := r.db.ExecContext(ctx, - "INSERT INTO kategorije (naziv, opis) VALUES (?, ?)", - k.Naziv, k.Opis, + "INSERT INTO kategorije (naziv, opis, marza) VALUES (?, ?, ?)", + k.Naziv, k.Opis, k.Marza, ) if err != nil { return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err) @@ -59,3 +63,35 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6 return id, nil } + +// DohvatiID vraća jednu kategoriju po ID-u +func (r *KategorijaRepo) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) { + var k model.Kategorija + var opis sql.NullString + var marza sql.NullFloat64 + err := r.db.QueryRowContext(ctx, + "SELECT id, naziv, opis, marza FROM kategorije WHERE id = ?", id). + Scan(&k.ID, &k.Naziv, &opis, &marza) + if err != nil { + return nil, fmt.Errorf("ntech: KategorijaRepo.DohvatiID: %w", err) + } + if opis.Valid { + k.Opis = opis.String + } + if marza.Valid { + k.Marza = &marza.Float64 + } + return &k, nil +} + +// Izmeni ažurira naziv, opis i maržu postojeće kategorije +func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error { + _, err := r.db.ExecContext(ctx, + "UPDATE kategorije SET naziv = ?, opis = ?, marza = ? WHERE id = ?", + k.Naziv, k.Opis, k.Marza, k.ID, + ) + if err != nil { + return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 3dc8c2d..88e8b61 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -21,7 +21,7 @@ func NoviKlijentRepo(db *sql.DB) *KlijentRepo { // Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) { upit := ` - SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa + SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa FROM klijenti WHERE 1=1` @@ -44,9 +44,9 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije var rezultat []model.Klijent for redovi.Next() { var k model.Klijent - var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString + var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString err := redovi.Scan( - &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, + &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err) @@ -58,6 +58,7 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije k.PIB = pib.String k.Telefon = telefon.String k.Email = email.String + k.Mesto = mesto.String k.Napomena = napomena.String rezultat = append(rezultat, k) } @@ -68,12 +69,12 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije // DohvatiID vraća jednog klijenta po ID-u func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) { var k model.Klijent - var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString + var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa + SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa FROM klijenti WHERE id = ?`, id).Scan( - &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, + &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err) @@ -86,6 +87,7 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, k.PIB = pib.String k.Telefon = telefon.String k.Email = email.String + k.Mesto = mesto.String k.Napomena = napomena.String return &k, nil @@ -97,11 +99,11 @@ func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, err k.Tip = "fizicko" } rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), - nullString(k.Email), nullString(k.Napomena), + nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena), ) if err != nil { return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err) @@ -123,11 +125,11 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error { _, err := r.db.ExecContext(ctx, ` UPDATE klijenti SET tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?, - pib = ?, telefon = ?, email = ?, napomena = ? + pib = ?, telefon = ?, email = ?, mesto = ?, napomena = ? WHERE id = ?`, k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG), nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon), - nullString(k.Email), nullString(k.Napomena), k.ID, + nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena), k.ID, ) if err != nil { return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err) diff --git a/internal/db/sqlite/nabavka.go b/internal/db/sqlite/nabavka.go index 2e3b735..8d68649 100644 --- a/internal/db/sqlite/nabavka.go +++ b/internal/db/sqlite/nabavka.go @@ -22,7 +22,7 @@ func NoviNabavkaRepo(db *sql.DB) *NabavkaRepo { func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error) { redovi, err := r.db.QueryContext(ctx, ` SELECT - n.id, n.dobavljac_id, n.napomena, n.ukupno, n.datum, + n.id, n.dobavljac_id, n.napomena, n.ukupno, n.metod_raspodele, n.datum, COALESCE(d.naziv, '') AS dobavljac_naziv FROM nabavke n LEFT JOIN dobavljaci d ON n.dobavljac_id = d.id @@ -36,10 +36,10 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err for redovi.Next() { var n model.NabavkaSaDetaljem var dobavljacID sql.NullInt64 - var napomena sql.NullString + var napomena, metod sql.NullString err := redovi.Scan( - &n.ID, &dobavljacID, &napomena, &n.Ukupno, &n.Datum, + &n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum, &n.DobavljacNaziv, ) if err != nil { @@ -50,6 +50,7 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err n.DobavljacID = &dobavljacID.Int64 } n.Napomena = napomena.String + n.MetodRaspodele = metod.String rezultat = append(rezultat, n) } @@ -61,12 +62,12 @@ func (r *NabavkaRepo) Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, err func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) { var n model.Nabavka var dobavljacID sql.NullInt64 - var napomena sql.NullString + var napomena, metod sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, dobavljac_id, napomena, ukupno, datum + SELECT id, dobavljac_id, napomena, ukupno, metod_raspodele, datum FROM nabavke WHERE id = ?`, id).Scan( - &n.ID, &dobavljacID, &napomena, &n.Ukupno, &n.Datum, + &n.ID, &dobavljacID, &napomena, &n.Ukupno, &metod, &n.Datum, ) if err != nil { return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiID: %w", err) @@ -76,6 +77,7 @@ func (r *NabavkaRepo) DohvatiID(ctx context.Context, id int64) (*model.Nabavka, n.DobavljacID = &dobavljacID.Int64 } n.Napomena = napomena.String + n.MetodRaspodele = metod.String return &n, nil } @@ -113,8 +115,33 @@ func (r *NabavkaRepo) DohvatiStavke(ctx context.Context, nabavkaID int64) ([]mod return rezultat, nil } -// Kreiraj upisuje novu nabavku sa svim stavkama u jednoj transakciji i ažurira stanje magacina -func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke) (int64, error) { +// DohvatiTroskove vraća sve zavisne troškove jedne nabavke +func (r *NabavkaRepo) DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error) { + redovi, err := r.db.QueryContext(ctx, ` + SELECT id, nabavka_id, naziv, iznos + FROM nabavka_troskovi + WHERE nabavka_id = ? + ORDER BY id ASC`, nabavkaID) + if err != nil { + return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiTroskove: %w", err) + } + defer redovi.Close() + + var rezultat []model.NabavkaTrosak + for redovi.Next() { + var t model.NabavkaTrosak + if err := redovi.Scan(&t.ID, &t.NabavkaID, &t.Naziv, &t.Iznos); err != nil { + return nil, fmt.Errorf("ntech: NabavkaRepo.DohvatiTroskove: scan: %w", err) + } + rezultat = append(rezultat, t) + } + + return rezultat, nil +} + +// Kreiraj upisuje novu nabavku sa svim stavkama i zavisnim troškovima u jednoj +// transakciji i ažurira stanje magacina +func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: begin: %w", err) @@ -130,9 +157,9 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo // upisujemo zaglavlje nabavke rezultat, err := tx.ExecContext(ctx, ` - INSERT INTO nabavke (dobavljac_id, napomena, ukupno) - VALUES (?, ?, ?)`, - nullInt64(n.DobavljacID), nullString(n.Napomena), ukupno, + INSERT INTO nabavke (dobavljac_id, napomena, ukupno, metod_raspodele) + VALUES (?, ?, ?, ?)`, + nullInt64(n.DobavljacID), nullString(n.Napomena), ukupno, nullString(n.MetodRaspodele), ) if err != nil { return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert nabavka: %w", err) @@ -143,6 +170,18 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: last insert id: %w", err) } + // upisujemo zavisne troškove (ako ih ima) + for _, t := range troskovi { + _, err := tx.ExecContext(ctx, ` + INSERT INTO nabavka_troskovi (nabavka_id, naziv, iznos) + VALUES (?, ?, ?)`, + nabavkaID, t.Naziv, t.Iznos, + ) + if err != nil { + return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert trošak: %w", err) + } + } + // upisujemo svaku stavku i ažuriramo stanje artikla u magacinu for _, s := range stavke { _, err := tx.ExecContext(ctx, ` diff --git a/internal/db/sqlite/nivelacija.go b/internal/db/sqlite/nivelacija.go new file mode 100644 index 0000000..da0dedd --- /dev/null +++ b/internal/db/sqlite/nivelacija.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "ntech/internal/model" +) + +// NivelacijaRepo je SQLite implementacija NivelacijaRepository interfejsa +type NivelacijaRepo struct { + db *sql.DB +} + +// NoviNivelacijaRepo kreira novi NivelacijaRepo +func NoviNivelacijaRepo(db *sql.DB) *NivelacijaRepo { + return &NivelacijaRepo{db: db} +} + +// ErrArtikalNePostoji se vraća kada se menja cena nepostojećeg artikla +var ErrArtikalNePostoji = errors.New("artikal ne postoji") + +// PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis. +// Stara cena se čita iz baze unutar transakcije; izvor je "rucno". +func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: begin: %w", err) + } + defer tx.Rollback() + + var stara float64 + err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrArtikalNePostoji + } + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err) + } + + if _, err = tx.ExecContext(ctx, "UPDATE artikli SET prodajna_cena = ? WHERE id = ?", novaCena, artikalID); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: update cene: %w", err) + } + + sada := time.Now() + rez, err := tx.ExecContext(ctx, ` + INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum) + VALUES (?, ?, ?, ?, 'rucno', ?, ?)`, + artikalID, stara, novaCena, razlog, izvorIDArg(korisnikID), sada) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: upis nivelacije: %w", err) + } + id, _ := rez.LastInsertId() + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: commit: %w", err) + } + + return &model.Nivelacija{ + ID: id, ArtikalID: artikalID, StaraCena: stara, NovaCena: novaCena, + Razlog: razlog, Izvor: "rucno", KorisnikID: korisnikID, Datum: sada, + }, nil +} + +// Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla). +func (r *NivelacijaRepo) Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error) { + izvor := n.Izvor + if izvor == "" { + izvor = "rucno" + } + datum := n.Datum + if datum.IsZero() { + datum = time.Now() + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + n.ArtikalID, n.StaraCena, n.NovaCena, n.Razlog, izvor, izvorIDArg(n.KorisnikID), datum) + if err != nil { + return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice. +func (r *NivelacijaRepo) Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error) { + upit := nivelacijaSelect + " WHERE 1=1" + args := []any{} + if !od.IsZero() { + upit += " AND n.datum >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND n.datum <= ?" + args = append(args, do) + } + upit += " ORDER BY n.datum DESC, n.id DESC" + return r.upitVisestruko(ctx, upit, args...) +} + +// ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo). +func (r *NivelacijaRepo) ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error) { + upit := nivelacijaSelect + " WHERE n.artikal_id = ? ORDER BY n.datum DESC, n.id DESC" + return r.upitVisestruko(ctx, upit, artikalID) +} + +// nivelacijaSelect je zajednički SELECT sa JOIN-ovima na artikle i korisnike (za prikaz). +const nivelacijaSelect = ` + SELECT n.id, n.artikal_id, COALESCE(a.naziv, ''), n.stara_cena, n.nova_cena, + COALESCE(n.razlog, ''), n.izvor, n.korisnik_id, COALESCE(k.korisnicko_ime, ''), + n.datum, n.datum_unosa + FROM nivelacije n + LEFT JOIN artikli a ON a.id = n.artikal_id + LEFT JOIN korisnici k ON k.id = n.korisnik_id` + +// upitVisestruko izvršava SELECT i skenira sve redove u listu nivelacija. +func (r *NivelacijaRepo) upitVisestruko(ctx context.Context, upit string, args ...any) ([]model.Nivelacija, error) { + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err) + } + defer redovi.Close() + + var rezultat []model.Nivelacija + for redovi.Next() { + var n model.Nivelacija + var korisnikID sql.NullInt64 + if err := redovi.Scan( + &n.ID, &n.ArtikalID, &n.ArtikalNaziv, &n.StaraCena, &n.NovaCena, + &n.Razlog, &n.Izvor, &korisnikID, &n.KorisnikIme, &n.Datum, &n.DatumUnosa, + ); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: scan: %w", err) + } + if korisnikID.Valid { + n.KorisnikID = &korisnikID.Int64 + } + rezultat = append(rezultat, n) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err) + } + return rezultat, nil +} diff --git a/internal/db/sqlite/pdv_evidencija.go b/internal/db/sqlite/pdv_evidencija.go new file mode 100644 index 0000000..f6a7ace --- /dev/null +++ b/internal/db/sqlite/pdv_evidencija.go @@ -0,0 +1,294 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/model" +) + +// --- KIR: knjiga izdatih računa --- + +// PdvKirRepo je SQLite implementacija PdvKirRepository interfejsa +type PdvKirRepo struct { + db *sql.DB +} + +// NoviPdvKirRepo kreira novi PdvKirRepo +func NoviPdvKirRepo(db *sql.DB) *PdvKirRepo { + return &PdvKirRepo{db: db} +} + +// Lista vraća zapise KIR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice +func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) { + upit := ` + SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kir WHERE 1=1` + args := []any{} + if !od.IsZero() { + upit += " AND datum_prometa >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND datum_prometa <= ?" + args = append(args, do) + } + upit += " ORDER BY datum_prometa ASC, id ASC" + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvKir + for redovi.Next() { + k, err := skenirajKir(redovi.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, k) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jedan zapis KIR-a po identifikatoru +func (r *PdvKirRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) { + red := r.db.QueryRowContext(ctx, ` + SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kir WHERE id = ?`, id) + k, err := skenirajKir(red.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiID: %w", err) + } + return &k, nil +} + +// skenirajKir čita jedan red KIR-a; izvor_id je nullable pa ide preko sql.NullInt64 +func skenirajKir(scan func(...any) error) (model.PdvKir, error) { + var k model.PdvKir + var izvorID sql.NullInt64 + if err := scan( + &k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &k.BrojDokumenta, + &k.KupacNaziv, &k.KupacPib, &k.KupacMesto, + &k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna, + &k.OslobodenSaPravom, &k.OslobodenBezPrava, &k.Ukupno, + &k.Napomena, &k.Izvor, &izvorID, &k.DatumUnosa, + ); err != nil { + return model.PdvKir{}, err + } + if izvorID.Valid { + k.IzvorID = &izvorID.Int64 + } + return k, nil +} + +// Kreiraj dodaje novi zapis u KIR i vraća njegov id +func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) { + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_kir ( + datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, kupac_pib, kupac_mesto, + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta, + k.KupacNaziv, k.KupacPib, k.KupacMesto, + k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, + k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena, + izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID)) + if err != nil { + return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Obrisi briše zapis KIR-a +func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: PdvKirRepo.Obrisi: %w", err) + } + return nil +} + +// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje) +func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { + return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err) + } + return nil +} + +// izvorIliRucno vraća izvor ili podrazumevano „rucno" (izvor kolona je NOT NULL) +func izvorIliRucno(izvor string) string { + if izvor == "" { + return "rucno" + } + return izvor +} + +// izvorIDArg pretvara *int64 u argument za upit (nil → NULL) +func izvorIDArg(id *int64) any { + if id == nil { + return nil + } + return *id +} + +// --- KPR: knjiga primljenih računa --- + +// PdvKprRepo je SQLite implementacija PdvKprRepository interfejsa +type PdvKprRepo struct { + db *sql.DB +} + +// NoviPdvKprRepo kreira novi PdvKprRepo +func NoviPdvKprRepo(db *sql.DB) *PdvKprRepo { + return &PdvKprRepo{db: db} +} + +// Lista vraća zapise KPR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice +func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) { + upit := ` + SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa + FROM pdv_kpr WHERE 1=1` + args := []any{} + if !od.IsZero() { + upit += " AND datum_prometa >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND datum_prometa <= ?" + args = append(args, do) + } + upit += " ORDER BY datum_prometa ASC, id ASC" + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvKpr + for redovi.Next() { + k, err := skenirajKpr(redovi.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, k) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jedan zapis KPR-a po identifikatoru +func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) { + red := r.db.QueryRowContext(ctx, ` + SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa + FROM pdv_kpr WHERE id = ?`, id) + k, err := skenirajKpr(red.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiID: %w", err) + } + return &k, nil +} + +// skenirajKpr čita jedan red KPR-a; datum_placanja je nullable pa ide preko sql.NullTime +func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) { + var k model.PdvKpr + var datumPlacanja sql.NullTime + var izvorID sql.NullInt64 + var uvoz int64 + if err := scan( + &k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &datumPlacanja, &k.BrojDokumenta, + &k.DobavljacNaziv, &k.DobavljacPib, &k.DobavljacMesto, + &k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna, + &k.PdvBezOdbitka, &k.OslobodenNabavka, &k.Ukupno, + &k.Napomena, &k.Izvor, &izvorID, &uvoz, &k.DatumUnosa, + ); err != nil { + return model.PdvKpr{}, err + } + if datumPlacanja.Valid { + k.DatumPlacanja = &datumPlacanja.Time + } + if izvorID.Valid { + k.IzvorID = &izvorID.Int64 + } + k.Uvoz = uvoz != 0 + return k, nil +} + +// Kreiraj dodaje novi zapis u KPR i vraća njegov id +func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) { + var datumPlacanja any + if k.DatumPlacanja != nil { + datumPlacanja = *k.DatumPlacanja + } + uvoz := 0 + if k.Uvoz { + uvoz = 1 + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_kpr ( + datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, dobavljac_pib, dobavljac_mesto, + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta, + k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto, + k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, + k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, + izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID), uvoz) + if err != nil { + return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Obrisi briše zapis KPR-a +func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: PdvKprRepo.Obrisi: %w", err) + } + return nil +} + +// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke) +func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { + return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/pdv_evidencija_test.go b/internal/db/sqlite/pdv_evidencija_test.go new file mode 100644 index 0000000..fff0c75 --- /dev/null +++ b/internal/db/sqlite/pdv_evidencija_test.go @@ -0,0 +1,173 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "ntech/internal/model" +) + +// istiDan poredi datume po godini/mesecu/danu (izbegava razlike u vremenskoj zoni/času) +func istiDan(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +func TestPdvKirRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvKirRepo(db) + ctx := context.Background() + + dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + dk := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) + + id, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dk, BrojDokumenta: "R-1", + KupacNaziv: "Kupac doo", KupacPib: "123456789", KupacMesto: "Niš", + OsnovicaOpsta: 100, PdvOpsta: 20, Ukupno: 120, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + + t.Run("datum i iznosi round-trip", func(t *testing.T) { + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if !istiDan(k.DatumPrometa, dp) { + t.Errorf("datum_prometa = %v, očekivano %v", k.DatumPrometa, dp) + } + if !istiDan(k.DatumKnjizenja, dk) { + t.Errorf("datum_knjizenja = %v, očekivano %v", k.DatumKnjizenja, dk) + } + if k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" || k.KupacMesto != "Niš" { + t.Errorf("kupac podaci ne odgovaraju: %+v", k) + } + if k.OsnovicaOpsta != 100 || k.PdvOpsta != 20 || k.Ukupno != 120 { + t.Errorf("iznosi ne odgovaraju: %+v", k) + } + }) + + t.Run("filter perioda", func(t *testing.T) { + uPeriodu, err := repo.Lista(ctx, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Lista u periodu: %v", err) + } + if len(uPeriodu) != 1 { + t.Errorf("u junu očekivano 1 zapis, dobijeno %d", len(uPeriodu)) + } + vanPerioda, err := repo.Lista(ctx, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Lista van perioda: %v", err) + } + if len(vanPerioda) != 0 { + t.Errorf("u julu očekivano 0 zapisa, dobijeno %d", len(vanPerioda)) + } + }) + + t.Run("brisanje", func(t *testing.T) { + if err := repo.Obrisi(ctx, id); err != nil { + t.Fatalf("Obrisi: %v", err) + } + sve, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(sve) != 0 { + t.Errorf("posle brisanja očekivano 0, dobijeno %d", len(sve)) + } + }) +} + +func TestPdvKprRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvKprRepo(db) + ctx := context.Background() + + dp := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + dpl := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + + t.Run("sa datumom plaćanja", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvKpr{ + DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: &dpl, BrojDokumenta: "U-1", + DobavljacNaziv: "Dobavljač doo", OsnovicaOpsta: 200, PdvOpsta: 40, Ukupno: 240, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if k.DatumPlacanja == nil { + t.Fatalf("datum_placanja je nil, očekivan %v", dpl) + } + if !istiDan(*k.DatumPlacanja, dpl) { + t.Errorf("datum_placanja = %v, očekivano %v", *k.DatumPlacanja, dpl) + } + if k.OsnovicaOpsta != 200 || k.PdvOpsta != 40 { + t.Errorf("iznosi ne odgovaraju: %+v", k) + } + }) + + t.Run("bez datuma plaćanja (NULL)", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvKpr{ + DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: nil, BrojDokumenta: "U-2", + DobavljacNaziv: "Dobavljač 2", OsnovicaPosebna: 50, PdvPosebna: 5, Ukupno: 55, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if k.DatumPlacanja != nil { + t.Errorf("datum_placanja = %v, očekivan nil", *k.DatumPlacanja) + } + }) +} + +func TestPdvKirIzvor(t *testing.T) { + db := testDB(t) + repo := NoviPdvKirRepo(db) + ctx := context.Background() + dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + izvorID := int64(7) + + // ručni zapis (bez izvora) → default „rucno", izvor_id NULL + if _, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "R-1", KupacNaziv: "Ručni", + }); err != nil { + t.Fatalf("Kreiraj ručni: %v", err) + } + // auto zapis iz prodaje + if _, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "P-1", KupacNaziv: "Iz prodaje", + Izvor: "prodaja", IzvorID: &izvorID, + }); err != nil { + t.Fatalf("Kreiraj auto: %v", err) + } + + sve, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(sve) != 2 { + t.Fatalf("očekivano 2 zapisa, dobijeno %d", len(sve)) + } + for _, z := range sve { + if z.BrojDokumenta == "R-1" && (z.Izvor != "rucno" || z.IzvorID != nil) { + t.Errorf("ručni zapis: izvor=%q izvor_id=%v, očekivano rucno/nil", z.Izvor, z.IzvorID) + } + if z.BrojDokumenta == "P-1" && (z.Izvor != "prodaja" || z.IzvorID == nil || *z.IzvorID != 7) { + t.Errorf("auto zapis: izvor=%q izvor_id=%v, očekivano prodaja/7", z.Izvor, z.IzvorID) + } + } + + // ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje + if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil { + t.Fatalf("ObrisiPoIzvoru: %v", err) + } + preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" { + t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali)) + } +} diff --git a/internal/db/sqlite/pdv_stopa.go b/internal/db/sqlite/pdv_stopa.go new file mode 100644 index 0000000..d2cfe38 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa.go @@ -0,0 +1,101 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "ntech/internal/model" +) + +// PdvStopaRepo je SQLite implementacija PdvStopaRepository interfejsa +type PdvStopaRepo struct { + db *sql.DB +} + +// NoviPdvStopaRepo kreira novi PdvStopaRepo +func NoviPdvStopaRepo(db *sql.DB) *PdvStopaRepo { + return &PdvStopaRepo{db: db} +} + +// Lista vraća stope iz šifarnika; ako je samoAktivne true, izostavlja arhivirane +func (r *PdvStopaRepo) Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) { + upit := ` + SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa + FROM pdv_stope + WHERE 1=1` + if samoAktivne { + upit += " AND aktivna = 1" + } + upit += " ORDER BY redosled ASC, stopa DESC" + + redovi, err := r.db.QueryContext(ctx, upit) + if err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvStopa + for redovi.Next() { + var s model.PdvStopa + if err := redovi.Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa); err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, s) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jednu stopu po identifikatoru +func (r *PdvStopaRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) { + var s model.PdvStopa + err := r.db.QueryRowContext(ctx, ` + SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa + FROM pdv_stope WHERE id = ?`, id). + Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa) + if err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.DohvatiID: %w", err) + } + return &s, nil +} + +// Kreiraj dodaje novu stopu i vraća njen id +func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) { + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_stope (naziv, stopa, oznaka, aktivna, redosled) + VALUES (?, ?, ?, ?, ?)`, + s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled) + if err != nil { + return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Izmeni menja podatke postojeće stope (osim datuma unosa) +func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE pdv_stope + SET naziv = ?, stopa = ?, oznaka = ?, aktivna = ?, redosled = ? + WHERE id = ?`, + s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled, s.ID) + if err != nil { + return fmt.Errorf("ntech: PdvStopaRepo.Izmeni: %w", err) + } + return nil +} + +// PostaviAktivnu arhivira (false) ili vraća u upotrebu (true) stopu, bez brisanja +func (r *PdvStopaRepo) PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error { + _, err := r.db.ExecContext(ctx, "UPDATE pdv_stope SET aktivna = ? WHERE id = ?", aktivna, id) + if err != nil { + return fmt.Errorf("ntech: PdvStopaRepo.PostaviAktivnu: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/pdv_stopa_test.go b/internal/db/sqlite/pdv_stopa_test.go new file mode 100644 index 0000000..22ea8c4 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa_test.go @@ -0,0 +1,97 @@ +package sqlite + +import ( + "context" + "testing" + + "ntech/internal/model" +) + +func TestPdvStopaRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvStopaRepo(db) + ctx := context.Background() + + // migracija 040 seeduje 3 stope; redosled ASC → opšta(20), posebna(10), oslobođeno(0) + t.Run("seed iz migracije", func(t *testing.T) { + stope, err := repo.Lista(ctx, true) + if err != nil { + t.Fatalf("Lista: %v", err) + } + if len(stope) != 3 { + t.Fatalf("očekivano 3 seed stope, dobijeno %d", len(stope)) + } + if stope[0].Stopa != 20 || stope[0].Oznaka != "opsta" { + t.Errorf("prva stopa = %v %q, očekivano 20 opsta", stope[0].Stopa, stope[0].Oznaka) + } + if stope[2].Stopa != 0 || stope[2].Oznaka != "oslobodjeno" { + t.Errorf("treća stopa = %v %q, očekivano 0 oslobodjeno", stope[2].Stopa, stope[2].Oznaka) + } + }) + + var novaID int64 + + t.Run("kreiraj i dohvati", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvStopa{ + Naziv: "Test stopa", Stopa: 11.5, Oznaka: "posebna", Aktivna: true, Redosled: 9, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + novaID = id + s, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if s.Naziv != "Test stopa" || s.Stopa != 11.5 || s.Oznaka != "posebna" || !s.Aktivna || s.Redosled != 9 { + t.Errorf("dohvaćeno %+v, ne poklapa se sa unetim", s) + } + }) + + t.Run("izmeni", func(t *testing.T) { + err := repo.Izmeni(ctx, &model.PdvStopa{ + ID: novaID, Naziv: "Izmenjena", Stopa: 12, Oznaka: "opsta", Aktivna: true, Redosled: 5, + }) + if err != nil { + t.Fatalf("Izmeni: %v", err) + } + s, err := repo.DohvatiID(ctx, novaID) + if err != nil { + t.Fatalf("DohvatiID posle izmene: %v", err) + } + if s.Naziv != "Izmenjena" || s.Stopa != 12 || s.Oznaka != "opsta" || s.Redosled != 5 { + t.Errorf("posle izmene %+v, ne poklapa se sa izmenom", s) + } + }) + + t.Run("arhiviranje izostavlja iz aktivnih ali zadržava u punoj listi", func(t *testing.T) { + if err := repo.PostaviAktivnu(ctx, novaID, false); err != nil { + t.Fatalf("PostaviAktivnu: %v", err) + } + aktivne, err := repo.Lista(ctx, true) + if err != nil { + t.Fatalf("Lista aktivne: %v", err) + } + for _, s := range aktivne { + if s.ID == novaID { + t.Errorf("arhivirana stopa %d ne sme biti među aktivnima", novaID) + } + } + sve, err := repo.Lista(ctx, false) + if err != nil { + t.Fatalf("Lista sve: %v", err) + } + nadjena := false + for _, s := range sve { + if s.ID == novaID { + nadjena = true + if s.Aktivna { + t.Errorf("stopa %d treba da bude aktivna=false posle arhiviranja", novaID) + } + } + } + if !nadjena { + t.Errorf("arhivirana stopa %d mora ostati u punoj listi (ne briše se)", novaID) + } + }) +} diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index 36ba23f..9d036d7 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strings" @@ -85,7 +86,15 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { } dobavljac, greska := parseFormuDobavljaca(r) + jeAjax := r.Header.Get("X-Requested-With") == "fetch" if greska != "" { + // fetch zahtev (iz modala u nabavci) dobija JSON grešku + if jeAjax { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"greska":%q}`, greska) + return + } podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "dobavljaci" @@ -99,11 +108,19 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { return } - if _, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac); err != nil { + id, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac) + if err != nil { http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError) return } + // fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog dobavljača + if jeAjax { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%d,"naziv":%q}`, id, dobavljac.Naziv) + return + } + http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther) } @@ -214,6 +231,8 @@ func parseFormuDobavljaca(r *http.Request) (model.Dobavljac, string) { KontaktOsoba: strings.TrimSpace(r.FormValue("kontakt_osoba")), Telefon: strings.TrimSpace(r.FormValue("telefon")), Email: email, + PIB: strings.TrimSpace(r.FormValue("pib")), + Mesto: strings.TrimSpace(r.FormValue("mesto")), Napomena: strings.TrimSpace(r.FormValue("napomena")), }, "" } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fa7dd79..b506a4d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,12 +1,14 @@ package handler import ( + "context" "database/sql" "html/template" "io/fs" "net/http" "sync" + "ntech/internal/config" "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" @@ -34,6 +36,10 @@ type Handler struct { PokusajiRepo db.PokusajiPrijaveRepository LoginIstorijsaRepo db.LoginIstorijsaRepository DozvoleRepo db.DozvoleRepository + PdvStopeRepo db.PdvStopaRepository + PdvKirRepo db.PdvKirRepository + PdvKprRepo db.PdvKprRepository + NivelacijaRepo db.NivelacijaRepository Verzija string AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template @@ -93,6 +99,10 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()), + PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza), + PdvKirRepo: sqlite.NoviPdvKirRepo(baza), + PdvKprRepo: sqlite.NoviPdvKprRepo(baza), + NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza), } } @@ -118,6 +128,20 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) + h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB) + h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB) + h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB) + h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB) +} + +// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu. +// Koristi se pri automatskom punjenju KIR/KPR iz prodaje/nabavke. +func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool { + podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) + if err != nil { + return false + } + return config.ModulUkljucen(podesavanja, modul) } // zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. @@ -159,6 +183,8 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s ps.CsrfToken = middleware.CsrfToken(r.Context()) ps.AssetV = h.AssetV ps.Flash = middleware.GetFlash(r, h.DB) + // uključeni zakonski moduli prema profilu firme — šabloni ih koriste za uslovni meni + ps.Moduli = config.SviModuli(podesavanja) // logika pozadine: // - lična pozadina → uvek se prikazuje i forsira tamnu temu, bez obzira na KoristiLokalnuTemu diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index 81630ca..4e1a1ab 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "strings" "ntech/internal/db/sqlite" "ntech/internal/model" @@ -64,6 +65,7 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { k := &model.Kategorija{ Naziv: naziv, Opis: r.FormValue("opis"), + Marza: parsirajMarzu(r.FormValue("marza")), } if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil { @@ -74,6 +76,56 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/magacin/kategorije?sacuvano=1", http.StatusSeeOther) } +// IzmeniKategoriju prima POST i ažurira naziv, opis i maržu postojeće kategorije +func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "kategorija.izmeni"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID kategorije", http.StatusBadRequest) + return + } + + naziv := r.FormValue("naziv") + if naziv == "" { + http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther) + return + } + + k := &model.Kategorija{ + ID: id, + Naziv: naziv, + Opis: r.FormValue("opis"), + Marza: parsirajMarzu(r.FormValue("marza")), + } + + if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil { + http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/magacin/kategorije?sacuvano=1", http.StatusSeeOther) +} + +// parsirajMarzu pretvara tekst iz forme u *float64; prazno/neispravno → nil (NULL u bazi) +func parsirajMarzu(s string) *float64 { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil || v < 0 { + return nil + } + return &v +} + // ObrisiKategoriju briše kategoriju po ID-u func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "kategorija.obrisi"); !ok { diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 739ab33..43b72a9 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -25,6 +25,11 @@ var saSidebar = []string{ "magacin", "magacin_forma", "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", + "pdv_stope", + "pdv_kir", "pdv_kir_forma", + "pdv_kpr", "pdv_kpr_forma", + "pdv_obracun", + "nivelacije", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 46d71f4..8651851 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -249,6 +249,7 @@ func parseFormuKlijenta(r *http.Request) (model.Klijent, string) { PIB: strings.TrimSpace(r.FormValue("pib")), Telefon: strings.TrimSpace(r.FormValue("telefon")), Email: email, + Mesto: strings.TrimSpace(r.FormValue("mesto")), Napomena: strings.TrimSpace(r.FormValue("napomena")), }, "" } diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 201f830..67ef383 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -2,6 +2,7 @@ package handler import ( "fmt" + "log/slog" "net/http" "strconv" @@ -182,12 +183,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { return } + // stara prodajna cena — za nivelacioni trag ako se promeni kroz izmenu + var staraCena float64 + if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil { + staraCena = stari.ProdajnaCena + } + artikal.ID = id if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) return } + // ako se prodajna cena promenila, automatski upiši nivelacioni zapis (izvor "izmena") + if razlika := artikal.ProdajnaCena - staraCena; razlika > 0.005 || razlika < -0.005 { + korisnikID := &k.ID + if _, e := h.NivelacijaRepo.Kreiraj(r.Context(), &model.Nivelacija{ + ArtikalID: id, + StaraCena: staraCena, + NovaCena: artikal.ProdajnaCena, + Izvor: "izmena", + KorisnikID: korisnikID, + }); e != nil { + slog.Error("auto-nivelacija pri izmeni artikla nije upisana", "artikal_id", id, "error", e) + } + } + http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther) } @@ -228,6 +249,15 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { artikal.ProdajnaCena = v } + // marža (%) je opciona; prazno polje ostaje NULL (artikal nasleđuje maržu kategorije/globalnu) + if m := r.FormValue("marza"); m != "" { + v, err := strconv.ParseFloat(m, 64) + if err != nil || v < 0 { + return artikal, "Marža mora biti pozitivan broj." + } + artikal.Marza = &v + } + if katID := r.FormValue("kategorija_id"); katID != "" { id, err := strconv.ParseInt(katID, 10, 64) if err == nil { diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index be106f0..9378571 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "html/template" + "log/slog" "net/http" "strconv" "strings" @@ -26,9 +27,11 @@ type PodaciNabavki struct { type PodaciFormeNabavke struct { model.PodaciStranice Artikli []model.ArtikalSaKategorijom - ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u +