Fiskalni pazar: presek stanja, dnevni izveštaj i razdvajanje praznina po prodaji/servisu

- Nova stranica fiskal_pazar: presek prometa od poslednjeg zaključenja, zaključenje dana, generisanje PDF izveštaja
- fiskal.go: dodati pozivi Presek/ZakljuciDan/GenerisiIzvestaj ka /api/financial/summary i /api/financial/report/summary
- PrazninaKnjigovodstva razdvojena na Prodaja/Servis varijante sa ID listama za direktne linkove sa dashboard-a
This commit is contained in:
2026-07-02 19:14:54 +02:00
parent 56e52036b6
commit 7c215e70e8
16 changed files with 965 additions and 140 deletions
+119 -62
View File
@@ -49,13 +49,15 @@ type PodaciFormeProdaje struct {
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
type PodaciDetaljiProdaje struct {
model.PodaciStranice
Nalog model.ProdajniNalog
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
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
Sacuvano bool
Nalog model.ProdajniNalog
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
StornoBezRefunda bool // true: nalog je storniran, ali fiskalni refund ka PFR-u nije uspeo
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
PotrebnaIdentKupcaRetry bool // isto, ali za retry dugme na već storniranom nalogu (StornoBezRefunda)
Sacuvano bool
}
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
@@ -404,14 +406,16 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "prodaja"
ps.NaslovStranice = "Detalji prodaje"
podaci := PodaciDetaljiProdaje{
PodaciStranice: ps,
Nalog: *nalog,
Stavke: stavke,
KlijentNaziv: klijentNaziv,
FiskalniRacun: fr,
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
PodaciStranice: ps,
Nalog: *nalog,
Stavke: stavke,
KlijentNaziv: klijentNaziv,
FiskalniRacun: fr,
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
StornoBezRefunda: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran,
PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca,
PotrebnaIdentKupcaRetry: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran && !imaIdentKupca,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
h.renderujTemplate(w, "prodaja_detalji", podaci)
@@ -702,53 +706,8 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
// fiskalni refund — best-effort
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(ctx, id); fr != nil && !fr.Storniran {
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 := h.imeKasira(ctx)
var pibFisk, jmbgFisk string
if nalogFisk.KlijentID != nil {
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil {
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
}
}
if pibFisk == "" && jmbgFisk == "" {
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
}
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
} else {
poreskeJSON, _ := json.Marshal(odgFisk.TaxItems)
siroviJSON, _ := json.Marshal(odgFisk)
refundFr := &model.FiskalniRacun{
ProdajaID: id,
TipRacuna: "Normal",
TipTransakcije: "Refund",
PfrBroj: odgFisk.InvoiceNumber,
PfrVreme: odgFisk.SdcDateTime,
Brojac: odgFisk.InvoiceCounter,
EkstenzijaBrojaca: odgFisk.InvoiceCounterExtension,
UrlVerifikacija: odgFisk.VerificationURL,
QRKod: odgFisk.VerificationQRCode,
PoreskeStavke: string(poreskeJSON),
UkupnoZaNaplatu: odgFisk.TotalAmount,
UkupanPorez: odgFisk.TotalTax,
SiroviOdgovor: string(siroviJSON),
Potpisao: odgFisk.SignedBy,
Zatrazio: odgFisk.RequestedBy,
Poruka: odgFisk.Messages,
}
if _, errFisk = h.FiskalRepo.Kreiraj(ctx, refundFr); errFisk != nil {
slog.Error("čuvanje fiskalnog refunda nije uspelo", "prodaja_id", id, "error", errFisk)
} else {
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
}
}
}
}
}
if err := h.posaljiFiskalniRefundProdaje(ctx, id, poreskiBrojKupca); err != nil {
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", err)
}
}
@@ -770,6 +729,104 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
return nil
}
// posaljiFiskalniRefundProdaje šalje fiskalni refund za već storniranu prodaju čiji
// originalni fiskalni (Sale) račun još nije označen kao storniran — koristi ga i
// stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi
// pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici.
func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error {
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
if err != nil {
return err
}
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
return nil
}
fk := h.fiskalKlijent()
if fk == nil {
return errors.New("fiskalizacija nije podešena")
}
nalogFisk, err := h.ProdajaRepo.DohvatiID(ctx, id)
if err != nil {
return err
}
stavkeFisk, err := h.ProdajaRepo.DohvatiStavke(ctx, id)
if err != nil {
return err
}
kasirFisk := h.imeKasira(ctx)
var pibFisk, jmbgFisk string
if nalogFisk.KlijentID != nil {
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil {
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
}
}
if pibFisk == "" && jmbgFisk == "" {
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
if pibFisk == "" && jmbgFisk == "" {
return errPotrebnaIdentKupca
}
}
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
odgFisk, err := fk.IzdajRacun(ctx, zahtevFisk)
if err != nil {
return err
}
poreskeJSON, _ := json.Marshal(odgFisk.TaxItems)
siroviJSON, _ := json.Marshal(odgFisk)
refundFr := &model.FiskalniRacun{
ProdajaID: id,
TipRacuna: "Normal",
TipTransakcije: "Refund",
PfrBroj: odgFisk.InvoiceNumber,
PfrVreme: odgFisk.SdcDateTime,
Brojac: odgFisk.InvoiceCounter,
EkstenzijaBrojaca: odgFisk.InvoiceCounterExtension,
UrlVerifikacija: odgFisk.VerificationURL,
QRKod: odgFisk.VerificationQRCode,
PoreskeStavke: string(poreskeJSON),
UkupnoZaNaplatu: odgFisk.TotalAmount,
UkupanPorez: odgFisk.TotalTax,
SiroviOdgovor: string(siroviJSON),
Potpisao: odgFisk.SignedBy,
Zatrazio: odgFisk.RequestedBy,
Poruka: odgFisk.Messages,
}
if _, err := h.FiskalRepo.Kreiraj(ctx, refundFr); err != nil {
return err
}
return h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
}
// PokusajRefundProdaje je ručni retry za "storno bez fiskalnog refunda" — kad je
// prodaja već stornirana u NTech-u, ali fiskalni refund ka PFR-u nije uspeo pri
// samom stornu (best-effort poziv je pao). Prikazuje se kao dugme na stranici detalja.
func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.storno"); !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.posaljiFiskalniRefundProdaje(r.Context(), id, poreskiBrojKupca); err != nil {
poruka := "Fiskalni refund nije uspeo — fiskalni server nije dostupan."
if errors.Is(err, errPotrebnaIdentKupca) {
poruka = errPotrebnaIdentKupca.Error()
}
middleware.SetFlash(w, r, h.DB, "greska", poruka)
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni refund je uspešno poslat.")
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), 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).
// primljeno je iznos koji je kupac predao (za tačan povraćaj na računu) — 0 ako