diff --git a/Fisk/server.py b/Fisk/server.py index 4cd9e03..7af6b40 100755 --- a/Fisk/server.py +++ b/Fisk/server.py @@ -1,10 +1,8 @@ #!/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. +Teron L-PFR Mock Server — glumi Teron fiskalni server za testiranje NTech-a. +Endpoint-i i format odgovora usklađeni sa Teron API dokumentacijom. +Port: 4566 (Teron standard) """ import json @@ -23,35 +21,31 @@ 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" +PORT = 4566 # Teron standard port +HOST = "0.0.0.0" +ESIR_ID = "NTECH001" # naš 8-char ESIR identifikator +BE_ID = "TRNMOCK1" # simulirani BE/LPFR identifikator -# Inicijalizuj foldere -for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR]: +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" +COUNTER_DIR = DATA_DIR / "counters" + +for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_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. +# Test PIN (pravi Teron traži PIN za BE karticu) 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 SQLite baza (read-only) — čita podatke o firmi NTECH_DB = os.environ.get("NTECH_SQLITE") or str(Path(__file__).parent.parent / "ntech.db") +# ── Firma ─────────────────────────────────────────────────── + 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.""" + """Čita podatke o firmi iz NTech baze.""" podaci = {} try: con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True) @@ -68,9 +62,11 @@ def ucitaj_firmu(): log(f" ⚠️ Ne mogu da pročitam firmu iz baze ({NTECH_DB}): {e}") naziv = podaci.get("naziv_firme") or "Test Company DOO" + pib = podaci.get("pib") or "123456789" return { "name": naziv, - "tin": podaci.get("pib") or "123456789", + "tin": f"RS{pib}", # Teron koristi RS prefiks + "tinPlain": pib, "mb": podaci.get("maticni_broj") or "12345678", "address": podaci.get("adresa") or "Test Address 1", "telefon": podaci.get("telefon") or "", @@ -80,86 +76,77 @@ def ucitaj_firmu(): "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}" +# ── Brojači ───────────────────────────────────────────────── + +def get_counter(tip="total"): + """Čita i inkrementira brojač za dati tip (total, pp, pr, ap, ar, kp, op, itd.).""" + f = COUNTER_DIR / f"{tip}.txt" + num = int(f.read_text().strip()) if f.exists() else 1 + f.write_text(str(num + 1)) + return num + +def peek_counter(tip="total"): + """Čita brojač bez inkrementiranja.""" + f = COUNTER_DIR / f"{tip}.txt" + num = int(f.read_text().strip()) if f.exists() else 1 + return max(1, num - 1) + +def counter_ext(invoice_type, transaction_type): + """Vraća sufiks tipa transakcije (ПП, ПР, АП...) i ključ brojača.""" + t = (str(invoice_type).lower(), str(transaction_type).lower()) + mapping = { + ("normal", "sale"): ("ПП", "pp"), + ("normal", "refund"): ("ПР", "pr"), + ("advance", "sale"): ("АП", "ap"), + ("advance", "refund"): ("АР", "ar"), + ("copy", "sale"): ("КП", "kp"), + ("copy", "refund"): ("КР", "kr"), + ("training", "sale"): ("ОП", "op"), + ("training", "refund"): ("ОР", "or"), + } + return mapping.get(t, ("НН", "other")) + +# ── Logging ───────────────────────────────────────────────── 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") +# ── QR ────────────────────────────────────────────────────── + 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 ────────────────────────────────────── +# ── Vreme ─────────────────────────────────────────────────── 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") -# Poreske stope po oznaci — cene su BRUTO (PDV uključen u totalAmount) +# ── PDV obračun ───────────────────────────────────────────── + +# Teron koristi: Ж (20%), Ђ (10%), Е (posebna), А (0% neobveznici), +# Г (oslobođen), З (0% bez prava odbitka) +# + starije oznake generičkog L-PFR-a za kompatibilnost TAX_RATES = { - "А": 0.0, "A": 0.0, # neobveznici PDV-a / oslobođen - "Б": 20.0, "B": 20.0, # opšta stopa 20% - "В": 0.0, "V": 0.0, # oslobođen sa pravom odbitka - "Г": 0.0, "G": 0.0, # oslobođen bez prava odbitka - "Д": 0.0, "D": 0.0, # nije predmet oporezivanja - "Ђ": 10.0, # autorski honorar (snižena stopa) - "Е": 10.0, "E": 10.0, # posebna snižena stopa 10% + # Teron oznake + "Ж": 20.0, # opšta stopa 20% + "Ђ": 10.0, # snižena stopa 10% + "Е": 10.0, # posebna snižena stopa + "А": 0.0, # neobveznici PDV-a + "Г": 0.0, # oslobođen bez prava na odbitak + "З": 0.0, # nije predmet oporezivanja + # Generičke oznake (kompatibilnost) + "Б": 20.0, "B": 20.0, + "В": 0.0, "V": 0.0, + "Д": 0.0, "D": 0.0, + "A": 0.0, "G": 0.0, "E": 10.0, } def izracunaj_pdv(items): @@ -185,211 +172,196 @@ def izracunaj_pdv(items): for d in grupe.values() ] +# ── Response handleri ─────────────────────────────────────── + def resp_attention(): return {"sdcDateTime": sada(), "status": "OK"} def resp_status(): + f = ucitaj_firmu() + last = peek_counter("total") + last_num = f"{ESIR_ID}-{BE_ID}-{last}" if last >= 1 else "" return { - "isPinRequired": True, + "isPinRequired": False, "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 { + "lastInvoiceNumber": last_num, + "protocolVersion": "1.0.0", + "serialNumber": ESIR_ID, "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") - items = full_data.get("items", []) - total_amount = round(sum(float(item.get("totalAmount", 0)) for item in items), 2) - full_data.setdefault("totalAmount", total_amount) - full_data.setdefault("payments", [{"type": "Cash", "amount": full_data["totalAmount"]}]) - tax_items = izracunaj_pdv(items) - full_data["taxItems"] = tax_items - full_data["totalTax"] = round(sum(t["amount"] for t in tax_items), 2) - full_data.setdefault("refund", 0) - full_data.setdefault("invoiceType", "Normal") - # Obogati odgovor koji ide ka ESIR-u (NTech-u) - invoice_data["taxItems"] = tax_items - invoice_data["totalAmount"] = full_data["totalAmount"] - invoice_data["totalTax"] = full_data["totalTax"] - invoice_data["messages"] = "Success" - invoice_data["businessName"] = full_data.get("company", "") - invoice_data["tin"] = full_data.get("tin", "") - invoice_data["locationName"] = full_data.get("store", "") - invoice_data["address"] = full_data.get("address", "") - - # 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") + log(" 🔓 PIN ispravan") return {"status": "OK", "message": "PIN verifikovan"} log(" 🔒 Pogrešan PIN") - return {"status": "ERROR", "code": "E003", "message": "Pogrešan PIN"} + return {"status": "ERROR", "code": "2100", "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, - } +def resp_settings_get(): return { - "contentType": "text/plain; charset=utf-8", - "receiptText": "Račun nije pronađen.", - "requestId": request_id, + "printerType": "Thermal", + "printerInterface": "None", + "lpfrEnabled": False, + "vpfrEnabled": False, + "authorizeLocalClients": False, + "authorizeRemoteClients": False, + "apiKey": "mock-api-key-0000", + "webserverAddress": f"http://127.0.0.1:{PORT}/", } -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 resp_settings_post(): + return {"status": "OK", "message": "Podešavanja sačuvana"} -def _build_report(title, start_date=None, end_date=None): - """Pravi izveštaj iz snimljenih računa.""" - _firma = ucitaj_firmu() +def resp_certificate(): + f = ucitaj_firmu() + return { + "serialNumber": BE_ID, + "tin": f["tin"], + "name": f["name"], + "validFrom": "2024-01-01T00:00:00+01:00", + "validTo": "2027-01-01T00:00:00+01:00", + "issuer": "Poreska uprava RS", + } + +def _build_invoice_response(req, request_id): + """Gradi Teron odgovor za fiskalni račun.""" + # Teron zahtev dolazi unutar invoiceRequest omotača + inv_req = req.get("invoiceRequest", req) + + invoice_type = inv_req.get("invoiceType", "Normal") + transaction_type = inv_req.get("transactionType", "Sale") + items = inv_req.get("items", []) + + # Brojači + total_cnt = get_counter("total") + ext, tip_key = counter_ext(invoice_type, transaction_type) + type_cnt = get_counter(tip_key) + + invoice_number = f"{ESIR_ID}-{BE_ID}-{total_cnt}" + invoice_counter = f"{type_cnt}/{total_cnt}{ext}" + verification_url = f"https://sandbox.suf.purs.gov.rs/v/?vl={invoice_number}" + qr_b64 = generate_qr(verification_url) + + # PDV i ukupan iznos + tax_items = izracunaj_pdv(items) + total_amount = round(sum(float(i.get("totalAmount", 0)) for i in items), 2) + total_tax = round(sum(t["amount"] for t in tax_items), 2) + + firma = ucitaj_firmu() + + # Odgovor koji ide ka NTech-u (ESIR-u) + odgovor = { + "requestedBy": ESIR_ID, + "signedBy": BE_ID, + "sdcDateTime": sada(), + "invoiceCounter": invoice_counter, + "invoiceCounterExtension": ext, + "invoiceNumber": invoice_number, + "verificationUrl": verification_url, + "verificationQRCode": qr_b64, + "taxItems": tax_items, + "totalAmount": total_amount, + "totalTax": total_tax, + "messages": "Success", + } + + # Puni podaci za snimanje i generisanje računa + full_data = { + **odgovor, + "requestId": request_id, + "invoiceType": invoice_type, + "transactionType": transaction_type, + "items": items, + "payments": inv_req.get("payment", [{"type": "Cash", "amount": total_amount}]), + "cashier": inv_req.get("cashier", "Kasir"), + "buyerId": inv_req.get("buyerId", ""), + "referentDocumentNumber": inv_req.get("referentDocumentNumber", ""), + "isFiscal": invoice_type not in ("Copy", "Training", "Proforma"), + "tin": firma["tinPlain"], + "company": firma["name"], + "store": firma["locationName"], + "address": firma["address"], + "district": firma["district"], + "refund": 0, + # za avansni konačni + "advancePaid": req.get("advancePaid", 0), + "advanceTax": req.get("advanceTax", 0), + } + + # Snimi JSON + inv_path = INVOICES_DIR / f"{total_cnt:06d}_{request_id}.json" + with open(inv_path, "w", encoding="utf-8") as fh: + json.dump(full_data, fh, indent=2, ensure_ascii=False) + + # Snimi QR PNG + qr_path = QR_DIR / f"{total_cnt:06d}_{request_id}.png" + with open(qr_path, "wb") as fh: + fh.write(base64.b64decode(qr_b64)) + + # Generiši tekst i HTML račun + receipt_text = generate_receipt(full_data, "latin") + txt_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.txt" + txt_path.write_text(receipt_text, encoding="utf-8") + + html_txt = generate_receipt_html(full_data, "latin") + html_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.html" + html_path.write_text(html_txt, encoding="utf-8") + + # Dodaj journal (tekst računa) u odgovor + odgovor["journal"] = receipt_text + + log(f" 🧾 {invoice_number} | {ext} | {total_amount:.2f} din | PDV {total_tax:.2f}") + return odgovor + +def resp_invoice(request_id, request_body=None): + if not request_body: + return {"error": "Telo zahteva je obavezno"}, 400 + return _build_invoice_response(request_body, request_id) + +def resp_invoice_final(request_id, request_body=None): + """Konačni račun koji zatvara avanse (/api/invoices/final).""" + if not request_body: + return {"error": "Telo zahteva je obavezno"}, 400 + return _build_invoice_response(request_body, request_id) + +def resp_invoice_last(): + """Vraća poslednji sačuvani račun.""" + files = sorted(INVOICES_DIR.glob("*.json"), reverse=True) + if not files: + return {"error": "Nema računa"}, 404 + try: + return json.loads(files[0].read_text(encoding="utf-8")) + except Exception: + return {"error": "Greška pri čitanju računa"}, 500 + +def resp_invoice_by_request(request_id): + for f in INVOICES_DIR.glob("*.json"): + try: + data = json.loads(f.read_text(encoding="utf-8")) + if data.get("requestId") == request_id: + return data + except Exception: + continue + return {"error": f"Račun {request_id} nije pronađen"}, 404 + +def resp_invoice_by_number(invoice_number): + for f in INVOICES_DIR.glob("*.json"): + try: + data = json.loads(f.read_text(encoding="utf-8")) + if data.get("invoiceNumber") == invoice_number: + return data + except Exception: + continue + return {"error": f"Račun {invoice_number} nije pronađen"}, 404 + +def resp_invoice_search(request_body=None): + """Osnovna pretraga — vraća CSV.""" invoices = [] for f in sorted(INVOICES_DIR.glob("*.json")): try: @@ -397,291 +369,135 @@ def _build_report(title, start_date=None, end_date=None): invoices.append(data) except Exception: continue + lines = [ + f"{d['invoiceNumber']},{d.get('invoiceType','Normal')},{d.get('transactionType','Sale')},{d.get('sdcDateTime','')},{d.get('totalAmount',0)}" + for d in invoices + ] + return "\n".join(lines) - total_payments_by_type = {} - total_tax_by_label = {} - per_tx_data = {} - invoice_count = 0 - grand_total = 0.0 - grand_tax = 0.0 +# ── Rute ──────────────────────────────────────────────────── - 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 +ROUTES = [ + ("GET", "api/attention", "attention"), + ("GET", "api/status", "status"), + ("POST", "api/pin", "verify_pin"), + ("GET", "api/settings", "settings_get"), + ("POST", "api/settings", "settings_post"), + ("GET", "api/certificate", "certificate"), + ("POST", "api/invoices/final", "invoice_final"), + ("GET", "api/invoices/last", "invoice_last"), + ("GET", "api/invoices/request/:requestId", "invoice_by_request"), + ("GET", "api/invoices/:invoiceNumber", "invoice_by_number"), + ("POST", "api/invoices/search", "invoice_search"), + ("POST", "api/invoices", "invoice"), +] - # 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, + "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, } # ── 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 + pass # koristimo naš log - def do_GET(self): - self._handle("GET") - - def do_POST(self): - self._handle("POST") + def do_GET(self): self._handle("GET") + def do_POST(self): self._handle("POST") + def do_DELETE(self): self._handle("DELETE") 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._cors() 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 + def _cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") - # Nađi rutu - r_handler_name = None - for r_method, r_pattern, r_handler_name in DEFAULT_ROUTES: + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return None + raw = self.rfile.read(length).decode("utf-8") + try: + return json.loads(raw) + except Exception: + return raw + + def _handle(self, method): + path = self.path.split("?")[0].strip("/") + status = 404 + body = {"error": "Not Found", "path": path} + content_type = "application/json; charset=utf-8" + matched = None + + for r_method, r_pattern, r_name in 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() + regex = "^" + re.sub(r":(\w+)", r"(?P<\1>[^/]+)", r_pattern) + "$" + m = re.match(regex, path) + if not m: + continue - 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 + matched = r_name + handler = HANDLERS.get(r_name) + params = m.groupdict() + request_id = self.headers.get("RequestId", f"mock-{int(time.time())}") - # Log - emoji = "✅" if status == 200 else "❌" - client = self.client_address[0] - log(f" {emoji} {method} {path} → {matched_route or '404'} | {client}") + # Poziv handlera + if r_name in ("invoice", "invoice_final"): + result = handler(request_id, self._read_body()) + elif r_name == "verify_pin": + result = handler(self._read_body()) + elif r_name == "invoice_by_request": + result = handler(params.get("requestId", "")) + elif r_name == "invoice_by_number": + result = handler(params.get("invoiceNumber", "")) + elif r_name == "invoice_search": + result = handler(self._read_body()) + elif r_name in ("settings_post",): + self._read_body() + result = handler() + else: + result = handler() - # 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: + # Razdvoji (body, status) ako handler vratio tuple + if isinstance(result, tuple): + body, status = result + else: + body = result + status = 200 + break + + # Serializacija + if r_name == "invoice_search" and isinstance(body, str): + resp_bytes = body.encode("utf-8") + content_type = "text/csv; charset=utf-8" + elif isinstance(body, (dict, list)): 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" + resp_bytes = str(body).encode("utf-8") + + emoji = "✅" if status == 200 else "❌" + log(f" {emoji} {method} /{path} → {matched or '404'} [{status}]") self.send_response(status) - for k, v in hdrs.items(): - self.send_header(k, v) + self._cors() + self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(resp_bytes))) self.end_headers() self.wfile.write(resp_bytes) @@ -689,19 +505,18 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler): # ── 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}") + firma = ucitaj_firmu() + log("╔══════════════════════════════════════════════════╗") + log("║ 🧾 Teron L-PFR Mock Server ║") + log(f"║ http://{HOST}:{PORT}/ ║") + log("╚══════════════════════════════════════════════════╝") + log(f" 📁 Podaci: {DATA_DIR}") + log(f" 🧾 Računi: {INVOICES_DIR}") + log(f" 📱 QR PNG: {QR_DIR}") + log(f" 📝 Log: {LOG_FILE}") + log(f" 🏢 Firma: {firma['name']} | PIB: {firma['tinPlain']}") + log(f" 🆔 ESIR ID: {ESIR_ID} | BE ID: {BE_ID}") + log(f" 🔑 Test PIN: {PIN_BE}") log(" ▶ Server pokrenut. Ctrl+C za gašenje.") server = http.server.HTTPServer((HOST, PORT), FiscalHandler) diff --git a/Fisk/start.sh b/Fisk/start.sh index 3b9d08b..156744a 100755 --- a/Fisk/start.sh +++ b/Fisk/start.sh @@ -6,7 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PID_FILE="$SCRIPT_DIR/data/server.pid" -PORT=8989 +PORT=4566 # Proveri da li server već radi check_running() {