Servis: sprečeno dupliranje KIR/KPO/fiskalnog pri ponovnom Preuzeto; KPO redni_broj
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.
This commit is contained in:
@@ -70,12 +70,14 @@ func (r *KpoRepo) Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis
|
|||||||
return rezultat, redovi.Err()
|
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) {
|
func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) {
|
||||||
rez, err := r.db.ExecContext(ctx, `
|
rez, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO kpo_unosi
|
INSERT INTO kpo_unosi
|
||||||
(datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
|
(datum_prometa, redni_broj, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, (SELECT COALESCE(MAX(redni_broj), 0) + 1 FROM kpo_unosi), ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
z.DatumPrometa.Format("2006-01-02"),
|
z.DatumPrometa.Format("2006-01-02"),
|
||||||
z.BrojDokumenta,
|
z.BrojDokumenta,
|
||||||
nullString(z.Opis),
|
nullString(z.Opis),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-30
@@ -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.")
|
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) {
|
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||||
klijent := h.fiskalKlijent()
|
if fr, _ := h.FiskalRepo.DohvatiPoServisu(r.Context(), id); fr == nil {
|
||||||
if klijent != nil && iznos > 0 {
|
klijent := h.fiskalKlijent()
|
||||||
h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos)
|
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
|
// 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.
|
// 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)
|
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||||
if nalog != nil && !nalog.PopravkaOdbijena && nalog.KlijentID != nil {
|
if nalog != nil && !nalog.PopravkaOdbijena && nalog.KlijentID != nil {
|
||||||
kupacNaziv, kupacPib, kupacMesto := "", "", ""
|
kupacNaziv, kupacPib, kupacMesto := "", "", ""
|
||||||
@@ -2229,29 +2233,8 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
||||||
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||||
|
|
||||||
kir := model.PdvKir{
|
sada := time.Now()
|
||||||
DatumPrometa: time.Now(),
|
kir := model.KirIzServisa(*nalog, radovi, delovi, kupacNaziv, kupacPib, kupacMesto, sada, sada)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if kir.Ukupno > 0 {
|
if kir.Ukupno > 0 {
|
||||||
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
|
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)
|
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
|
// auto-KPO za servisni nalog — preskoči ako zapis već postoji (ponovni ulazak u Preuzeto)
|
||||||
if h.modulUkljucen(r.Context(), "kpo") && iznos > 0 {
|
postojiKpo, _ := h.KpoRepo.PostojiZaIzvor(r.Context(), "servis", id)
|
||||||
|
if h.modulUkljucen(r.Context(), "kpo") && iznos > 0 && !postojiKpo {
|
||||||
nalogKpo, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
nalogKpo, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||||
if nalogKpo != nil {
|
if nalogKpo != nil {
|
||||||
kpoZ := model.KpoZapis{
|
kpoZ := model.KpoZapis{
|
||||||
|
|||||||
@@ -129,6 +129,38 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
|
|||||||
return k
|
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
|
// KirStorno gradi storno (knjižno odobrenje) zapis za dati KIR red: iznosi su
|
||||||
// negirani original, upisan pod istim izvorom/izvor_id tako da PostojiZaIzvor
|
// 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
|
// ostane tačan i backfill ne pokuša ponovni upis. Zakon o računovodstvu ne
|
||||||
|
|||||||
@@ -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
|
// 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
|
// (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.
|
// se ne dira — storno se samo dograđuje kao novi red.
|
||||||
|
|||||||
Reference in New Issue
Block a user