diff --git a/.gitignore b/.gitignore index 0d4face..cd029fd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,15 @@ web/static/uploads/ *.kate-swp .*.kate-swp +# Fiskalni test server — kod ide u repo, ali ne i runtime artefakti +Fisk/__pycache__/ +Fisk/data/invoices/ +Fisk/data/qr/ +Fisk/data/receipts/ +Fisk/data/*.log +Fisk/data/*.pid +Fisk/data/counter.txt + # IDE podešavanja .vscode/ .idea/ diff --git a/Fisk/data/locale_cyrillic.properties b/Fisk/data/locale_cyrillic.properties new file mode 100644 index 0000000..18b057c --- /dev/null +++ b/Fisk/data/locale_cyrillic.properties @@ -0,0 +1,48 @@ +# Locale: Serbian Cyrillic +fiscal-invoice=ФИСКАЛНИ РАЧУН +non-fiscal-invoice=ОВО НИЈЕ ФИСКАЛНИ РАЧУН +buyer-id=ИД купца +buyer-cost-center=Опција купца +cashier-id=Касир +pos-invoice-number=ЕСИР број +pos-time=ЕСИР време +ref-doc-number=Реф. број +ref-doc-dt=Реф. време +sdc-invoice-counter=Бројач +sdc-invoice-number=Број рачуна +sdc-time=Време +uid=УИД +items=Артикли +item-name=Назив +item-qty=Кол. +item-price=Цена +item-amount=Износ +gtin=ГТИН +labels=Ознаке +name=Назив +qty=Кол. +quantity=Количина +price=Цена +amount=Износ +unitPrice=Јед. цена +tax-label=ПДВ +tax-name=Порез +tax-rate=Стопа +tax-amount=Порез +tax=Порез +total-tax=Укупан порез +advance-tax=Авансни порез +to-pay=За уплату +paid-in-advance=Плаћено авансно +remaining=Преостало +refund=Рефундација +total-refund=Укупна рефундација +total-payment=Укупно уплаћено +payment=Уплаћено +summary=УКУПАН ПРОМЕТ +invoice-count=Број рачуна +period=Период +report-number=Број извештаја +per-transaction-type=ПРОМЕТ ПО ВРСТИ +customer-signature=Потпис купца +end-of-fiscal-invoice=КРАЈ ФИСКАЛНОГ РАЧУНА diff --git a/Fisk/data/locale_latin.properties b/Fisk/data/locale_latin.properties new file mode 100644 index 0000000..702d365 --- /dev/null +++ b/Fisk/data/locale_latin.properties @@ -0,0 +1,48 @@ +# Locale: Serbian Latin +fiscal-invoice=FISKALNI RAČUN +non-fiscal-invoice=OVO NIJE FISKALNI RAČUN +buyer-id=ID kupca +buyer-cost-center=Opcija kupca +cashier-id=Kasir +pos-invoice-number=ESIR broj +pos-time=ESIR vreme +ref-doc-number=Ref. broj +ref-doc-dt=Ref. vreme +sdc-invoice-counter=Brojač +sdc-invoice-number=Broj računa +sdc-time=Vreme +uid=UID +items=Artikli +item-name=Naziv +item-qty=Kol. +item-price=Cena +item-amount=Iznos +gtin=GTIN +labels=Oznake +name=Naziv +qty=Kol. +quantity=Količina +price=Cena +amount=Iznos +unitPrice=Jed. cena +tax-label=PDV +tax-name=Porez +tax-rate=Stopa +tax-amount=Porez +tax=Porez +total-tax=Ukupan porez +advance-tax=Avansni porez +to-pay=Za uplatu +paid-in-advance=Plaćeno avansno +remaining=Preostalo +refund=Refundacija +total-refund=Ukupna refundacija +total-payment=Ukupno uplaćeno +payment=Uplaćeno +summary=UKUPAN PROMET +invoice-count=Broj računa +period=Period +report-number=Broj izveštaja +per-transaction-type=PROMET PO VRSTI +customer-signature=Potpis kupca +end-of-fiscal-invoice=KRAJ FISKALNOG RAČUNA diff --git a/Fisk/receipt.py b/Fisk/receipt.py new file mode 100644 index 0000000..161f296 --- /dev/null +++ b/Fisk/receipt.py @@ -0,0 +1,629 @@ +#!/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) diff --git a/Fisk/server.py b/Fisk/server.py new file mode 100755 index 0000000..76ef5ff --- /dev/null +++ b/Fisk/server.py @@ -0,0 +1,669 @@ +#!/usr/bin/env python3 +""" +myLPFR Mock Server — Kompletan fiskalni server za testiranje +================================================================ +Podržava sve /agent/v3, /api/v3 i /extension/v3 endpoint-e. +Automatski generiše QR kodove, snima račune, vodi log. +Pokreće se preko start.sh, gasi preko stop.sh. +""" + +import json +import http.server +import re +import sys +import base64 +import io +import os +import sqlite3 +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path + +import qrcode +from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale + +# ── Konfiguracija ────────────────────────────────────────── +PORT = 8989 +HOST = "0.0.0.0" +DATA_DIR = Path(__file__).parent / "data" +INVOICES_DIR = DATA_DIR / "invoices" +QR_DIR = DATA_DIR / "qr" +RECEIPTS_DIR = DATA_DIR / "receipts" +LOG_FILE = DATA_DIR / "server.log" + +# Inicijalizuj foldere +for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR]: + d.mkdir(parents=True, exist_ok=True) + +# Prefix računa (čitaj iz fajla, ili kreni od 1) +COUNTER_FILE = DATA_DIR / "counter.txt" + +# ── Bezbednosni element (mock kartica) ───────────────────── +# Fiksni test PIN — pravu karticu otključava korisnik svojim PIN-om, +# ovde je samo test vrednost kojom glumimo otključavanje. +PIN_BE = "1234" + +# Putanja do NTech SQLite baze — čita se read-only. Podrazumevano ../ntech.db +# (koren repozitorijuma), može se promeniti preko NTECH_SQLITE. +NTECH_DB = os.environ.get("NTECH_SQLITE") or str(Path(__file__).parent.parent / "ntech.db") + +def ucitaj_firmu(): + """Čita podatke o firmi iz NTech baze (read-only) i vraća ih kao dict. + Bezbednosni element 'već zna' identitet poreskog obveznika — ovde to glumimo + čitanjem profila firme iz tabele podesavanja. Ako baza ili ključ nedostaje, + vraćamo test vrednosti da server i dalje radi.""" + podaci = {} + try: + con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True) + try: + cur = con.execute( + "SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " + "('naziv_firme','pib','maticni_broj','adresa','telefon'," + "'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad')" + ) + podaci = {k: v for k, v in cur.fetchall()} + finally: + con.close() + except Exception as e: + log(f" ⚠️ Ne mogu da pročitam firmu iz baze ({NTECH_DB}): {e}") + + naziv = podaci.get("naziv_firme") or "Test Company DOO" + return { + "name": naziv, + "tin": podaci.get("pib") or "123456789", + "mb": podaci.get("maticni_broj") or "12345678", + "address": podaci.get("adresa") or "Test Address 1", + "telefon": podaci.get("telefon") or "", + "locationName": podaci.get("poslovna_jedinica_naziv") or naziv, + "businessUnitId": podaci.get("poslovna_jedinica_oznaka") or "BU-001", + "district": podaci.get("opstina") or "Savski Venac", + "city": podaci.get("grad") or "Beograd", + } + +def get_next_invoice_number(): + """Vraća i inkrementira broj računa.""" + if COUNTER_FILE.exists(): + num = int(COUNTER_FILE.read_text().strip()) + else: + num = 1 + COUNTER_FILE.write_text(str(num + 1)) + return f"{num:06d}" + +def log(msg): + """Upisuje poruku u log fajl i na stdout.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{now}] {msg}" + print(line, flush=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(line + "\n") + +def generate_qr(url): + """Pravi QR kod PNG i vraća base64 string.""" + img = qrcode.make(url) + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("utf-8") + +# ── Gradi rute iz OpenAPI speca ───────────────────────────── + +# Ako postoji myLPFR-api-docs.json, koristi ga. +# Ako ne, koristi hardkodovane rute. +SPEC_FILE = Path(__file__).parent.parent / "myLPFR-api-docs.json" + +# Default rute (pale su iz Swagger speca) +DEFAULT_ROUTES = [ + # Agent API + ("GET", "agent/v3/attention", "attention"), + ("GET", "agent/v3/environment-parameters", "environment"), + ("POST", "agent/v3/invoices", "invoice"), + ("GET", "agent/v3/invoices/:requestId", "invoice_lookup"), + ("POST", "agent/v3/open-drawer", "open_drawer"), + ("POST", "agent/v3/pin", "verify_pin"), + ("POST", "agent/v3/print-text", "print_text"), + ("GET", "agent/v3/receipts/:requestId", "receipt"), + ("GET", "agent/v3/receipts/:requestId/text","receipt_text"), + ("GET", "agent/v3/receipts/:requestId/html","receipt_html"), + ("GET", "agent/v3/reports/daily", "daily_report"), + ("GET", "agent/v3/reports/daily/text", "daily_report_text"), + ("GET", "agent/v3/reports/periodic", "periodic_report"), + ("GET", "agent/v3/reports/periodic/text", "periodic_report_text"), + ("GET", "agent/v3/status", "status"), + ("GET", "agent/v3/subject", "subject"), + # E-SDC API + ("GET", "api/v3/attention", "attention"), + ("GET", "api/v3/environment-parameters", "environment"), + ("POST", "api/v3/invoices", "invoice"), + ("GET", "api/v3/invoices/:requestId", "invoice_lookup"), + ("POST", "api/v3/pin", "verify_pin"), + ("GET", "api/v3/status", "status"), + # Extension API + ("GET", "extension/v3/notifications", "notifications"), + ("GET", "extension/v3/reports/daily", "daily_report"), + ("GET", "extension/v3/reports/periodic", "periodic_report"), + ("GET", "extension/v3/status-codes", "status_codes"), + ("GET", "extension/v3/subject", "subject"), +] + +# ── Response handler-i ────────────────────────────────────── + +def sada(): + """Trenutno vreme u ISO formatu sa +02:00.""" + tz = timezone(timedelta(hours=2)) + return datetime.now(tz).strftime("%Y-%m-%dT%H:%M:%S.000+02:00") + +def resp_attention(): + return {"sdcDateTime": sada(), "status": "OK"} + +def resp_status(): + return { + "isPinRequired": True, + "auditRequired": False, + "sdcDateTime": sada(), + "lastInvoiceNumber": get_last_invoice_number(), + "protocolVersion": "1.0.0.0", + "secureElementVersion": "1.0", + "hardwareVersion": "1.0", + "softwareVersion": "0.3.18", + "deviceSerialNumber": "50-0002-NX6LC40XR3TQ", + "make": "MyOffice DOO", + "model": "myLPFR", + "mssc": [], + "gsc": ["1300", "0210"], + "supportedLanguages": ["sr-Cyrl-RS", "sr-Latin-RS"], + "uid": "", + "taxCoreApi": "https://suf-sandbox.purs.gov.rs", + "currentTaxRates": None, + "allTaxRates": [], + } + +def resp_environment(): + f = ucitaj_firmu() + return { + "tin": f["tin"], + "uid": "550e8400-e29b-41d4-a716-446655440000", + "taxCoreApi": "https://suf-sandbox.purs.gov.rs", + "sufVersion": "3.0", + "supportedLanguages": ["sr-Cyrl-RS", "sr-Latin-RS"], + "taxRates": [{ + "validFrom": "2026-01-01", + "groupId": 1, + "taxCategories": [{ + "categoryId": 1, + "name": "PDV", + "categoryType": "0", + "orderId": 1, + "taxRates": [ + {"rateId": 1, "rate": 20.0, "label": "S"}, + {"rateId": 2, "rate": 10.0, "label": "P"}, + ], + }], + }], + } + +def resp_subject(): + f = ucitaj_firmu() + return { + "tin": f["tin"], + "mb": f["mb"], + "uid": "550e8400-e29b-41d4-a716-446655440000", + "name": f["name"], + "address": f["address"], + "city": f["city"], + "country": "RS", + "district": f["district"], + "locationName": f["locationName"], + "businessUnitId": f["businessUnitId"], + } + +def resp_invoice(request_id, request_body=None): + invoice_number = get_next_invoice_number() + verification_url = f"https://suf-sandbox.purs.gov.rs/verify/{request_id}" + qr_b64 = generate_qr(verification_url) + uid = f"550e8400-e29b-41d4-a716-{invoice_number.zfill(12)}" + + invoice_data = { + "uid": uid, + "requestId": request_id, + "signedXml": f"{uid}{invoice_number}{request_id}{sada()}", + "sdcDateTime": sada(), + "invoiceNumber": invoice_number, + "verificationUrl": verification_url, + "qrCode": qr_b64, + "encryptedInternalData": f"ENC_{uid}", + "signature": f"SIG_{invoice_number}_{request_id[:8]}", + } + + # Proširi podatke iz tela zahteva (za štampu) + full_data = dict(invoice_data) + if request_body: + full_data.update(request_body) + full_data["invoiceNumber"] = invoice_number + full_data["sdcDateTime"] = invoice_data["sdcDateTime"] + full_data["qrCode"] = qr_b64 + full_data["isFiscal"] = full_data.get("isFiscal", True) + f = ucitaj_firmu() + full_data.setdefault("tin", f["tin"]) + full_data.setdefault("company", f["name"]) + full_data.setdefault("store", f["locationName"]) + full_data.setdefault("address", f["address"]) + full_data.setdefault("district", f["district"]) + full_data.setdefault("cashier", "Marko Marković") + full_data.setdefault("transactionType", "NSX") + full_data.setdefault("totalAmount", sum(item.get("amount", item.get("unitPrice", 0) * item.get("quantity", 0)) for item in full_data.get("items", []))) + full_data.setdefault("payments", [{"type": "Cash", "amount": full_data["totalAmount"]}]) + full_data.setdefault("taxItems", []) + full_data.setdefault("totalTax", sum(t.get("amount", 0) for t in full_data.get("taxItems", []))) + full_data.setdefault("refund", 0) + full_data.setdefault("invoiceType", "Normal") + + # Snimi kompletan račun + invoice_path = INVOICES_DIR / f"{invoice_number}_{request_id}.json" + with open(invoice_path, "w", encoding="utf-8") as f: + json.dump(full_data, f, indent=2, ensure_ascii=False) + + # Snimi QR kod + qr_path = QR_DIR / f"{invoice_number}_{request_id}.png" + with open(qr_path, "wb") as f: + f.write(base64.b64decode(qr_b64)) + + # Generiši i snimi tekst računa (latinica) + receipt_text = generate_receipt(full_data, "latin") + receipt_path = RECEIPTS_DIR / f"{invoice_number}_{request_id}.txt" + receipt_path.write_text(receipt_text, encoding="utf-8") + + # Generiši HTML račun + receipt_html = generate_receipt_html(full_data, "latin") + html_path = RECEIPTS_DIR / f"{invoice_number}_{request_id}.html" + html_path.write_text(receipt_html, encoding="utf-8") + + log(f" 🧾 RAČUN {invoice_number} | requestId={request_id} | QR={qr_path.name} | Račun={receipt_path.name} | HTML={html_path.name}") + return invoice_data + +def resp_invoice_lookup(request_id): + """Pronađi postojeći račun po requestId.""" + for f in INVOICES_DIR.glob("*.json"): + try: + data = json.loads(f.read_text(encoding="utf-8")) + if data.get("requestId") == request_id: + log(f" 🔍 Pronađen račun: {f.name}") + return data + except Exception: + continue + return None + +def get_last_invoice_number(): + """Poslednji broj računa (bez inkrementiranja).""" + if COUNTER_FILE.exists(): + num = int(COUNTER_FILE.read_text().strip()) - 1 + return f"{num:06d}" if num >= 1 else "" + return "" + +def resp_verify_pin(request_body=None): + """Glumi otključavanje kartice PIN-om. Prihvata telo kao JSON {"pin": "..."} + ili kao goli string. Poredi sa fiksnim test PIN-om PIN_BE.""" + uneti = "" + if isinstance(request_body, dict): + uneti = str(request_body.get("pin", "")).strip() + elif isinstance(request_body, str): + uneti = request_body.strip().strip('"') + if uneti == PIN_BE: + log(" 🔓 PIN ispravan — kartica otključana") + return {"status": "OK", "message": "PIN verifikovan"} + log(" 🔒 Pogrešan PIN") + return {"status": "ERROR", "code": "E003", "message": "Pogrešan PIN"} + +def resp_open_drawer(): + return {"status": "OK", "message": "Fioka otvorena"} + +def resp_print_text(): + return {"status": "OK", "message": "Tekst odštampan"} + +def resp_receipt(request_id): + """Vraća sačuvani račun u tekst formatu (za štampu).""" + # Prvo probaj da nađeš po requestId + for f in sorted(RECEIPTS_DIR.glob("*.txt"), reverse=True): + if request_id in f.stem: + return { + "contentType": "text/plain; charset=utf-8", + "receiptText": f.read_text(encoding="utf-8"), + "requestId": request_id, + } + return { + "contentType": "text/plain; charset=utf-8", + "receiptText": "Račun nije pronađen.", + "requestId": request_id, + } + +def resp_receipt_html(request_id): + """Vraća sačuvani račun u HTML formatu (za A4 štampu iz browsera).""" + for f in sorted(RECEIPTS_DIR.glob("*.html"), reverse=True): + if request_id in f.stem: + return f.read_text(encoding="utf-8") + return "

