From 7c215e70e86126173e423e7c54c4ce848bd5b839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Thu, 2 Jul 2026 19:14:54 +0200 Subject: [PATCH] =?UTF-8?q?Fiskalni=20pazar:=20presek=20stanja,=20dnevni?= =?UTF-8?q?=20izve=C5=A1taj=20i=20razdvajanje=20praznina=20po=20prodaji/se?= =?UTF-8?q?rvisu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Fisk/server.py | 177 +++++++++++++++++-- cmd/ntech/main.go | 5 + internal/db/sqlite/fiskal.go | 2 +- internal/fiskal/fiskal.go | 145 ++++++++++++++++ internal/handler/fiskal_pazar.go | 120 +++++++++++++ internal/handler/kes.go | 1 + internal/handler/prodaja.go | 181 +++++++++++++------- internal/handler/servis.go | 115 +++++++++++-- internal/handler/uskladjivanje.go | 98 ++++++++--- internal/middleware/dozvole.go | 5 + internal/model/stranica.go | 41 ++++- web/templates/komponente/sidebar.html | 9 +- web/templates/stranice/dashboard.html | 32 +++- web/templates/stranice/fiskal_pazar.html | 96 +++++++++++ web/templates/stranice/prodaja_detalji.html | 28 ++- web/templates/stranice/servis_detalji.html | 50 +++++- 16 files changed, 965 insertions(+), 140 deletions(-) create mode 100644 internal/handler/fiskal_pazar.go create mode 100644 web/templates/stranice/fiskal_pazar.html diff --git a/Fisk/server.py b/Fisk/server.py index 5f75ac6..613a78e 100755 --- a/Fisk/server.py +++ b/Fisk/server.py @@ -38,6 +38,7 @@ QR_DIR = DATA_DIR / "qr" RECEIPTS_DIR = DATA_DIR / "receipts" LOG_FILE = DATA_DIR / "server.log" COUNTER_DIR = DATA_DIR / "counters" +PERIOD_FILE = DATA_DIR / "period_start.txt" for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]: d.mkdir(parents=True, exist_ok=True) @@ -525,6 +526,150 @@ def resp_invoice_search(request_body=None): ] return "\n".join(lines) +# ── Dnevni pazar / presek stanja ─────────────────────────── + +def get_period_start(): + """Vreme poslednjeg preseka stanja; ako ga još nema, postavlja se na sada.""" + if PERIOD_FILE.exists(): + return PERIOD_FILE.read_text().strip() + ts = sada() + PERIOD_FILE.write_text(ts) + return ts + +def reset_period(): + """Zatvara tekući period (presek stanja) — sledeći GET summary počinje od sada.""" + PERIOD_FILE.write_text(sada()) + +def compute_summary(from_iso=None, to_iso=None): + """Sabira promet iz sačuvanih fiskalnih računa u zadatom periodu. + Bez argumenata: od poslednjeg preseka stanja (get_period_start) do sada.""" + start = from_iso or get_period_start() + end = to_iso or sada() + + total = 0.0 + total_cash = 0.0 + count = 0 + by_tax = {} + by_cashier = {} + by_payment = {} + by_article = {} + by_article_advance = {} + + for f in sorted(INVOICES_DIR.glob("*.json")): + try: + inv = json.loads(f.read_text(encoding="utf-8")) + except Exception: + continue + t = inv.get("sdcDateTime", "") + if not t or not (start <= t <= end) or not inv.get("isFiscal", True): + continue + count += 1 + + amount = float(inv.get("totalAmount", 0)) + is_refund = inv.get("transactionType") == "Refund" + znak = -1 if is_refund else 1 + total += znak * amount + + for p in inv.get("payments", []): + ptype = p.get("paymentType") or p.get("type") or "Other" + pamt = znak * float(p.get("amount", 0)) + by_payment[ptype] = by_payment.get(ptype, 0.0) + pamt + if ptype == "Cash": + total_cash += pamt + + kasir = inv.get("cashier", "Kasir") + by_cashier[kasir] = by_cashier.get(kasir, 0.0) + znak * amount + + for ti in inv.get("taxItems", []): + label = ti.get("label", "") + if label not in by_tax: + by_tax[label] = {"label": label, "rate": ti.get("rate", 0), + "category": "VAT" if ti.get("rate", 0) > 0 else "N-TAX", + "amount": 0.0, "osnovica": 0.0} + by_tax[label]["amount"] += znak * float(ti.get("amount", 0)) + + cilj = by_article_advance if inv.get("invoiceType") == "Advance" else by_article + for item in inv.get("items", []): + naziv = item.get("name", "") + if naziv not in cilj: + cilj[naziv] = {"articleName": naziv, "gtin": None, "plu": None, + "taxLabel": (item.get("labels") or [""])[0], + "amount": 0.0, "quantity": 0.0} + cilj[naziv]["amount"] += znak * float(item.get("totalAmount", 0)) + cilj[naziv]["quantity"] += znak * float(item.get("quantity", 0)) + for label in item.get("labels", []): + if label in by_tax: + by_tax[label]["osnovica"] += znak * float(item.get("totalAmount", 0)) + + return { + "startOfPeriod": start, + "endOfPeriod": end, + "invoiceCount": count, + "total": round(total, 2), + "totalCash": round(total_cash, 2), + "totalByTax": [ + {"amount": round(v["amount"], 4), "category": v["category"], "label": v["label"], + "rate": v["rate"], "osnovica": round(v["osnovica"], 2)} + for v in by_tax.values() + ], + "totalByCashier": [{"amount": round(v, 2), "name": k} for k, v in by_cashier.items()], + "totalByPaymentType": [{"amount": round(v, 2), "paymentType": k} for k, v in by_payment.items()], + "totalByArticle": [ + {**v, "amount": round(v["amount"], 2)} for v in by_article.values() + ], + "totalByArticleAdvance": [ + {**v, "amount": round(v["amount"], 2)} for v in by_article_advance.values() + ], + } + +def resp_financial_summary_get(): + return compute_summary() + +def resp_financial_summary_delete(): + reset_period() + log(" 📊 Presek stanja urađen — brojači prometa resetovani") + return ("", 204) + +def resp_financial_report_summary(request_body=None): + body = request_body or {} + from_date = body.get("fromDate") + to_date = body.get("toDate") + from_iso = f"{from_date}T00:00:00.000+02:00" if from_date else None + to_iso = f"{to_date}T23:59:59.999+02:00" if to_date else None + summary = compute_summary(from_iso, to_iso) + + cert = be_command({"command": "certificate"}) + firma = ucitaj_firmu() + lang = "cyrillic" if str(body.get("language", "")).lower().startswith("sr-cyrl") else "latin" + title = "ДНЕВНИ ИЗВЕШТАЈ" if lang == "cyrillic" else "DNEVNI IZVEŠTAJ" + + report_data = { + "title": title, + "number": get_counter("report"), + "dateTime": sada(), + "tin": firma["tinPlain"], + "businessName": firma["name"], + "locationName": firma["locationName"], + "address": firma["address"], + "district": firma["district"], + "uid": cert.get("jid", ESIR_ID), + "startDate": (from_date or summary["startOfPeriod"][:10]), + "endDate": (to_date or summary["endOfPeriod"][:10]), + "total": { + "invoiceCount": summary["invoiceCount"], + "payments": [{"paymentType": p["paymentType"], "amount": p["amount"]} for p in summary["totalByPaymentType"]], + "totalPayments": summary["total"], + "taxItems": [{"label": t["label"], "rate": t["rate"], "total": t["osnovica"], "amount": t["amount"]} for t in summary["totalByTax"]], + "totalTax": round(sum(t["amount"] for t in summary["totalByTax"]), 2), + }, + "perTransactionType": [], + } + tekst = generate_report(report_data, lang) + pdf_b64 = base64.b64encode(tekst.encode("utf-8")).decode("ascii") + filename = f"{title} - {report_data['startDate']} - {report_data['endDate']}.pdf" + log(f" 📄 Dnevni izveštaj #{report_data['number']} generisan ({summary['invoiceCount']} računa)") + return {"reportPdfBase64": pdf_b64, "reportName": title, "filename": filename} + # ── Rute ──────────────────────────────────────────────────── ROUTES = [ @@ -540,21 +685,27 @@ ROUTES = [ ("GET", "api/invoices/:invoiceNumber", "invoice_by_number"), ("POST", "api/invoices/search", "invoice_search"), ("POST", "api/invoices", "invoice"), + ("GET", "api/financial/summary", "financial_summary_get"), + ("DELETE", "api/financial/summary", "financial_summary_delete"), + ("POST", "api/financial/report/summary", "financial_report_summary"), ] HANDLERS = { - "attention": resp_attention, - "status": resp_status, - "verify_pin": resp_verify_pin, - "settings_get": resp_settings_get, - "settings_post": resp_settings_post, - "certificate": resp_certificate, - "invoice": resp_invoice, - "invoice_final": resp_invoice_final, - "invoice_last": resp_invoice_last, - "invoice_by_request": resp_invoice_by_request, - "invoice_by_number": resp_invoice_by_number, - "invoice_search": resp_invoice_search, + "attention": resp_attention, + "status": resp_status, + "verify_pin": resp_verify_pin, + "settings_get": resp_settings_get, + "settings_post": resp_settings_post, + "certificate": resp_certificate, + "invoice": resp_invoice, + "invoice_final": resp_invoice_final, + "invoice_last": resp_invoice_last, + "invoice_by_request": resp_invoice_by_request, + "invoice_by_number": resp_invoice_by_number, + "invoice_search": resp_invoice_search, + "financial_summary_get": resp_financial_summary_get, + "financial_summary_delete": resp_financial_summary_delete, + "financial_report_summary": resp_financial_report_summary, } # ── HTTP Handler ───────────────────────────────────────────── @@ -619,6 +770,8 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler): result = handler(params.get("invoiceNumber", "")) elif r_name == "invoice_search": result = handler(self._read_body()) + elif r_name == "financial_report_summary": + result = handler(self._read_body()) elif r_name in ("settings_post",): self._read_body() result = handler() diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 0314090..d530a42 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -291,6 +291,9 @@ func main() { r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) + r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) + r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) + r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) @@ -392,6 +395,7 @@ func main() { r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/garantni-list", h.StampaGarantnog) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/eskalacioni-list", h.StampaEskalacionogLista) r.With(doz("servis.izmeni")).Post("/servis/{id}/fiskalizuj", h.RetryFiskalizacija) + r.With(doz("servis.storno")).Post("/servis/{id}/refund-fiskalizuj", h.PokusajRefundServisa) r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus) r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju) r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija-dana", h.AzurirajGarancijaDana) @@ -420,6 +424,7 @@ func main() { r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju) r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje) r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje) + r.With(doz("prodaja.storno")).Post("/prodaja/{id}/refund-fiskalizuj", h.PokusajRefundProdaje) 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}/fiskalni-racun", h.StampaFiskalnogProdaje) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje) diff --git a/internal/db/sqlite/fiskal.go b/internal/db/sqlite/fiskal.go index e5e36be..fe37f55 100644 --- a/internal/db/sqlite/fiskal.go +++ b/internal/db/sqlite/fiskal.go @@ -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, diff --git a/internal/fiskal/fiskal.go b/internal/fiskal/fiskal.go index 3acbfa8..599f8ed 100644 --- a/internal/fiskal/fiskal.go +++ b/internal/fiskal/fiskal.go @@ -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 != "" { diff --git a/internal/handler/fiskal_pazar.go b/internal/handler/fiskal_pazar.go new file mode 100644 index 0000000..5ed226a --- /dev/null +++ b/internal/handler/fiskal_pazar.go @@ -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) +} diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 8c08fd3..94bbed0 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -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", diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index d22e4b0..358e25f 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -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 diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 2d1b62f..6537e13 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -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") diff --git a/internal/handler/uskladjivanje.go b/internal/handler/uskladjivanje.go index b032e25..893fc4c 100644 --- a/internal/handler/uskladjivanje.go +++ b/internal/handler/uskladjivanje.go @@ -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 { diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index 1a0c711..7e5462e 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -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 diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 7846551..54d07d2 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -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 } diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 4b46d81..4af7d81 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -213,13 +213,13 @@ Podešavanja + style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "fiskal-pazar") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}"> Podešavanja -