Files
GoNtech/internal/model/pdv_evidencija_test.go
T
Dasko 3c61063bbb 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.
2026-07-02 11:27:19 +02:00

255 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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%: CenaBezPdv je već izračunata neto vrednost (v. ProdajaRepo.Kreiraj) → osnovica 2×120=240, PDV 240×20%=48
{Kolicina: 2, CenaBezPdv: 120, PdvStopa: 20},
// 10%: osnovica 1×110=110, PDV 110×10%=11
{Kolicina: 1, CenaBezPdv: 110, PdvStopa: 10},
// 0%: 1 × 50 = 50 (oslobođeno, bez PDV)
{Kolicina: 1, CenaBezPdv: 50, PdvStopa: 0},
}
k := KirIzProdaje(nalog, stavke, "Kupac doo", "123456789", "Niš", nalog.Datum)
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, 240) || !blizu(k.PdvOpsta, 48) {
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 240/48", k.OsnovicaOpsta, k.PdvOpsta)
}
if !blizu(k.OsnovicaPosebna, 110) || !blizu(k.PdvPosebna, 11) {
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 110/11", k.OsnovicaPosebna, k.PdvPosebna)
}
if !blizu(k.OslobodenSaPravom, 50) {
t.Errorf("oslobođeno=%v, očekivano 50", k.OslobodenSaPravom)
}
if !blizu(k.Ukupno, 459) {
t.Errorf("ukupno=%v, očekivano 459 (288+121+50 bruto)", k.Ukupno)
}
}
// TestPdvKirDodajNeto: osnovica zadata kao NETO (servisni rad/deo) se NE deli,
// PDV se dodaje naviše po stvarnoj stopi i razvrstava u opštu/posebnu/oslobođen.
func TestPdvKirDodajNeto(t *testing.T) {
var k PdvKir
// rad 1000 neto @ 20% → osnovica 1000 (NE 833.33!), PDV 200, ukupno bruto 1200
k.DodajNeto(1000, 20)
if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) {
t.Errorf("20%%: osnovica=%v pdv=%v, očekivano 1000/200 (neto se ne deli)", k.OsnovicaOpsta, k.PdvOpsta)
}
// deo 1000 neto @ 10% → posebna osnovica 1000, PDV 100
k.DodajNeto(1000, 10)
if !blizu(k.OsnovicaPosebna, 1000) || !blizu(k.PdvPosebna, 100) {
t.Errorf("10%%: osnovica=%v pdv=%v, očekivano 1000/100", k.OsnovicaPosebna, k.PdvPosebna)
}
// usluga 500 neto @ 0% → oslobođen promet, bez PDV
k.DodajNeto(500, 0)
if !blizu(k.OslobodenSaPravom, 500) {
t.Errorf("0%%: oslobođeno=%v, očekivano 500", k.OslobodenSaPravom)
}
// ukupno (bruto) = 1200 + 1100 + 500 = 2800
if !blizu(k.Ukupno, 2800) {
t.Errorf("ukupno=%v, očekivano 2800 (1200+1100+500)", k.Ukupno)
}
}
// 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.
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{
{Osnovica: 200, PdvStopa: 20}, // PDV 40
{Osnovica: 100, PdvStopa: 10}, // PDV 10
{Osnovica: 50, PdvStopa: 0}, // oslobođena nabavka
}
k := KprIzNabavke(nabavka, "Dobavljač doo", "987654321", "Beograd", stavke)
if k.Izvor != "nabavka" || k.IzvorID == nil || *k.IzvorID != 3 {
t.Errorf("izvor=%q izvor_id=%v, očekivano nabavka/3", k.Izvor, k.IzvorID)
}
if k.BrojDokumenta != "NAB-3" || k.DobavljacNaziv != "Dobavljač doo" || k.DobavljacPib != "987654321" {
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.OslobodenNabavka, 50) {
t.Errorf("oslobođena nabavka=%v, očekivano 50", k.OslobodenNabavka)
}
if !blizu(k.Ukupno, 400) {
t.Errorf("ukupno=%v, očekivano 400 (240+110+50)", k.Ukupno)
}
}
func TestObracunajPdv(t *testing.T) {
// izlazni: 200 (opšta) + 50 (posebna) = 250; odbitni: 80 + 20 = 100; obaveza = 150
kir := PdvKirSume{PdvOpsta: 200, PdvPosebna: 50}
kpr := PdvKprSume{PdvOpsta: 80, PdvPosebna: 20, PdvBezOdbitka: 30}
o := ObracunajPdv(kir, kpr)
if !blizu(o.IzlazniPdvUkupno, 250) {
t.Errorf("izlazni=%v, očekivano 250", o.IzlazniPdvUkupno)
}
// PdvBezOdbitka (30) ne sme da uđe u odbitni PDV
if !blizu(o.OdbitniPdvUkupno, 100) {
t.Errorf("odbitni=%v, očekivano 100 (bez PdvBezOdbitka)", o.OdbitniPdvUkupno)
}
if !blizu(o.Obaveza, 150) || !o.ZaUplatu() {
t.Errorf("obaveza=%v ZaUplatu=%v, očekivano 150/true", o.Obaveza, o.ZaUplatu())
}
// kada je odbitni veći od izlaznog → negativna obaveza (povraćaj/prenos), ZaUplatu=false
o2 := ObracunajPdv(PdvKirSume{PdvOpsta: 40}, PdvKprSume{PdvOpsta: 100})
if !blizu(o2.Obaveza, -60) || o2.ZaUplatu() {
t.Errorf("obaveza=%v ZaUplatu=%v, očekivano -60/false", o2.Obaveza, o2.ZaUplatu())
}
}
func TestMapirajPPPDV(t *testing.T) {
kir := PdvKirSume{
OsnovicaOpsta: 1000.4, PdvOpsta: 200.08, // 003=1000, 103=200
OsnovicaPosebna: 500.5, PdvPosebna: 50.05, // 004=501 (round), 104=50
OslobodenSaPravom: 300.2, OslobodenBezPrava: 100.9, // 001=300, 002=101
}
kpr := PdvKprSume{
OsnovicaOpsta: 400, PdvOpsta: 80,
OsnovicaPosebna: 100, PdvPosebna: 10,
PdvBezOdbitka: 25, // ne sme da uđe nigde u PPPDV
}
p := MapirajPPPDV(kir, kpr, PdvKprSume{})
if p.Polje001 != 300 || p.Polje002 != 101 || p.Polje003 != 1000 || p.Polje103 != 200 {
t.Errorf("I deo (001/002/003/103) = %d/%d/%d/%d", p.Polje001, p.Polje002, p.Polje003, p.Polje103)
}
if p.Polje004 != 501 || p.Polje104 != 50 {
t.Errorf("posebna 004/104 = %d/%d, očekivano 501/50", p.Polje004, p.Polje104)
}
// 005 = 300+101+1000+501 = 1902; 105 = 200+50 = 250
if p.Polje005 != 1902 || p.Polje105 != 250 {
t.Errorf("zbir 005/105 = %d/%d, očekivano 1902/250", p.Polje005, p.Polje105)
}
// u ovom scenariju nema uvoza (prazan kprUvoz) ni poljoprivrednika → 006/106/007/107 = 0
if p.Polje006 != 0 || p.Polje106 != 0 || p.Polje007 != 0 || p.Polje107 != 0 {
t.Errorf("006/106/007/107 moraju biti 0")
}
// 008 = 400+100 = 500; 108 = 80+10 = 90 (PdvBezOdbitka 25 izostavljen)
if p.Polje008 != 500 || p.Polje108 != 90 {
t.Errorf("ostali prethodni 008/108 = %d/%d, očekivano 500/90", p.Polje008, p.Polje108)
}
if p.Polje009 != 500 || p.Polje109 != 90 {
t.Errorf("zbir prethodnog 009/109 = %d/%d, očekivano 500/90", p.Polje009, p.Polje109)
}
// 110 = 250 90 = 160 (za uplatu)
if p.Polje110 != 160 || p.Povracaj {
t.Errorf("110=%d Povracaj=%v, očekivano 160/false", p.Polje110, p.Povracaj)
}
// scenario povraćaja: izlazni < odbitni → 110 negativan, Povracaj=true
p2 := MapirajPPPDV(PdvKirSume{PdvOpsta: 30}, PdvKprSume{PdvOpsta: 90}, PdvKprSume{})
if p2.Polje110 != -60 || !p2.Povracaj || p2.Polje110Apsolutno() != 60 {
t.Errorf("povraćaj: 110=%d Povracaj=%v abs=%d, očekivano -60/true/60", p2.Polje110, p2.Povracaj, p2.Polje110Apsolutno())
}
// scenario uvoza: uvozni KPR ide u 006/106, domaći u 008/108
p3 := MapirajPPPDV(
PdvKirSume{},
PdvKprSume{OsnovicaOpsta: 1000, PdvOpsta: 200}, // domaće → 008/108
PdvKprSume{OsnovicaOpsta: 500, PdvOpsta: 100, OsnovicaPosebna: 50, PdvPosebna: 5}, // uvoz → 006/106
)
if p3.Polje006 != 550 || p3.Polje106 != 105 {
t.Errorf("uvoz 006/106 = %d/%d, očekivano 550/105", p3.Polje006, p3.Polje106)
}
if p3.Polje008 != 1000 || p3.Polje108 != 200 {
t.Errorf("domaće 008/108 = %d/%d, očekivano 1000/200", p3.Polje008, p3.Polje108)
}
// 009 = 006+007+008 = 550+0+1000 = 1550; 109 = 106+107+108 = 105+0+200 = 305
if p3.Polje009 != 1550 || p3.Polje109 != 305 {
t.Errorf("zbir prethodnog 009/109 = %d/%d, očekivano 1550/305", p3.Polje009, p3.Polje109)
}
}