Račun nije pronađen

" + +def _build_report(title, start_date=None, end_date=None): + """Pravi izveštaj iz snimljenih računa.""" + _firma = ucitaj_firmu() + invoices = [] + for f in sorted(INVOICES_DIR.glob("*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + invoices.append(data) + except Exception: + continue + + total_payments_by_type = {} + total_tax_by_label = {} + per_tx_data = {} + invoice_count = 0 + grand_total = 0.0 + grand_tax = 0.0 + + for inv in invoices: + invoice_count += 1 + # Plaćanja + for p in inv.get("payments", []): + ptype = p.get("type", "Other") + amt = float(p.get("amount", 0)) + total_payments_by_type[ptype] = total_payments_by_type.get(ptype, 0.0) + amt + grand_total += amt + # Porezi + for t in inv.get("taxItems", []): + lbl = t.get("label", "") + rate = float(t.get("rate", 0)) + amt = float(t.get("amount", 0)) + key = f"{lbl}_{rate}" + if key not in total_tax_by_label: + total_tax_by_label[key] = {"label": lbl, "rate": rate, "total": 0.0, "amount": 0.0} + total_tax_by_label[key]["total"] += float(inv.get("totalAmount", 0)) + total_tax_by_label[key]["amount"] += amt + grand_tax += amt + # Po tipu transakcije + tx = inv.get("transactionType", "NSX") + if tx not in per_tx_data: + per_tx_data[tx] = {"transactionTypeExt": tx, "invoiceCount": 0, "payments": {}, "taxItems": {}} + per_tx_data[tx]["invoiceCount"] += 1 + for p in inv.get("payments", []): + ptype = p.get("type", "Other") + amt = float(p.get("amount", 0)) + per_tx_data[tx]["payments"][ptype] = per_tx_data[tx]["payments"].get(ptype, 0.0) + amt + for t in inv.get("taxItems", []): + lbl = t.get("label", "") + rate = float(t.get("rate", 0)) + amt = float(t.get("amount", 0)) + key = f"{lbl}_{rate}" + if key not in per_tx_data[tx]["taxItems"]: + per_tx_data[tx]["taxItems"][key] = {"label": lbl, "rate": rate, "total": 0.0, "amount": 0.0} + per_tx_data[tx]["taxItems"][key]["total"] += float(inv.get("totalAmount", 0)) + per_tx_data[tx]["taxItems"][key]["amount"] += amt + + # Formatiraj + payments_list = [{"paymentType": k, "amount": v} for k, v in total_payments_by_type.items()] + tax_list = list(total_tax_by_label.values()) + per_tx_list = [] + for tx, data in per_tx_data.items(): + tx_payments = [{"paymentType": k, "amount": v} for k, v in data["payments"].items()] + tx_taxes = list(data["taxItems"].values()) + tx_total_pmts = sum(p["amount"] for p in tx_payments) + tx_total_taxes = sum(t["amount"] for t in tx_taxes) + per_tx_list.append({ + "transactionTypeExt": tx, + "invoiceCount": data["invoiceCount"], + "payments": tx_payments, + "totalPayments": tx_total_pmts, + "taxItems": tx_taxes, + "totalTax": tx_total_taxes, + }) + + report_data = { + "title": title, + "number": 1, + "dateTime": sada(), + "tin": _firma["tin"], + "businessName": _firma["name"], + "locationName": _firma["locationName"], + "address": _firma["address"], + "district": _firma["district"], + "uid": "550e8400-e29b-41d4-a716-000000000001", + "startDate": start_date or datetime.now().strftime("%Y-%m-%d"), + "endDate": end_date or datetime.now().strftime("%Y-%m-%d"), + "total": { + "invoiceCount": invoice_count, + "payments": payments_list, + "totalPayments": grand_total, + "taxItems": tax_list, + "totalTax": grand_tax, + }, + "perTransactionType": per_tx_list, + } + return report_data + +def resp_daily_report(): + today = datetime.now().strftime("%Y-%m-%d") + locale = load_locale("latin") + report_data = _build_report(locale.get("daily-report", "DNEVNI IZVEŠTAJ"), today, today) + + # Snimi izveštaj + report_path = RECEIPTS_DIR / f"daily-report-{today}.json" + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report_data, f, indent=2, ensure_ascii=False) + + # Generiši tekst izveštaj + report_text = generate_report(report_data, "latin") + text_path = RECEIPTS_DIR / f"daily-report-{today}.txt" + text_path.write_text(report_text, encoding="utf-8") + + log(f" 📊 DNEVNI IZVEŠTAJ | računa: {report_data['total']['invoiceCount']} | ukupno: {report_data['total']['totalPayments']:.2f}") + return report_data + +def resp_periodic_report(): + today = datetime.now().strftime("%Y-%m-%d") + locale = load_locale("latin") + report_data = _build_report(locale.get("periodic-report", "PERIODIČNI IZVEŠTAJ"), "2026-01-01", today) + + report_path = RECEIPTS_DIR / f"periodic-report-{today}.json" + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report_data, f, indent=2, ensure_ascii=False) + + report_text = generate_report(report_data, "latin") + text_path = RECEIPTS_DIR / f"periodic-report-{today}.txt" + text_path.write_text(report_text, encoding="utf-8") + + log(f" 📊 PERIODIČNI IZVEŠTAJ | računa: {report_data['total']['invoiceCount']} | ukupno: {report_data['total']['totalPayments']:.2f}") + return report_data + +def resp_notifications(): + return [{ + "id": "1", + "type": "INFO", + "message": "Sistem funkcioniše ispravno", + "timestamp": sada(), + }] + +def resp_status_codes(): + return { + "codes": [ + {"code": "S001", "description": "Uspešno potpisan račun"}, + {"code": "E001", "description": "Greška pri potpisivanju"}, + {"code": "E002", "description": "Kartica nije prisutna"}, + {"code": "E003", "description": "Pogrešan PIN"}, + {"code": "E004", "description": "Nema konekcije ka SUF serveru"}, + ], + } + +# Mapiranje handler-a +HANDLERS = { + "attention": resp_attention, + "status": resp_status, + "environment": resp_environment, + "subject": resp_subject, + "invoice": resp_invoice, + "invoice_lookup": resp_invoice_lookup, + "verify_pin": resp_verify_pin, + "open_drawer": resp_open_drawer, + "print_text": resp_print_text, + "receipt": resp_receipt, + "receipt_text": resp_receipt, + "receipt_html": resp_receipt_html, + "daily_report": resp_daily_report, + "daily_report_text": resp_daily_report, + "periodic_report": resp_periodic_report, + "periodic_report_text": resp_periodic_report, + "notifications": resp_notifications, + "status_codes": resp_status_codes, +} + +# ── HTTP Handler ───────────────────────────────────────────── + +class FiscalHandler(http.server.BaseHTTPRequestHandler): + """Glavni handler za sve fiskalne endpoint-e.""" + + def log_message(self, fmt, *args): + """Override — koristi naš log umesto default stderr.""" + pass # Logujemo ručno u _handle + + def do_GET(self): + self._handle("GET") + + def do_POST(self): + self._handle("POST") + + def do_OPTIONS(self): + """CORS preflight.""" + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept-Language, RequestId") + self.end_headers() + + def _handle(self, method): + path = self.path.split("?")[0] + hdrs = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Accept-Language, RequestId", + } + status = 404 + body = None + matched_route = None + + # Nađi rutu + r_handler_name = None + for r_method, r_pattern, r_handler_name in DEFAULT_ROUTES: + if r_method != method: + continue + # Konvertuj :param u regex + regex = re.sub(r":(\w+)", r"(?P<\1>[^/]+)", r_pattern) + regex = f"^{regex}$" + m = re.match(regex, path.strip("/")) + if m: + matched_route = r_pattern + handler = HANDLERS.get(r_handler_name) + if handler: + request_id = self.headers.get("RequestId", "unknown") + params = m.groupdict() + + if r_handler_name == "invoice": + # Pročitaj telo zahteva + content_len = int(self.headers.get("Content-Length", 0)) + request_body = None + if content_len > 0: + try: + raw = self.rfile.read(content_len) + request_body = json.loads(raw.decode("utf-8")) + except Exception: + request_body = None + body = handler(request_id, request_body) + elif r_handler_name == "verify_pin": + # Pročitaj telo (PIN) — JSON {"pin": "..."} ili goli string + content_len = int(self.headers.get("Content-Length", 0)) + request_body = None + if content_len > 0: + try: + raw = self.rfile.read(content_len).decode("utf-8") + try: + request_body = json.loads(raw) + except Exception: + request_body = raw + except Exception: + request_body = None + body = handler(request_body) + elif r_handler_name == "invoice_lookup": + rid = params.get("requestId", "unknown") + result = handler(rid) + if result: + body = result + else: + status = 404 + body = {"error": f"Račun {rid} nije pronađen"} + elif r_handler_name == "receipt": + body = handler(params.get("requestId", "unknown")) + elif r_handler_name == "receipt_text": + result = handler(params.get("requestId", "unknown")) + body = result.get("receiptText", "Račun nije pronađen.") + elif r_handler_name == "receipt_html": + body = handler(params.get("requestId", "unknown")) + else: + body = handler() + status = 200 + break + + # Log + emoji = "✅" if status == 200 else "❌" + client = self.client_address[0] + log(f" {emoji} {method} {path} → {matched_route or '404'} | {client}") + + # Pošalji odgovor + if r_handler_name in ("daily_report_text", "periodic_report_text") and body: + report_text = generate_report(body, "latin") + resp_bytes = report_text.encode("utf-8") + hdrs["Content-Type"] = "text/plain; charset=utf-8" + elif r_handler_name == "receipt_html" and body: + # HTML odgovor + resp_bytes = body.encode("utf-8") if isinstance(body, str) else body + hdrs["Content-Type"] = "text/html; charset=utf-8" + elif isinstance(body, str) and r_handler_name == "receipt_text": + # Tekst odgovor + resp_bytes = body.encode("utf-8") if isinstance(body, str) else body + hdrs["Content-Type"] = "text/plain; charset=utf-8" + elif body is not None: + resp_bytes = json.dumps(body, indent=2, ensure_ascii=False).encode("utf-8") + hdrs["Content-Type"] = "application/json; charset=utf-8" + else: + resp_bytes = json.dumps({"error": "Not Found", "path": path}, ensure_ascii=False).encode("utf-8") + hdrs["Content-Type"] = "application/json; charset=utf-8" + + self.send_response(status) + for k, v in hdrs.items(): + self.send_header(k, v) + self.send_header("Content-Length", str(len(resp_bytes))) + self.end_headers() + self.wfile.write(resp_bytes) + +# ── Main ───────────────────────────────────────────────────── + +def main(): + log("╔══════════════════════════════════════════════╗") + log("║ 🧾 myLPFR Mock Server — Fiskalni server ║") + log("║ http://{}:{}/ ║".format(HOST, PORT)) + log("║ {} ruta | QR: AUTO | Snimanje: UKLJUČENO ║".format(len(DEFAULT_ROUTES))) + log("╚══════════════════════════════════════════════╝") + log(f" 📁 Podaci: {DATA_DIR}") + log(f" 🧾 Računi: {INVOICES_DIR}") + log(f" 📱 QR PNG: {QR_DIR}") + log(f" 📝 Log: {LOG_FILE}") + f = ucitaj_firmu() + log(f" 💳 Kartica (BE) — baza: {NTECH_DB}") + log(f" Firma: {f['name']} | PIB: {f['tin']} | MB: {f['mb']}") + log(f" Adresa: {f['address']} | Test PIN: {PIN_BE}") + log(" ▶ Server pokrenut. Ctrl+C za gašenje.") + + server = http.server.HTTPServer((HOST, PORT), FiscalHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + log(" ⏹ Server zaustavljen.") + server.server_close() + +if __name__ == "__main__": + main() diff --git a/Fisk/start.sh b/Fisk/start.sh new file mode 100755 index 0000000..3b9d08b --- /dev/null +++ b/Fisk/start.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# ── start.sh — Pokreće fiskalni mock server ───────────────── +# ./start.sh — pokreće server u pozadini +# ./start.sh -f — pokreće server u prvom planu (vidiš logove) +# ./start.sh status — proverava da li je server živ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PID_FILE="$SCRIPT_DIR/data/server.pid" +PORT=8989 + +# Proveri da li server već radi +check_running() { + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + return 0 # radi + fi + fi + # Proveri i port + if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then + return 0 # radi + fi + return 1 # ne radi +} + +stop_server() { + echo "⏹ Zaustavljam server..." + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + kill "$PID" 2>/dev/null && echo " PID $PID ugašen" + rm -f "$PID_FILE" + fi + fuser -k "${PORT}/tcp" 2>/dev/null + echo "✅ Server zaustavljen" +} + +case "${1:-}" in + status) + if check_running; then + echo "🟢 Server je POKRENUT na http://localhost:$PORT" + echo " PID: $(cat "$PID_FILE" 2>/dev/null || echo '?')" + else + echo "🔴 Server NIJE pokrenut" + fi + ;; + stop) + stop_server + ;; + -f|--foreground) + echo "🔧 Pokrećem server u PRVOM PLANU (Ctrl+C gasi)..." + cd "$SCRIPT_DIR" + python3 -u server.py + ;; + *) + if check_running; then + echo "⚠️ Server je VEĆ pokrenut na http://localhost:$PORT" + echo " Koristi './start.sh stop' da ga zaustaviš, ili './start.sh status'" + exit 1 + fi + echo "🔧 Pokrećem server u pozadini..." + cd "$SCRIPT_DIR" + mkdir -p data + nohup python3 -u server.py > "$SCRIPT_DIR/data/server_stdout.log" 2>&1 & + PID=$! + echo "$PID" > "$PID_FILE" + sleep 2 + if check_running; then + echo "✅ Server pokrenut! PID: $PID" + echo " http://localhost:$PORT" + echo "" + echo " ./start.sh stop — zaustavi server" + echo " ./start.sh status — proveri status" + echo " ./start.sh -f — pokreni u prvom planu (vidiš logove uživo)" + else + echo "❌ Server nije uspeo da se pokrene. Proveri: data/server_stdout.log" + rm -f "$PID_FILE" + exit 1 + fi + ;; +esac diff --git a/Fisk/stop.sh b/Fisk/stop.sh new file mode 100755 index 0000000..748b85b --- /dev/null +++ b/Fisk/stop.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# ── stop.sh — Zaustavlja fiskalni mock server ─────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PID_FILE="$SCRIPT_DIR/data/server.pid" +PORT=8989 + +echo "⏹ Zaustavljam fiskalni mock server..." + +# Pokušaj preko PID fajla +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill "$PID" 2>/dev/null; then + echo " PID $PID ugašen." + fi + rm -f "$PID_FILE" +fi + +# Osiguraj da port nije zauzet +fuser -k "${PORT}/tcp" 2>/dev/null + +sleep 0.5 + +# Proveri +if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then + echo "⚠️ Port $PORT je još uvek zauzet. Pokušaj: kill -9 \$(fuser ${PORT}/tcp)" +else + echo "✅ Server zaustavljen. Port $PORT je slobodan." +fi diff --git a/fisk-test.sh b/fisk-test.sh new file mode 100755 index 0000000..3c29de7 --- /dev/null +++ b/fisk-test.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════ +# fisk-test.sh — Interaktivni fiskalni test server +# ═══════════════════════════════════════════════════════════════ +# Pokreće myLPFR Mock Server, pokazuje info, +# i čeka da pritisneš 'q' da ga ugasiš. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FISK_DIR="$SCRIPT_DIR/Fisk" +PID_FILE="$FISK_DIR/data/server.pid" +PORT=8989 +HOST="localhost" +BASE_URL="http://$HOST:$PORT" + +# ── Boje za output ───────────────────────────────────────── +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# ── Cleanup na izlaz ─────────────────────────────────────── +cleanup() { + echo "" + echo -e "${YELLOW}⏹ Gasim server...${NC}" + + # Preko PID fajla + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + kill "$PID" 2>/dev/null && echo -e " PID ${BOLD}$PID${NC} ugašen" + rm -f "$PID_FILE" + fi + + # Pobrini se da port 8989 bude slobodan + fuser -k "${PORT}/tcp" 2>/dev/null || true + sleep 0.5 + + if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then + echo -e "${RED}⚠️ Port $PORT je još uvek zauzet.${NC}" + echo " Ručno: kill -9 \$(fuser ${PORT}/tcp)" + else + echo -e "${GREEN}✅ Server zaustavljen. Port $PORT je slobodan.${NC}" + fi + exit 0 +} + +trap cleanup INT TERM + +# ── Provera da li server već radi ────────────────────────── +check_running() { + # Provera preko PID fajla + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + return 0 + fi + fi + # Provera preko porta + if ss -tlnp 2>/dev/null | grep -q ":$PORT " || \ + netstat -tlnp 2>/dev/null | grep -q ":$PORT "; then + return 0 + fi + return 1 +} + +# ── Dohvatanje PID-a servera ─────────────────────────────── +get_pid() { + if [ -f "$PID_FILE" ]; then + cat "$PID_FILE" + else + ss -tlnp 2>/dev/null | grep ":$PORT " | sed -E 's/.*pid=([0-9]+).*/\1/' | head -1 + fi +} + +# ═══════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════ + +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ 🧾 myLPFR Fiskalni Test Server ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}" +echo "" + +if check_running; then + PID=$(get_pid) + echo -e "${GREEN}🟢 Server je VEĆ pokrenut${NC}" + echo -e " URL: ${CYAN}$BASE_URL${NC}" + echo -e " PID: ${BOLD}$PID${NC}" +else + echo -e "${YELLOW}🔧 Pokrećem fiskalni server...${NC}" + + # Osiguraj data folder + mkdir -p "$FISK_DIR/data" + + # Pokreni server u pozadini + cd "$FISK_DIR" + nohup python3 -u server.py > "$FISK_DIR/data/server_stdout.log" 2>&1 & + PID=$! + echo "$PID" > "$PID_FILE" + cd "$SCRIPT_DIR" + + # Sačekaj da server bude spreman + echo -n " Čekam server" + for i in $(seq 1 20); do + sleep 0.3 + echo -n "." + if check_running; then + echo "" + break + fi + done + + if check_running; then + echo "" + echo -e "${GREEN}✅ Server pokrenut!${NC}" + else + echo "" + echo -e "${RED}❌ Server nije uspeo da se pokrene!${NC}" + echo " Proveri log: tail -f $FISK_DIR/data/server_stdout.log" + rm -f "$PID_FILE" + exit 1 + fi +fi + +# ── Prikaži info o serveru ───────────────────────────────── +echo "" +echo -e "${BOLD}── Podaci o serveru ──────────────────────────────${NC}" +echo -e " 🌐 URL: ${CYAN}$BASE_URL${NC}" +echo -e " 📦 PID: ${BOLD}$(get_pid)${NC}" +echo -e " 📁 Data dir: ${FISK_DIR}/data" +echo -e " 🧾 Računi: ${FISK_DIR}/data/invoices" +echo -e " 📱 QR kodovi: ${FISK_DIR}/data/qr" +echo -e " 📝 Log servera: ${FISK_DIR}/data/server.log" +echo -e " 📤 Stdout log: ${FISK_DIR}/data/server_stdout.log" +echo "" + +# Prikaži trenutni brojač ako postoji +COUNTER_FILE="$FISK_DIR/data/counter.txt" +if [ -f "$COUNTER_FILE" ]; then + echo -e " 🔢 Sledeći račun: ${BOLD}$(cat "$COUNTER_FILE")${NC}" +else + echo -e " 🔢 Sledeći račun: ${BOLD}000001${NC}" +fi + +# Prikaži poslednjih 5 log linija servera +if [ -f "$FISK_DIR/data/server.log" ] && [ -s "$FISK_DIR/data/server.log" ]; then + echo "" + echo -e "${BOLD}── Poslednje log linije ─────────────────────────${NC}" + tail -5 "$FISK_DIR/data/server.log" | while read -r line; do + echo -e " ${CYAN}$line${NC}" + done +fi + +echo "" +echo -e "${BOLD}──────────────────────────────────────────────────${NC}" +echo -e " ${GREEN}Server je spreman za testiranje.${NC}" +echo -e " Pritisni ${BOLD}q${NC} + Enter da zaustaviš server." +echo -e "${BOLD}──────────────────────────────────────────────────${NC}" +echo "" + +# ── Čekaj 'q' ───────────────────────────────────────────── +while true; do + read -r -p " ⌨ Unesi 'q' za gašenje: " input + if [ "$input" = "q" ] || [ "$input" = "Q" ]; then + cleanup + fi +done