Prodaja: retry fiskalizacije kad ESIR nije dostupan pri kreiranju

This commit is contained in:
2026-07-01 11:35:17 +02:00
parent e7dcaf4433
commit 0f47c3e56b
6 changed files with 160 additions and 56 deletions
+1
View File
@@ -418,6 +418,7 @@ func main() {
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) 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.obrisi")).Post("/prodaja/obrisi/{id}", h.ObrisiProdaju)
r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje) 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}/stampa", h.StampaProdaje)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje)
+1
View File
@@ -385,6 +385,7 @@ type FiskalRepository interface {
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error) DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
OznačiKaoStorniran(ctx context.Context, id int64) error OznačiKaoStorniran(ctx context.Context, id int64) error
ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, 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. // ServisniLogRepository definiše operacije nad log-om događaja servisnog naloga.
+22
View File
@@ -137,6 +137,28 @@ func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, e
return out, rows.Err() 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. // OznačiKaoStorniran postavlja storniran=1 za fiskalni račun sa datim ID-jem.
func (r *FiskalRepo) OznačiKaoStorniran(ctx context.Context, id int64) error { 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) _, err := r.db.ExecContext(ctx, "UPDATE fiskalni_racuni SET storniran = 1 WHERE id = ?", id)
+117 -56
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -24,10 +25,11 @@ import (
// PodaciProdaje su podaci za stranicu sa listom prodajnih naloga // PodaciProdaje su podaci za stranicu sa listom prodajnih naloga
type PodaciProdaje struct { type PodaciProdaje struct {
model.PodaciStranice model.PodaciStranice
Nalozi []model.ProdajniNalogSaDetaljem Nalozi []model.ProdajniNalogSaDetaljem
Sacuvano bool Sacuvano bool
Obrisan bool Obrisan bool
Pretraga string Pretraga string
NemaFiskalnog map[int64]bool // ID-evi naloga bez izdatog fiskalnog računa
} }
// PodaciFormeProdaje su podaci za formu unosa nove prodaje // PodaciFormeProdaje su podaci za formu unosa nove prodaje
@@ -46,6 +48,7 @@ type PodaciDetaljiProdaje struct {
Stavke []model.StavkaProdajeSaArtiklom Stavke []model.StavkaProdajeSaArtiklom
KlijentNaziv string KlijentNaziv string
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa
Sacuvano bool Sacuvano bool
} }
@@ -110,6 +113,11 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) {
return 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 := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "prodaja" ps.Stranica = "prodaja"
ps.NaslovStranice = "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", Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1", Obrisan: r.URL.Query().Get("obrisan") == "1",
Pretraga: pretraga, Pretraga: pretraga,
NemaFiskalnog: nemaFiskalnog,
} }
h.renderujTemplate(w, "prodaja", podaci) 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) // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije)
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
klijent := h.fiskalKlijent() if klijent := h.fiskalKlijent(); klijent != nil {
if klijent != nil { h.fiskalizujProdaju(r.Context(), id, klijent)
// 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)
}
}
}
}
} }
} }
@@ -370,6 +329,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
Stavke: stavke, Stavke: stavke,
KlijentNaziv: klijentNaziv, KlijentNaziv: klijentNaziv,
FiskalniRacun: fr, FiskalniRacun: fr,
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
Sacuvano: r.URL.Query().Get("sacuvano") == "1", 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) 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 // renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) { func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) {
h.renderujTemplate(w, "prodaja_forma", podaci) h.renderujTemplate(w, "prodaja_forma", podaci)
+2
View File
@@ -37,6 +37,7 @@
<td style="padding: 12px 16px; font-size: 13px; font-family: monospace; color: var(--tekst-glavni)"> <td style="padding: 12px 16px; font-size: 13px; font-family: monospace; color: var(--tekst-glavni)">
{{.BrojNaloga}} {{.BrojNaloga}}
{{if .Stornirano}}<span class="status-badge status-stornirano" style="margin-left: 8px">Stornirano</span>{{end}} {{if .Stornirano}}<span class="status-badge status-stornirano" style="margin-left: 8px">Stornirano</span>{{end}}
{{if index $.NemaFiskalnog .ID}}<span style="display:inline-block;margin-left:6px;font-size:10px;font-weight:600;padding:1px 6px;border-radius:4px;background:#fef3c7;color:#92400e;border:1px solid #fde68a;vertical-align:middle;" title="Fiskalni račun nije izdat">!</span>{{end}}
</td> </td>
<td style="padding: 12px 16px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.Datum.Format "02.01.2006."}}</td> <td style="padding: 12px 16px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.Datum.Format "02.01.2006."}}</td>
<td style="padding: 12px 16px; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}</td> <td style="padding: 12px 16px; font-size: 14px; font-weight: 500; color: var(--tekst-glavni)">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}—{{end}}</td>
@@ -76,6 +77,7 @@
<div style="font-size: 13px; font-family: monospace; color: var(--tekst-glavni)"> <div style="font-size: 13px; font-family: monospace; color: var(--tekst-glavni)">
{{.BrojNaloga}} {{.BrojNaloga}}
{{if .Stornirano}}<span class="status-badge status-stornirano" style="margin-left: 8px">Stornirano</span>{{end}} {{if .Stornirano}}<span class="status-badge status-stornirano" style="margin-left: 8px">Stornirano</span>{{end}}
{{if index $.NemaFiskalnog .ID}}<span style="display:inline-block;margin-left:4px;font-size:10px;font-weight:600;padding:1px 5px;border-radius:4px;background:#fef3c7;color:#92400e;border:1px solid #fde68a;vertical-align:middle;" title="Fiskalni račun nije izdat">!</span>{{end}}
</div> </div>
<div style="font-size: 14px; font-weight: 500; color: var(--tekst-glavni); margin-top: 2px">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}</div> <div style="font-size: 14px; font-weight: 500; color: var(--tekst-glavni); margin-top: 2px">{{if .KlijentNaziv}}{{.KlijentNaziv}}{{else}}Bez klijenta{{end}}</div>
<div style="font-size: 12px; color: var(--tekst-sporedni); margin-top: 2px">{{.Datum.Format "02.01.2006."}}</div> <div style="font-size: 12px; color: var(--tekst-sporedni); margin-top: 2px">{{.Datum.Format "02.01.2006."}}</div>
@@ -286,6 +286,23 @@
</div> </div>
{{end}} {{end}}
{{if .FiskalGreska}}
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #f59e0b;border-radius:8px;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">⚠ Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
</div>
{{if index $.Dozvole "prodaja.dodaj"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-primarno" style="background:#d97706;border-color:#d97706;white-space:nowrap;">Pokušaj ponovo</button>
</form>
{{end}}
</div>
</div>
{{end}}
{{if index $.Dozvole "prodaja.obrisi"}} {{if index $.Dozvole "prodaja.obrisi"}}
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-top:14px;border-top:0.5px solid var(--ivica);"> <div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-top:14px;border-top:0.5px solid var(--ivica);">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">