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() {