From 3a0f98622b3d9cce3de2422701644d6fb1a3ada4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Wed, 1 Jul 2026 23:01:26 +0200 Subject: [PATCH] Knjigovodstvo i fiskalizacija: dashboard upozorenje za praznine, KPO dozvole, pojednostavljen storno, ispravke verifikacije i kasira MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard kartica prikazuje naloge bez KIR/KPO/fiskalnog upisa i sumnjive duplikate (double-submit), sa zaštitom od automatskog dupliranja prihoda pri bulk dopuni - Registrovane nedostajuće KPO dozvole (kpo.pregled/dodaj/obrisi) — modul je bio potpuno nedostupan - Uklonjen suvišan/nefunkcionalan 'Ukloni nalog' (duplirao Storno bez stvarne razlike); Storno u listi prodaje sad vodi direktno na formu - Kasir na fiskalnom računu koristi ime prijavljenog korisnika umesto fiksnog podešavanja - Fiskalni računi za nalog uvek prikazuju najnoviji zapis (original vs. refund posle storna) - Verifikaciona stranica (/v/) jasno označena kao lokalna test simulacija, ne prava provera kod PURS-a; dodat prikaz kusura - Mock brojač fiskalnih računa (kartica emulator) sad preživljava restart servera --- cmd/ntech/main.go | 1 - internal/be/kartica.go | 50 +++- internal/db/sqlite/fiskal.go | 2 + internal/handler/dashboard.go | 8 + internal/handler/fiskal_verifikacija.go | 9 + internal/handler/handler.go | 23 ++ internal/handler/kpo.go | 32 ++- internal/handler/pdv_kir.go | 19 +- internal/handler/prodaja.go | 47 +--- internal/handler/servis.go | 15 +- internal/handler/uskladjivanje.go | 229 ++++++++++++++++++ internal/middleware/dozvole.go | 9 +- internal/middleware/dozvole_test.go | 2 +- internal/model/stranica.go | 19 ++ web/templates/stranice/admin_dozvole.html | 6 - web/templates/stranice/dashboard.html | 19 ++ .../stranice/fiskal_verifikacija.html | 14 +- web/templates/stranice/prodaja.html | 10 +- web/templates/stranice/prodaja_detalji.html | 25 +- web/templates/stranice/servis_detalji.html | 2 +- 20 files changed, 417 insertions(+), 124 deletions(-) create mode 100644 internal/handler/uskladjivanje.go diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 5cb538e..44b3fb0 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -416,7 +416,6 @@ func main() { r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja) r.Get("/prodaja/nova", h.NovaProdaja) r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) - r.With(doz("prodaja.obrisi")).Post("/prodaja/obrisi/{id}", h.ObrisiProdaju) r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje) r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje) diff --git a/internal/be/kartica.go b/internal/be/kartica.go index 26a104e..dd5dac4 100644 --- a/internal/be/kartica.go +++ b/internal/be/kartica.go @@ -4,8 +4,10 @@ package be import ( + "context" "database/sql" "encoding/base64" + "encoding/json" "fmt" "log/slog" "math/rand/v2" @@ -14,6 +16,8 @@ import ( "strings" "sync" "time" + + "ntech/internal/db/sqlite" ) // tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača) @@ -33,6 +37,7 @@ var tipExt = map[[2]string][2]string{ // Kartica drži stanje emuliranog bezbednosnog elementa. type Kartica struct { mu sync.Mutex + db *sql.DB // identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju) JID string @@ -59,6 +64,7 @@ type Kartica struct { func novaKartica(db *sql.DB) *Kartica { k := &Kartica{ + db: db, JID: env("BE_JID", "TRNMOCK1"), PIN: env("BE_PIN", "1234"), Limit: envFloat("BE_LIMIT", 500000), @@ -91,7 +97,7 @@ func (k *Kartica) ucitajFirmu(db *sql.DB) { "SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " + "('naziv_firme','pib','maticni_broj','adresa','telefon'," + "'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," + - "'be_pin','be_limit')", + "'be_pin','be_limit','be_total_counter','be_counters_json','be_unread_amount')", ) if err != nil { slog.Warn("be: ne mogu da čitam podesavanja", "error", err) @@ -154,6 +160,45 @@ func (k *Kartica) ucitajFirmu(db *sql.DB) { } } } + + // brojači se pamte preko restarta — fiskalni brojač NIKAD ne sme da krene + // ispočetka, to bi proizvelo duplirane PFR brojeve za stvarne (ili mock) račune + if tc := p["be_total_counter"]; tc != "" { + if n, err := strconv.Atoi(tc); err == nil { + k.totalCounter = n + } + } + if cj := p["be_counters_json"]; cj != "" { + var sacuvani map[string]int + if err := json.Unmarshal([]byte(cj), &sacuvani); err == nil { + for key, val := range sacuvani { + k.counters[key] = val + } + } + } + if ua := p["be_unread_amount"]; ua != "" { + if f, err := strconv.ParseFloat(ua, 64); err == nil { + k.unreadAmount = f + } + } +} + +// sacuvajBrojace upisuje trenutno stanje brojača u podesavanja, da posle restarta +// emulatora fiskalni brojač nastavi odakle je stao (poziva se pod k.mu iz cmdSign/ +// cmdResetAudit). +func (k *Kartica) sacuvajBrojace() { + if k.db == nil { + return + } + ctx := context.Background() + if err := sqlite.SacuvajPodesavanje(ctx, k.db, "be_total_counter", strconv.Itoa(k.totalCounter)); err != nil { + slog.Warn("be: ne mogu da sačuvam brojač", "error", err) + return + } + if cj, err := json.Marshal(k.counters); err == nil { + _ = sqlite.SacuvajPodesavanje(ctx, k.db, "be_counters_json", string(cj)) + } + _ = sqlite.SacuvajPodesavanje(ctx, k.db, "be_unread_amount", strconv.FormatFloat(k.unreadAmount, 'f', -1, 64)) } func (k *Kartica) postaviTestPodatke() { @@ -220,6 +265,7 @@ func (k *Kartica) cmdResetAudit() map[string]any { k.mu.Lock() defer k.mu.Unlock() k.unreadAmount = 0 + k.sacuvajBrojace() return map[string]any{"status": "ok", "unread_amount": 0} } @@ -260,6 +306,8 @@ func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float } } + k.sacuvajBrojace() + // lažni potpis — random base64 dovoljno dug da izgleda realistično sig := mockPotpis() diff --git a/internal/db/sqlite/fiskal.go b/internal/db/sqlite/fiskal.go index 45238f3..47e4778 100644 --- a/internal/db/sqlite/fiskal.go +++ b/internal/db/sqlite/fiskal.go @@ -62,6 +62,7 @@ func (r *FiskalRepo) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*mo poreske_stavke, ukupno_za_naplatu, ukupan_porez, sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja FROM fiskalni_racuni WHERE prodaja_id = ? + ORDER BY id DESC LIMIT 1 `, prodajaID).Scan( &fr.ID, &fr.ProdajaID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme, &fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod, @@ -93,6 +94,7 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod poreske_stavke, ukupno_za_naplatu, ukupan_porez, sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja FROM fiskalni_racuni WHERE servis_id = ? + ORDER BY id DESC LIMIT 1 `, servisID).Scan( &fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme, &fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod, diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 4521119..ee5c2f0 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -101,6 +101,13 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } + var prazninaKnjigovodstva model.PrazninaKnjigovodstva + if p, err := h.PraznineKnjigovodstva(ctx); err != nil { + slog.Error("dashboard: praznine knjigovodstva", "error", err) + } else { + prazninaKnjigovodstva = p + } + // poslednjih 5 prodajnih naloga var poslednjeProdaje []model.StavkaProdajePregled if redovi, err := h.IzvestajRepo.PoslednjeProdaje(ctx, 5); err != nil { @@ -130,6 +137,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { KriticneZalihe: kriticneZalihe, PoslednjeProdaje: poslednjeProdaje, FlashGreska: flashGreska, + PrazninaKnjige: prazninaKnjigovodstva, } h.renderujTemplate(w, "dashboard", podaci) diff --git a/internal/handler/fiskal_verifikacija.go b/internal/handler/fiskal_verifikacija.go index b21ffdd..34f189b 100644 --- a/internal/handler/fiskal_verifikacija.go +++ b/internal/handler/fiskal_verifikacija.go @@ -52,6 +52,7 @@ type fiskalVerifikacijaPodaci struct { Podaci *FiskalVlPodaci Greska string JeFiskalni bool + Kusur float64 // zbir plaćanja umanjen za ukupan iznos, prikazuje se samo ako je > 0 } func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) { @@ -88,5 +89,13 @@ func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) { vlPodaci.InvoiceType != "Training" && vlPodaci.InvoiceType != "Proforma" + var placeno float64 + for _, p := range vlPodaci.Payments { + placeno += p.Amount + } + if kusur := placeno - vlPodaci.TotalAmount; kusur > 0.005 { + podaci.Kusur = kusur + } + h.renderujStandalone(w, "fiskal_verifikacija", podaci) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 0be8629..38ccecb 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -6,6 +6,7 @@ import ( "html/template" "io/fs" "net/http" + "strings" "sync" "ntech/internal/config" @@ -257,3 +258,25 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent { } return fiskal.NoviKlijent(url, key) } + +// imeKasira vraća ime i prezime prijavljenog korisnika za polje "kasir" na fiskalnom +// računu (korisničko ime kao rezerva ako ime/prezime nisu uneti). Pada nazad na +// podešavanje pfr_kasir (npr. za pozadinske pozive bez korisnika u kontekstu), pa na +// "NTech" ako ni to nije uneto. +func (h *Handler) imeKasira(ctx context.Context) string { + kasir := "" + if k := middleware.KorisnikIzKonteksta(ctx); k != nil { + if k.Ime != "" || k.Prezime != "" { + kasir = strings.TrimSpace(k.Ime + " " + k.Prezime) + } else { + kasir = k.KorisnickoIme + } + } + if kasir == "" { + kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") + } + if kasir == "" { + kasir = "NTech" + } + return kasir +} diff --git a/internal/handler/kpo.go b/internal/handler/kpo.go index daf4e2c..fa3ed82 100644 --- a/internal/handler/kpo.go +++ b/internal/handler/kpo.go @@ -7,7 +7,6 @@ import ( "strings" "time" - appdb "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -161,24 +160,28 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context() - var kreirano, preskoceno int + var kreirano, preskoceno, sumnjivo int // prodajni nalozi - nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) + nalozi, prodajaPostoji, prodajaKandidati, err := h.kpoKandidatiProdaje(ctx) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return } + prodajaDuplikati := sumnjiviDuplikati(prodajaKandidati) for _, nd := range nalozi { if nd.Stornirano { preskoceno++ continue } - postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID) - if postoji { + if prodajaPostoji[nd.ID] { preskoceno++ continue } + if prodajaDuplikati[nd.ID] { + sumnjivo++ + continue + } z := model.KpoZapis{ DatumPrometa: nd.Datum, BrojDokumenta: nd.BrojNaloga, @@ -196,18 +199,19 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) { } // naplaćeni servisni nalozi - servisNalozi, err := h.ServisRepo.Lista(ctx, "", "") + naplaceni, servisPostoji, servisKandidati, nenaplaceniBroj, err := h.kpoKandidatiServisa(ctx) if err != nil { slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err) } else { - for _, sn := range servisNalozi { - if sn.Status != model.StatusPreuzeto || sn.Naplaceno == 0 { + preskoceno += nenaplaceniBroj + servisDuplikati := sumnjiviDuplikati(servisKandidati) + for _, sn := range naplaceni { + if servisPostoji[sn.ID] { preskoceno++ continue } - postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID) - if postoji { - preskoceno++ + if servisDuplikati[sn.ID] { + sumnjivo++ continue } datum := sn.DatumPrijema @@ -231,6 +235,10 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) { } } - middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno)) + poruka := fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno) + if sumnjivo > 0 { + poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo) + } + middleware.SetFlash(w, r, h.DB, "uspeh", poruka) http.Redirect(w, r, "/kpo", http.StatusSeeOther) } diff --git a/internal/handler/pdv_kir.go b/internal/handler/pdv_kir.go index 2cd4f87..ef70535 100644 --- a/internal/handler/pdv_kir.go +++ b/internal/handler/pdv_kir.go @@ -8,7 +8,6 @@ import ( "strings" "time" - appdb "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" "ntech/internal/model" @@ -223,23 +222,27 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) { ctx := r.Context() sada := time.Now() - nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) + nalozi, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return } + duplikati := sumnjiviDuplikati(kandidati) - var kreirano, preskoceno int + var kreirano, preskoceno, sumnjivo int for _, nd := range nalozi { if nd.KlijentID == nil { preskoceno++ continue } - postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID) - if postoji { + if postojiMap[nd.ID] { preskoceno++ continue } + if duplikati[nd.ID] { + sumnjivo++ + continue + } stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, nd.ID) if err != nil { slog.Error("backfill KIR: greška pri čitanju stavki", "prodaja_id", nd.ID, "error", err) @@ -267,7 +270,11 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) { kreirano++ } - middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno)) + poruka := fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno) + if sumnjivo > 0 { + poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo) + } + middleware.SetFlash(w, r, h.DB, "uspeh", poruka) http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther) } diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index e72f557..a4fcad0 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -27,7 +27,6 @@ type PodaciProdaje struct { model.PodaciStranice Nalozi []model.ProdajniNalogSaDetaljem Sacuvano bool - Obrisan bool Pretraga string Od string Do string @@ -189,7 +188,6 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { PodaciStranice: ps, Nalozi: nalozi, Sacuvano: r.URL.Query().Get("sacuvano") == "1", - Obrisan: r.URL.Query().Get("obrisan") == "1", Pretraga: pretraga, Od: od, Do: do, @@ -533,39 +531,6 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis fmt.Fprint(w, `

`) } -// ObrisiProdaju prima POST zahtev i stornira nalog umesto fizičkog brisanja. -// Jednom izdat račun ne sme da nestane — umesto brisanja koristi se storno. -func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) { - k, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi") - if !ok { - return - } - id, err := parseID(chi.URLParam(r, "id")) - if err != nil { - http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) - return - } - - if err := r.ParseForm(); err != nil { - http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) - return - } - poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca")) - if err := h.stornirajProdaju(r.Context(), id, "administrativno uklanjanje", &k.ID, poreskiBrojKupca); err != nil { - poruka := "Greška pri uklanjanju. Možda je nalog već storniran." - if errors.Is(err, errPotrebnaIdentKupca) { - poruka = errPotrebnaIdentKupca.Error() - } else { - slog.Error("greška pri uklanjanju naloga", "error", err) - } - middleware.SetFlash(w, r, h.DB, "greska", poruka) - http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) - return - } - - http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther) -} - // 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) { var nalog model.ProdajniNalog @@ -701,8 +666,6 @@ func klasifikujPoreskiBroj(broj string) (pib, jmbg string) { // stornirajProdaju sprovodi kompletan storno prodajnog naloga: menja status naloga i // vraća artikle na stanje, uklanja vezani auto-KIR zapis, šalje fiskalni refund // (best-effort — storno ostaje validan i bez uspešnog refunda) i briše KPO zapis. -// Zajednička je za StornoProdaje i ObrisiProdaju kako obe akcije imaju isti, -// potpuni efekat prema PDV/fiskalnoj i KPO evidenciji. func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, korisnikID *int64, poreskiBrojKupca string) error { // Refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG) — ako je nalog // bez klijenta (ili klijent nema upisan PIB/JMBG), mora se ručno uneti pre storna. @@ -735,10 +698,7 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, if fk := h.fiskalKlijent(); fk != nil { if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil { if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil { - kasirFisk, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") - if kasirFisk == "" { - kasirFisk = "NTech" - } + kasirFisk := h.imeKasira(ctx) var pibFisk, jmbgFisk string if nalogFisk.KlijentID != nil { if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil { @@ -810,10 +770,7 @@ func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijen return } - kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") - if kasir == "" { - kasir = "NTech" - } + kasir := h.imeKasira(ctx) var pib, jmbg string if nalog.KlijentID != nil { diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 1e38cee..831c8a9 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -2340,20 +2340,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent } } - kasir := "" - if kor := middleware.KorisnikIzKonteksta(ctx); kor != nil { - if kor.Ime != "" || kor.Prezime != "" { - kasir = strings.TrimSpace(kor.Ime + " " + kor.Prezime) - } else { - kasir = kor.KorisnickoIme - } - } - if kasir == "" { - kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") - } - if kasir == "" { - kasir = "NTech" - } + kasir := h.imeKasira(ctx) zahtev := fiskal.InvoiceRequest{ InvoiceRequest: fiskal.InvoiceRequestBody{ diff --git a/internal/handler/uskladjivanje.go b/internal/handler/uskladjivanje.go new file mode 100644 index 0000000..77bbb23 --- /dev/null +++ b/internal/handler/uskladjivanje.go @@ -0,0 +1,229 @@ +package handler + +import ( + "context" + "time" + + appdb "ntech/internal/db" + "ntech/internal/model" +) + +// kandidatUskladjivanja opisuje jedan nalog koji se razmatra za automatski backfill +// KIR/KPO upisa — koristi se samo za detekciju sumnjivih duplikata (double-submit), +// ne za sam upis. +type kandidatUskladjivanja struct { + ID int64 + KlijentID *int64 + Iznos float64 + Datum time.Time + ImaUpis bool +} + +// sumnjiviDuplikati vraća ID-eve naloga BEZ upisa koje automatski backfill treba da +// PRESKOČI — jer postoji drugi (bliski) nalog istog klijenta i iznosa u roku od minuta +// koji već ima upis, ili je stariji i takođe čeka na upis. Ovo je znak dvostrukog +// slanja forme (double-submit bag na naplati), ne dva odvojena prometa — automatska +// dopuna bi inače duplirala prihod. Prosleđuje se CEO skup naloga (i sa i bez upisa) +// radi poređenja. +func sumnjiviDuplikati(svi []kandidatUskladjivanja) map[int64]bool { + const prozor = 60 * time.Second + sumnjivi := make(map[int64]bool) + for _, a := range svi { + if a.ImaUpis { + continue + } + for _, b := range svi { + if a.ID == b.ID { + continue + } + if !istKlijent(a.KlijentID, b.KlijentID) || a.Iznos != b.Iznos { + continue + } + razlika := a.Datum.Sub(b.Datum) + if razlika < 0 { + razlika = -razlika + } + if razlika > prozor { + continue + } + if b.ImaUpis || b.ID < a.ID { + sumnjivi[a.ID] = true + break + } + } + } + return sumnjivi +} + +// kirKandidatiProdaje vraća sve B2B prodajne naloge (nezavisno od toga da li već imaju +// 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{}) + if err != nil { + return nil, nil, nil, err + } + postojiMap := make(map[int64]bool, len(nalozi)) + var kandidati []kandidatUskladjivanja + for _, nd := range nalozi { + if nd.KlijentID == nil { + continue + } + postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID) + postojiMap[nd.ID] = postoji + kandidati = append(kandidati, kandidatUskladjivanja{ + ID: nd.ID, KlijentID: nd.KlijentID, Iznos: nd.Ukupno, Datum: nd.Datum, ImaUpis: postoji, + }) + } + return nalozi, postojiMap, kandidati, nil +} + +// kpoKandidatiProdaje vraća sve nestornirane prodajne naloge sa mapom postojećih KPO +// upisa i kandidatima za detekciju duplikata. +func (h *Handler) kpoKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) { + nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{}) + if err != nil { + return nil, nil, nil, err + } + postojiMap := make(map[int64]bool, len(nalozi)) + var kandidati []kandidatUskladjivanja + for _, nd := range nalozi { + if nd.Stornirano { + continue + } + postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID) + postojiMap[nd.ID] = postoji + kandidati = append(kandidati, kandidatUskladjivanja{ + ID: nd.ID, KlijentID: nd.KlijentID, Iznos: nd.Ukupno, Datum: nd.Datum, ImaUpis: postoji, + }) + } + return nalozi, postojiMap, kandidati, nil +} + +// kpoKandidatiServisa vraća sve naplaćene i preuzete servisne naloge sa mapom +// postojećih KPO upisa, kandidatima za detekciju duplikata, i brojem naloga koji su +// izostavljeni jer nisu (još) naplaćeni/preuzeti. +func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.ServisniNalogSaKlijentom, postojiMap map[int64]bool, kandidati []kandidatUskladjivanja, nenaplaceniBroj int, err error) { + servisNalozi, err := h.ServisRepo.Lista(ctx, "", "") + if err != nil { + return nil, nil, nil, 0, err + } + naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi)) + for _, sn := range servisNalozi { + if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 { + naplaceni = append(naplaceni, sn) + } else { + nenaplaceniBroj++ + } + } + postojiMap = make(map[int64]bool, len(naplaceni)) + for _, sn := range naplaceni { + postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID) + postojiMap[sn.ID] = postoji + datum := sn.DatumPrijema + if sn.DatumZavrsetka != nil { + datum = *sn.DatumZavrsetka + } + kandidati = append(kandidati, kandidatUskladjivanja{ + ID: sn.ID, KlijentID: sn.KlijentID, Iznos: sn.Naplaceno, Datum: datum, ImaUpis: postoji, + }) + } + return naplaceni, postojiMap, kandidati, nenaplaceniBroj, nil +} + +// PraznineKnjigovodstva broji naloge (prodaja + servis) koji čekaju KIR/KPO/fiskalni +// upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati +// (double-submit) pa ih automatski backfill preskače. +func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) { + var p model.PrazninaKnjigovodstva + + if h.modulUkljucen(ctx, "pdv") { + _, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx) + if err != nil { + return p, err + } + duplikati := sumnjiviDuplikati(kandidati) + for id, postoji := range postojiMap { + if postoji { + continue + } + if duplikati[id] { + p.SumnjiviDupli++ + continue + } + p.BezKir++ + } + } + + if h.modulUkljucen(ctx, "kpo") { + _, postojiMapP, kandidatiP, err := h.kpoKandidatiProdaje(ctx) + if err != nil { + return p, err + } + _, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx) + if err != nil { + return p, err + } + duplikatiP := sumnjiviDuplikati(kandidatiP) + duplikatiS := sumnjiviDuplikati(kandidatiS) + for id, postoji := range postojiMapP { + if postoji { + continue + } + if duplikatiP[id] { + p.SumnjiviDupli++ + continue + } + p.BezKpo++ + } + for id, postoji := range postojiMapS { + if postoji { + continue + } + if duplikatiS[id] { + p.SumnjiviDupli++ + continue + } + p.BezKpo++ + } + } + + if h.modulUkljucen(ctx, "fiskalizacija") { + if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil { + p.BezFiskalnog += len(m) + } + if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil { + p.BezFiskalnog += len(m) + } + if n, err := h.stornoBezRefunda(ctx); err == nil { + p.BezRefunda = n + } + } + + return p, nil +} + +// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni +// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali +// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao). +// Servis nema storno funkciju — obuhvaćena je samo prodaja. +func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) { + var n int + err := h.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM prodajni_nalozi pn + JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id + AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0 + WHERE pn.stornirano = 1 + `).Scan(&n) + return n, err +} + +func istKlijent(a, b *int64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index ef7d241..e425ce5 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -26,7 +26,6 @@ var sveAkcije = []string{ "servis.obrisi", "prodaja.pregled", "prodaja.dodaj", - "prodaja.obrisi", "prodaja.storno", "klijent.pregled", "klijent.dodaj", @@ -43,6 +42,9 @@ var sveAkcije = []string{ "pdv.pregled", "pdv.dodaj", "pdv.obrisi", + "kpo.pregled", + "kpo.dodaj", + "kpo.obrisi", } // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu @@ -78,7 +80,7 @@ func ImaDozvolu(uloga, akcija string) bool { "servis.izmeni", "servis.obrisi": return true // prodaja - case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi", "prodaja.storno": + case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno": return true // klijent case "klijent.pregled", "klijent.dodaj", @@ -103,6 +105,9 @@ func ImaDozvolu(uloga, akcija string) bool { // PDV evidencija (KIR/KPR) — administrativno, radnik nema case "pdv.pregled", "pdv.dodaj", "pdv.obrisi": return true + // KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema + case "kpo.pregled", "kpo.dodaj", "kpo.obrisi": + return true } return false diff --git a/internal/middleware/dozvole_test.go b/internal/middleware/dozvole_test.go index d150592..2970c3e 100644 --- a/internal/middleware/dozvole_test.go +++ b/internal/middleware/dozvole_test.go @@ -32,7 +32,7 @@ func TestImaDozvoluRadnik(t *testing.T) { } } // radnik NE sme: brisanje, storno, izveštaje, podešavanja, backup - neSme := []string{"prodaja.obrisi", "prodaja.storno", "servis.obrisi", "klijent.obrisi", + neSme := []string{"prodaja.storno", "servis.obrisi", "klijent.obrisi", "dobavljac.obrisi", "izvestaj.pregled", "podesavanja.izmeni", "backup.pokreni", "dashboard.prihod"} for _, a := range neSme { if ImaDozvolu("radnik", a) { diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 0da0def..afc07d6 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -71,4 +71,23 @@ type PodaciDashboarda struct { KriticneZalihe []StavkaZalihe PoslednjeProdaje []StavkaProdajePregled FlashGreska string + PrazninaKnjige PrazninaKnjigovodstva +} + +// PrazninaKnjigovodstva sažima broj naloga bez KIR/KPO/fiskalnog upisa i broj +// sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje +// se kao kartica na dashboard-u. +type PrazninaKnjigovodstva struct { + BezKir int + BezKpo int + BezFiskalnog int + BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao) + SumnjiviDupli int +} + +// Ukupno vraća zbir svih praznina UKLJUČUJUĆI sumnjive duplikate — koristi se da se +// odluči da li se kartica uopšte prikazuje na dashboard-u (i kad ostanu samo +// duplikati za ručnu proveru, kartica mora da ostane vidljiva). +func (p PrazninaKnjigovodstva) Ukupno() int { + return p.BezKir + p.BezKpo + p.BezFiskalnog + p.BezRefunda + p.SumnjiviDupli } diff --git a/web/templates/stranice/admin_dozvole.html b/web/templates/stranice/admin_dozvole.html index 0971b64..e80b763 100644 --- a/web/templates/stranice/admin_dozvole.html +++ b/web/templates/stranice/admin_dozvole.html @@ -195,12 +195,6 @@ {{if eq .KorisnikUloga "superadmin"}}{{end}} - - - Brisanje prodaje - - {{if eq .KorisnikUloga "superadmin"}}{{end}} - Storniranje prodaje diff --git a/web/templates/stranice/dashboard.html b/web/templates/stranice/dashboard.html index 15afea5..fc16031 100644 --- a/web/templates/stranice/dashboard.html +++ b/web/templates/stranice/dashboard.html @@ -19,6 +19,25 @@ {{ if .FlashGreska }}
{{ .FlashGreska }}
{{ end }} +{{ if gt .PrazninaKnjige.Ukupno 0 }} +
+
+
+
Knjigovodstvo — praznine u evidenciji
+
+ {{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}{{ .PrazninaKnjige.BezFiskalnog }} bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }} + {{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }} +
+
+
+ {{ if gt .PrazninaKnjige.BezKir 0 }}KIR{{ end }} + {{ if gt .PrazninaKnjige.BezKpo 0 }}KPO{{ end }} + {{ if gt .PrazninaKnjige.BezFiskalnog 0 }}Fiskalizacija{{ end }} + {{ if gt .PrazninaKnjige.BezRefunda 0 }}Storno bez refunda{{ end }} +
+
+
+{{ end }}
diff --git a/web/templates/stranice/fiskal_verifikacija.html b/web/templates/stranice/fiskal_verifikacija.html index de6eb36..4431e10 100644 --- a/web/templates/stranice/fiskal_verifikacija.html +++ b/web/templates/stranice/fiskal_verifikacija.html @@ -64,9 +64,11 @@ /* podnožje */ .podnozje { text-align: center; padding: 20px 16px 8px; font-size: 11px; color: #94a3b8; line-height: 1.6; } .podnozje a { color: #64748b; text-decoration: none; } + .test-traka { background: #7c2d12; color: #fed7aa; text-align: center; padding: 8px 16px; font-size: 12px; font-weight: 600; } +
⚠ TESTNA STRANICA — lokalna simulacija (Teron mock), NIJE prava provera kod Poreske uprave RS
{{if .Greska}} @@ -80,8 +82,8 @@ {{if .JeFiskalni}}
-
Račun je verifikovan
-
Fiskalni račun je evidentiran u sistemu
+
Podaci pročitani sa QR koda
+
Testni prikaz — nije prava provera kod Poreske uprave RS
{{else}}
@@ -201,6 +203,12 @@ {{dinari .Amount}} din
{{end}} + {{if $.Kusur}} +
+ Kusur + {{dinari $.Kusur}} din +
+ {{end}}
{{end}} @@ -222,7 +230,7 @@
Fiskalizacija — NTech sistem
- Poreski identifikacioni broj se proverava kod Poreske uprave RS + Podaci su dekodirani direktno iz QR koda na ovom serveru — ovo NIJE zvanična provera kod Poreske uprave Republike Srbije.
{{end}}{{/* if .Greska */}} diff --git a/web/templates/stranice/prodaja.html b/web/templates/stranice/prodaja.html index c8ad519..4bad78a 100644 --- a/web/templates/stranice/prodaja.html +++ b/web/templates/stranice/prodaja.html @@ -2,8 +2,6 @@
{{if .Sacuvano}}
Prodaja je uspešno sačuvana.
- {{end}} {{if .Obrisan}} -
Prodajni nalog je uspešno obrisan.
{{end}} @@ -26,7 +24,7 @@
@@ -65,12 +63,6 @@
Detalji - {{if and (index $.Dozvole "prodaja.storno") (not .Stornirano)}} -
- - -
- {{end}}
diff --git a/web/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html index 80c2901..7d6bf04 100644 --- a/web/templates/stranice/prodaja_detalji.html +++ b/web/templates/stranice/prodaja_detalji.html @@ -241,7 +241,7 @@
-
+
{{if and (index $.Dozvole "prodaja.storno") (not .Nalog.Stornirano) }} @@ -276,7 +276,7 @@

Brojač: {{.FiskalniRacun.Brojac}}

{{if .FiskalniRacun.UrlVerifikacija}}

- Provera na PURS-u → + Otvori QR verifikaciju (test) →

{{end}}
@@ -315,27 +315,6 @@
{{end}} - {{if index $.Dozvole "prodaja.obrisi"}} -
-
-
Uklanjanje naloga
-
- Nalog će biti storniran (vraća artikle na stanje) i označen kao uklonjen. Ne briše se fizički — ostaje u evidenciji. -
-
-
- - {{if .PotrebnaIdentKupca}} - - {{end}} - -
-
- {{end}} -
diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index e0877c4..994b542 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -987,7 +987,7 @@

Brojač: {{.FiskalniRacun.Brojac}}

{{if .FiskalniRacun.UrlVerifikacija}}

- Provera na PURS-u → + Otvori QR verifikaciju (test) →

{{end}}