From 3c61063bbbd942df1049bf8b078091aef10d4f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 2 Jul 2026 11:27:19 +0200 Subject: [PATCH] =?UTF-8?q?Servis:=20spre=C4=8Deno=20dupliranje=20KIR/KPO/?= =?UTF-8?q?fiskalnog=20pri=20ponovnom=20Preuzeto;=20KPO=20redni=5Fbroj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ponovni ulazak naloga u status Preuzeto (dupli klik, dupli submit modala naplate, sekvenca Preuzeto→Završeno→Preuzeto) je ponovo pokretao fiskalizaciju i upis u KIR/KPO bez provere da li već postoje — svaki ulazak je duplirao prihod i PDV obavezu, a dashboard/backfill duplikat nikad nije prijavljivao jer je dovoljan bio bar jedan zapis. Dodate PostojiZaIzvor/DohvatiPoServisu provere pre svakog upisa (isti obrazac kao RetryFiskalizacija). KIR upis servisa refaktorisan u model.KirIzServisa (deljeno sa budućim backfill-om). KpoRepo.Kreiraj sad dodeljuje redni_broj automatski (COALESCE(MAX,0)+1) — kolona je postojala u šemi ali se nikad nije popunjavala, pa knjiga o ostvarenom prometu paušalaca nije imala redni broj koji Pravilnik zahteva. --- internal/db/sqlite/kpo.go | 8 +++-- internal/db/sqlite/kpo_test.go | 40 ++++++++++++++++++++++++ internal/handler/servis.go | 44 +++++++++------------------ internal/model/pdv_evidencija.go | 32 +++++++++++++++++++ internal/model/pdv_evidencija_test.go | 32 +++++++++++++++++++ 5 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 internal/db/sqlite/kpo_test.go diff --git a/internal/db/sqlite/kpo.go b/internal/db/sqlite/kpo.go index bb73d49..b025e99 100644 --- a/internal/db/sqlite/kpo.go +++ b/internal/db/sqlite/kpo.go @@ -70,12 +70,14 @@ func (r *KpoRepo) Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis return rezultat, redovi.Err() } -// Kreiraj upisuje novi KPO zapis i vraća njegov ID. +// Kreiraj upisuje novi KPO zapis i vraća njegov ID. redni_broj se dodeljuje +// automatski kao sledeći u nizu (Pravilnik o KPO zahteva kontinuiran redni broj +// bez prekida — knjiga se nikad ne briše, pa brojevi ostaju u nizu i posle storna). func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) { rez, err := r.db.ExecContext(ctx, ` INSERT INTO kpo_unosi - (datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + (datum_prometa, redni_broj, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id) + VALUES (?, (SELECT COALESCE(MAX(redni_broj), 0) + 1 FROM kpo_unosi), ?, ?, ?, ?, ?, ?, ?)`, z.DatumPrometa.Format("2006-01-02"), z.BrojDokumenta, nullString(z.Opis), diff --git a/internal/db/sqlite/kpo_test.go b/internal/db/sqlite/kpo_test.go new file mode 100644 index 0000000..7d48be8 --- /dev/null +++ b/internal/db/sqlite/kpo_test.go @@ -0,0 +1,40 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "ntech/internal/model" +) + +// TestKpoRedniBroj: redni_broj mora biti dodeljen automatski i bez prekida — +// Pravilnik o KPO zahteva kontinuiran niz, i knjiga se nikad ne briše (storno +// se dograđuje kao nov red, ne zamenjuje stari). +func TestKpoRedniBroj(t *testing.T) { + db := testDB(t) + repo := NoviKpoRepo(db) + ctx := context.Background() + dp := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + + for _, brojDok := range []string{"SN-1", "SN-2", "SN-3"} { + if _, err := repo.Kreiraj(ctx, &model.KpoZapis{ + DatumPrometa: dp, BrojDokumenta: brojDok, Prihod: 100, + }); err != nil { + t.Fatalf("Kreiraj %s: %v", brojDok, err) + } + } + + zapisi, err := repo.Lista(ctx, time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("Lista: %v", err) + } + if len(zapisi) != 3 { + t.Fatalf("očekivano 3 zapisa, dobijeno %d", len(zapisi)) + } + for i, z := range zapisi { + if z.RedniBroj == nil || *z.RedniBroj != i+1 { + t.Errorf("zapis %q: redni_broj=%v, očekivano %d", z.BrojDokumenta, z.RedniBroj, i+1) + } + } +} diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 841e00b..5a1eefa 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -2204,17 +2204,21 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { middleware.SetFlash(w, r, h.DB, "greska", "Naplata nije sačuvana. Pokušajte ponovo.") } - // fiskalizacija servisa — ako je modul uključen + // fiskalizacija servisa — ako je modul uključen; preskoči ako fiskalni račun + // za ovaj nalog već postoji (ponovni ulazak u Preuzeto ne sme duplirati PFR zahtev) if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { - klijent := h.fiskalKlijent() - if klijent != nil && iznos > 0 { - h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos) + if fr, _ := h.FiskalRepo.DohvatiPoServisu(r.Context(), id); fr == nil { + klijent := h.fiskalKlijent() + if klijent != nil && iznos > 0 { + h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos) + } } } // automatski upis u KIR ako je firma PDV obveznik i servis je na klijenta (B2B/identifikovan // kupac) — maloprodaja bez klijenta ide samo preko fiskalizacije, isto kao u Prodaji. - if h.modulUkljucen(r.Context(), "pdv") { + // Preskoči ako KIR zapis za ovaj nalog već postoji (ponovni ulazak u Preuzeto). + if postojiKir, _ := h.PdvKirRepo.PostojiZaIzvor(r.Context(), "servis", id); h.modulUkljucen(r.Context(), "pdv") && !postojiKir { nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id) if nalog != nil && !nalog.PopravkaOdbijena && nalog.KlijentID != nil { kupacNaziv, kupacPib, kupacMesto := "", "", "" @@ -2229,29 +2233,8 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id) delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id) - kir := model.PdvKir{ - DatumPrometa: time.Now(), - DatumKnjizenja: time.Now(), - BrojDokumenta: nalog.BrojNaloga, - KupacNaziv: kupacNaziv, - KupacPib: kupacPib, - KupacMesto: kupacMesto, - Izvor: "servis", - IzvorID: &id, - } - // cena_komada rada/dela je NETO; PDV se dodaje naviše po stvarnoj stopi - for _, r := range radovi { - if r.Predlozeno { - continue - } - kir.DodajNeto(r.Ukupno(), r.PdvStopa) - } - for _, d := range delovi { - if d.Predlozeno { - continue - } - kir.DodajNeto(d.Ukupno(), d.PdvStopa) - } + sada := time.Now() + kir := model.KirIzServisa(*nalog, radovi, delovi, kupacNaziv, kupacPib, kupacMesto, sada, sada) if kir.Ukupno > 0 { if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil { slog.Error("auto-upis u KIR za servis nije uspeo", "servis_id", id, "error", e) @@ -2259,8 +2242,9 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { } } } - // auto-KPO za servisni nalog - if h.modulUkljucen(r.Context(), "kpo") && iznos > 0 { + // auto-KPO za servisni nalog — preskoči ako zapis već postoji (ponovni ulazak u Preuzeto) + postojiKpo, _ := h.KpoRepo.PostojiZaIzvor(r.Context(), "servis", id) + if h.modulUkljucen(r.Context(), "kpo") && iznos > 0 && !postojiKpo { nalogKpo, _ := h.ServisRepo.DohvatiID(r.Context(), id) if nalogKpo != nil { kpoZ := model.KpoZapis{ diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 8178001..1bc4c8b 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -129,6 +129,38 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac return k } +// KirIzServisa gradi KIR zapis iz preuzetog servisnog naloga: radovi i ugrađeni +// delovi (bez predloženih koji čekaju odobrenje klijenta) se sabiraju kao NETO +// stavke po svojoj PDV stopi. datumPrometa je trenutak prometa (za sveže naloge +// „sada", za backfill starih naloga datum preuzimanja); datumKnjizenja je kad se +// zapis stvarno upisuje u knjigu (za backfill kasni za periodom). +func KirIzServisa(nalog ServisniNalog, radovi []ServisniRad, delovi []ServisniDeoSaArtiklom, kupacNaziv, kupacPib, kupacMesto string, datumPrometa, datumKnjizenja time.Time) PdvKir { + id := nalog.ID + k := PdvKir{ + DatumPrometa: datumPrometa, + DatumKnjizenja: datumKnjizenja, + BrojDokumenta: nalog.BrojNaloga, + KupacNaziv: kupacNaziv, + KupacPib: kupacPib, + KupacMesto: kupacMesto, + Izvor: "servis", + IzvorID: &id, + } + for _, r := range radovi { + if r.Predlozeno { + continue + } + k.DodajNeto(r.Ukupno(), r.PdvStopa) + } + for _, d := range delovi { + if d.Predlozeno { + continue + } + k.DodajNeto(d.Ukupno(), d.PdvStopa) + } + return k +} + // KirStorno gradi storno (knjižno odobrenje) zapis za dati KIR red: iznosi su // negirani original, upisan pod istim izvorom/izvor_id tako da PostojiZaIzvor // ostane tačan i backfill ne pokuša ponovni upis. Zakon o računovodstvu ne diff --git a/internal/model/pdv_evidencija_test.go b/internal/model/pdv_evidencija_test.go index c206195..d0227c4 100644 --- a/internal/model/pdv_evidencija_test.go +++ b/internal/model/pdv_evidencija_test.go @@ -70,6 +70,38 @@ func TestPdvKirDodajNeto(t *testing.T) { } } +// TestKirIzServisa: radovi i ugrađeni delovi se sabiraju kao NETO stavke po +// PDV stopi; predloženi (neprihvaćeni) redovi se ne naplaćuju. +func TestKirIzServisa(t *testing.T) { + nalog := ServisniNalog{ID: 7, BrojNaloga: "SN-2607-001"} + radovi := []ServisniRad{ + {Naziv: "Zamena ekrana", Kolicina: 1, CenaKomada: 1000, PdvStopa: 20}, // osnovica 1000, PDV 200 + {Naziv: "Predlog", Kolicina: 1, CenaKomada: 5000, PdvStopa: 20, Predlozeno: true}, + } + delovi := []ServisniDeoSaArtiklom{ + {ServisniDeo: ServisniDeo{Kolicina: 2, CenaKomada: 300}, PdvStopa: 10}, // osnovica 600, PDV 60 + } + + k := KirIzServisa(nalog, radovi, delovi, "Kupac doo", "123456789", "Niš", + time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)) + + if k.Izvor != "servis" || k.IzvorID == nil || *k.IzvorID != 7 { + t.Errorf("izvor=%q izvor_id=%v, očekivano servis/7", k.Izvor, k.IzvorID) + } + if k.BrojDokumenta != "SN-2607-001" { + t.Errorf("broj_dokumenta=%q, očekivano SN-2607-001", k.BrojDokumenta) + } + if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) { + t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 1000/200 (predloženi rad se ne računa)", k.OsnovicaOpsta, k.PdvOpsta) + } + if !blizu(k.OsnovicaPosebna, 600) || !blizu(k.PdvPosebna, 60) { + t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 600/60", k.OsnovicaPosebna, k.PdvPosebna) + } + if !blizu(k.Ukupno, 1860) { + t.Errorf("ukupno=%v, očekivano 1860 (1200+660)", k.Ukupno) + } +} + // TestKirStorno: storno stavka negira sve kolone originala, čuva izvor/izvor_id // (da PostojiZaIzvor ostane tačan i backfill ne pokuša ponovni upis) i original // se ne dira — storno se samo dograđuje kao novi red.