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, `