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
+1 -1
View File
@@ -131,7 +131,7 @@ func (r *FiskalRepo) DohvatiPoServisuITip(ctx context.Context, servisID int64, t
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
FROM fiskalni_racuni
WHERE servis_id = ? AND tip_racuna = ? AND tip_transakcije = ? AND storniran = 0
WHERE servis_id = ? AND tip_racuna = ? AND tip_transakcije = ?
ORDER BY id DESC LIMIT 1
`, servisID, tipRacuna, tipTransakcije).Scan(
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
+145
View File
@@ -153,6 +153,151 @@ func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*Invoi
return &odgovor, nil
}
// FinancialSummary je odgovor na GET /api/financial/summary — promet od
// poslednjeg preseka stanja (dnevni pazar), razložen po više kategorija.
type FinancialSummary struct {
StartOfPeriod string `json:"startOfPeriod"`
EndOfPeriod string `json:"endOfPeriod"`
InvoiceCount int `json:"invoiceCount"`
Total float64 `json:"total"`
TotalCash float64 `json:"totalCash"`
TotalByTax []SummaryTaxStavka `json:"totalByTax"`
TotalByCashier []SummaryImeIznos `json:"totalByCashier"`
TotalByPaymentType []SummaryPlacanje `json:"totalByPaymentType"`
TotalByArticle []SummaryArtikal `json:"totalByArticle"`
TotalByArticleAdvance []SummaryArtikal `json:"totalByArticleAdvance"`
}
// SummaryTaxStavka je promet po jednoj poreskoj oznaci/stopi.
type SummaryTaxStavka struct {
Label string `json:"label"`
Category string `json:"category"`
Rate float64 `json:"rate"`
Amount float64 `json:"amount"`
Osnovica float64 `json:"osnovica"`
}
// SummaryImeIznos je generički par ime-iznos (kasir, i sl.).
type SummaryImeIznos struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
}
// SummaryPlacanje je promet po vrsti plaćanja (Cash, Card, ...).
type SummaryPlacanje struct {
PaymentType string `json:"paymentType"`
Amount float64 `json:"amount"`
}
// SummaryArtikal je promet po artiklu.
type SummaryArtikal struct {
ArticleName string `json:"articleName"`
TaxLabel string `json:"taxLabel"`
Amount float64 `json:"amount"`
Quantity float64 `json:"quantity"`
}
// ReportSummaryZahtev je telo zahteva za POST /api/financial/report/summary.
// FromDate/ToDate su opcioni (YYYY-MM-DD) — bez njih izveštaj pokriva period
// od poslednjeg preseka stanja.
type ReportSummaryZahtev struct {
FromDate string `json:"fromDate,omitempty"`
ToDate string `json:"toDate,omitempty"`
Language string `json:"language,omitempty"`
}
// ReportSummaryOdgovor je odgovor na generisanje dnevnog/periodičnog izveštaja.
type ReportSummaryOdgovor struct {
ReportPdfBase64 string `json:"reportPdfBase64"`
ReportName string `json:"reportName"`
Filename string `json:"filename"`
}
// Presek vraća promet od poslednjeg preseka stanja (GET /api/financial/summary).
// Poziv je bezbedan (read-only), ne menja stanje na PFR-u.
func (k *Klijent) Presek(ctx context.Context) (*FinancialSummary, error) {
url := k.BaseURL + "/api/financial/summary"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: %w", err)
}
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
telo, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ntech: fiskal.Presek: HTTP %d: %s", resp.StatusCode, string(telo))
}
var rezultat FinancialSummary
if err := json.NewDecoder(resp.Body).Decode(&rezultat); err != nil {
return nil, fmt.Errorf("ntech: fiskal.Presek: JSON parse: %w", err)
}
return &rezultat, nil
}
// ZakljuciDan svodi promet PFR-a na nulu — dnevni presek stanja
// (DELETE /api/financial/summary). Nepovratna akcija.
func (k *Klijent) ZakljuciDan(ctx context.Context) error {
url := k.BaseURL + "/api/financial/summary"
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
if err != nil {
return fmt.Errorf("ntech: fiskal.ZakljuciDan: %w", err)
}
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return fmt.Errorf("ntech: fiskal.ZakljuciDan: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
telo, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ntech: fiskal.ZakljuciDan: HTTP %d: %s", resp.StatusCode, string(telo))
}
return nil
}
// GenerisiIzvestaj traži od PFR-a dnevni/periodični izveštaj u PDF formatu
// (POST /api/financial/report/summary).
func (k *Klijent) GenerisiIzvestaj(ctx context.Context, zahtev ReportSummaryZahtev) (*ReportSummaryOdgovor, error) {
telo, err := json.Marshal(zahtev)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: marshal: %w", err)
}
url := k.BaseURL + "/api/financial/report/summary"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(telo))
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: %w", err)
}
req.Header.Set("Content-Type", "application/json")
k.dodajAuth(req)
resp, err := k.HTTPKlijent.Do(req)
if err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: server nedostupan: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
teloOdg, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: HTTP %d: %s", resp.StatusCode, string(teloOdg))
}
var odgovor ReportSummaryOdgovor
if err := json.NewDecoder(resp.Body).Decode(&odgovor); err != nil {
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: JSON parse: %w", err)
}
return &odgovor, nil
}
// dodajAuth postavlja Authorization header ako je API ključ podešen.
func (k *Klijent) dodajAuth(req *http.Request) {
if k.APIKey != "" {
+120
View File
@@ -0,0 +1,120 @@
package handler
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
)
// PodaciFiskalniPazar su podaci za stranicu dnevnog fiskalnog preseka (pazara).
type PodaciFiskalniPazar struct {
model.PodaciStranice
Presek *fiskal.FinancialSummary
Greska string
FiskalPodesen bool // true: pfr_url je podešen
}
// FiskalniPazar prikazuje trenutni promet od poslednjeg preseka stanja
// (GET /api/financial/summary na PFR-u) — read-only, ne menja stanje.
func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
return
}
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "fiskal-pazar"
ps.NaslovStranice = "Dnevni fiskalni presek"
podaci := PodaciFiskalniPazar{PodaciStranice: ps}
klijent := h.fiskalKlijent()
if klijent == nil {
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
h.renderujTemplate(w, "fiskal_pazar", podaci)
return
}
podaci.FiskalPodesen = true
presek, err := klijent.Presek(r.Context())
if err != nil {
podaci.Greska = "Fiskalni server nije dostupan — proveri da li je PFR uređaj/mock pokrenut."
} else {
podaci.Presek = presek
}
h.renderujTemplate(w, "fiskal_pazar", podaci)
}
// ZakljuciFiskalniDan svodi promet PFR-a na nulu (DELETE /api/financial/summary).
// Nepovratna akcija — traži potvrdu na strani korisnika (confirm() u šablonu).
func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.zakljucenje"); !ok {
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
if err := klijent.ZakljuciDan(r.Context()); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Presek stanja nije uspeo — fiskalni server nije dostupan.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Dan je zaključen — presek stanja urađen, brojači prometa resetovani.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
}
// FiskalniIzvestaj traži od PFR-a dnevni/periodični izveštaj (POST
// /api/financial/report/summary) i vraća ga korisniku kao fajl za preuzimanje.
// Bez fromDate/toDate parametara izveštaj pokriva period od poslednjeg preseka stanja.
func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
klijent := h.fiskalKlijent()
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
zahtev := fiskal.ReportSummaryZahtev{
FromDate: strings.TrimSpace(r.FormValue("od_datuma")),
ToDate: strings.TrimSpace(r.FormValue("do_datuma")),
}
odgovor, err := klijent.GenerisiIzvestaj(r.Context(), zahtev)
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Generisanje izveštaja nije uspelo — fiskalni server nije dostupan.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return
}
sadrzaj, err := base64.StdEncoding.DecodeString(odgovor.ReportPdfBase64)
if err != nil {
http.Error(w, "Neispravan sadržaj izveštaja", http.StatusBadGateway)
return
}
imeFajla := odgovor.Filename
if imeFajla == "" {
imeFajla = "dnevni-izvestaj.pdf"
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, imeFajla))
w.Write(sadrzaj)
}
+1
View File
@@ -28,6 +28,7 @@ var saSidebar = []string{
"magacin", "magacin_forma", "magacin_kartica",
"nabavke", "nabavka_forma", "nabavka_detalji",
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija",
"fiskal_pazar",
"pdv_stope",
"pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar",
"pdv_kpr", "pdv_kpr_forma",
+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
+100 -15
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image/png"
"log/slog"
@@ -86,10 +87,13 @@ type PodaciDetaljiNaloga struct {
KorisceneUsluge map[int64]bool // ID-evi usluga već dodatih na nalog — izostavljaju se iz dropdown-a
SviStatusi []string
FiskalniRacun *model.FiskalniRacun // konačan (Normal/Sale) ako postoji, inače najnoviji izdat; nil ako nije fiskalizovano
AvansRacun *model.FiskalniRacun // avansni (Advance/Sale) — prikazan zasebno kad ga konačan račun "prekrije" u FiskalniRacun
PovracajAvansa *model.FiskalniRacun // avansni povraćaj (Advance/Refund) — kad je avans premašio konačnu cenu
RokPodizanja *time.Time // DatumZavrsetka + 30 dana; nil dok nije završeno
FiskalGreska bool // true: fiskalizacija aktivna, status Preuzeto, 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)
}
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -597,7 +601,7 @@ func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) {
// refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG)
var originalniFiskal *model.FiskalniRacun
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoServisu(ctx, id); fr != nil && !fr.Storniran {
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(ctx, id, "Normal", "Sale"); fr != nil && !fr.Storniran {
originalniFiskal = fr
imaIdentKupca := false
if nalog, e := h.ServisRepo.DohvatiID(ctx, id); e == nil && nalog.KlijentID != nil {
@@ -653,13 +657,59 @@ func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) {
// fiskalni refund — best-effort
if originalniFiskal != nil {
if fk := h.fiskalKlijent(); fk != nil {
h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca)
if err := h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca); err != nil {
slog.Error("fiskalni refund servisa nije uspeo", "servis_id", id, "error", err)
}
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// PokusajRefundServisa je ručni retry za "storno bez fiskalnog refunda" — kad je
// servisni nalog već storniran 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) PokusajRefundServisa(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.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"))
ctx := r.Context()
fr, err := h.FiskalRepo.DohvatiPoServisuITip(ctx, id, "Normal", "Sale")
if err != nil || fr == nil || fr.Storniran {
middleware.SetFlash(w, r, h.DB, "greska", "Nema fiskalnog računa koji čeka refund.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
fk := h.fiskalKlijent()
if fk == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
if err := h.refundujServis(ctx, id, fk, fr.ID, fr.PfrBroj, 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, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima
func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
@@ -907,6 +957,13 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
if fr3, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Advance", "Refund"); err == nil {
podaci.PovracajAvansa = fr3
}
// avansni račun se prikazuje kao FiskalniRacun samo dok konačan (Normal/Sale) ne postoji;
// posle preuzimanja ga konačan "prekrije" u glavnom prikazu, pa ga ovde učitavamo zasebno
if fr4, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Advance", "Sale"); err == nil && fr4 != nil {
if podaci.FiskalniRacun == nil || podaci.FiskalniRacun.TipRacuna != "Advance" {
podaci.AvansRacun = fr4
}
}
// fiskalna greška: modul aktivan, nalog preuzet, ali nema KONAČNOG (Normal/Sale)
// fiskalnog računa — avansni (Advance/Sale) se ne računa kao konačan, inače bi
// nalog sa avansom lažno izgledao fiskalizovan i dugme Fiskalizuj bi bilo skriveno
@@ -915,9 +972,17 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
podaci.FiskalGreska = true
}
}
// storno bez refunda: nalog storniran, ali originalni fiskalni Sale račun nije
// naknadno označen kao storniran — znači da je storno u bazi prošao, a fiskalni
// refund ka PFR-u NIJE uspeo (best-effort poziv je pao)
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano &&
podaci.FiskalniRacun != nil && podaci.FiskalniRacun.TipTransakcije == "Sale" && !podaci.FiskalniRacun.Storniran {
podaci.StornoBezRefunda = true
}
// storno zahteva PIB/JMBG kupca za refundaciju ako klijent nema upisan poreski broj
podaci.PotrebnaIdentKupca = h.modulUkljucen(r.Context(), config.ModulFiskalizacija) &&
!nalog.Stornirano && podaci.FiskalniRacun != nil && !podaci.FiskalniRacun.Storniran && !imaIdentKupca
podaci.PotrebnaIdentKupcaRetry = podaci.StornoBezRefunda && !imaIdentKupca
h.renderujTemplate(w, "servis_detalji", podaci)
}
@@ -2686,26 +2751,27 @@ func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniD
// refundujServis šalje fiskalni Refund zahtev za storniran servisni nalog —
// best-effort, greška se samo loguje (storno u bazi ostaje validan i bez uspešnog
// refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda).
func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) {
// refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda). Vraća
// grešku i pozivaocu (npr. PokusajRefundServisa) da bi ručni retry mogao da je prikaže.
func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) error {
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
if err != nil {
slog.Error("refundujServis: nije pronađen nalog", "id", servisID, "error", err)
return
return err
}
radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, servisID)
if err != nil {
slog.Error("refundujServis: greška pri dohvatanju radova", "id", servisID, "error", err)
return
return err
}
delovi, err := h.deloviSaPotrazivanima(ctx, servisID)
if err != nil {
slog.Error("refundujServis: greška pri dohvatanju delova", "id", servisID, "error", err)
return
return err
}
items := stavkeFiskalnogServisa(radovi, delovi)
if len(items) == 0 {
return
return nil
}
pib, jmbg := "", ""
@@ -2716,6 +2782,9 @@ func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *f
}
if pib == "" && jmbg == "" {
pib, jmbg = klasifikujPoreskiBroj(poreskiBrojKupca)
if pib == "" && jmbg == "" {
return errPotrebnaIdentKupca
}
}
zahtev := fiskal.InvoiceRequest{
@@ -2739,7 +2808,7 @@ func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *f
odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev)
if errFisk != nil {
slog.Error("fiskalni refund servisa nije uspeo", "servis_id", servisID, "error", errFisk)
return
return errFisk
}
poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
@@ -2764,9 +2833,9 @@ func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *f
}
if _, e := h.FiskalRepo.Kreiraj(ctx, fr); e != nil {
slog.Error("čuvanje fiskalnog refunda servisa nije uspelo", "servis_id", servisID, "error", e)
return
return e
}
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, originalniFiskalID)
return h.FiskalRepo.OznačiKaoStorniran(ctx, originalniFiskalID)
}
// RetryFiskalizacija pokušava ponovo da izda fiskalni račun za nalog čija je
@@ -2783,9 +2852,16 @@ func (h *Handler) RetryFiskalizacija(w http.ResponseWriter, r *http.Request) {
}
// provjeri da KONAČAN (Normal/Sale) fiskal još nije izdat (zaštita od duplikata) —
// avansni (Advance/Sale) račun se ne računa kao konačan, ne sme blokirati retry
// avansni (Advance/Sale) račun se ne računa kao konačan, ne sme blokirati retry.
// Provera NE filtrira po storniran — i storniran (refundovan) Sale znači da je
// nalog već bio fiskalizovan; ponovno izdavanje bi fiskalno duplo naplatilo
// stornirani servis (bag koji je izazivao beskonačnu petlju sa refund retry-jem).
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr != nil {
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun već postoji.")
poruka := "Fiskalni račun već postoji."
if fr.Storniran {
poruka = "Nalog je već bio fiskalizovan i storniran — ne može se ponovo fiskalizovati."
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
@@ -2823,7 +2899,8 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
return
}
var racuni []*model.FiskalniRacun
if r.URL.Query().Get("tip") == "povracaj" {
switch r.URL.Query().Get("tip") {
case "povracaj":
// avansni povraćaj viška — eksplicitno traženo (drugi dokument, ne konačan račun)
fr, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Advance", "Refund")
if err != nil || fr == nil {
@@ -2831,7 +2908,15 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
return
}
racuni = []*model.FiskalniRacun{fr}
} else {
case "avans":
// izvorni avansni račun — eksplicitno traženo (konačan ga posle preuzimanja "prekrije")
fr, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Advance", "Sale")
if err != nil || fr == nil {
http.Error(w, "Fiskalni račun nije pronađen", http.StatusNotFound)
return
}
racuni = []*model.FiskalniRacun{fr}
default:
// podrazumevano: konačan (Normal/Sale) račun — ako još ne postoji (npr. samo avans
// je fiskalizovan pre preuzimanja), padni nazad na najnoviji izdat
fr, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale")
+71 -27
View File
@@ -194,7 +194,7 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
continue
}
p.BezKir++
p.BezKirProdajaID = append(p.BezKirProdajaID, id)
}
for id, postoji := range postojiMapS {
if postoji {
@@ -204,8 +204,10 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
continue
}
p.BezKir++
p.BezKirServisID = append(p.BezKirServisID, id)
}
p.BezKirProdaja = len(p.BezKirProdajaID)
p.BezKirServis = len(p.BezKirServisID)
}
if h.modulUkljucen(ctx, "kpo") {
@@ -227,7 +229,7 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
continue
}
p.BezKpo++
p.BezKpoProdajaID = append(p.BezKpoProdajaID, id)
}
for id, postoji := range postojiMapS {
if postoji {
@@ -237,44 +239,86 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
p.SumnjiviDupli++
continue
}
p.BezKpo++
p.BezKpoServisID = append(p.BezKpoServisID, id)
}
p.BezKpoProdaja = len(p.BezKpoProdajaID)
p.BezKpoServis = len(p.BezKpoServisID)
}
if h.modulUkljucen(ctx, "fiskalizacija") {
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
p.BezFiskalnogProdaja += len(m)
for id := range m {
p.BezFiskalnogProdajaID = append(p.BezFiskalnogProdajaID, id)
}
p.BezFiskalnogProdaja = len(p.BezFiskalnogProdajaID)
}
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
p.BezFiskalnogServis += len(m)
for id := range m {
p.BezFiskalnogServisID = append(p.BezFiskalnogServisID, id)
}
p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
}
if n, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefunda = n
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefundaProdajaID = idsP
p.BezRefundaServisID = idsS
p.BezRefundaProdaja = len(idsP)
p.BezRefundaServis = len(idsS)
}
}
return p, nil
}
// stornoBezRefunda broji stornirane prodajne i servisne 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).
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) {
var n int
err := h.DB.QueryRowContext(ctx, `
SELECT
(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)
+
(SELECT COUNT(*) FROM servisni_nalozi sn
JOIN fiskalni_racuni fr ON fr.servis_id = sn.id
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE sn.stornirano = 1)
`).Scan(&n)
return n, err
// stornoBezRefunda vraća ID-jeve storniranih prodajnih i servisnih naloga č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).
func (h *Handler) stornoBezRefunda(ctx context.Context) (prodajaIDs, servisIDs []int64, err error) {
redoviP, err := h.DB.QueryContext(ctx, `
SELECT DISTINCT pn.id FROM prodajni_nalozi pn
JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id
AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE pn.stornirano = 1
`)
if err != nil {
return nil, nil, err
}
defer redoviP.Close()
for redoviP.Next() {
var id int64
if err := redoviP.Scan(&id); err != nil {
return nil, nil, err
}
prodajaIDs = append(prodajaIDs, id)
}
if err := redoviP.Err(); err != nil {
return nil, nil, err
}
// tip_racuna='Normal' je bitno: servis sa avansom ima i Advance/Sale zapis koji
// se NIKAD ne stornira pri storno servisa (samo konačan Normal/Sale) — bez ovog
// filtera bi JOIN dao dva reda za isti servis (duplikat u listi na dashboard-u)
redoviS, err := h.DB.QueryContext(ctx, `
SELECT DISTINCT sn.id FROM servisni_nalozi sn
JOIN fiskalni_racuni fr ON fr.servis_id = sn.id
AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE sn.stornirano = 1
`)
if err != nil {
return nil, nil, err
}
defer redoviS.Close()
for redoviS.Next() {
var id int64
if err := redoviS.Scan(&id); err != nil {
return nil, nil, err
}
servisIDs = append(servisIDs, id)
}
if err := redoviS.Err(); err != nil {
return nil, nil, err
}
return prodajaIDs, servisIDs, nil
}
func istKlijent(a, b *int64) bool {
+5
View File
@@ -46,6 +46,8 @@ var sveAkcije = []string{
"kpo.pregled",
"kpo.dodaj",
"kpo.obrisi",
"fiskal.pazar",
"fiskal.zakljucenje",
}
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
@@ -109,6 +111,9 @@ func ImaDozvolu(uloga, akcija string) bool {
// KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
return true
// dnevni fiskalni pazar — administrativno, radnik nema (zaključenje dana je nepovratno)
case "fiskal.pazar", "fiskal.zakljucenje":
return true
}
return false
+34 -7
View File
@@ -78,12 +78,33 @@ type PodaciDashboarda struct {
// sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje
// se kao kartica na dashboard-u.
type PrazninaKnjigovodstva struct {
BezKir int
BezKpo int
BezFiskalnogProdaja int // prodajni nalozi bez izdatog fiskalnog računa
BezFiskalnogServis int // preuzeti servisni nalozi bez izdatog fiskalnog računa
BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao)
SumnjiviDupli int
BezKirProdaja int // B2B prodaje bez KIR upisa
BezKirServis int // B2B servisi bez KIR upisa
BezKirProdajaID []int64 // ID-jevi — za direktan link sa dashboard-a
BezKirServisID []int64
BezKpoProdaja int // prodaje bez KPO upisa
BezKpoServis int // servisi bez KPO upisa
BezKpoProdajaID []int64
BezKpoServisID []int64
BezFiskalnogProdaja int // prodajni nalozi bez izdatog fiskalnog računa
BezFiskalnogServis int // preuzeti servisni nalozi bez izdatog fiskalnog računa
BezFiskalnogProdajaID []int64
BezFiskalnogServisID []int64
BezRefundaProdaja int // stornirane prodaje čiji fiskalni refund nije uspeo (best-effort poziv pao)
BezRefundaServis int // stornirani servisi čiji fiskalni refund nije uspeo (best-effort poziv pao)
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
SumnjiviDupli int
}
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
func (p PrazninaKnjigovodstva) BezKir() int {
return p.BezKirProdaja + p.BezKirServis
}
// BezKpo vraća ukupan broj naloga (prodaja + servis) bez KPO upisa.
func (p PrazninaKnjigovodstva) BezKpo() int {
return p.BezKpoProdaja + p.BezKpoServis
}
// BezFiskalnog vraća ukupan broj naloga (prodaja + servis) bez izdatog fiskalnog
@@ -92,9 +113,15 @@ func (p PrazninaKnjigovodstva) BezFiskalnog() int {
return p.BezFiskalnogProdaja + p.BezFiskalnogServis
}
// BezRefunda vraća ukupan broj storniranih naloga (prodaja + servis) čiji fiskalni
// refund nije uspeo — koristi se za prikaz zbirnog broja na dashboard-u.
func (p PrazninaKnjigovodstva) BezRefunda() int {
return p.BezRefundaProdaja + p.BezRefundaServis
}
// 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
return p.BezKir() + p.BezKpo() + p.BezFiskalnog() + p.BezRefunda() + p.SumnjiviDupli
}