diff --git a/internal/db/repository.go b/internal/db/repository.go index 6512970..3c4cec4 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -58,6 +58,9 @@ type PdvStopaRepository interface { 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 + // PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu), + // ili nil ako u šifarniku nema nijedne aktivne opšte stope. + PodrazumevanaOpsta(ctx context.Context) (*model.PdvStopa, error) } // PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR) diff --git a/internal/db/sqlite/pdv_stopa.go b/internal/db/sqlite/pdv_stopa.go index d2cfe38..28b56dc 100644 --- a/internal/db/sqlite/pdv_stopa.go +++ b/internal/db/sqlite/pdv_stopa.go @@ -78,6 +78,28 @@ func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, e return id, nil } +// PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu), +// ili nil ako u šifarniku nema nijedne aktivne opšte stope — poziocalac tada mora +// sam odlučiti o fallback vrednosti (šifarnik nikad ne sme biti prazan u praksi, +// ali ne oslanjamo se na to). +func (r *PdvStopaRepo) PodrazumevanaOpsta(ctx context.Context) (*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 oznaka = 'opsta' AND aktivna = 1 + ORDER BY redosled ASC, stopa DESC + LIMIT 1`). + Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.PodrazumevanaOpsta: %w", err) + } + return &s, 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, ` diff --git a/internal/db/sqlite/servisni_radovi.go b/internal/db/sqlite/servisni_radovi.go index 02a0d14..c528a52 100644 --- a/internal/db/sqlite/servisni_radovi.go +++ b/internal/db/sqlite/servisni_radovi.go @@ -20,9 +20,14 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo { // DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) { + // COALESCE fallback je za „ad-hoc" rad bez veze na cenovnik usluga + // (sr.usluga_id NULL) — tad se PDV stopa uzima iz šifarnika (Podešavanja → + // Kalkulacija i PDV), ne hardkodovana vrednost. 20.0 ostaje samo kao + // apsolutno poslednje utočište ako šifarnik nema nijednu aktivnu opštu stopu. redovi, err := r.db.QueryContext(ctx, ` SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno, - COALESCE(u.sifra, ''), COALESCE(u.pdv_stopa, 20.0) + COALESCE(u.sifra, ''), + COALESCE(u.pdv_stopa, (SELECT stopa FROM pdv_stope WHERE oznaka = 'opsta' AND aktivna = 1 ORDER BY redosled ASC, stopa DESC LIMIT 1), 20.0) FROM servis_radovi sr LEFT JOIN usluge u ON u.id = sr.usluga_id WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID) diff --git a/internal/fiskal/mapiranje.go b/internal/fiskal/mapiranje.go index dc85f24..49c82b1 100644 --- a/internal/fiskal/mapiranje.go +++ b/internal/fiskal/mapiranje.go @@ -64,10 +64,11 @@ func NapraviZahtev( ukupanIznos := 0.0 for _, s := range stavke { - stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena - if s.PdvStopa > 0 { - stopa = s.PdvStopa - } + // stopa je uvek ono što je snimljeno na stavci — 0 je legitimna vrednost + // (van sistema PDV/neobveznik), ne „nije postavljeno". Nikad je ne prepisivati + // nazad na opštu stopu, jer bi to poništilo nuliranje PDV-a za ne-PDV obveznike + // (v. docs/Greške.md). + stopa := s.PdvStopa // CenaBezPdv iz baze je već diskontovana (popust primenjen pri kreiranju naloga); // popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena. netoCena := s.CenaBezPdv @@ -147,16 +148,12 @@ func NapraviRefundZahtev( // mora biti dosledan, jer ga PFR ne povezuje po ID-u već po sadržaju stavke). const NazivAvansneStavke = "Аванс" -// StopaAvansa je pretpostavljena PDV stopa za avans — u trenutku uplate avansa -// stavke (radovi/delovi) obično još nisu poznate, pa se ne može tačno odrediti -// stvarna stopa. Koristi se opšta stopa (20%) kao razumna pretpostavka za -// srpsko tržište; ako firma posluje isključivo po posebnoj/nultoj stopi, ovo -// treba prilagoditi ručno (nema opšteg rešenja bez poznavanja stavki unapred). -const StopaAvansa = 20.0 - // NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka -// "Аванс" u punom (bruto) iznosu. -func NapraviAvansZahtev(iznos float64, nacinPlacanja, kasir string) InvoiceRequest { +// "Аванс" u punom (bruto) iznosu. stopa je PDV stopa avansa — u trenutku uplate +// stavke (radovi/delovi) obično još nisu poznate, pa pozivalac prosleđuje opštu +// stopu iz šifarnika (Podešavanja → Kalkulacija i PDV) kad je firma PDV obveznik, +// ili 0 kad nije — nikad hardkodovanu vrednost (v. docs/Greške.md §4.1). +func NapraviAvansZahtev(iznos, stopa float64, nacinPlacanja, kasir string) InvoiceRequest { return InvoiceRequest{ InvoiceRequest: InvoiceRequestBody{ InvoiceType: "Advance", @@ -165,7 +162,7 @@ func NapraviAvansZahtev(iznos float64, nacinPlacanja, kasir string) InvoiceReque {Amount: iznos, PaymentType: TipPlacanja(nacinPlacanja)}, }, Items: []InvoiceItem{ - {Name: NazivAvansneStavke, Labels: []string{OznakaPDV(StopaAvansa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1}, + {Name: NazivAvansneStavke, Labels: []string{OznakaPDV(stopa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1}, }, Cashier: kasir, }, @@ -175,15 +172,18 @@ func NapraviAvansZahtev(iznos float64, nacinPlacanja, kasir string) InvoiceReque // NapraviAvansRefundZahtev gradi Advance/Refund zahtev za povraćaj dela ili celog // avansa (npr. kad avans premaši konačnu cenu popravke). referentBroj je PfrBroj // originalnog avansnog računa. -func NapraviAvansRefundZahtev(iznos float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest { - zahtev := NapraviAvansZahtev(iznos, nacinPlacanja, kasir) +func NapraviAvansRefundZahtev(iznos, stopa float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest { + zahtev := NapraviAvansZahtev(iznos, stopa, nacinPlacanja, kasir) zahtev.InvoiceRequest.TransactionType = "Refund" zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj return zahtev } -// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po StopaAvansa -// (npr. 500 din avansa po 20% → porez ≈ 83.33 din). -func PorezIzBrutoAvansa(bruto float64) float64 { - return math.Round(bruto*StopaAvansa/(100+StopaAvansa)*100) / 100 +// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po datoj stopi +// (npr. 500 din avansa po 20% → porez ≈ 83.33 din). stopa=0 (ne-PDV obveznik) → 0. +func PorezIzBrutoAvansa(bruto, stopa float64) float64 { + if stopa <= 0 { + return 0 + } + return math.Round(bruto*stopa/(100+stopa)*100) / 100 } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 38ccecb..8069c63 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -168,6 +168,35 @@ func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool { return config.ModulUkljucen(podesavanja, modul) } +// podrazumevanaPdvStopa vraća opštu PDV stopu iz šifarnika (Podešavanja → Kalkulacija +// i PDV → Šifarnik PDV stopa) — jedini izvor „koja je opšta stopa" u programu. +// 20.0 se koristi samo kao krajnji fallback ako šifarnik nema nijednu aktivnu +// opštu stopu (npr. korisnik ih sve arhivirao) — ne kao podrazumevana poslovna +// pretpostavka, već da se izbegne prekid rada. +func (h *Handler) podrazumevanaPdvStopa(ctx context.Context) float64 { + stopa, err := h.PdvStopeRepo.PodrazumevanaOpsta(ctx) + if err != nil || stopa == nil { + return 20.0 + } + return stopa.Stopa +} + +// validnePdvStope vraća skup PDV stopa koje korisnik sme da izabere na stavci — +// aktivne stope iz šifarnika (Podešavanja → Kalkulacija i PDV), plus 0 koje je +// uvek dozvoljeno (van sistema PDV/oslobođeno). Zamenjuje hardkodovanu proveru +// „0, 10 ili 20" — šifarnik je jedini izvor važećih stopa. +func (h *Handler) validnePdvStope(ctx context.Context) map[float64]bool { + validne := map[float64]bool{0: true} + stope, err := h.PdvStopeRepo.Lista(ctx, true) + if err != nil { + return validne + } + for _, s := range stope { + validne[s.Stopa] = true + } + return validne +} + // 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/magacin_forma.go b/internal/handler/magacin_forma.go index 6653d2a..2c6f559 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -62,7 +62,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Kategorije: kategorije, Dobavljaci: dobavljaci, - Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: 20.0}, + Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())}, Izmena: false, }) } @@ -79,7 +79,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) { return } - artikal, greska := parseFormuArtikla(r) + artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context())) if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) kategorije, _ := h.KategorijeRepo.Lista(r.Context()) @@ -210,7 +210,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { return } - artikal, greska := parseFormuArtikla(r) + artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context())) if greska != "" { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) kategorije, _ := h.KategorijeRepo.Lista(r.Context()) @@ -325,7 +325,9 @@ func citajDobavljaceForme(r *http.Request) []int64 { } // parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku -func parseFormuArtikla(r *http.Request) (model.Artikal, string) { +// podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) — +// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost. +func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artikal, string) { naziv := r.FormValue("naziv") if naziv == "" { return model.Artikal{}, "Naziv artikla je obavezan." @@ -389,8 +391,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) { artikal.NabavnaCena = v } - // PDV stopa — podrazumevano 20% - pdvStopa := 20.0 + // PDV stopa — podrazumevano opšta stopa iz šifarnika + pdvStopa := podrazumevanaStopa if p := r.FormValue("pdv_stopa"); p != "" { if v, err := strconv.ParseFloat(p, 64); err == nil && v >= 0 { pdvStopa = v diff --git a/internal/handler/parseformu_test.go b/internal/handler/parseformu_test.go index dcc3950..6aa1693 100644 --- a/internal/handler/parseformu_test.go +++ b/internal/handler/parseformu_test.go @@ -22,7 +22,7 @@ func TestParseFormuArtikla(t *testing.T) { t.Run("validan", func(t *testing.T) { a, greska := parseFormuArtikla(formZahtev(url.Values{ "naziv": {"Pumpa"}, "kolicina": {"10"}, "prodajna_cena": {"1500.50"}, - })) + }), 20.0) if greska != "" { t.Fatalf("neočekivana greška: %q", greska) } @@ -31,18 +31,18 @@ func TestParseFormuArtikla(t *testing.T) { } }) t.Run("naziv obavezan", func(t *testing.T) { - if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}})); greska == "" { + if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}}), 20.0); greska == "" { t.Fatal("prazan naziv mora dati grešku") } }) t.Run("negativna količina", func(t *testing.T) { - _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}})) + _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}}), 20.0) if greska == "" { t.Fatal("negativna količina mora dati grešku") } }) t.Run("neispravna cena", func(t *testing.T) { - _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}})) + _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}}), 20.0) if greska == "" { t.Fatal("neispravna cena mora dati grešku") } @@ -91,7 +91,7 @@ func TestParseFormuKlijenta(t *testing.T) { func TestParseFormuProdaje(t *testing.T) { t.Run("bez stavki", func(t *testing.T) { - _, _, greska := parseFormuProdaje(formZahtev(url.Values{})) + _, _, greska := parseFormuProdaje(formZahtev(url.Values{}), map[float64]bool{0: true, 10: true, 20: true}) if greska == "" { t.Fatal("prodaja bez stavki mora dati grešku") } @@ -99,7 +99,7 @@ func TestParseFormuProdaje(t *testing.T) { t.Run("količina nula", func(t *testing.T) { _, _, greska := parseFormuProdaje(formZahtev(url.Values{ "artikal_id[]": {"1"}, "kolicina[]": {"0"}, "cena_po_komadu[]": {"100"}, - })) + }), map[float64]bool{0: true, 10: true, 20: true}) if greska == "" { t.Fatal("količina 0 mora dati grešku") } @@ -107,7 +107,7 @@ func TestParseFormuProdaje(t *testing.T) { t.Run("neispravan artikal", func(t *testing.T) { _, _, greska := parseFormuProdaje(formZahtev(url.Values{ "artikal_id[]": {"0"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"}, - })) + }), map[float64]bool{0: true, 10: true, 20: true}) if greska == "" { t.Fatal("artikal_id 0 mora dati grešku") } @@ -115,7 +115,7 @@ func TestParseFormuProdaje(t *testing.T) { t.Run("validna stavka", func(t *testing.T) { nalog, stavke, greska := parseFormuProdaje(formZahtev(url.Values{ "artikal_id[]": {"3"}, "kolicina[]": {"2"}, "cena_po_komadu[]": {"250"}, "pdv_stopa[]": {"20"}, - })) + }), map[float64]bool{0: true, 10: true, 20: true}) if greska != "" { t.Fatalf("neočekivana greška: %q", greska) } @@ -133,7 +133,7 @@ func TestParseFormuProdaje(t *testing.T) { t.Run("nesklad broja stavki", func(t *testing.T) { _, _, greska := parseFormuProdaje(formZahtev(url.Values{ "artikal_id[]": {"1", "2"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"}, - })) + }), map[float64]bool{0: true, 10: true, 20: true}) if greska == "" { t.Fatal("nesklad broja stavki mora dati grešku") } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index b44ba42..77a4988 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -285,6 +285,20 @@ func validirajMaticniBroj(broj string) string { return "" } +// validirajProfilFirme proverava da li je kombinacija pravnog/poreskog statusa +// pravno moguća — vidi docs/Greške.md §1. „Samo evidencija” gasi ceo zakonski +// sloj, pa dok je firma u tom režimu ne postoji osnov da bude PDV obveznik +// niti da izdaje fiskalne račune. +func validirajProfilFirme(rezim, pdvObveznik, fiskalizacija string) string { + if rezim != "samo_evidencija" { + return "" + } + if pdvObveznik == "da" || fiskalizacija == "da" { + return "Režim „Samo evidencija” ne dozvoljava PDV obveznika ni izdavanje fiskalnih računa — prebacite firmu na režim „Pun” ako stvarno posluje." + } + return "" +} + // SacuvajPodesavanja prima POST i čuva podešavanja u bazu func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { @@ -320,6 +334,19 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { return } + if _, jeFirmaSekcija := r.Form["_sekcija_firma"]; jeFirmaSekcija { + greska := validirajProfilFirme(r.FormValue("firma_rezim"), r.FormValue("firma_pdv_obveznik"), r.FormValue("firma_fiskalizacija")) + if greska != "" { + middleware.SetFlash(w, r, h.DB, "greska", greska) + sledeci := "/podesavanja" + if r.FormValue("_next") == "/admin/podesavanja/opste" { + sledeci = "/admin/podesavanja/opste" + } + http.Redirect(w, r, sledeci, http.StatusSeeOther) + return + } + } + polja := map[string]string{ "naziv_firme": r.FormValue("naziv_firme"), "podnazlov": r.FormValue("podnazlov"), diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 358e25f..eed3d9c 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -245,7 +245,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { return } - nalog, stavke, greska := parseFormuProdaje(r) + nalog, stavke, greska := parseFormuProdaje(r, h.validnePdvStope(r.Context())) renderujGresku := func(poruka string) { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) @@ -536,7 +536,9 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis } // parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku -func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) { +// validneStope su dozvoljene PDV stope na stavci (v. Handler.validnePdvStope) — +// izvedene iz šifarnika, ne hardkodovane. +func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.ProdajniNalog, []model.StavkaProdaje, string) { var nalog model.ProdajniNalog if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" { @@ -589,8 +591,8 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd return nalog, nil, "Neispravna PDV stopa u stavci." } } - if pdvStopa != 0 && pdvStopa != 10 && pdvStopa != 20 { - return nalog, nil, "PDV stopa mora biti 0, 10 ili 20." + if !validneStope[pdvStopa] { + return nalog, nil, "PDV stopa u stavci nije u šifarniku PDV stopa." } var popust float64 diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 6537e13..3c69835 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -2531,8 +2531,15 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no if nacinPlacanja == "" { nacinPlacanja = "Gotovina" } + // stopa avansa: opšta stopa iz šifarnika za PDV obveznika, 0 (van sistema PDV) + // za ne-obveznika — stavke (radovi/delovi) još nisu poznate u trenutku avansa, + // pa se ne može koristiti njihova stvarna stopa (v. docs/Greške.md §4.2). + stopaAvansa := 0.0 + if h.modulUkljucen(ctx, config.ModulPdv) { + stopaAvansa = h.podrazumevanaPdvStopa(ctx) + } - zahtev := fiskal.NapraviAvansZahtev(delta, nacinPlacanja, h.imeKasira(ctx)) + zahtev := fiskal.NapraviAvansZahtev(delta, stopaAvansa, nacinPlacanja, h.imeKasira(ctx)) odgovor, err := klijent.IzdajRacun(ctx, zahtev) if err != nil { slog.Error("fiskalizacija avansa servisa nije uspela", "servis_id", servisID, "error", err) @@ -2587,7 +2594,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent return } - items := stavkeFiskalnogServisa(radovi, delovi) + items := stavkeFiskalnogServisa(radovi, delovi, h.modulUkljucen(ctx, config.ModulPdv)) if len(items) == 0 { slog.Warn("fiskalizujServis: nema stavki za fiskalni račun, zahtev odbačen", "id", servisID) return @@ -2626,6 +2633,13 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent // zatvara — PFR sam raspoređuje avans na ovaj račun preko Advance* polja. // Ako avans premašuje punu cenu (visak), zatvara se samo do pune cene, a // razlika se posle uspešnog Sale-a vraća posebnim Advance/Refund zahtevom. + // stopa avansa — ista logika kao pri fiskalizaciji avansa (v. fiskalizujAvansServisa); + // pretpostavlja da se profil firme nije promenio između avansa i konačne naplate. + stopaAvansa := 0.0 + if h.modulUkljucen(ctx, config.ModulPdv) { + stopaAvansa = h.podrazumevanaPdvStopa(ctx) + } + var visakAvansa float64 var avansRacun *model.FiskalniRacun if avansNeto, e := h.FiskalRepo.SumaAvansaPoServisu(ctx, servisID); e == nil && avansNeto > 0 { @@ -2641,7 +2655,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent visakAvansa = math.Round((iskoriscenAvans-punaCena)*100) / 100 iskoriscenAvans = punaCena } - porez := fiskal.PorezIzBrutoAvansa(iskoriscenAvans) + porez := fiskal.PorezIzBrutoAvansa(iskoriscenAvans, stopaAvansa) zahtev.AdvancePaid = &iskoriscenAvans zahtev.AdvanceTax = &porez zahtev.AdvanceLastInvoiceNumber = avansRacun.PfrBroj @@ -2685,7 +2699,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent // avans je premašio punu cenu — vrati razliku posebnim Advance/Refund zahtevom if visakAvansa > 0 && avansRacun != nil { - refundZahtev := fiskal.NapraviAvansRefundZahtev(visakAvansa, nacinPlacanja, kasir, avansRacun.PfrBroj) + refundZahtev := fiskal.NapraviAvansRefundZahtev(visakAvansa, stopaAvansa, nacinPlacanja, kasir, avansRacun.PfrBroj) refundOdgovor, errRefund := klijent.IzdajRacun(ctx, refundZahtev) if errRefund != nil { slog.Error("povraćaj viška avansa nije uspeo", "servis_id", servisID, "error", errRefund) @@ -2720,17 +2734,27 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent // stavkeFiskalnogServisa gradi stavke fiskalnog računa od radova i ugrađenih // delova servisnog naloga — koristi se i za Sale (fiskalizujServis) i za // Refund (refundujServis), predloženi (neprihvaćeni) redovi se ne naplaćuju. -func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniDeoSaArtiklom) []fiskal.InvoiceItem { +// pdvUkljucen odražava trenutni profil firme (modul „pdv") — kad je false, +// stope se prisilno nuliraju bez obzira šta piše u tabelama usluga/artikala +// (ista odbrana kao u Prodaji, v. prodaja.go i docs/Greške.md §2.1). +func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniDeoSaArtiklom, pdvUkljucen bool) []fiskal.InvoiceItem { + stopaZa := func(stopa float64) float64 { + if !pdvUkljucen { + return 0 + } + return stopa + } items := make([]fiskal.InvoiceItem, 0) for _, r := range radovi { if r.Predlozeno { continue } + stopa := stopaZa(r.PdvStopa) items = append(items, fiskal.InvoiceItem{ Name: r.Naziv, - Labels: []string{fiskal.OznakaPDV(r.PdvStopa)}, - TotalAmount: fiskal.BrutoCena(r.Ukupno(), r.PdvStopa), - UnitPrice: fiskal.BrutoCena(r.CenaKomada, r.PdvStopa), + Labels: []string{fiskal.OznakaPDV(stopa)}, + TotalAmount: fiskal.BrutoCena(r.Ukupno(), stopa), + UnitPrice: fiskal.BrutoCena(r.CenaKomada, stopa), Quantity: r.Kolicina, }) } @@ -2738,11 +2762,12 @@ func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniD if d.Predlozeno { continue } + stopa := stopaZa(d.PdvStopa) items = append(items, fiskal.InvoiceItem{ Name: d.ArtikalNaziv, - Labels: []string{fiskal.OznakaPDV(d.PdvStopa)}, - TotalAmount: fiskal.BrutoCena(d.Ukupno(), d.PdvStopa), - UnitPrice: fiskal.BrutoCena(d.CenaKomada, d.PdvStopa), + Labels: []string{fiskal.OznakaPDV(stopa)}, + TotalAmount: fiskal.BrutoCena(d.Ukupno(), stopa), + UnitPrice: fiskal.BrutoCena(d.CenaKomada, stopa), Quantity: float64(d.Kolicina), }) } @@ -2769,7 +2794,7 @@ func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *f slog.Error("refundujServis: greška pri dohvatanju delova", "id", servisID, "error", err) return err } - items := stavkeFiskalnogServisa(radovi, delovi) + items := stavkeFiskalnogServisa(radovi, delovi, h.modulUkljucen(ctx, config.ModulPdv)) if len(items) == 0 { return nil } diff --git a/internal/handler/usluge.go b/internal/handler/usluge.go index 3498fb6..f9a6cd0 100644 --- a/internal/handler/usluge.go +++ b/internal/handler/usluge.go @@ -63,7 +63,7 @@ func (h *Handler) NovaUsluga(w http.ResponseWriter, r *http.Request) { if err != nil { sifra = "USL-001" } - h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: 20, JedinicaMere: "usluga"}, false, "") + h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: h.podrazumevanaPdvStopa(r.Context()), JedinicaMere: "usluga"}, false, "") } // IzmeniUslugu prikazuje formu sa postojećom uslugom @@ -86,7 +86,7 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok { return } - usluga, greska := parseFormuUsluge(r) + usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context())) if greska != "" { h.renderujFormuUsluge(w, r, usluga, false, greska) return @@ -108,7 +108,7 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) { http.Error(w, "Neispravan ID usluge", http.StatusBadRequest) return } - usluga, greska := parseFormuUsluge(r) + usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context())) usluga.ID = id if greska != "" { h.renderujFormuUsluge(w, r, usluga, true, greska) @@ -139,7 +139,9 @@ func (h *Handler) ObrisiUslugu(w http.ResponseWriter, r *http.Request) { } // parseFormuUsluge čita i validira polja forme usluge. Vraća model i poruku o grešci. -func parseFormuUsluge(r *http.Request) (model.Usluga, string) { +// podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) — +// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost. +func parseFormuUsluge(r *http.Request, podrazumevanaStopa float64) (model.Usluga, string) { if err := r.ParseForm(); err != nil { return model.Usluga{}, "Greška pri čitanju forme." } @@ -154,7 +156,7 @@ func parseFormuUsluge(r *http.Request) (model.Usluga, string) { } pdv, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("pdv_stopa")), 64) if err != nil || pdv < 0 { - pdv = 20 + pdv = podrazumevanaStopa } jm := strings.TrimSpace(r.FormValue("jedinica_mere"))