#!/usr/bin/env python3
"""
Generisanje fiskalnog računa za A4 štampač.
Prati izgled definisan u LPFR VM template-u i locale fajlovima.
"""
from pathlib import Path
from datetime import datetime
DATA_DIR = Path(__file__).parent / "data"
def load_locale(lang="latin"):
"""Učitava lokalizacioni fajl u dict."""
filename = f"locale_{lang}.properties"
path = DATA_DIR / filename
if not path.exists():
return {}
locale = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
locale[key.strip()] = value.strip()
return locale
def _sr(n, decimals=2):
"""Srpski format: tačka za hiljade, zarez za decimale — 2.967,17"""
s = f"{n:,.{decimals}f}"
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
def price(n):
"""Formatira cenu: ###.###,00"""
return _sr(n, 2)
def qty(n):
"""Formatira količinu: ###.###,000"""
return _sr(n, 3)
def amount(n):
"""Formatira iznos: ###.###,00"""
return _sr(n, 2)
def number(n):
"""Formatira broj: ###.###"""
return _sr(n, 0)
def dt(iso_string):
"""Konvertuje ISO datetime u format: dd.MM.yyyy. HH:mm:ss"""
try:
d = datetime.fromisoformat(iso_string.replace("+02:00", "").replace("Z", ""))
return d.strftime("%d.%m.%Y. %H:%M:%S")
except Exception:
return iso_string
def center(text, width=48):
"""Centrira tekst unutar širine."""
return text.center(width)
def layout(left, right, width=48):
"""Formatira dve kolone: levo poravnato levo, desno poravnato desno."""
left_str = str(left) if left else ""
right_str = str(right) if right else ""
space = width - len(left_str) - len(right_str)
if space < 1:
space = 1
return left_str + " " * space + right_str
def wrap(text, width=48):
"""Prelamanje teksta (vraća listu linija)."""
if not text:
return []
words = text.split()
lines = []
current = ""
for word in words:
if len(current) + len(word) + 1 <= width:
current = (current + " " + word).strip()
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines if lines else [text]
def separator(char="-", width=48):
"""Linija separatora."""
return char * width
def title(text, char="=", width=48):
"""Naslov okružen linijama."""
return separator(char, width) + "\n" + center(text, width) + "\n" + separator(char, width)
# ── Transakcije prevod ──────────────────────────────────────
TRANSACTION_TYPES_CYR = {
"NSX": "ПРОМЕТ - ПРОДАЈА", "NRX": "ПРОМЕТ - РЕФУНДАЦИЈА",
"CSX": "КОПИЈА - ПРОДАЈА", "CRX": "КОПИЈА - РЕФУНДАЦИЈА",
"ASX": "АВАНС - ПРОДАЈА", "ARX": "АВАНС - РЕФУНДАЦИЈА",
"PSX": "ПРЕДРАЧУН - ПРОДАЈА", "PRX": "ПРЕДРАЧУН - РЕФУНДАЦИЈА",
"TSX": "ОБУКА - ПРОДАЈА", "TRX": "ОБУКА - РЕФУНДАЦИЈА",
}
TRANSACTION_TYPES_LAT = {
"NSX": "PROMET - PRODAJA", "NRX": "PROMET - REFUNDACIJA",
"CSX": "KOPIJA - PRODAJA", "CRX": "KOPIJA - REFUNDACIJA",
"ASX": "AVANS - PRODAJA", "ARX": "AVANS - REFUNDACIJA",
"PSX": "PREDRAČUN - PRODAJA", "PRX": "PREDRAČUN - REFUNDACIJA",
"TSX": "OBUKA - PRODAJA", "TRX": "OBUKA - REFUNDACIJA",
}
# NTech šalje invoiceType+transactionType kao reči (Normal/Sale) umesto koda (NSX)
_INV_TX_TO_CODE = {
("Normal", "Sale"): "NSX", ("Normal", "Refund"): "NRX",
("Advance", "Sale"): "ASX", ("Advance", "Refund"): "ARX",
("Copy", "Sale"): "CSX", ("Copy", "Refund"): "CRX",
("Training", "Sale"): "TSX", ("Training", "Refund"): "TRX",
("Proforma", "Sale"): "PSX", ("Proforma", "Refund"): "PRX",
}
def _tx_code(inv):
"""Vraća 3-slovni kod transakcije (NSX, NRX...) iz full_data rečnika."""
it = inv.get("invoiceType", "Normal")
tt = inv.get("transactionType", "Sale")
return _INV_TX_TO_CODE.get((it, tt), tt)
# ── Glavna funkcija ─────────────────────────────────────────
def generate_receipt(invoice_data, lang="latin"):
"""
Generiše fiskalni račun spreman za A4 štampu.
Prati agent-invoice.vm template 1:1.
"""
m = load_locale(lang)
if not m:
m = load_locale("latin")
tx_types = TRANSACTION_TYPES_LAT if lang == "latin" else TRANSACTION_TYPES_CYR
W = 48
inv = invoice_data
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
lines = []
# ── PREAMBLE ──
preamble = inv.get("preamble", "")
if preamble:
for pl in wrap(preamble, W):
lines.append(pl)
lines.append("")
# ── NASLOV ──
is_fiscal = inv.get("isFiscal", True)
if is_fiscal:
lines.append(title(m.get("fiscal-invoice", "FISKALNI RAČUN"), "=", W))
else:
lines.append(title(m.get("non-fiscal-invoice", "OVO NIJE FISKALNI RAČUN"), "=", W))
# ── ZAGLAVLJE ──
for field in ["tin", "company", "store", "address", "district"]:
val = inv.get(field, "")
if val:
lines.append(center(str(val), W))
lines.append(separator("-", W))
# Buyer info
if inv.get("buyerId"):
lines.append(layout(m.get("buyer-id", "ID kupca"), inv["buyerId"], W))
if inv.get("buyerCostCenterId"):
lines.append(layout(m.get("buyer-cost-center", "Opcija kupca"), inv["buyerCostCenterId"], W))
if inv.get("cashier"):
lines.append(layout(m.get("cashier-id", "Kasir"), inv["cashier"], W))
if inv.get("posNumber"):
lines.append(layout(m.get("pos-invoice-number", "ESIR broj"), inv["posNumber"], W))
if inv.get("posDateTime"):
lines.append(layout(m.get("pos-time", "ESIR vreme"), dt(inv["posDateTime"]), W))
# Referentni dokument (storno)
if inv.get("referentDocumentNumber"):
lines.append(layout(m.get("ref-doc-number", "Ref. broj"), inv["referentDocumentNumber"], W))
if inv.get("referentDocumentDT"):
lines.append(layout(m.get("ref-doc-dt", "Ref. vreme"), dt(inv["referentDocumentDT"]), W))
# ── TIP TRANSAKCIJE ──
lines.append(title(tx_label, "-", W))
# ── ARTIKLI ──
lines.append(center(m.get("items", "Artikli"), W))
lines.append(separator("=", W))
# Zaglavlje tabele (prati agent-invoice.vm layout)
lbl_name = m.get("item-name", "Naziv")
lbl_price = m.get("item-price", "Cena")
lbl_qty = m.get("item-qty", "Kol.")
lbl_amount = m.get("item-amount", "Ukupno")
header = lbl_name + lbl_price.rjust(10) + lbl_qty.rjust(11)
lines.append(layout(header, lbl_amount, W))
for item in inv.get("items", []):
name = item.get("name", "")
gtin = item.get("gtin", "")
labels = item.get("labels", [])
label_str = " ".join(labels) if labels else ""
if gtin:
name_line = f"{gtin} {name} {label_str}".strip()
else:
name_line = f"{name} {label_str}".strip()
for wl in wrap(name_line, W):
lines.append(wl)
ip = float(item.get("unitPrice") or item.get("price", 0))
iq = float(item.get("quantity") or item.get("qty", 0))
ia = float(item.get("amount") or (ip * iq))
sign = "-" if inv.get("transactionType") == "Refund" else ""
row = price(ip).rjust(14) + qty(iq).rjust(9)
lines.append(layout(row, sign + amount(ia), W))
lines.append(separator("-", W))
# ── UKUPNO ──
if inv.get("transactionType") == "Refund":
lines.append(layout(m.get("total-refund", "Ukupna refundacija"), amount(inv.get("totalAmount", 0)), W))
else:
lines.append(layout(m.get("to-pay", "Za uplatu"), amount(inv.get("totalAmount", 0)), W))
# ── PLAĆANJA ──
advance = float(inv.get("advance", 0))
advance_tax = float(inv.get("advanceTax", 0))
is_covered_by_advance = inv.get("coveredByAdvance", False)
if advance:
lines.append(layout(m.get("paid-in-advance", "Uplaćeno avansom"), amount(advance), W))
if advance_tax:
lines.append(layout(m.get("advance-tax", "PDV na avans"), amount(advance_tax), W))
if not is_covered_by_advance:
for p in inv.get("payments", []):
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
lines.append(layout(ptype, amount(float(p.get("amount", 0))), W))
if inv.get("invoiceType") == "Proforma":
lines.append(layout(m.get("refund", "Povraćaj"), amount(0), W))
else:
lines.append(layout(m.get("refund", "Povraćaj"), amount(float(inv.get("refund", 0))), W))
if advance:
lines.append(layout(m.get("remaining", "Preostalo"), amount(float(inv.get("remaining", 0))), W))
lines.append(separator("=", W))
# ── NEFISKALNI RAČUN ──
if not is_fiscal:
lines.append(center(m.get("non-fiscal-invoice", "OVO NIJE FISKALNI RAČUN"), W))
lines.append(separator("-", W))
# ── POREZI ──
tax_hdr = (m.get("tax-label", "Oznaka") +
m.get("tax-name", "Ime").rjust(8) +
m.get("tax-rate", "Stopa").rjust(8))
lines.append(layout(tax_hdr, m.get("tax-amount", "Porez"), W))
for tax in inv.get("taxItems", []):
row = (str(tax.get("label", "")) +
str(tax.get("name", "")).rjust(13) +
number(float(tax.get("rate", 0))).rjust(7) + "%")
lines.append(layout(row, amount(float(tax.get("amount", 0))), W))
lines.append(separator("-", W))
lines.append(layout(m.get("total-tax", "Ukupan porez"), amount(float(inv.get("totalTax", 0))), W))
lines.append(separator("=", W))
# ── PFR VREDNOSTI ──
lines.append(layout(m.get("sdc-time", "PFR vreme"), dt(inv.get("sdcDateTime", "")), W))
lines.append(layout(m.get("sdc-invoice-number", "PFR broj računa"), str(inv.get("invoiceNumber", "")), W))
lines.append(layout(m.get("sdc-invoice-counter", "Brojač računa"), str(inv.get("invoiceCounter", "")), W))
lines.append(separator("=", W))
# ── QR KOD ──
lines.append("{{{{QR-KOD}}}}")
lines.append("")
# ── POTPIS KUPCA (Copy + Refund) ──
if inv.get("invoiceType") == "Copy" and inv.get("transactionType") == "Refund":
lines.append("")
lines.append(f"{m.get('customer-signature', 'Potpis kupca')}: ______________________")
lines.append("")
# ── KRAJ ──
if is_fiscal:
lines.append(title(m.get("end-of-fiscal-invoice", "KRAJ FISKALNOG RAČUNA"), "=", W))
else:
lines.append(title(m.get("non-fiscal-invoice", "OVO NIJE FISKALNI RAČUN"), "=", W))
# ── PORUKA ──
msg = inv.get("message", "")
if msg:
lines.append("")
for ml in wrap(msg, W):
lines.append(ml)
return "\n".join(lines)
def generate_receipt_html(invoice_data, lang="latin"):
"""
Generiše HTML verziju fiskalnog računa — za A4 štampu iz browsera.
Prati agent-invoice.vm template 1:1.
"""
m = load_locale(lang)
if not m:
m = load_locale("latin")
tx_types = TRANSACTION_TYPES_LAT if lang == "latin" else TRANSACTION_TYPES_CYR
inv = invoice_data
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
is_fiscal = inv.get("isFiscal", True)
is_refund = inv.get("transactionType") == "Refund"
inv_type = inv.get("invoiceType", "Normal")
sign = "-" if is_refund else ""
# Preamble
preamble_html = ""
preamble = inv.get("preamble", "")
if preamble:
preamble_html = f'
{preamble}
'
# Items
items_rows = ""
for item in inv.get("items", []):
gtin = f' (GTIN: {item["gtin"]})' if item.get("gtin") else ""
labels = " ".join(item.get("labels", []))
ip = float(item.get("unitPrice") or item.get("price", 0))
iq = float(item.get("quantity") or item.get("qty", 0))
ia = float(item.get("amount") or (ip * iq))
items_rows += f"""
| {item.get('name', '')}{gtin} {labels} |
{price(ip)} |
{qty(iq)} |
{sign}{amount(ia)} |
"""
# Payments
payments_rows = ""
advance = float(inv.get("advance", 0))
advance_tax = float(inv.get("advanceTax", 0))
covered = inv.get("coveredByAdvance", False)
if advance:
payments_rows += f'| {m.get("paid-in-advance", "Uplaćeno avansom")} | {amount(advance)} |
'
if advance_tax:
payments_rows += f'| {m.get("advance-tax", "PDV na avans")} | {amount(advance_tax)} |
'
if not covered:
for p in inv.get("payments", []):
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
payments_rows += f'| {ptype} | {amount(float(p.get("amount", 0)))} |
'
if inv_type == "Proforma":
payments_rows += f'| {m.get("refund", "Povraćaj")} | {amount(0)} |
'
else:
payments_rows += f'| {m.get("refund", "Povraćaj")} | {amount(float(inv.get("refund", 0)))} |
'
if advance:
payments_rows += f'| {m.get("remaining", "Preostalo")} | {amount(float(inv.get("remaining", 0)))} |
'
# Non-fiscal notice
non_fiscal_html = ""
if not is_fiscal:
non_fiscal_html = f'{m.get("non-fiscal-invoice", "OVO NIJE FISKALNI RAČUN")}
'
# Tax
tax_rows = ""
for tax in inv.get("taxItems", []):
tax_rows += f"""
| {tax.get('label', '')} |
{tax.get('name', '')} |
{number(float(tax.get('rate', 0)))}% |
{amount(float(tax.get('amount', 0)))} |
"""
# Customer signature (Copy + Refund)
sig_html = ""
if inv_type == "Copy" and inv.get("transactionType") == "Refund":
sig_html = f'{m.get("customer-signature", "Potpis kupca")}: ______________________
'
# Message
msg_html = ""
msg = inv.get("message", "")
if msg:
msg_html = f'{msg}
'
qr_src = f"data:image/png;base64,{inv.get('qrCode', '')}"
title_text = m.get("fiscal-invoice", "FISKALNI RAČUN") if is_fiscal else m.get("non-fiscal-invoice", "OVO NIJE FISKALNI RAČUN")
end_text = m.get("end-of-fiscal-invoice", "KRAJ FISKALNOG RAČUNA") if is_fiscal else m.get("non-fiscal-invoice", "")
# ── Buyer info (dvokolonski: levo=header, desno=buyer) ──
buyer_col = ""
if inv.get("buyerId"):
buyer_col += f'{m.get("buyer-id", "ID kupca")}{inv["buyerId"]}
'
if inv.get("buyerCostCenterId"):
buyer_col += f'{m.get("buyer-cost-center", "Opcija kupca")}{inv["buyerCostCenterId"]}
'
ref_doc = ""
if inv.get("referentDocumentNumber"):
ref_doc += f'{m.get("ref-doc-number", "Ref. broj")}: {inv["referentDocumentNumber"]}
'
if inv.get("referentDocumentDT"):
ref_doc += f'{m.get("ref-doc-dt", "Ref. vreme")}: {dt(inv["referentDocumentDT"])}
'
html = f"""
{title_text} | {inv.get('invoiceNumber', '')}
{preamble_html}
{title_text}
{inv.get('company', '')}
{inv.get('tin', '')}
{inv.get('store', '')}
{inv.get('address', '')}
{inv.get('district', '')}
{m.get('cashier-id', 'Kasir')}: {inv.get('cashier', '')}
{m.get('pos-invoice-number', 'ESIR broj')}: {inv.get('posNumber', '')}
{m.get('pos-time', 'ESIR vreme')}: {dt(inv.get('posDateTime', ''))}
{buyer_col}
{ref_doc}
{tx_label}
| {m.get('item-name', 'Naziv')} | {m.get('item-price', 'Cena')} | {m.get('item-qty', 'Kol.')} | {m.get('item-amount', 'Ukupno')} |
{items_rows}
| {m.get('to-pay', 'Za uplatu') if not is_refund else m.get('total-refund', 'Ukupna refundacija')} | {amount(float(inv.get('totalAmount', 0)))} |
{payments_rows}
{non_fiscal_html}
| {m.get('tax-label', 'Oznaka')} | {m.get('tax-name', 'Naziv')} | {m.get('tax-rate', 'Stopa')} | {m.get('tax-amount', 'Porez')} |
{tax_rows}
| {m.get('total-tax', 'Ukupan porez')} | {amount(float(inv.get('totalTax', 0)))} |
| {m.get('sdc-time', 'PFR vreme')} | {dt(inv.get('sdcDateTime', ''))} |
| {m.get('sdc-invoice-number', 'PFR broj računa')} | {inv.get('invoiceNumber', '')} |
| {m.get('sdc-invoice-counter', 'Brojač računa')} | {inv.get('invoiceCounter', '')} |
{sig_html}
{end_text}
{msg_html}
"""
return html
# ═══════════════════════════════════════════════════════════════
# DNEVNI / PERIODIČNI IZVEŠTAJI (standard-report.vm)
# ═══════════════════════════════════════════════════════════════
def generate_report(report_data, lang="latin"):
"""
Generiše dnevni ili periodični izveštaj.
Prati standard-report.vm template 1:1.
report_data = {
"title": "DNEVNI IZVEŠTAJ",
"number": 1,
"dateTime": "2026-06-21T16:00:00.000+02:00",
"tin": "123456789",
"businessName": "Test Company DOO",
"locationName": "Test Location",
"address": "Test Address 1, Beograd",
"district": "Savski Venac",
"uid": "550e8400...",
"startDate": "2026-06-21",
"endDate": "2026-06-21",
"total": {
"invoiceCount": 42,
"payments": [
{"paymentType": "Cash", "amount": 25000.00},
{"paymentType": "Card", "amount": 15000.00},
],
"totalPayments": 40000.00,
"taxItems": [
{"label": "S", "rate": 20.0, "total": 250000.00, "amount": 50000.00},
{"label": "P", "rate": 10.0, "total": 100000.00, "amount": 10000.00},
],
"totalTax": 60000.00,
},
"perTransactionType": [
{
"transactionTypeExt": "NSX",
"invoiceCount": 30,
"payments": [...],
"totalPayments": 30000.00,
"taxItems": [...],
"totalTax": 45000.00,
}
],
}
"""
m = load_locale(lang)
if not m:
m = load_locale("latin")
W = 48
rep = report_data
lines = []
lines.append(separator("=", W))
# Zaglavlje
for field in ["tin", "businessName", "locationName", "address", "district"]:
val = rep.get(field, "")
if val:
lines.append(center(str(val), W))
lines.append(separator("-", W))
# Naslov
title_text = rep.get("title", "IZVEŠTAJ")
lines.append(center(title_text, W))
# Period
start = rep.get("startDate", "")
end = rep.get("endDate", "")
if start and end:
period_text = m.get("period", "PERIOD") + f": {start} - {end}"
lines.append(center(period_text, W))
lines.append(separator("-", W))
# Broj izveštaja, JID, vreme
if rep.get("number"):
lines.append(layout(m.get("report-number", "Broj izveštaja"), str(rep["number"]), W))
lines.append(layout(m.get("uid", "JID"), rep.get("uid", ""), W))
lines.append(layout(m.get("sdc-time", "PFR vreme"), dt(rep.get("dateTime", "")), W))
lines.append(separator("-", W))
lines.append(center(m.get("summary", "UKUPAN PROMET"), W))
lines.append(separator("-", W))
total = rep.get("total", {})
# Broj računa
lines.append(layout(m.get("invoice-count", "Broj računa"), str(total.get("invoiceCount", 0)), W))
# Plaćanja
lines.append(title(m.get("payment", "Uplaćeno"), "=", W))
for p in total.get("payments", []):
ptype = m.get(p.get("paymentType", ""), p.get("paymentType", "Drugo"))
lines.append(layout(ptype, amount(float(p.get("amount", 0))), W))
lines.append(separator("-", W))
lines.append(layout(m.get("total-payment", "Ukupno uplaćeno"), amount(float(total.get("totalPayments", 0))), W))
# Porezi
lines.append(title(m.get("tax", "Porez"), "=", W))
tax_hdr = m.get("tax-rate", "Stopa") + m.get("item-amount", "Osnovica").rjust(20)
lines.append(layout(tax_hdr, m.get("tax-amount", "Porez"), W))
lines.append(separator("-", W))
for t in total.get("taxItems", []):
row = str(t.get("label", "")) + number(float(t.get("rate", 0))).rjust(7) + amount(float(t.get("total", 0))).rjust(17)
lines.append(layout(row, amount(float(t.get("amount", 0))), W))
lines.append(separator("-", W))
lines.append(layout(m.get("total-tax", "Ukupan porez"), amount(float(total.get("totalTax", 0))), W))
# Po tipu transakcije
per_tx = rep.get("perTransactionType", [])
if per_tx:
lines.append(separator("=", W))
lines.append(center(m.get("per-transaction-type", "PROMET PO VRSTI"), W))
for summary in per_tx:
lines.append(separator("=", W))
tx_code = summary.get("transactionTypeExt", "")
tx_label_full = TRANSACTION_TYPES_LAT.get(tx_code, "") if lang == "latin" else TRANSACTION_TYPES_CYR.get(tx_code, "")
if tx_label_full:
lines.append(center(tx_label_full, W))
lines.append(separator("-", W))
lines.append(layout(m.get("invoice-count", "Broj računa"), str(summary.get("invoiceCount", 0)), W))
lines.append(title(m.get("payment", "Uplaćeno"), "=", W))
for p in summary.get("payments", []):
ptype = m.get(p.get("paymentType", ""), p.get("paymentType", "Drugo"))
lines.append(layout(ptype, amount(float(p.get("amount", 0))), W))
lines.append(separator("-", W))
lbl = m.get("total-refund", "Refundacija") if "Refund" in tx_code else m.get("total-payment", "Ukupno")
lines.append(layout(lbl, amount(float(summary.get("totalPayments", 0))), W))
lines.append(title(m.get("tax", "Porez"), "=", W))
lines.append(layout(tax_hdr, m.get("tax-amount", "Porez"), W))
lines.append(separator("-", W))
for t in summary.get("taxItems", []):
row = str(t.get("label", "")) + number(float(t.get("rate", 0))).rjust(7) + amount(float(t.get("total", 0))).rjust(17)
lines.append(layout(row, amount(float(t.get("amount", 0))), W))
lines.append(separator("-", W))
lines.append(layout(m.get("total-tax", "Ukupan porez"), amount(float(summary.get("totalTax", 0))), W))
lines.append(separator("=", W))
return "\n".join(lines)