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
+165 -12
View File
@@ -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()
+5
View File
@@ -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)
+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
}
+7 -2
View File
@@ -213,13 +213,13 @@
<span>Podešavanja</span>
<span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
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 "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
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}}">
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
<span class="nav-tooltip">Podešavanja</span>
</button>
<div class="nav-podmeni {{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 "podesavanja-fiskalizacija") (eq .Stranica "dozvole")}}otvoren{{end}}">
<div class="nav-podmeni {{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 "podesavanja-fiskalizacija") (eq .Stranica "fiskal-pazar") (eq .Stranica "dozvole")}}otvoren{{end}}">
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
<span>Opšte</span>
@@ -250,6 +250,11 @@
<span>Fiskalizacija</span>
<span class="nav-tooltip">Fiskalizacija</span>
</a>
<a href="/fiskal/pazar" class="nav-stavka nav-podstavka {{if eq .Stranica "fiskal-pazar"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span>Dnevni pazar</span>
<span class="nav-tooltip">Dnevni pazar</span>
</a>
{{if or (eq .KorisnikUloga "superadmin") (eq .KorisnikUloga "admin")}}
<a href="/admin/dozvole" class="nav-stavka nav-podstavka {{if eq .Stranica "dozvole"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
+24 -8
View File
@@ -22,19 +22,35 @@
{{ if gt .PrazninaKnjige.Ukupno 0 }}
<div class="kartica animiraj" style="margin-bottom:16px;background:#fef3c7;border:1px solid #fde68a;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<div style="font-weight:600;color:#92400e;">Knjigovodstvo — praznine u evidenciji</div>
<div style="font-size:13px;color:#92400e;margin-top:2px;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div>
<div style="font-weight:600;color:#fdba74;">Knjigovodstvo — praznine u evidenciji</div>
<div style="font-size:13px;color:#fdba74;margin-top:2px;">
{{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogProdaja 0 }}{{ .PrazninaKnjige.BezFiskalnogProdaja }} prodaja bez fiskalnog računa{{ if or (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogServis 0 }}{{ .PrazninaKnjige.BezFiskalnogServis }} servis bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }}
{{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }}
{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) }}
<div style="margin-top:4px;">
Bez fiskalnog računa:
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
{{ if and .PrazninaKnjige.BezFiskalnogProdajaID .PrazninaKnjige.BezFiskalnogServisID }}, {{ end }}
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
</div>
{{ end }}
{{ if gt .PrazninaKnjige.BezRefunda 0 }}
<div style="margin-top:4px;">
Storno bez refunda:
{{ range $i, $id := .PrazninaKnjige.BezRefundaProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
{{ if and .PrazninaKnjige.BezRefundaProdajaID .PrazninaKnjige.BezRefundaServisID }}, {{ end }}
{{ range $i, $id := .PrazninaKnjige.BezRefundaServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO</a>{{ end }}
{{ if gt .PrazninaKnjige.BezFiskalnogProdaja 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Fiskalizacija — prodaja</a>{{ end }}
{{ if gt .PrazninaKnjige.BezFiskalnogServis 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/arhiva" class="btn-sekundarno" style="font-size:13px;">Fiskalizacija — servis</a>{{ end }}
{{ if gt .PrazninaKnjige.BezRefunda 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?stornirano=1&prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Storno bez refunda</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }}
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
{{template "base" .}}
{{define "naslov"}}Dnevni fiskalni presek — NTech{{end}}
{{define "sadrzaj"}}
<div class="stranica-stack" style="width:100%;max-width:720px;">
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Dnevni fiskalni presek</span>
</div>
{{if .Greska}}
<div class="fisk-status greska" style="margin-bottom:16px;">&#10007; {{.Greska}}</div>
{{end}}
{{if .Presek}}
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:16px;">
Promet od poslednjeg preseka stanja: <strong>{{.Presek.StartOfPeriod}}</strong> do sada.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;">
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Broj računa</div>
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{.Presek.InvoiceCount}}</div>
</div>
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Ukupan promet</div>
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{dinari .Presek.Total}} din</div>
</div>
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
<div style="font-size:12px;color:var(--tekst-sporedni);">Gotovina</div>
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .Presek.TotalCash}} din</div>
</div>
</div>
{{if .Presek.TotalByPaymentType}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po vrsti plaćanja</div>
{{range .Presek.TotalByPaymentType}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.PaymentType}}</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{if .Presek.TotalByTax}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po poreskim stopama</div>
{{range .Presek.TotalByTax}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.Label}} ({{.Rate}}%)</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{if .Presek.TotalByCashier}}
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po kasiru</div>
{{range .Presek.TotalByCashier}}
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
<span style="color:var(--tekst-sporedni);">{{.Name}}</span>
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{if .FiskalPodesen}}
<div style="display:flex;gap:10px;flex-wrap:wrap;padding-top:12px;border-top:0.5px solid var(--ivica);">
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
<form method="POST" action="/fiskal/pazar/izvestaj" style="display:inline;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Preuzmi dnevni izveštaj</button>
</form>
{{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;"
onsubmit="return confirm('Zaključiti dan? Ovo trajno svodi brojače prometa na nulu — radnja se ne može poništiti.')">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;">Zaključi dan</button>
</form>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
+25 -3
View File
@@ -300,10 +300,11 @@
{{if .FiskalGreska}}
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #f59e0b;border-radius:8px;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
<div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
</div>
{{if index $.Dozvole "prodaja.dodaj"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj">
@@ -315,6 +316,27 @@
</div>
{{end}}
{{if .StornoBezRefunda}}
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #dc2626;border-radius:8px;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:14px;">
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
{{if index $.Dozvole "prodaja.storno"}}
<form method="POST" action="/prodaja/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .PotrebnaIdentKupcaRetry}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
{{end}}
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
</div>
</div>
</div>
+47 -3
View File
@@ -1031,6 +1031,28 @@
</div>
{{end}}
{{if .AvansRacun}}
<div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Avansni fiskalni račun</span>
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun?tip=avans" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
</div>
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Račun za primljeni avans, izdat pre preuzimanja uređaja.</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
<div style="flex:1;min-width:200px;">
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.AvansRacun.PfrBroj}}</p>
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.AvansRacun.PfrVreme}}</p>
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.AvansRacun.Brojac}}</p>
</div>
{{if .AvansRacun.QRKod}}
<div>
<img src="data:image/png;base64,{{.AvansRacun.QRKod}}" alt="QR kod" style="width:25mm;height:25mm;">
</div>
{{end}}
</div>
</div>
{{end}}
{{if .PovracajAvansa}}
<div class="kartica detalji-kartica animiraj">
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
@@ -1055,10 +1077,11 @@
{{if .FiskalGreska}}
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #f59e0b;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div>
<div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
<div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div>
</div>
{{if index .Dozvole "servis.izmeni"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/fiskalizuj">
@@ -1070,6 +1093,27 @@
</div>
{{end}}
{{if .StornoBezRefunda}}
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #dc2626;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
<div style="display:flex;align-items:center;gap:14px;">
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
{{if index .Dozvole "servis.storno"}}
<form method="POST" action="/servis/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
{{if .PotrebnaIdentKupcaRetry}}
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
{{end}}
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
</form>
{{end}}
</div>
</div>
</div>
{{end}}
<!-- storno -->
{{if and (index .Dozvole "servis.storno") (eq .Nalog.Status "Preuzeto") (not .Nalog.Stornirano)}}
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">