Kartica emulator (BE) + fiskalna verifikacija + ime/prezime korisnika

- internal/be: emulator kartice (TCP listener za L-PFR mock)
- /v/: javna stranica za verifikaciju fiskalnog računa (QR kod)
- /servis/{id}/fiskalni-racun: štampa fiskalnog računa
- Podešavanja fiskalizacije: BE status/reset, proširen UI
- Korisnik: polja ime i prezime (model, DB, handler, admin profil)
- Fisk server: ažuriran receipt.py i server.py
-, : dokumentacija
This commit is contained in:
2026-06-26 23:17:52 +02:00
parent 985a2e7410
commit 97fd4490ea
21 changed files with 1425 additions and 116 deletions
+16 -8
View File
@@ -8,9 +8,9 @@ pos-invoice-number=ЕСИР број
pos-time=ЕСИР време
ref-doc-number=Реф. број
ref-doc-dt=Реф. време
sdc-invoice-counter=Бројач
sdc-invoice-number=Број рачуна
sdc-time=Време
sdc-invoice-counter=Бројач рачуна
sdc-invoice-number=ПФР број рачуна
sdc-time=ПФР време
uid=УИД
items=Артикли
item-name=Назив
@@ -25,17 +25,17 @@ quantity=Количина
price=Цена
amount=Износ
unitPrice=Јед. цена
tax-label=ПДВ
tax-name=Порез
tax-label=Ознака
tax-name=Име
tax-rate=Стопа
tax-amount=Порез
tax=Порез
total-tax=Укупан порез
total-tax=Укупан износ пореза
advance-tax=Авансни порез
to-pay=За уплату
to-pay=Укупан износ
paid-in-advance=Плаћено авансно
remaining=Преостало
refund=Рефундација
refund=Повраћај
total-refund=Укупна рефундација
total-payment=Укупно уплаћено
payment=Уплаћено
@@ -46,3 +46,11 @@ report-number=Број извештаја
per-transaction-type=ПРОМЕТ ПО ВРСТИ
customer-signature=Потпис купца
end-of-fiscal-invoice=КРАЈ ФИСКАЛНОГ РАЧУНА
# Типови плаћања
Cash=Готовина
Card=Платна картица
Check=Чекови
Voucher=Ваучер
MobileMoney=Инстант плаћање
WireTransfer=Пренос на рачун
Other=Остало
+16 -8
View File
@@ -8,9 +8,9 @@ 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
sdc-invoice-counter=Brojač računa
sdc-invoice-number=PFR broj računa
sdc-time=PFR vreme
uid=UID
items=Artikli
item-name=Naziv
@@ -25,17 +25,17 @@ quantity=Količina
price=Cena
amount=Iznos
unitPrice=Jed. cena
tax-label=PDV
tax-name=Porez
tax-label=Oznaka
tax-name=Ime
tax-rate=Stopa
tax-amount=Porez
tax=Porez
total-tax=Ukupan porez
total-tax=Ukupan iznos poreza
advance-tax=Avansni porez
to-pay=Za uplatu
to-pay=Ukupan iznos
paid-in-advance=Plaćeno avansno
remaining=Preostalo
refund=Refundacija
refund=Povraćaj
total-refund=Ukupna refundacija
total-payment=Ukupno uplaćeno
payment=Uplaćeno
@@ -46,3 +46,11 @@ report-number=Broj izveštaja
per-transaction-type=PROMET PO VRSTI
customer-signature=Potpis kupca
end-of-fiscal-invoice=KRAJ FISKALNOG RAČUNA
# Tipovi plaćanja
Cash=Gotovina
Card=Platna kartica
Check=Čekovi
Voucher=Vaučer
MobileMoney=Instant plaćanje
WireTransfer=Prenos na račun
Other=Ostalo
+36 -17
View File
@@ -25,21 +25,26 @@ def load_locale(lang="latin"):
locale[key.strip()] = value.strip()
return locale
def _sr(n, decimals=2):
"""Srpski format: tačka za hiljade, zarez za decimale — 2.967,17"""
s = f"{n:,.{decimals}f}"
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
def price(n):
"""Formatira cenu: ###,###.00"""
return f"{n:,.2f}"
"""Formatira cenu: ###.###,00"""
return _sr(n, 2)
def qty(n):
"""Formatira količinu: ###,###.000"""
return f"{n:,.3f}"
"""Formatira količinu: ###.###,000"""
return _sr(n, 3)
def amount(n):
"""Formatira iznos: ###,###.00"""
return f"{n:,.2f}"
"""Formatira iznos: ###.###,00"""
return _sr(n, 2)
def number(n):
"""Formatira broj: ###,###"""
return f"{n:,.0f}"
"""Formatira broj: ###.###"""
return _sr(n, 0)
def dt(iso_string):
"""Konvertuje ISO datetime u format: dd.MM.yyyy. HH:mm:ss"""
@@ -103,6 +108,20 @@ TRANSACTION_TYPES_LAT = {
"PSX": "PREDRAČUN - PRODAJA", "PRX": "PREDRAČUN - REFUNDACIJA",
"TSX": "OBUKA - PRODAJA", "TRX": "OBUKA - REFUNDACIJA",
}
# NTech šalje invoiceType+transactionType kao reči (Normal/Sale) umesto koda (NSX)
_INV_TX_TO_CODE = {
("Normal", "Sale"): "NSX", ("Normal", "Refund"): "NRX",
("Advance", "Sale"): "ASX", ("Advance", "Refund"): "ARX",
("Copy", "Sale"): "CSX", ("Copy", "Refund"): "CRX",
("Training", "Sale"): "TSX", ("Training", "Refund"): "TRX",
("Proforma", "Sale"): "PSX", ("Proforma", "Refund"): "PRX",
}
def _tx_code(inv):
"""Vraća 3-slovni kod transakcije (NSX, NRX...) iz full_data rečnika."""
it = inv.get("invoiceType", "Normal")
tt = inv.get("transactionType", "Sale")
return _INV_TX_TO_CODE.get((it, tt), tt)
# ── Glavna funkcija ─────────────────────────────────────────
@@ -118,6 +137,7 @@ def generate_receipt(invoice_data, lang="latin"):
tx_types = TRANSACTION_TYPES_LAT if lang == "latin" else TRANSACTION_TYPES_CYR
W = 48
inv = invoice_data
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
lines = []
# ── PREAMBLE ──
@@ -160,8 +180,6 @@ def generate_receipt(invoice_data, lang="latin"):
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 ──
@@ -215,7 +233,8 @@ def generate_receipt(invoice_data, lang="latin"):
if not is_covered_by_advance:
for p in inv.get("payments", []):
ptype = m.get(p.get("type", ""), p.get("type", "Drugo"))
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
lines.append(layout(ptype, amount(float(p.get("amount", 0))), W))
if inv.get("invoiceType") == "Proforma":
lines.append(layout(m.get("refund", "Povraćaj"), amount(0), W))
@@ -249,7 +268,7 @@ def generate_receipt(invoice_data, lang="latin"):
# ── 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(layout(m.get("sdc-invoice-counter", "Brojač računa"), str(inv.get("invoiceCounter", "")), W))
lines.append(separator("=", W))
# ── QR KOD ──
@@ -290,8 +309,7 @@ def generate_receipt_html(invoice_data, lang="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)
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
is_fiscal = inv.get("isFiscal", True)
is_refund = inv.get("transactionType") == "Refund"
inv_type = inv.get("invoiceType", "Normal")
@@ -331,7 +349,8 @@ def generate_receipt_html(invoice_data, lang="latin"):
payments_rows += f'<tr><td class="l">{m.get("advance-tax", "PDV na avans")}</td><td class="r">{amount(advance_tax)}</td></tr>'
if not covered:
for p in inv.get("payments", []):
ptype = m.get(p.get("type", ""), p.get("type", "Drugo"))
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
payments_rows += f'<tr><td class="l">{ptype}</td><td class="r">{amount(float(p.get("amount", 0)))}</td></tr>'
if inv_type == "Proforma":
payments_rows += f'<tr><td class="l">{m.get("refund", "Povraćaj")}</td><td class="r">{amount(0)}</td></tr>'
@@ -404,7 +423,7 @@ def generate_receipt_html(invoice_data, lang="latin"):
.hdr-sub {{ font-size: 11pt; font-weight: bold; }}
.title {{ font-weight: bold; font-size: 12pt; margin: 1.5mm 0; }}
.qr {{ text-align: center; margin: 2mm 0; }}
.qr img {{ width: 25mm; height: 25mm; }}
.qr img {{ width: 60mm; height: 60mm; }}
.preamble {{ font-style: italic; margin: 1mm 0; font-size: 7pt; }}
.row {{ display: flex; justify-content: space-between; }}
.col-left {{ width: 72%; }}
@@ -472,7 +491,7 @@ def generate_receipt_html(invoice_data, lang="latin"):
<table>
<tr><td class="l">{m.get('sdc-time', 'PFR vreme')}</td><td class="r">{dt(inv.get('sdcDateTime', ''))}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-number', 'PFR broj računa')}</td><td class="r">{inv.get('invoiceNumber', '')}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-counter', 'Brojač računa')}</td><td class="r">{inv.get('invoiceNumber', '')}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-counter', 'Brojač računa')}</td><td class="r">{inv.get('invoiceCounter', '')}</td></tr>
</table>
<div class="sep-double"></div>
+182 -38
View File
@@ -17,6 +17,9 @@ import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
import socket
import urllib.parse
import qrcode
from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale
@@ -24,7 +27,10 @@ from receipt import generate_receipt, generate_receipt_html, generate_report, lo
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
# Kartica emulator (NTech goroutine)
BE_HOST = os.environ.get("BE_HOST", "127.0.0.1")
BE_PORT = int(os.environ.get("BE_PORT", "4567"))
DATA_DIR = Path(__file__).parent / "data"
INVOICES_DIR = DATA_DIR / "invoices"
@@ -36,12 +42,94 @@ 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)
# Test PIN (pravi Teron traži PIN za BE karticu)
PIN_BE = "1234"
# NTech SQLite baza (read-only) — čita podatke o firmi
# NTech SQLite baza (read-only) — fallback kad kartica emulator nije dostupan
NTECH_DB = os.environ.get("NTECH_SQLITE") or str(Path(__file__).parent.parent / "ntech.db")
def _ucitaj_verify_host():
"""Čita verify_host iz env, pa iz NTech SQLite baze."""
if v := os.environ.get("VERIFY_HOST", ""):
return v
try:
con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True)
try:
cur = con.execute("SELECT vrednost FROM podesavanja WHERE kljuc='verify_host'")
row = cur.fetchone()
return row[0] if row and row[0] else ""
finally:
con.close()
except Exception:
return ""
# Host za verifikacioni link na QR kodu (npr. "ntech.moja-firma.rs:3000").
# Ako je prazno, koristi se sandbox.suf.purs.gov.rs.
VERIFY_HOST = _ucitaj_verify_host()
def _ucitaj_fiskalni_pismo():
"""Čita fiskalni_pismo iz env var FISKALNI_PISMO ili iz NTech SQLite baze.
Vrednosti: 'latin' (podrazumevano) ili 'cyrillic'."""
if v := os.environ.get("FISKALNI_PISMO", ""):
return v if v in ("latin", "cyrillic") else "latin"
try:
con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True)
try:
cur = con.execute("SELECT vrednost FROM podesavanja WHERE kljuc='fiskalni_pismo'")
row = cur.fetchone()
v = row[0] if row and row[0] else "latin"
return v if v in ("latin", "cyrillic") else "latin"
finally:
con.close()
except Exception:
return "latin"
# Pismo fiskalnog računa: 'latin' ili 'cyrillic'
FISKALNI_PISMO = _ucitaj_fiskalni_pismo()
def be_command(cmd: dict) -> dict:
"""Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect((BE_HOST, BE_PORT))
s.sendall((json.dumps(cmd) + "\n").encode("utf-8"))
buf = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
if b"\n" in buf:
break
return json.loads(buf.decode("utf-8").strip())
except Exception as e:
log(f" ⚠️ be_command({cmd.get('command')}) greška: {e}")
return {"status": "error", "message": str(e)}
def build_vl(full_data):
"""Gradi base64-kodirani payload za vl parametar verifikacionog URL-a."""
payload = {
"n": full_data.get("invoiceNumber", ""),
"ic": full_data.get("invoiceCounter", ""),
"t": full_data.get("sdcDateTime", ""),
"a": full_data.get("totalAmount", 0),
"c": full_data.get("tin", ""),
"co": full_data.get("company", ""),
"lo": full_data.get("store", ""),
"ad": full_data.get("address", ""),
"g": full_data.get("city", ""),
"di": full_data.get("district", ""),
"it": full_data.get("invoiceType", "Normal"),
"tr": full_data.get("transactionType", "Sale"),
"tx": full_data.get("taxItems", []),
"pm": full_data.get("payments", []),
"ca": full_data.get("cashier", ""),
"bi": full_data.get("buyerId", ""),
"items": full_data.get("items", []),
}
j = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return base64.b64encode(j.encode("utf-8")).decode("ascii")
# ── Firma ───────────────────────────────────────────────────
def ucitaj_firmu():
@@ -178,17 +266,20 @@ 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 ""
st = be_command({"command": "status"})
cert = be_command({"command": "certificate"})
total = st.get("total_counter", 0)
jid = cert.get("jid", "UNKNOWN")
tin = cert.get("tin", "RS000000000")
last_num = f"{ESIR_ID}-{jid}-{total}" if total >= 1 else ""
return {
"isPinRequired": False,
"isPinRequired": st.get("pin_required", False),
"auditRequired": False,
"sdcDateTime": sada(),
"lastInvoiceNumber": last_num,
"protocolVersion": "1.0.0",
"serialNumber": ESIR_ID,
"tin": f["tin"],
"tin": tin,
}
def resp_verify_pin(request_body=None):
@@ -197,11 +288,12 @@ def resp_verify_pin(request_body=None):
uneti = str(request_body.get("pin", "")).strip()
elif isinstance(request_body, str):
uneti = request_body.strip().strip('"')
if uneti == PIN_BE:
resp = be_command({"command": "verify_pin", "pin": uneti})
if resp.get("status") == "ok":
log(" 🔓 PIN ispravan")
return {"status": "OK", "message": "PIN verifikovan"}
log(" 🔒 Pogrešan PIN")
return {"status": "ERROR", "code": "2100", "message": "Pogrešan PIN"}
return {"status": "ERROR", "code": resp.get("code", "2100"), "message": resp.get("message", "Pogrešan PIN")}
def resp_settings_get():
return {
@@ -219,14 +311,14 @@ def resp_settings_post():
return {"status": "OK", "message": "Podešavanja sačuvana"}
def resp_certificate():
f = ucitaj_firmu()
c = be_command({"command": "certificate"})
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",
"serialNumber": c.get("jid", ESIR_ID),
"tin": c.get("tin", "RS000000000"),
"name": c.get("name", ""),
"validFrom": c.get("valid_from", "2024-01-01T00:00:00+01:00"),
"validTo": c.get("valid_to", "2027-01-01T00:00:00+01:00"),
"issuer": c.get("issuer", "Poreska uprava RS"),
}
def _build_invoice_response(req, request_id):
@@ -238,27 +330,77 @@ def _build_invoice_response(req, request_id):
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()
# Kartica emulator: podatke firme i potpis/brojače
cert = be_command({"command": "certificate"})
sign = be_command({
"command": "sign",
"invoice_type": invoice_type,
"transaction_type": transaction_type,
"total_amount": total_amount,
})
if sign.get("status") == "blocked":
raise RuntimeError(f"Kartica blokirana: {sign.get('message')}")
jid = cert.get("jid", ESIR_ID)
total_cnt = sign.get("counter", 1)
type_cnt = sign.get("type_counter", 1)
ext = sign.get("counter_extension", "ПП")
invoice_number = f"{ESIR_ID}-{jid}-{total_cnt}"
invoice_counter = f"{type_cnt}/{total_cnt}{ext}"
# firma podaci sa kartice
firma = {
"tinPlain": cert.get("tin_plain", "000000000"),
"tin": cert.get("tin", "RS000000000"),
"name": cert.get("name", "Test Company DOO"),
"locationName": cert.get("location_name", cert.get("name", "Test Company DOO")),
"address": cert.get("address", "Test Adresa 1"),
"city": cert.get("city", "Beograd"),
"district": cert.get("district", "Savski Venac"),
}
# Verifikacioni URL i QR kod
if VERIFY_HOST:
vl_payload = {
"n": invoice_number,
"ic": invoice_counter,
"t": sada(),
"a": total_amount,
"c": firma["tinPlain"],
"co": firma["name"],
"lo": firma["locationName"],
"ad": firma["address"],
"g": firma["city"],
"di": firma["district"],
"it": invoice_type,
"tr": transaction_type,
"tx": tax_items,
"pm": inv_req.get("payment", [{"type": "Cash", "amount": total_amount}]),
"ca": inv_req.get("cashier", "Kasir"),
"bi": inv_req.get("buyerId", ""),
"items": items,
}
vl = base64.b64encode(
json.dumps(vl_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
).decode("ascii")
scheme = "https" if VERIFY_HOST.startswith("https://") else "http"
host = VERIFY_HOST.removeprefix("https://").removeprefix("http://")
verification_url = f"{scheme}://{host}/v/?vl={urllib.parse.quote(vl, safe='')}"
else:
verification_url = f"https://sandbox.suf.purs.gov.rs/v/?vl={invoice_number}"
qr_b64 = generate_qr(verification_url)
# Odgovor koji ide ka NTech-u (ESIR-u)
odgovor = {
"requestedBy": ESIR_ID,
"signedBy": BE_ID,
"signedBy": jid,
"sdcDateTime": sada(),
"invoiceCounter": invoice_counter,
"invoiceCounterExtension": ext,
@@ -304,12 +446,12 @@ def _build_invoice_response(req, request_id):
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")
# Generiši tekst i HTML račun (pismo određuje podešavanje fiskalni_pismo)
receipt_text = generate_receipt(full_data, FISKALNI_PISMO)
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_txt = generate_receipt_html(full_data, FISKALNI_PISMO)
html_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.html"
html_path.write_text(html_txt, encoding="utf-8")
@@ -505,7 +647,10 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
# ── Main ─────────────────────────────────────────────────────
def main():
firma = ucitaj_firmu()
cert = be_command({"command": "certificate"})
jid = cert.get("jid", "?")
tin = cert.get("tin_plain", "?")
name = cert.get("name", "?")
log("╔══════════════════════════════════════════════════╗")
log("║ 🧾 Teron L-PFR Mock Server ║")
log(f"║ http://{HOST}:{PORT}/ ║")
@@ -514,9 +659,8 @@ def main():
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(f" 🏢 Firma: {name} | PIB: {tin}")
log(f" 🆔 ESIR ID: {ESIR_ID} | BE JID: {jid} (kartica: {BE_HOST}:{BE_PORT})")
log(" ▶ Server pokrenut. Ctrl+C za gašenje.")
server = http.server.HTTPServer((HOST, PORT), FiscalHandler)
+1 -1
View File
@@ -3,7 +3,7 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PID_FILE="$SCRIPT_DIR/data/server.pid"
PORT=8989
PORT=4566
echo "⏹ Zaustavljam fiskalni mock server..."