diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index dd7d638..d0d7825 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -418,6 +418,7 @@ func main() { 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) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje) diff --git a/internal/db/repository.go b/internal/db/repository.go index 596bea0..519682c 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -385,6 +385,7 @@ type FiskalRepository interface { DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error) OznačiKaoStorniran(ctx context.Context, id int64) error ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) + ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error) } // ServisniLogRepository definiše operacije nad log-om događaja servisnog naloga. diff --git a/internal/db/sqlite/fiskal.go b/internal/db/sqlite/fiskal.go index 2646f25..45238f3 100644 --- a/internal/db/sqlite/fiskal.go +++ b/internal/db/sqlite/fiskal.go @@ -137,6 +137,28 @@ func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, e return out, rows.Err() } +// ProdajeBezFiskalnog vraća skup ID-eva prodajnih naloga koji nisu stornirani +// a nemaju fiskalni račun (za oznaku u listi naloga). +func (r *FiskalRepo) ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT pn.id FROM prodajni_nalozi pn + LEFT JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id AND fr.storniran = 0 + WHERE pn.stornirano = 0 AND fr.id IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[int64]bool) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + // OznačiKaoStorniran postavlja storniran=1 za fiskalni račun sa datim ID-jem. func (r *FiskalRepo) OznačiKaoStorniran(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, "UPDATE fiskalni_racuni SET storniran = 1 WHERE id = ?", id) diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 750aa90..65da517 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "errors" "fmt" @@ -24,10 +25,11 @@ import ( // PodaciProdaje su podaci za stranicu sa listom prodajnih naloga type PodaciProdaje struct { model.PodaciStranice - Nalozi []model.ProdajniNalogSaDetaljem - Sacuvano bool - Obrisan bool - Pretraga string + Nalozi []model.ProdajniNalogSaDetaljem + Sacuvano bool + Obrisan bool + Pretraga string + NemaFiskalnog map[int64]bool // ID-evi naloga bez izdatog fiskalnog računa } // PodaciFormeProdaje su podaci za formu unosa nove prodaje @@ -46,6 +48,7 @@ type PodaciDetaljiProdaje struct { Stavke []model.StavkaProdajeSaArtiklom KlijentNaziv string FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano + FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa Sacuvano bool } @@ -110,6 +113,11 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { return } + var nemaFiskalnog map[int64]bool + if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { + nemaFiskalnog, _ = h.FiskalRepo.ProdajeBezFiskalnog(r.Context()) + } + ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Prodaja" @@ -119,6 +127,7 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { Sacuvano: r.URL.Query().Get("sacuvano") == "1", Obrisan: r.URL.Query().Get("obrisan") == "1", Pretraga: pretraga, + NemaFiskalnog: nemaFiskalnog, } h.renderujTemplate(w, "prodaja", podaci) @@ -246,58 +255,8 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije) if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { - klijent := h.fiskalKlijent() - if klijent != nil { - // učitaj nalog sa ID-jem (potreban za buyerId) - spremljen, e := h.ProdajaRepo.DohvatiID(r.Context(), id) - if e == nil { - spremljeneStavke, e2 := h.ProdajaRepo.DohvatiStavke(r.Context(), id) - if e2 == nil { - kasir, _ := sqlite.DohvatiPodesavanje(r.Context(), h.DB, "pfr_kasir") - if kasir == "" { - kasir = "NTech" - } - - var pib, jmbg string - if spremljen.KlijentID != nil { - if kk, e3 := h.KlijentiRepo.DohvatiID(r.Context(), *spremljen.KlijentID); e3 == nil { - pib, jmbg = kk.PIB, kk.JMBG - } - } - - zahtev := fiskal.NapraviZahtev(*spremljen, spremljeneStavke, pib, jmbg, kasir) - - odgovor, errFisk := klijent.IzdajRacun(r.Context(), zahtev) - if errFisk != nil { - slog.Error("fiskalizacija nije uspela", "prodaja_id", id, "error", errFisk) - } else { - poreskeJSON, _ := json.Marshal(odgovor.TaxItems) - siroviJSON, _ := json.Marshal(odgovor) - - fr := &model.FiskalniRacun{ - ProdajaID: id, - TipRacuna: "Normal", - TipTransakcije: "Sale", - PfrBroj: odgovor.InvoiceNumber, - PfrVreme: odgovor.SdcDateTime, - Brojac: odgovor.InvoiceCounter, - EkstenzijaBrojaca: odgovor.InvoiceCounterExtension, - UrlVerifikacija: odgovor.VerificationURL, - QRKod: odgovor.VerificationQRCode, - PoreskeStavke: string(poreskeJSON), - UkupnoZaNaplatu: odgovor.TotalAmount, - UkupanPorez: odgovor.TotalTax, - SiroviOdgovor: string(siroviJSON), - Potpisao: odgovor.SignedBy, - Zatrazio: odgovor.RequestedBy, - Poruka: odgovor.Messages, - } - if _, errFisk := h.FiskalRepo.Kreiraj(r.Context(), fr); errFisk != nil { - slog.Error("greška pri čuvanju fiskalnog računa", "prodaja_id", id, "error", errFisk) - } - } - } - } + if klijent := h.fiskalKlijent(); klijent != nil { + h.fiskalizujProdaju(r.Context(), id, klijent) } } @@ -370,6 +329,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) { Stavke: stavke, KlijentNaziv: klijentNaziv, FiskalniRacun: fr, + FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil, Sacuvano: r.URL.Query().Get("sacuvano") == "1", } @@ -619,6 +579,107 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) } +// fiskalizujProdaju šalje fiskalni zahtev za prodajni nalog. Best-effort: greške +// se loguju, ne zaustavljaju tok pozivaoca (kreiranje prodaje ili ručni retry). +func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijent *fiskal.Klijent) { + nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID) + if err != nil { + slog.Error("fiskalizujProdaju: nije pronađen nalog", "prodaja_id", prodajaID, "error", err) + return + } + stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, prodajaID) + if err != nil { + slog.Error("fiskalizujProdaju: greška pri dohvatanju stavki", "prodaja_id", prodajaID, "error", err) + return + } + + kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") + if kasir == "" { + kasir = "NTech" + } + + var pib, jmbg string + if nalog.KlijentID != nil { + if kk, e := h.KlijentiRepo.DohvatiID(ctx, *nalog.KlijentID); e == nil { + pib, jmbg = kk.PIB, kk.JMBG + } + } + + zahtev := fiskal.NapraviZahtev(*nalog, stavke, pib, jmbg, kasir) + + odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev) + if errFisk != nil { + slog.Error("fiskalizacija prodaje nije uspela", "prodaja_id", prodajaID, "error", errFisk) + return + } + + poreskeJSON, _ := json.Marshal(odgovor.TaxItems) + siroviJSON, _ := json.Marshal(odgovor) + fr := &model.FiskalniRacun{ + ProdajaID: prodajaID, + TipRacuna: "Normal", + TipTransakcije: "Sale", + PfrBroj: odgovor.InvoiceNumber, + PfrVreme: odgovor.SdcDateTime, + Brojac: odgovor.InvoiceCounter, + EkstenzijaBrojaca: odgovor.InvoiceCounterExtension, + UrlVerifikacija: odgovor.VerificationURL, + QRKod: odgovor.VerificationQRCode, + PoreskeStavke: string(poreskeJSON), + UkupnoZaNaplatu: odgovor.TotalAmount, + UkupanPorez: odgovor.TotalTax, + SiroviOdgovor: string(siroviJSON), + Potpisao: odgovor.SignedBy, + Zatrazio: odgovor.RequestedBy, + Poruka: odgovor.Messages, + } + if _, err := h.FiskalRepo.Kreiraj(ctx, fr); err != nil { + slog.Error("greška pri čuvanju fiskalnog računa", "prodaja_id", prodajaID, "error", err) + } +} + +// RetryFiskalizacijaProdaje pokušava ponovo da izda fiskalni račun za prodajni +// nalog čija je prethodna fiskalizacija pala (ESIR/PFR nedostupan pri kreiranju). +func (h *Handler) RetryFiskalizacijaProdaje(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "prodaja.dodaj"); !ok { + return + } + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) + return + } + + // zaštita od duplikata — ako fiskalni račun već postoji, ne šalji ponovo + if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil { + middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun već postoji.") + http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + return + } + + nalog, err := h.ProdajaRepo.DohvatiID(r.Context(), id) + if err != nil || nalog.Stornirano { + http.Error(w, "Nalog ne postoji ili je stornirano", http.StatusBadRequest) + return + } + + klijent := h.fiskalKlijent() + if klijent == nil { + middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.") + http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + return + } + + h.fiskalizujProdaju(r.Context(), id, klijent) + + if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil { + middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun izdat.") + } else { + middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uspela. Proverite vezu sa ESIR/PFR i pokušajte ponovo.") + } + http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) +} + // renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) { h.renderujTemplate(w, "prodaja_forma", podaci) diff --git a/web/templates/stranice/prodaja.html b/web/templates/stranice/prodaja.html index 3d3f87f..63bed84 100644 --- a/web/templates/stranice/prodaja.html +++ b/web/templates/stranice/prodaja.html @@ -37,6 +37,7 @@ {{.BrojNaloga}} {{if .Stornirano}}Stornirano{{end}} + {{if index $.NemaFiskalnog .ID}}!{{end}} {{.Datum.Format "02.01.2006."}} {{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}} @@ -76,6 +77,7 @@
{{.BrojNaloga}} {{if .Stornirano}}Stornirano{{end}} + {{if index $.NemaFiskalnog .ID}}!{{end}}
{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}
{{.Datum.Format "02.01.2006."}}
diff --git a/web/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html index 18c88bd..4d31f50 100644 --- a/web/templates/stranice/prodaja_detalji.html +++ b/web/templates/stranice/prodaja_detalji.html @@ -286,6 +286,23 @@ {{end}} + {{if .FiskalGreska}} +
+
+
+
⚠ Fiskalni račun nije izdat
+
Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.
+
+ {{if index $.Dozvole "prodaja.dodaj"}} +
+ + +
+ {{end}} +
+
+ {{end}} + {{if index $.Dozvole "prodaja.obrisi"}}