#!/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 price(n): """Formatira cenu: ###,###.00""" return f"{n:,.2f}" def qty(n): """Formatira količinu: ###,###.000""" return f"{n:,.3f}" def amount(n): """Formatira iznos: ###,###.00""" return f"{n:,.2f}" def number(n): """Formatira broj: ###,###""" return f"{n:,.0f}" 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", } # ── 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 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 ── tx_code = inv.get("transactionType", "NSX") tx_label = tx_types.get(tx_code, tx_code) 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", []): ptype = m.get(p.get("type", ""), p.get("type", "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("invoiceNumber", "")), 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_code = inv.get("transactionType", "NSX") tx_label = tx_types.get(tx_code, tx_code) 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", []): ptype = m.get(p.get("type", ""), p.get("type", "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}
{items_rows}
{m.get('item-name', 'Naziv')}{m.get('item-price', 'Cena')}{m.get('item-qty', 'Kol.')}{m.get('item-amount', 'Ukupno')}
{payments_rows}
{m.get('to-pay', 'Za uplatu') if not is_refund else m.get('total-refund', 'Ukupna refundacija')}{amount(float(inv.get('totalAmount', 0)))}
{non_fiscal_html} {tax_rows}
{m.get('tax-label', 'Oznaka')}{m.get('tax-name', 'Naziv')}{m.get('tax-rate', 'Stopa')}{m.get('tax-amount', 'Porez')}
{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('invoiceNumber', '')}
QR kod za verifikaciju
{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)