Knjigovodstvo: storno prodaje/nabavke više ne briše KIR/KPR/KPO

Storno je fizički brisao vezani KIR/KPR/KPO zapis — zakon o računovodstvu ne
dozvoljava brisanje poslovnih knjiga (KPO dodatno ima redni broj bez prekida).
Sad se original čuva, a storno se dograđuje kao poništavajuća stavka
(negirani iznosi, referenca na original) — model.KirStorno/KprStorno/KpoStorno.

Usput ispravljen i pravi uzrok prijavljenog bug-a: kirKandidatiProdaje je
filtrirala samo internu mapu, ne i listu naloga koju obilazi KirBackfillProdaje
— stornirani nalozi su zbog toga dobijali ponovni (pogrešan) KIR upis.
This commit is contained in:
2026-07-02 10:35:24 +02:00
parent 3a0f98622b
commit 45831467e0
10 changed files with 253 additions and 36 deletions
+9 -5
View File
@@ -66,8 +66,9 @@ 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
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error)
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
// PostojiPoBrojuDokumenta vraća true ako postoji zapis sa datim brojem dokumenta
@@ -83,8 +84,9 @@ 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
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno nabavke
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error)
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
}
@@ -413,5 +415,7 @@ type KpoRepository interface {
Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error)
Obrisi(ctx context.Context, id int64) error
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje —
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error)
}
+36 -5
View File
@@ -115,10 +115,41 @@ func (r *KpoRepo) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int6
return n > 0, nil
}
// ObrisiPoIzvoru briše KPO zapise vezane za dati izvor (npr. pri stornu prodaje).
func (r *KpoRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: KpoRepo.ObrisiPoIzvoru: %w", err)
// DohvatiPoIzvoru vraća sve KPO zapise vezane za dati izvor (npr. za storno prodaje).
func (r *KpoRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT id, datum_prometa, redni_broj, broj_dokumenta, opis, prihod,
nacin_placanja, napomena, izvor, izvor_id, datum_unosa
FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: %w", err)
}
return nil
defer redovi.Close()
var rezultat []model.KpoZapis
for redovi.Next() {
var z model.KpoZapis
var opis, nacinPlacanja, napomena sql.NullString
var redniBroj sql.NullInt64
var izvorIDCol sql.NullInt64
if err := redovi.Scan(
&z.ID, &z.DatumPrometa, &redniBroj, &z.BrojDokumenta, &opis, &z.Prihod,
&nacinPlacanja, &napomena, &z.Izvor, &izvorIDCol, &z.DatumUnosa,
); err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: scan: %w", err)
}
if redniBroj.Valid {
n := int(redniBroj.Int64)
z.RedniBroj = &n
}
z.Opis = opis.String
z.NacinPlacanja = nacinPlacanja.String
z.Napomena = napomena.String
if izvorIDCol.Valid {
z.IzvorID = &izvorIDCol.Int64
}
rezultat = append(rezultat, z)
}
return rezultat, redovi.Err()
}
+50 -10
View File
@@ -128,12 +128,32 @@ 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)
// DohvatiPoIzvoru vraća sve KIR zapise vezane za dati izvor (npr. za storno prodaje)
func (r *PdvKirRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, datum_unosa
FROM pdv_kir WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
}
return nil
defer redovi.Close()
var rezultat []model.PdvKir
for redovi.Next() {
k, err := skenirajKir(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
}
return rezultat, nil
}
// PostojiZaIzvor vraća true ako postoji bar jedan KIR zapis za dati izvor i izvorID
@@ -318,12 +338,32 @@ 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)
// DohvatiPoIzvoru vraća sve KPR zapise vezane za dati izvor (npr. za storno nabavke)
func (r *PdvKprRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa
FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
}
return nil
defer redovi.Close()
var rezultat []model.PdvKpr
for redovi.Next() {
k, err := skenirajKpr(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
}
return rezultat, nil
}
// PostojiZaIzvor vraća true ako postoji bar jedan KPR zapis za dati izvor i izvorID
+6 -6
View File
@@ -162,12 +162,12 @@ func TestPdvKirIzvor(t *testing.T) {
}
}
// ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje
if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil {
t.Fatalf("ObrisiPoIzvoru: %v", err)
// DohvatiPoIzvoru vraća samo vezani auto zapis
poIzvoru, err := repo.DohvatiPoIzvoru(ctx, "prodaja", 7)
if err != nil {
t.Fatalf("DohvatiPoIzvoru: %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))
if len(poIzvoru) != 1 || poIzvoru[0].BrojDokumenta != "P-1" {
t.Errorf("očekivan samo auto zapis P-1, dobijeno %d", len(poIzvoru))
}
}
+11 -3
View File
@@ -377,9 +377,17 @@ func (h *Handler) StornoNabavke(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?greska=storno", http.StatusSeeOther)
return
}
// stornirana nabavka ne ulazi u PDV — ukloni vezani auto-KPR zapis
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
// stornirana nabavka se ne briše iz KPR (zakon o računovodstvu ne dozvoljava
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
if zapisi, err := h.PdvKprRepo.DohvatiPoIzvoru(r.Context(), "nabavka", id); err != nil {
slog.Error("čitanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
} else {
for _, z := range zapisi {
storno := model.KprStorno(z, razlog, time.Now())
if _, e := h.PdvKprRepo.Kreiraj(r.Context(), &storno); e != nil {
slog.Error("upis storno KPR zapisa nije uspeo", "nabavka_id", id, "error", e)
}
}
}
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
+22 -6
View File
@@ -687,9 +687,17 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
return err
}
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis
if err := h.PdvKirRepo.ObrisiPoIzvoru(ctx, "prodaja", id); err != nil {
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
// stornirana prodaja se ne briše iz KIR (zakon o računovodstvu ne dozvoljava
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
if zapisi, err := h.PdvKirRepo.DohvatiPoIzvoru(ctx, "prodaja", id); err != nil {
slog.Error("čitanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
} else {
for _, z := range zapisi {
storno := model.KirStorno(z, razlog, time.Now())
if _, e := h.PdvKirRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KIR zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
}
// fiskalni refund — best-effort
@@ -744,10 +752,18 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
}
}
// brisanje KPO zapisa na storno
// stornirana prodaja se ne briše iz KPO (redni broj mora ostati bez prekida) —
// dograđuje se storno stavka koja poništava original
if h.modulUkljucen(ctx, "kpo") {
if e := h.KpoRepo.ObrisiPoIzvoru(ctx, "prodaja", id); e != nil {
slog.Error("brisanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
if zapisi, e := h.KpoRepo.DohvatiPoIzvoru(ctx, "prodaja", id); e != nil {
slog.Error("čitanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
} else {
for _, z := range zapisi {
storno := model.KpoStorno(z, razlog, time.Now())
if _, e := h.KpoRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KPO zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
}
}
+9 -1
View File
@@ -59,10 +59,18 @@ func sumnjiviDuplikati(svi []kandidatUskladjivanja) map[int64]bool {
// KIR upis) sa mapom postojećih upisa i kandidatima za detekciju duplikata. Koristi se
// i za backfill (KirBackfillProdaje) i za brojanje praznina na dashboard-u.
func (h *Handler) kirKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) {
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
svi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
if err != nil {
return nil, nil, nil, err
}
// stornirani nalozi se nikad ne razmatraju za KIR upis — ni za backfill ni za
// brojanje praznina — jer stornirana prodaja ne predstavlja oporeziv promet
var nalozi []model.ProdajniNalogSaDetaljem
for _, nd := range svi {
if !nd.Stornirano {
nalozi = append(nalozi, nd)
}
}
postojiMap := make(map[int64]bool, len(nalozi))
var kandidati []kandidatUskladjivanja
for _, nd := range nalozi {
+21
View File
@@ -22,3 +22,24 @@ type KpoSume struct {
Prihod float64
Broj int
}
// KpoStorno gradi storno zapis za dati KPO red: negiran prihod, upisan pod istim
// izvorom/izvor_id kao original. Knjiga o ostvarenom prometu paušalaca vodi redni
// broj bez prekida (Pravilnik) — original se nikad ne briše, storno se dodaje kao
// poništavajuća stavka koja upućuje na original.
func KpoStorno(original KpoZapis, razlog string, datumStorna time.Time) KpoZapis {
napomena := "Storno naloga " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return KpoZapis{
DatumPrometa: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
Opis: original.Opis,
Prihod: -original.Prihod,
NacinPlacanja: original.NacinPlacanja,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
}
}
+57
View File
@@ -129,6 +129,36 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
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
// dozvoljava brisanje knjigovodstvenih evidencija — storno se knjiži kao
// poništavajuća stavka koja upućuje na original, a ne kao brisanje reda.
func KirStorno(original PdvKir, razlog string, datumStorna time.Time) PdvKir {
napomena := "Storno računa " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return PdvKir{
DatumPrometa: datumStorna,
DatumKnjizenja: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
KupacNaziv: original.KupacNaziv,
KupacPib: original.KupacPib,
KupacMesto: original.KupacMesto,
OsnovicaOpsta: -original.OsnovicaOpsta,
PdvOpsta: -original.PdvOpsta,
OsnovicaPosebna: -original.OsnovicaPosebna,
PdvPosebna: -original.PdvPosebna,
OslobodenSaPravom: -original.OslobodenSaPravom,
OslobodenBezPrava: -original.OslobodenBezPrava,
Ukupno: -original.Ukupno,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
}
}
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
type PdvKpr struct {
ID int64
@@ -153,6 +183,33 @@ type PdvKpr struct {
DatumUnosa time.Time
}
// KprStorno gradi storno zapis za dati KPR red — vidi KirStorno.
func KprStorno(original PdvKpr, razlog string, datumStorna time.Time) PdvKpr {
napomena := "Storno dokumenta " + original.BrojDokumenta
if razlog != "" {
napomena += ": " + razlog
}
return PdvKpr{
DatumPrometa: datumStorna,
DatumKnjizenja: datumStorna,
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
DobavljacNaziv: original.DobavljacNaziv,
DobavljacPib: original.DobavljacPib,
DobavljacMesto: original.DobavljacMesto,
OsnovicaOpsta: -original.OsnovicaOpsta,
PdvOpsta: -original.PdvOpsta,
OsnovicaPosebna: -original.OsnovicaPosebna,
PdvPosebna: -original.PdvPosebna,
PdvBezOdbitka: -original.PdvBezOdbitka,
OslobodenNabavka: -original.OslobodenNabavka,
Ukupno: -original.Ukupno,
Napomena: napomena,
Izvor: original.Izvor,
IzvorID: original.IzvorID,
Uvoz: original.Uvoz,
}
}
// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
func (k PdvKpr) OznakaPoreskogBroja() string {
cifre := 0
+32
View File
@@ -70,6 +70,38 @@ func TestPdvKirDodajNeto(t *testing.T) {
}
}
// 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.
func TestKirStorno(t *testing.T) {
izvorID := int64(18)
original := PdvKir{
BrojDokumenta: "PR-2607-0013", KupacNaziv: "Kupac doo", KupacPib: "123456789",
OsnovicaOpsta: 1000, PdvOpsta: 200, OsnovicaPosebna: 100, PdvPosebna: 10,
OslobodenSaPravom: 50, OslobodenBezPrava: 20, Ukupno: 1380,
Izvor: "prodaja", IzvorID: &izvorID,
}
datumStorna := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
s := KirStorno(original, "kupac odustao", datumStorna)
if s.BrojDokumenta != "STORNO-PR-2607-0013" {
t.Errorf("broj_dokumenta=%q, očekivano STORNO-PR-2607-0013", s.BrojDokumenta)
}
if s.Izvor != "prodaja" || s.IzvorID == nil || *s.IzvorID != 18 {
t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/18 (isto kao original)", s.Izvor, s.IzvorID)
}
if !blizu(s.OsnovicaOpsta, -1000) || !blizu(s.PdvOpsta, -200) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano -1000/-200", s.OsnovicaOpsta, s.PdvOpsta)
}
if !blizu(s.Ukupno, -1380) {
t.Errorf("ukupno=%v, očekivano -1380", s.Ukupno)
}
if original.Ukupno != 1380 {
t.Errorf("original izmenjen: ukupno=%v, očekivano da ostane 1380", original.Ukupno)
}
}
func TestKprIzNabavke(t *testing.T) {
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
stavke := []NabavkaStavkaPdv{