From 290e5c085a502a4898d3e45f32ad567850e3c7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 19:27:26 +0200 Subject: [PATCH 01/26] =?UTF-8?q?feat(podesavanja):=20profil=20firme=20?= =?UTF-8?q?=E2=80=94=20pravni=20i=20poreski=20status=20(Faza=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodata kartica „Pravni i poreski status" na Podešavanja → Opšte: pravni oblik, režim rada, PDV obveznik, fiskalizacija. Čuva se u postojećoj key-value tabeli podesavanja (bez migracije). Fiskalizacija se zasivi i forsira na „Ne" u režimu „samo evidencija". --- internal/handler/podesavanja.go | 113 ++++++++-------- web/templates/stranice/podesavanja_opste.html | 121 ++++++++++++++++++ 2 files changed, 184 insertions(+), 50 deletions(-) diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index c8d79bd..1fca047 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -23,25 +23,30 @@ import ( // PodaciPodesavanja su podaci za stranicu podešavanja type PodaciPodesavanja struct { model.PodaciStranice - NazivFirme string - Podnazlov string - Adresa string - Telefon string - PIB string - LogoTip string - LogoPutanja string - Sacuvano bool - Verzija string - LogoGreska string - BackupVracen bool - Backupi []BackupInfo - BackupIntervalSati string - BackupBrojKopija string - LoginPozadina string - LoginPozadinaOpacity string - LoginPozadinaBlurPozadine string - LoginPozadinaBlurKartice string - LoginPozadinaZatamnjenjeKartice string + NazivFirme string + Podnazlov string + Adresa string + Telefon string + PIB string + LogoTip string + LogoPutanja string + // profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale + FirmaPravniOblik string + FirmaPdvObveznik string + FirmaFiskalizacija string + FirmaRezim string + Sacuvano bool + Verzija string + LogoGreska string + BackupVracen bool + Backupi []BackupInfo + BackupIntervalSati string + BackupBrojKopija string + LoginPozadina string + LoginPozadinaOpacity string + LoginPozadinaBlurPozadine string + LoginPozadinaBlurKartice string + LoginPozadinaZatamnjenjeKartice string } // BackupInfo opisuje jedan backup fajl @@ -69,19 +74,19 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) { ps.Stranica = "podesavanja" ps.NaslovStranice = "Podešavanja" podaci := PodaciPodesavanja{ - PodaciStranice: ps, - NazivFirme: podesavanja["naziv_firme"], - Podnazlov: podesavanja["podnazlov"], - Adresa: podesavanja["adresa"], - Telefon: podesavanja["telefon"], - PIB: podesavanja["pib"], - LogoTip: podesavanja["logo_tip"], - LogoPutanja: podesavanja["logo_putanja"], - Sacuvano: r.URL.Query().Get("sacuvano") == "1", - BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", - Verzija: h.Verzija, - LogoGreska: r.URL.Query().Get("logo_greska"), - Backupi: ucitajListuBackupa(), + PodaciStranice: ps, + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + Adresa: podesavanja["adresa"], + Telefon: podesavanja["telefon"], + PIB: podesavanja["pib"], + LogoTip: podesavanja["logo_tip"], + LogoPutanja: podesavanja["logo_putanja"], + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", + Verzija: h.Verzija, + LogoGreska: r.URL.Query().Get("logo_greska"), + Backupi: ucitajListuBackupa(), LoginPozadina: podesavanja["login_pozadina"], LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), @@ -234,6 +239,11 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { "telefon": r.FormValue("telefon"), "pib": r.FormValue("pib"), "logo_tip": r.FormValue("logo_tip"), + // profil firme (Faza 0) — radio dugmad uvek šalju vrednost, pa se uredno čuvaju + "firma_pravni_oblik": r.FormValue("firma_pravni_oblik"), + "firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"), + "firma_fiskalizacija": r.FormValue("firma_fiskalizacija"), + "firma_rezim": r.FormValue("firma_rezim"), } for kljuc, vrednost := range polja { @@ -566,10 +576,10 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req } for kljuc, vrednost := range map[string]string{ - "login_pozadina_blur_pozadine": blurPozadineStr, - "login_pozadina_blur_kartice": blurKarticeStr, - "login_pozadina_opacity": opacityStr, - "login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr, + "login_pozadina_blur_pozadine": blurPozadineStr, + "login_pozadina_blur_kartice": blurKarticeStr, + "login_pozadina_opacity": opacityStr, + "login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr, } { if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err) @@ -593,19 +603,23 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac ps.Stranica = "podesavanja" ps.NaslovStranice = naslov return PodaciPodesavanja{ - PodaciStranice: ps, - NazivFirme: podesavanja["naziv_firme"], - Podnazlov: podesavanja["podnazlov"], - Adresa: podesavanja["adresa"], - Telefon: podesavanja["telefon"], - PIB: podesavanja["pib"], - LogoTip: podesavanja["logo_tip"], - LogoPutanja: podesavanja["logo_putanja"], - Sacuvano: r.URL.Query().Get("sacuvano") == "1", - BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", - Verzija: h.Verzija, - LogoGreska: r.URL.Query().Get("logo_greska"), - Backupi: ucitajListuBackupa(), + PodaciStranice: ps, + NazivFirme: podesavanja["naziv_firme"], + Podnazlov: podesavanja["podnazlov"], + Adresa: podesavanja["adresa"], + Telefon: podesavanja["telefon"], + PIB: podesavanja["pib"], + LogoTip: podesavanja["logo_tip"], + LogoPutanja: podesavanja["logo_putanja"], + FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"), + FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"), + FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"), + FirmaRezim: vrednostIliDefault(podesavanja, "firma_rezim", "samo_evidencija"), + Sacuvano: r.URL.Query().Get("sacuvano") == "1", + BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno", + Verzija: h.Verzija, + LogoGreska: r.URL.Query().Get("logo_greska"), + Backupi: ucitajListuBackupa(), LoginPozadina: podesavanja["login_pozadina"], LoginPozadinaOpacity: vrednostIliDefault(podesavanja, "login_pozadina_opacity", "50"), LoginPozadinaBlurPozadine: vrednostIliDefault(podesavanja, "login_pozadina_blur_pozadine", "0"), @@ -657,4 +671,3 @@ func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) { podaci.Stranica = "podesavanja-sistem" h.renderujTemplate(w, "podesavanja_sistem", podaci) } - diff --git a/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html index 96480c8..adffe2c 100644 --- a/web/templates/stranice/podesavanja_opste.html +++ b/web/templates/stranice/podesavanja_opste.html @@ -117,6 +117,94 @@ + +
+ +
+
+ + Pravni i poreski status +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+
„Samo evidencija" gasi ceo zakonski sloj — program vodi servis i zalihe, ali ne izdaje fiskalne ni poreske dokumente.
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+
+ {{if .LogoGreska}} @@ -135,4 +223,37 @@ })(); {{end}} + + {{end}} From 3817ba4cbd20e2eb2c77a512fed997c03d25ba63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 19:38:54 +0200 Subject: [PATCH 02/26] =?UTF-8?q?feat(config):=20helper=20ModulUkljucen=20?= =?UTF-8?q?=E2=80=94=20uklju=C4=8Denost=20modula=20po=20profilu=20firme=20?= =?UTF-8?q?(Faza=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Čista funkcija izvodi iz profila firme (podesavanja) koji su zakonski moduli aktivni. Režim „samo evidencija" gasi sve. Pravila: pdv prema prekidaču obveznika, fiskalizacija zaseban prekidač, kpo za paušalce, dvojno za doo. Table-driven test sa 18 slučajeva. --- internal/config/moduli.go | 56 ++++++++++++++++++++++++++++ internal/config/moduli_test.go | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 internal/config/moduli.go create mode 100644 internal/config/moduli_test.go diff --git a/internal/config/moduli.go b/internal/config/moduli.go new file mode 100644 index 0000000..dcf5fcd --- /dev/null +++ b/internal/config/moduli.go @@ -0,0 +1,56 @@ +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 + } +} diff --git a/internal/config/moduli_test.go b/internal/config/moduli_test.go new file mode 100644 index 0000000..f90dae2 --- /dev/null +++ b/internal/config/moduli_test.go @@ -0,0 +1,68 @@ +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) + } + }) + } +} From 6d066f6704ee212a568f56651d9a4c38a41219c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 20:23:24 +0200 Subject: [PATCH 03/26] =?UTF-8?q?feat(moduli):=20mehanizam=20uklju=C4=8Den?= =?UTF-8?q?ih=20modula=20=E2=80=94=20Moduli=20mapa=20i=20RequireModul=20(F?= =?UTF-8?q?aza=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.SviModuli + PodaciStranice.Moduli (uslovni meni, analogno Dozvole) i middleware.RequireModul (zaštita ruta, analogno RequireDozvola). Sloj iznad RBAC-a: zahtev mora proći i „modul uključen" i „korisnik sme". Dopunjen test (TestSviModuli). Time je Faza 0 kompletna. --- internal/config/moduli.go | 11 +++++++++++ internal/config/moduli_test.go | 28 ++++++++++++++++++++++++++++ internal/handler/handler.go | 3 +++ internal/middleware/moduli.go | 27 +++++++++++++++++++++++++++ internal/model/stranica.go | 3 ++- 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 internal/middleware/moduli.go diff --git a/internal/config/moduli.go b/internal/config/moduli.go index dcf5fcd..db15eca 100644 --- a/internal/config/moduli.go +++ b/internal/config/moduli.go @@ -54,3 +54,14 @@ func ModulUkljucen(podesavanja map[string]string, modul string) bool { 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 index f90dae2..4f89f6c 100644 --- a/internal/config/moduli_test.go +++ b/internal/config/moduli_test.go @@ -66,3 +66,31 @@ func TestModulUkljucen(t *testing.T) { }) } } + +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/handler/handler.go b/internal/handler/handler.go index fa7dd79..565ce5f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "sync" + "ntech/internal/config" "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" @@ -159,6 +160,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/middleware/moduli.go b/internal/middleware/moduli.go new file mode 100644 index 0000000..6dacc7b --- /dev/null +++ b/internal/middleware/moduli.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "context" + "net/http" +) + +// RequireModul je chi middleware koji propušta zahtev samo ako je traženi zakonski +// modul uključen za firmu (prema profilu firme iz podešavanja). Ovo je sloj IZNAD +// RBAC-a: „da li firma uopšte koristi modul", nezavisno od „da li korisnik sme" +// (RequireDozvola). Zahtev mora proći oba sloja. +// +// Provera se prosleđuje kao funkcija (proveri) da paket middleware ne zavisi od +// config/sqlite — isti obrazac kao RequireDozvola. U praksi je to closure koja +// učita podešavanja i pozove config.ModulUkljucen. +func RequireModul(proveri func(ctx context.Context, modul string) bool, modul string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !proveri(r.Context(), modul) { + postaviFlashGresku(w, "Ovaj modul nije uključen za vašu firmu.") + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 02a707c..3474113 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -39,11 +39,12 @@ type PodaciStranice struct { LogoTip string // "sa_nazivom", "bez_naziva", "slika" LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika" Korisnik string - KorisnikIme string // korisničko ime prijavljenog korisnika + KorisnikIme string // korisničko ime prijavljenog korisnika KorisnikUloga string // uloga: "superadmin", "admin", "radnik" CsrfToken string // CSRF zaštitni token za forme AssetV string // verzija statičkih fajlova (cache-busting za CSS/JS) Dozvole map[string]bool // mapa akcija → dozvoljeno/nije + Moduli map[string]bool // mapa zakonskih modula → uključen za firmu (profil firme) Flash *FlashPoruka // jednokratna poruka nakon redirecta // app pozadina — popunjava se iz podešavanja za sve stranice AppPozadina string From 15995167b7ae06aec061f1819304eb07983e5838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 20:33:48 +0200 Subject: [PATCH 04/26] =?UTF-8?q?feat(pdv):=20=C5=A1ifarnik=20PDV=20stopa?= =?UTF-8?q?=20=E2=80=94=20migracija,=20model=20i=20repozitorijum=20(Faza?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabela pdv_stope (seed 20/10/0%), model PdvStopa, PdvStopaRepository + SQLite implementacija (arhiviranje umesto brisanja) i integracioni test (migracija + CRUD round-trip). --- internal/db/repository.go | 13 +++- internal/db/sqlite/pdv_stopa.go | 101 +++++++++++++++++++++++++++ internal/db/sqlite/pdv_stopa_test.go | 97 +++++++++++++++++++++++++ internal/handler/handler.go | 3 + internal/model/pdv_stopa.go | 15 ++++ migrations/040_pdv_stope.sql | 21 ++++++ 6 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 internal/db/sqlite/pdv_stopa.go create mode 100644 internal/db/sqlite/pdv_stopa_test.go create mode 100644 internal/model/pdv_stopa.go create mode 100644 migrations/040_pdv_stope.sql diff --git a/internal/db/repository.go b/internal/db/repository.go index ab4e689..b7ed66a 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -23,6 +23,15 @@ type KategorijaRepository interface { Kreiraj(ctx context.Context, k *model.Kategorija) (int64, 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 +} + // ArtikalFilter definiše parametre za filtriranje liste artikala type ArtikalFilter struct { Pretraga string @@ -117,8 +126,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/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/handler.go b/internal/handler/handler.go index 565ce5f..c0b96cc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -35,6 +35,7 @@ type Handler struct { PokusajiRepo db.PokusajiPrijaveRepository LoginIstorijsaRepo db.LoginIstorijsaRepository DozvoleRepo db.DozvoleRepository + PdvStopeRepo db.PdvStopaRepository Verzija string AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template @@ -94,6 +95,7 @@ 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), } } @@ -119,6 +121,7 @@ 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) } // zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. diff --git a/internal/model/pdv_stopa.go b/internal/model/pdv_stopa.go new file mode 100644 index 0000000..4da463b --- /dev/null +++ b/internal/model/pdv_stopa.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// PdvStopa je jedna stavka u šifarniku PDV stopa (Faza 1 knjigovodstvenog modula). +// Stope su podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda. +type PdvStopa struct { + ID int64 + Naziv string // npr. "Opšta stopa" + Stopa float64 // procenat, npr. 20.0 + Oznaka string // "opsta" | "posebna" | "oslobodjeno" + Aktivna bool // false = arhivirana (ne nudi se u listama, ali stari zapisi ostaju ispravni) + Redosled int // redosled prikaza + DatumUnosa time.Time +} diff --git a/migrations/040_pdv_stope.sql b/migrations/040_pdv_stope.sql new file mode 100644 index 0000000..b82489f --- /dev/null +++ b/migrations/040_pdv_stope.sql @@ -0,0 +1,21 @@ +-- Šifarnik PDV stopa (Faza 1 knjigovodstvenog modula). +-- Stope se čuvaju kao podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda. +-- Početne vrednosti po važećem zakonu (jun 2026): opšta 20%, posebna 10%, oslobođeno 0%. +CREATE TABLE IF NOT EXISTS pdv_stope ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + naziv TEXT NOT NULL, -- npr. "Opšta stopa" + stopa REAL NOT NULL, -- procenat, npr. 20.0 + oznaka TEXT NOT NULL, -- "opsta" | "posebna" | "oslobodjeno" + aktivna INTEGER NOT NULL DEFAULT 1, -- 1 = u upotrebi, 0 = arhivirana + redosled INTEGER NOT NULL DEFAULT 0, -- redosled prikaza u listama + datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Seed početnih stopa samo ako je tabela prazna (idempotentno, ne gazi ručne izmene). +INSERT INTO pdv_stope (naziv, stopa, oznaka, redosled) +SELECT naziv, stopa, oznaka, redosled FROM ( + SELECT 'Opšta stopa' AS naziv, 20.0 AS stopa, 'opsta' AS oznaka, 1 AS redosled + UNION ALL SELECT 'Posebna stopa', 10.0, 'posebna', 2 + UNION ALL SELECT 'Oslobođeno (0%)', 0.0, 'oslobodjeno', 3 +) +WHERE NOT EXISTS (SELECT 1 FROM pdv_stope); From d06a353a5252c3c78b4c9bb0edd6b8bc9ff4851f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 13 Jun 2026 20:45:43 +0200 Subject: [PATCH 05/26] =?UTF-8?q?feat(pdv):=20=C5=A1ifarnik=20PDV=20stopa?= =?UTF-8?q?=20=E2=80=94=20handler,=20rute=20i=20UI=20(Faza=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handleri (prikaz, dodaj, izmeni, arhiviraj/vrati) sa validacijom i flash porukama; rute pod /admin/podesavanja/pdv-stope (dozvole podesavanja.*); stranica pdv_stope registrovana u kes.go i dodata u meni Podešavanja. Šifarnik je opšti (bez RequireModul) jer ga koristi i kalkulacija. --- cmd/ntech/main.go | 22 ++-- internal/handler/kes.go | 1 + internal/handler/pdv_stopa.go | 154 ++++++++++++++++++++++++++ web/templates/komponente/sidebar.html | 11 +- web/templates/stranice/pdv_stope.html | 90 +++++++++++++++ 5 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 internal/handler/pdv_stopa.go create mode 100644 web/templates/stranice/pdv_stope.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index bcbdf77..2f950c8 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)) @@ -229,6 +233,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/pdv-stope", 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) @@ -327,7 +335,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 +428,3 @@ func ocistiStareBackupe(folder string, max int) { _ = os.Remove(f) } } - diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 739ab33..e0a4e91 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -25,6 +25,7 @@ var saSidebar = []string{ "magacin", "magacin_forma", "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", + "pdv_stope", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/pdv_stopa.go b/internal/handler/pdv_stopa.go new file mode 100644 index 0000000..247343e --- /dev/null +++ b/internal/handler/pdv_stopa.go @@ -0,0 +1,154 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +// validneOznakeStope su dozvoljene oznake PDV stope (vrsta po zakonu) +var validneOznakeStope = map[string]bool{ + "opsta": true, + "posebna": true, + "oslobodjeno": true, +} + +// PodaciPdvStope su podaci za stranicu šifarnika PDV stopa +type PodaciPdvStope struct { + model.PodaciStranice + Stope []model.PdvStopa +} + +// PdvStope renderuje šifarnik PDV stopa (sve stope, uključujući arhivirane) +func (h *Handler) PdvStope(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !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 + } + stope, err := h.PdvStopeRepo.Lista(r.Context(), false) + if err != nil { + http.Error(w, "Greška pri učitavanju PDV stopa", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "podesavanja-pdv-stope" + ps.NaslovStranice = "PDV stope" + h.renderujTemplate(w, "pdv_stope", PodaciPdvStope{PodaciStranice: ps, Stope: stope}) +} + +// parsePdvStopuForma čita i proverava polja forme; vraća popunjenu stopu i poruku o grešci +func parsePdvStopuForma(r *http.Request) (model.PdvStopa, string) { + naziv := strings.TrimSpace(r.FormValue("naziv")) + oznaka := strings.TrimSpace(r.FormValue("oznaka")) + stopaTekst := strings.TrimSpace(strings.Replace(r.FormValue("stopa"), ",", ".", 1)) + redosled, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("redosled"))) + + if naziv == "" { + return model.PdvStopa{}, "Naziv stope je obavezan." + } + if !validneOznakeStope[oznaka] { + return model.PdvStopa{}, "Oznaka mora biti opšta, posebna ili oslobođeno." + } + stopa, err := strconv.ParseFloat(stopaTekst, 64) + if err != nil || stopa < 0 || stopa > 100 { + return model.PdvStopa{}, "Stopa mora biti broj između 0 i 100." + } + + return model.PdvStopa{ + Naziv: naziv, + Stopa: stopa, + Oznaka: oznaka, + Aktivna: true, + Redosled: redosled, + }, "" +} + +// DodajPdvStopu prima POST i upisuje novu stopu u šifarnik +func (h *Handler) DodajPdvStopu(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + stopa, greska := parsePdvStopuForma(r) + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) + return + } + if _, err := h.PdvStopeRepo.Kreiraj(r.Context(), &stopa); err != nil { + http.Error(w, "Greška pri čuvanju PDV stope", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je dodata.") + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} + +// IzmeniPdvStopu prima POST i menja postojeću stopu +func (h *Handler) IzmeniPdvStopu(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID stope", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + stopa, greska := parsePdvStopuForma(r) + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) + return + } + stopa.ID = id + if err := h.PdvStopeRepo.Izmeni(r.Context(), &stopa); err != nil { + http.Error(w, "Greška pri izmeni PDV stope", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "PDV stopa je izmenjena.") + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} + +// PromeniAktivnostPdvStope arhivira ili vraća stopu u upotrebu (toggle, bez brisanja) +func (h *Handler) PromeniAktivnostPdvStope(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID stope", http.StatusBadRequest) + return + } + postojeca, err := h.PdvStopeRepo.DohvatiID(r.Context(), id) + if err != nil { + http.Error(w, "PDV stopa nije pronađena", http.StatusNotFound) + return + } + if err := h.PdvStopeRepo.PostaviAktivnu(r.Context(), id, !postojeca.Aktivna); err != nil { + http.Error(w, "Greška pri promeni statusa PDV stope", http.StatusInternalServerError) + return + } + poruka := "PDV stopa je arhivirana." + if !postojeca.Aktivna { + poruka = "PDV stopa je vraćena u upotrebu." + } + middleware.SetFlash(w, r, h.DB, "uspeh", poruka) + http.Redirect(w, r, "/admin/podesavanja/pdv-stope", http.StatusSeeOther) +} diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index a2bfd80..b2c296f 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -147,19 +147,19 @@ {{if index .Dozvole "podesavanja.pregled"}}
-
diff --git a/web/templates/stranice/pdv_kir_forma.html b/web/templates/stranice/pdv_kir_forma.html index e6eb6b5..673db88 100644 --- a/web/templates/stranice/pdv_kir_forma.html +++ b/web/templates/stranice/pdv_kir_forma.html @@ -34,6 +34,18 @@
Kupac
+ {{if .Klijenti}} +
+ + +
Izbor popunjava naziv i PIB/JMBG; možeš ih i ručno izmeniti.
+
+ {{end}}
@@ -94,4 +106,18 @@
+ + {{end}} From 966d1f6c9817fe8dde630f6a338635747df2f836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sun, 14 Jun 2026 02:05:33 +0200 Subject: [PATCH 09/26] =?UTF-8?q?feat(pdv):=20KPR=20=E2=80=94=20knjiga=20p?= =?UTF-8?q?rimljenih=20ra=C4=8Duna=20+=20PIB/mesto=20dobavlja=C4=8Da=20(Fa?= =?UTF-8?q?za=202a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KPR (handler, rute pod RequireModul("pdv"), UI sa sumama po stopama, izbor dobavljača, datum plaćanja, PDV bez odbitka / oslobođena nabavka) + stavka u meniju. Dobavljači dobili PIB i mesto (migracija 043) jer KPR traži PIB dobavljača za POPDV. Time je Faza 2a kompletna (KIR + KPR). --- cmd/ntech/main.go | 4 + internal/db/sqlite/dobavljac.go | 27 ++-- internal/handler/dobavljac.go | 2 + internal/handler/kes.go | 1 + internal/handler/pdv_kpr.go | 164 ++++++++++++++++++++ internal/model/dobavljac.go | 2 + internal/model/pdv_evidencija.go | 40 +++++ migrations/043_dobavljac_pib_mesto.sql | 4 + web/templates/komponente/sidebar.html | 5 + web/templates/stranice/dobavljac_forma.html | 14 ++ web/templates/stranice/pdv_kpr.html | 89 +++++++++++ web/templates/stranice/pdv_kpr_forma.html | 126 +++++++++++++++ 12 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 internal/handler/pdv_kpr.go create mode 100644 migrations/043_dobavljac_pib_mesto.sql create mode 100644 web/templates/stranice/pdv_kpr.html create mode 100644 web/templates/stranice/pdv_kpr_forma.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 4e4cb63..60095ec 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -266,6 +266,10 @@ func main() { 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.Get("/magacin", h.Magacin) r.Get("/magacin/novi", h.NoviArtikal) r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) 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/handler/dobavljac.go b/internal/handler/dobavljac.go index 36ba23f..1e92408 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -214,6 +214,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/kes.go b/internal/handler/kes.go index 31b12cb..35ab4da 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -27,6 +27,7 @@ var saSidebar = []string{ "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "pdv_stope", "pdv_kir", "pdv_kir_forma", + "pdv_kpr", "pdv_kpr_forma", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/pdv_kpr.go b/internal/handler/pdv_kpr.go new file mode 100644 index 0000000..0a79452 --- /dev/null +++ b/internal/handler/pdv_kpr.go @@ -0,0 +1,164 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +// PodaciPdvKpr su podaci za pregled knjige primljenih računa +type PodaciPdvKpr struct { + model.PodaciStranice + Zapisi []model.PdvKpr + Sume model.PdvKprSume + Od string + Do string +} + +// PodaciPdvKprForma su podaci za formu unosa zapisa KPR +type PodaciPdvKprForma struct { + model.PodaciStranice + Greska string + Danas string + Dobavljaci []model.Dobavljac // za izbor dobavljača iz postojećih +} + +// PdvKpr renderuje pregled knjige primljenih računa sa sumama po stopama +func (h *Handler) PdvKpr(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.pregled"); !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 + } + + odStr := r.URL.Query().Get("od") + doStr := r.URL.Query().Get("do") + zapisi, err := h.PdvKprRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr)) + if err != nil { + http.Error(w, "Greška pri učitavanju knjige primljenih računa", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "pdv-kpr" + ps.NaslovStranice = "KPR — knjiga primljenih računa" + h.renderujTemplate(w, "pdv_kpr", PodaciPdvKpr{ + PodaciStranice: ps, + Zapisi: zapisi, + Sume: model.SumirajKpr(zapisi), + Od: odStr, + Do: doStr, + }) +} + +// NoviPdvKpr prikazuje praznu formu za unos zapisa u KPR +func (h *Handler) NoviPdvKpr(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !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 + } + dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "") + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "pdv-kpr" + ps.NaslovStranice = "Novi ulazni račun (KPR)" + h.renderujTemplate(w, "pdv_kpr_forma", PodaciPdvKprForma{ + PodaciStranice: ps, + Danas: time.Now().Format("2006-01-02"), + Dobavljaci: dobavljaci, + }) +} + +// SacuvajPdvKpr prima POST i upisuje novi zapis u KPR +func (h *Handler) SacuvajPdvKpr(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + datumPrometa, e1 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_prometa"))) + datumKnjizenja, e2 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_knjizenja"))) + brojDokumenta := strings.TrimSpace(r.FormValue("broj_dokumenta")) + dobavljacNaziv := strings.TrimSpace(r.FormValue("dobavljac_naziv")) + + greska := "" + switch { + case e1 != nil: + greska = "Datum prometa je obavezan i mora biti ispravan." + case e2 != nil: + greska = "Datum knjiženja je obavezan i mora biti ispravan." + case brojDokumenta == "": + greska = "Broj dokumenta je obavezan." + case dobavljacNaziv == "": + greska = "Naziv dobavljača je obavezan." + } + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + http.Redirect(w, r, "/pdv/kpr/nova", http.StatusSeeOther) + return + } + + z := model.PdvKpr{ + DatumPrometa: datumPrometa, + DatumKnjizenja: datumKnjizenja, + BrojDokumenta: brojDokumenta, + DobavljacNaziv: dobavljacNaziv, + DobavljacPib: strings.TrimSpace(r.FormValue("dobavljac_pib")), + DobavljacMesto: strings.TrimSpace(r.FormValue("dobavljac_mesto")), + OsnovicaOpsta: parsiraIznos(r.FormValue("osnovica_opsta")), + PdvOpsta: parsiraIznos(r.FormValue("pdv_opsta")), + OsnovicaPosebna: parsiraIznos(r.FormValue("osnovica_posebna")), + PdvPosebna: parsiraIznos(r.FormValue("pdv_posebna")), + PdvBezOdbitka: parsiraIznos(r.FormValue("pdv_bez_odbitka")), + OslobodenNabavka: parsiraIznos(r.FormValue("osloboden_nabavka")), + Napomena: strings.TrimSpace(r.FormValue("napomena")), + } + // datum plaćanja je opcionalan + if dp := parsiraDatumOpcionalno(r.FormValue("datum_placanja")); !dp.IsZero() { + z.DatumPlacanja = &dp + } + // ukupna vrednost računa — zbir osnovica, PDV-a i oslobođene nabavke (računa server) + z.Ukupno = z.OsnovicaOpsta + z.PdvOpsta + z.OsnovicaPosebna + z.PdvPosebna + + z.PdvBezOdbitka + z.OslobodenNabavka + + if _, err := h.PdvKprRepo.Kreiraj(r.Context(), &z); err != nil { + http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "Ulazni račun je dodat u KPR.") + http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther) +} + +// ObrisiPdvKpr briše zapis iz KPR +func (h *Handler) ObrisiPdvKpr(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.obrisi"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID zapisa", http.StatusBadRequest) + return + } + if err := h.PdvKprRepo.Obrisi(r.Context(), id); err != nil { + http.Error(w, "Greška pri brisanju zapisa", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "Zapis je obrisan iz KPR.") + http.Redirect(w, r, "/pdv/kpr", http.StatusSeeOther) +} diff --git a/internal/model/dobavljac.go b/internal/model/dobavljac.go index f99c766..7c6c075 100644 --- a/internal/model/dobavljac.go +++ b/internal/model/dobavljac.go @@ -9,6 +9,8 @@ type Dobavljac struct { KontaktOsoba string Telefon string Email string + PIB string + Mesto string Napomena string DatumUnosa time.Time } diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index e909dd7..c9f8204 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -94,3 +94,43 @@ type PdvKpr struct { Napomena string DatumUnosa time.Time } + +// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme). +func (k PdvKpr) OznakaPoreskogBroja() string { + cifre := 0 + for _, r := range k.DobavljacPib { + if r >= '0' && r <= '9' { + cifre++ + } + } + if cifre == 13 { + return "JMBG" + } + return "PIB" +} + +// PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige). +type PdvKprSume struct { + OsnovicaOpsta float64 + PdvOpsta float64 + OsnovicaPosebna float64 + PdvPosebna float64 + PdvBezOdbitka float64 + OslobodenNabavka float64 + Ukupno float64 +} + +// SumirajKpr sabira sve kolone iz liste KPR zapisa. +func SumirajKpr(zapisi []PdvKpr) PdvKprSume { + var s PdvKprSume + for _, z := range zapisi { + s.OsnovicaOpsta += z.OsnovicaOpsta + s.PdvOpsta += z.PdvOpsta + s.OsnovicaPosebna += z.OsnovicaPosebna + s.PdvPosebna += z.PdvPosebna + s.PdvBezOdbitka += z.PdvBezOdbitka + s.OslobodenNabavka += z.OslobodenNabavka + s.Ukupno += z.Ukupno + } + return s +} diff --git a/migrations/043_dobavljac_pib_mesto.sql b/migrations/043_dobavljac_pib_mesto.sql new file mode 100644 index 0000000..50218c3 --- /dev/null +++ b/migrations/043_dobavljac_pib_mesto.sql @@ -0,0 +1,4 @@ +-- Dodaje PIB i mesto/grad u dobavljače. PIB dobavljača je obavezan podatak za +-- knjigu primljenih računa (KPR) i POPDV (evidencija prethodnog/odbitnog PDV-a). +ALTER TABLE dobavljaci ADD COLUMN pib TEXT; +ALTER TABLE dobavljaci ADD COLUMN mesto TEXT; diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 4b3df81..720ab75 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -100,6 +100,11 @@ KIR KIR — knjiga izdatih računa + + + KPR + KPR — knjiga primljenih računa + {{end}} diff --git a/web/templates/stranice/dobavljac_forma.html b/web/templates/stranice/dobavljac_forma.html index f2d108d..f334738 100644 --- a/web/templates/stranice/dobavljac_forma.html +++ b/web/templates/stranice/dobavljac_forma.html @@ -59,6 +59,20 @@ style="width:100%;">
+ +
+
+ + +
+
+ + +
+
+
diff --git a/web/templates/stranice/pdv_kpr.html b/web/templates/stranice/pdv_kpr.html new file mode 100644 index 0000000..e966830 --- /dev/null +++ b/web/templates/stranice/pdv_kpr.html @@ -0,0 +1,89 @@ +{{template "base" .}} + +{{define "naslov"}}KPR — knjiga primljenih računa — NTech{{end}} + +{{define "sadrzaj"}} +
+ + +
+
+
+
+ + +
+
+ + +
+ + {{if or .Od .Do}}Poništi filter{{end}} +
+ + Nov ulazni račun +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + {{range .Zapisi}} + + + + + + + + + + + + + + {{else}} + + {{end}} + + {{if .Zapisi}} + + + + + + + + + + + + + + {{end}} +
Datum prometaBroj dok.DobavljačOsn. 20%PDV 20%Osn. 10%PDV 10%PDV bez odb.Oslob. nab.Ukupno
{{.DatumPrometa.Format "02.01.2006."}}{{.BrojDokumenta}}{{.DobavljacNaziv}}{{if .DobavljacPib}}
{{.OznakaPoreskogBroja}}: {{.DobavljacPib}}
{{end}}
{{printf "%.2f" .OsnovicaOpsta}}{{printf "%.2f" .PdvOpsta}}{{printf "%.2f" .OsnovicaPosebna}}{{printf "%.2f" .PdvPosebna}}{{printf "%.2f" .PdvBezOdbitka}}{{printf "%.2f" .OslobodenNabavka}}{{printf "%.2f" .Ukupno}} +
+ +
+
Nema zapisa u izabranom periodu. Dodaj prvi.
UKUPNO ({{len .Zapisi}}){{printf "%.2f" .Sume.OsnovicaOpsta}}{{printf "%.2f" .Sume.PdvOpsta}}{{printf "%.2f" .Sume.OsnovicaPosebna}}{{printf "%.2f" .Sume.PdvPosebna}}{{printf "%.2f" .Sume.PdvBezOdbitka}}{{printf "%.2f" .Sume.OslobodenNabavka}}{{printf "%.2f" .Sume.Ukupno}}
+
+
+ +
+{{end}} diff --git a/web/templates/stranice/pdv_kpr_forma.html b/web/templates/stranice/pdv_kpr_forma.html new file mode 100644 index 0000000..1bbdcca --- /dev/null +++ b/web/templates/stranice/pdv_kpr_forma.html @@ -0,0 +1,126 @@ +{{template "base" .}} + +{{define "naslov"}}Novi ulazni račun (KPR) — NTech{{end}} + +{{define "sadrzaj"}} +
+ + + + Nazad na KPR + + +
+ +
+
Dokument
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Dobavljač
+
+ {{if .Dobavljaci}} +
+ + +
Izbor popunjava naziv, PIB i mesto; možeš ih i ručno izmeniti.
+
+ {{end}} +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
Iznosi po stopama
+
PDV (opšta/posebna) je iznos koji se može odbiti. „Ukupno" se računa automatski.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ Odustani + +
+
+
+ +
+ + +{{end}} From 1539ec799f082410db2086c4a064e7cccac1f99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sun, 14 Jun 2026 02:27:23 +0200 Subject: [PATCH 10/26] =?UTF-8?q?feat(pdv):=20izvor=20veza=20u=20KIR/KPR?= =?UTF-8?q?=20=E2=80=94=20temelj=20za=20auto-punjenje=20(Faza=202b-0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kolone izvor ('rucno'/'prodaja'/'nabavka') + izvor_id na pdv_kir/pdv_kpr (migracija 044, postojeći zapisi → 'rucno'). Repo Kreiraj upisuje izvor, nova ObrisiPoIzvoru za čišćenje pri stornu/brisanju izvora. Test. --- internal/db/repository.go | 4 + internal/db/sqlite/pdv_evidencija.go | 101 ++++++++++++++++------ internal/db/sqlite/pdv_evidencija_test.go | 44 ++++++++++ internal/model/pdv_evidencija.go | 4 + migrations/044_kir_kpr_izvor.sql | 8 ++ 5 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 migrations/044_kir_kpr_izvor.sql diff --git a/internal/db/repository.go b/internal/db/repository.go index 0cfc3e1..aa82b94 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -38,6 +38,8 @@ type PdvKirRepository interface { 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) @@ -46,6 +48,8 @@ type PdvKprRepository interface { 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 } // ArtikalFilter definiše parametre za filtriranje liste artikala diff --git a/internal/db/sqlite/pdv_evidencija.go b/internal/db/sqlite/pdv_evidencija.go index 8e2b470..8f620c0 100644 --- a/internal/db/sqlite/pdv_evidencija.go +++ b/internal/db/sqlite/pdv_evidencija.go @@ -28,7 +28,7 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi 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, ''), datum_unosa + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa FROM pdv_kir WHERE 1=1` args := []any{} if !od.IsZero() { @@ -49,14 +49,8 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi var rezultat []model.PdvKir for redovi.Next() { - var k model.PdvKir - if err := redovi.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.DatumUnosa, - ); err != nil { + k, err := skenirajKir(redovi.Scan) + if err != nil { return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: scan: %w", err) } rezultat = append(rezultat, k) @@ -69,26 +63,39 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi // DohvatiID vraća jedan zapis KIR-a po identifikatoru func (r *PdvKirRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) { - var k model.PdvKir - err := r.db.QueryRowContext(ctx, ` + 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, ''), datum_unosa - FROM pdv_kir WHERE id = ?`, id).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.DatumUnosa, - ) + 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, ` @@ -96,12 +103,13 @@ func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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) + 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) } @@ -120,6 +128,30 @@ func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error { 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 @@ -139,7 +171,7 @@ func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKp dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, pdv_bez_odbitka, osloboden_nabavka, ukupno, - COALESCE(napomena, ''), datum_unosa + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa FROM pdv_kpr WHERE 1=1` args := []any{} if !od.IsZero() { @@ -179,7 +211,7 @@ func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, er dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, pdv_bez_odbitka, osloboden_nabavka, ukupno, - COALESCE(napomena, ''), datum_unosa + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa FROM pdv_kpr WHERE id = ?`, id) k, err := skenirajKpr(red.Scan) if err != nil { @@ -192,18 +224,22 @@ func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, er func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) { var k model.PdvKpr var datumPlacanja sql.NullTime + var izvorID sql.NullInt64 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.DatumUnosa, + &k.Napomena, &k.Izvor, &izvorID, &k.DatumUnosa, ); err != nil { return model.PdvKpr{}, err } if datumPlacanja.Valid { k.DatumPlacanja = &datumPlacanja.Time } + if izvorID.Valid { + k.IzvorID = &izvorID.Int64 + } return k, nil } @@ -218,12 +254,13 @@ func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id + ) 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) + k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, + izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID)) if err != nil { return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: %w", err) } @@ -241,3 +278,11 @@ func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error { } 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 index 2397a11..fff0c75 100644 --- a/internal/db/sqlite/pdv_evidencija_test.go +++ b/internal/db/sqlite/pdv_evidencija_test.go @@ -127,3 +127,47 @@ func TestPdvKprRepo(t *testing.T) { } }) } + +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/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index c9f8204..6cd161f 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -20,6 +20,8 @@ type PdvKir struct { OslobodenBezPrava float64 Ukupno float64 Napomena string + Izvor string // "rucno" | "prodaja" | "nabavka" + IzvorID *int64 // id izvornog naloga (nil za ručni unos) DatumUnosa time.Time } @@ -92,6 +94,8 @@ type PdvKpr struct { OslobodenNabavka float64 Ukupno float64 Napomena string + Izvor string // "rucno" | "prodaja" | "nabavka" + IzvorID *int64 // id izvorne nabavke (nil za ručni unos) DatumUnosa time.Time } diff --git a/migrations/044_kir_kpr_izvor.sql b/migrations/044_kir_kpr_izvor.sql new file mode 100644 index 0000000..d756047 --- /dev/null +++ b/migrations/044_kir_kpr_izvor.sql @@ -0,0 +1,8 @@ +-- Veza KIR/KPR zapisa sa izvorom (Faza 2b — automatsko punjenje). +-- izvor: 'rucno' (ručni unos), 'prodaja', 'nabavka'; izvor_id = id naloga/nabavke. +-- Postojeći zapisi dobijaju 'rucno' (DEFAULT). Omogućava brisanje vezanog zapisa +-- pri stornu/brisanju izvora i sprečava duplikate. +ALTER TABLE pdv_kir ADD COLUMN izvor TEXT NOT NULL DEFAULT 'rucno'; +ALTER TABLE pdv_kir ADD COLUMN izvor_id INTEGER; +ALTER TABLE pdv_kpr ADD COLUMN izvor TEXT NOT NULL DEFAULT 'rucno'; +ALTER TABLE pdv_kpr ADD COLUMN izvor_id INTEGER; From 76b147074d8a5d2bab7a51485489f19cdd1d5633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sun, 14 Jun 2026 02:45:07 +0200 Subject: [PATCH 11/26] =?UTF-8?q?feat(pdv):=20prodaja=20=E2=86=92=20KIR=20?= =?UTF-8?q?automatski=20(Faza=202b-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kad se sačuva prodaja na klijenta (PDV obveznik), zapis se sam zavede u KIR (model.KirIzProdaje grupiše stavke po stopi). Storno/brisanje prodaje uklanja vezani KIR zapis (ObrisiPoIzvoru). Maloprodaja građanima (bez klijenta) se preskače — ide preko fiskalizacije (Faza 3). Helper modulUkljucen; auto-zapisi u UI nemaju ručno brisanje. Test. --- internal/handler/handler.go | 11 +++++++ internal/handler/prodaja.go | 24 +++++++++++++++ internal/model/pdv_evidencija.go | 38 ++++++++++++++++++++++++ internal/model/pdv_evidencija_test.go | 42 +++++++++++++++++++++++++++ web/templates/stranice/pdv_kir.html | 4 +++ 5 files changed, 119 insertions(+) create mode 100644 internal/model/pdv_evidencija_test.go diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5c6fb55..d45149b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,6 +1,7 @@ package handler import ( + "context" "database/sql" "html/template" "io/fs" @@ -130,6 +131,16 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.PdvKprRepo = sqlite.NoviPdvKprRepo(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. // U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje). func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) { diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index b75fd78..0f3ebda 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -197,6 +197,22 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { return } + // automatski zavedi u KIR ako je firma PDV obveznik i prodaja je na klijenta (B2B faktura). + // Maloprodaja građanima (bez klijenta) ide zbirno preko fiskalizacije (Faza 3) — preskače se. + if nalog.KlijentID != nil && h.modulUkljucen(r.Context(), "pdv") { + if klijent, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil { + nalog.ID = id + pib := klijent.PIB + if klijent.Tip != "pravno" { + pib = klijent.JMBG + } + kir := model.KirIzProdaje(nalog, stavke, klijent.PunoIme(), pib, klijent.Mesto) + if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil { + slog.Error("auto-upis u KIR nije uspeo", "prodaja_id", id, "error", e) + } + } + } + http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) } @@ -319,6 +335,10 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) { http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError) return } + // ukloni vezani auto-KIR zapis (ako ga je ova prodaja kreirala) + if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil { + slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err) + } http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther) } @@ -408,6 +428,10 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) return } + // stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis + if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil { + slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err) + } middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.") http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 6cd161f..9f27e9e 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -76,6 +76,44 @@ func SumirajKir(zapisi []PdvKir) PdvKirSume { return s } +// KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi +// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV, +// pa se osnovica izvodi deljenjem sa (1 + stopa/100). +func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupacPib, kupacMesto string) PdvKir { + id := nalog.ID + k := PdvKir{ + DatumPrometa: nalog.Datum, + DatumKnjizenja: nalog.Datum, + BrojDokumenta: nalog.BrojNaloga, + KupacNaziv: kupacNaziv, + KupacPib: kupacPib, + KupacMesto: kupacMesto, + Izvor: "prodaja", + IzvorID: &id, + } + for _, s := range stavke { + ukupnoLinija := float64(s.Kolicina) * s.CenaPoKomadu + osnovica := ukupnoLinija + if s.PdvStopa > 0 { + osnovica = ukupnoLinija / (1 + s.PdvStopa/100) + } + pdv := ukupnoLinija - osnovica + switch s.PdvStopa { + case 20: + k.OsnovicaOpsta += osnovica + k.PdvOpsta += pdv + case 10: + k.OsnovicaPosebna += osnovica + k.PdvPosebna += pdv + default: + // 0% / oslobođeno — osnovica bez PDV-a u oslobođen promet sa pravom na odbitak + k.OslobodenSaPravom += osnovica + } + k.Ukupno += ukupnoLinija + } + return k +} + // PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV). type PdvKpr struct { ID int64 diff --git a/internal/model/pdv_evidencija_test.go b/internal/model/pdv_evidencija_test.go new file mode 100644 index 0000000..58b1663 --- /dev/null +++ b/internal/model/pdv_evidencija_test.go @@ -0,0 +1,42 @@ +package model + +import ( + "math" + "testing" + "time" +) + +func blizu(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +func TestKirIzProdaje(t *testing.T) { + nalog := ProdajniNalog{ID: 5, BrojNaloga: "P-1", Datum: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)} + stavke := []StavkaProdaje{ + // 20%: 2 × 120 = 240 (osnovica 200, PDV 40) + {Kolicina: 2, CenaPoKomadu: 120, PdvStopa: 20}, + // 10%: 1 × 110 = 110 (osnovica 100, PDV 10) + {Kolicina: 1, CenaPoKomadu: 110, PdvStopa: 10}, + // 0%: 1 × 50 = 50 (oslobođeno, bez PDV) + {Kolicina: 1, CenaPoKomadu: 50, PdvStopa: 0}, + } + + k := KirIzProdaje(nalog, stavke, "Kupac doo", "123456789", "Niš") + + if k.Izvor != "prodaja" || k.IzvorID == nil || *k.IzvorID != 5 { + t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/5", k.Izvor, k.IzvorID) + } + if k.BrojDokumenta != "P-1" || k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" { + t.Errorf("zaglavlje ne odgovara: %+v", k) + } + if !blizu(k.OsnovicaOpsta, 200) || !blizu(k.PdvOpsta, 40) { + t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 200/40", k.OsnovicaOpsta, k.PdvOpsta) + } + if !blizu(k.OsnovicaPosebna, 100) || !blizu(k.PdvPosebna, 10) { + t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 100/10", k.OsnovicaPosebna, k.PdvPosebna) + } + if !blizu(k.OslobodenSaPravom, 50) { + t.Errorf("oslobođeno=%v, očekivano 50", k.OslobodenSaPravom) + } + if !blizu(k.Ukupno, 400) { + t.Errorf("ukupno=%v, očekivano 400 (240+110+50)", k.Ukupno) + } +} diff --git a/web/templates/stranice/pdv_kir.html b/web/templates/stranice/pdv_kir.html index f66faab..cf297a9 100644 --- a/web/templates/stranice/pdv_kir.html +++ b/web/templates/stranice/pdv_kir.html @@ -55,9 +55,13 @@ {{printf "%.2f" .OslobodenUkupno}} {{printf "%.2f" .Ukupno}} + {{if eq .Izvor "rucno"}}
+ {{else}} + iz prodaje + {{end}} {{else}} From 7fc2e9bcc31f5a0ab976205e3869446223b96b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sun, 14 Jun 2026 08:16:41 +0200 Subject: [PATCH 12/26] =?UTF-8?q?feat(pdv):=20nabavka=20=E2=86=92=20KPR=20?= =?UTF-8?q?automatski=20(Faza=202b-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDV se izvodi iz stope artikla po stavci (aproksimacija: nabavna cena = osnovica bez PDV). Grupisanje po stopi (20→opšta, 10→posebna, ostalo→oslobođena nabavka), broj dokumenta NAB-, veza izvor/izvor_id. Auto-zapisi se ne mogu ručno brisati u KPR; brisanje nabavke uklanja vezani KPR zapis. --- internal/handler/nabavka.go | 33 +++++++++++++++++++- internal/model/pdv_evidencija.go | 45 ++++++++++++++++++++++++++- internal/model/pdv_evidencija_test.go | 30 ++++++++++++++++++ web/templates/stranice/pdv_kpr.html | 4 +++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index be106f0..ecc0101 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,7 +27,7 @@ type PodaciNabavki struct { type PodaciFormeNabavke struct { model.PodaciStranice Artikli []model.ArtikalSaKategorijom - ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u +
@@ -87,6 +87,8 @@ Artikal Količina Cena/kom (din) + Marža % + Prodajna/kom (din) Ukupno @@ -96,7 +98,7 @@