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:
+165
-12
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">✗ {{.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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user