From 97fd4490eae757a1bc73caf87de38c51fc7c2fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 26 Jun 2026 23:17:52 +0200 Subject: [PATCH] =?UTF-8?q?Kartica=20emulator=20(BE)=20+=20fiskalna=20veri?= =?UTF-8?q?fikacija=20+=20ime/prezime=20korisnika=20-=20internal/be:=20emu?= =?UTF-8?q?lator=20kartice=20(TCP=20listener=20za=20L-PFR=20mock)=20-=20/v?= =?UTF-8?q?/:=20javna=20stranica=20za=20verifikaciju=20fiskalnog=20ra?= =?UTF-8?q?=C4=8Duna=20(QR=20kod)=20-=20/servis/{id}/fiskalni-racun:=20?= =?UTF-8?q?=C5=A1tampa=20fiskalnog=20ra=C4=8Duna=20-=20Pode=C5=A1avanja=20?= =?UTF-8?q?fiskalizacije:=20BE=20status/reset,=20pro=C5=A1iren=20UI=20-=20?= =?UTF-8?q?Korisnik:=20polja=20ime=20i=20prezime=20(model,=20DB,=20handler?= =?UTF-8?q?,=20admin=20profil)=20-=20Fisk=20server:=20a=C5=BEuriran=20rece?= =?UTF-8?q?ipt.py=20i=20server.py=20-,=20:=20dokumentacija?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fisk/data/locale_cyrillic.properties | 24 +- Fisk/data/locale_latin.properties | 24 +- Fisk/receipt.py | 53 +++- Fisk/server.py | 220 ++++++++++--- Fisk/stop.sh | 2 +- cmd/ntech/main.go | 13 + internal/be/kartica.go | 299 ++++++++++++++++++ internal/be/server.go | 103 ++++++ internal/db/repository.go | 1 + internal/db/sqlite/korisnici.go | 24 +- internal/handler/admin.go | 27 ++ internal/handler/fiskal_verifikacija.go | 92 ++++++ internal/handler/kes.go | 2 +- internal/handler/podesavanja.go | 113 ++++++- internal/handler/servis.go | 113 +++++-- internal/model/korisnik.go | 2 + migrations/086_korisnik_ime_prezime.sql | 2 + web/templates/stranice/admin_profil.html | 21 ++ .../stranice/fiskal_verifikacija.html | 232 ++++++++++++++ .../stranice/podesavanja_fiskalizacija.html | 115 +++++++ web/templates/stranice/servis_detalji.html | 59 +++- 21 files changed, 1425 insertions(+), 116 deletions(-) create mode 100644 internal/be/kartica.go create mode 100644 internal/be/server.go create mode 100644 internal/handler/fiskal_verifikacija.go create mode 100644 migrations/086_korisnik_ime_prezime.sql create mode 100644 web/templates/stranice/fiskal_verifikacija.html diff --git a/Fisk/data/locale_cyrillic.properties b/Fisk/data/locale_cyrillic.properties index 18b057c..2d8a7ea 100644 --- a/Fisk/data/locale_cyrillic.properties +++ b/Fisk/data/locale_cyrillic.properties @@ -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=Остало diff --git a/Fisk/data/locale_latin.properties b/Fisk/data/locale_latin.properties index 702d365..c7b8109 100644 --- a/Fisk/data/locale_latin.properties +++ b/Fisk/data/locale_latin.properties @@ -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 diff --git a/Fisk/receipt.py b/Fisk/receipt.py index 161f296..e53ed86 100644 --- a/Fisk/receipt.py +++ b/Fisk/receipt.py @@ -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'{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")) + pt = p.get("paymentType", p.get("type", "")) + ptype = m.get(pt, pt or "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)}' @@ -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"): - +
{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', '')}
{m.get('sdc-invoice-counter', 'Brojač računa')}{inv.get('invoiceCounter', '')}
diff --git a/Fisk/server.py b/Fisk/server.py index 7af6b40..2f4f41e 100755 --- a/Fisk/server.py +++ b/Fisk/server.py @@ -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) diff --git a/Fisk/stop.sh b/Fisk/stop.sh index 748b85b..1fc1886 100755 --- a/Fisk/stop.sh +++ b/Fisk/stop.sh @@ -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..." diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 96c6d74..922ba93 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -18,6 +18,7 @@ import ( "ntech" "ntech/internal/auth" + "ntech/internal/be" "ntech/internal/config" "ntech/internal/db" "ntech/internal/db/sqlite" @@ -168,6 +169,13 @@ func main() { slog.Info("keš šablona kreiran", "broj", len(kes)) } + // kartica emulator — TCP listener za Fisk (L-PFR mock); preskačemo ako je isključen + if be.JeUkljucen(db) { + go be.Pokreni(db) + } else { + slog.Info("kartica emulator isključen (be_enabled=false)") + } + // Pozadinske gorutine se pokreću posle kreiranja h i rade preko h.SaBazom, // pa uvek koriste TRENUTNU konekciju baze (posle obnove backupa h.DB se menja). @@ -242,6 +250,7 @@ func main() { r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) r.Post("/status/{token}/odbij", h.ServisJavniOdbij) r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) + r.Get("/v/", h.FiskalVerifikacija) // zaštićene rute — zahtevaju prijavljenog korisnika r.Group(func(r chi.Router) { @@ -280,6 +289,8 @@ func main() { r.Get("/admin/podesavanja/servis", h.PodesavanjaServis) r.Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija) r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) + r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) + r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) @@ -362,6 +373,7 @@ func main() { r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice) + r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/fiskalni-racun", h.StampaFiskalnog) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/revers", h.StampaReversa) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/predracun", h.StampaPredracuna) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/nalepnica", h.StampaNalepnice) @@ -427,6 +439,7 @@ func main() { }) r.Get("/admin/profil", h.AdminProfil) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) + r.Post("/admin/profil/ime-prezime", h.AdminSacuvajImePrezime) r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija) r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija) diff --git a/internal/be/kartica.go b/internal/be/kartica.go new file mode 100644 index 0000000..26a104e --- /dev/null +++ b/internal/be/kartica.go @@ -0,0 +1,299 @@ +// Package be implementira emulator bezbednosnog elementa (pametne kartice). +// Sluša na TCP portu i obrađuje 4 JSON komande: status, certificate, verify_pin, sign. +// Podatke o firmi čita iz NTech SQLite baze pri pokretanju. +package be + +import ( + "database/sql" + "encoding/base64" + "fmt" + "log/slog" + "math/rand/v2" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača) +var tipExt = map[[2]string][2]string{ + {"Normal", "Sale"}: {"ПП", "pp"}, + {"Normal", "Refund"}: {"ПР", "pr"}, + {"Advance", "Sale"}: {"АП", "ap"}, + {"Advance", "Refund"}: {"АР", "ar"}, + {"Copy", "Sale"}: {"КП", "kp"}, + {"Copy", "Refund"}: {"КР", "kr"}, + {"Training", "Sale"}: {"ОП", "op"}, + {"Training", "Refund"}: {"ОР", "or"}, + {"Proforma", "Sale"}: {"РП", "rp"}, + {"Proforma", "Refund"}: {"РР", "rr"}, +} + +// Kartica drži stanje emuliranog bezbednosnog elementa. +type Kartica struct { + mu sync.Mutex + + // identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju) + JID string + PIN string + TIN string // RS+PIB + TINPlain string // samo PIB + Name string + Address string + City string + District string + BusinessUnitID string + LocationName string + ValidFrom string + ValidTo string + Issuer string + + // stanje u toku rada + pinUnesen bool + totalCounter int + counters map[string]int + Limit float64 + unreadAmount float64 +} + +func novaKartica(db *sql.DB) *Kartica { + k := &Kartica{ + JID: env("BE_JID", "TRNMOCK1"), + PIN: env("BE_PIN", "1234"), + Limit: envFloat("BE_LIMIT", 500000), + Issuer: "Poreska uprava RS", + counters: map[string]int{ + "pp": 0, "pr": 0, "ap": 0, "ar": 0, + "kp": 0, "kr": 0, "op": 0, "or": 0, + "rp": 0, "rr": 0, + }, + } + + now := time.Now() + k.ValidFrom = now.Format("2006-01-02T00:00:00+01:00") + k.ValidTo = now.AddDate(3, 0, 0).Format("2006-01-02T00:00:00+01:00") + + // čitaj podatke firme, PIN i limit iz baze (env var ima prednost) + k.ucitajFirmu(db) + + slog.Info("kartica emulator inicijalizovan", + "jid", k.JID, + "tin", k.TINPlain, + "firma", k.Name, + "limit", k.Limit, + ) + return k +} + +func (k *Kartica) ucitajFirmu(db *sql.DB) { + rows, err := db.Query( + "SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " + + "('naziv_firme','pib','maticni_broj','adresa','telefon'," + + "'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," + + "'be_pin','be_limit')", + ) + if err != nil { + slog.Warn("be: ne mogu da čitam podesavanja", "error", err) + k.postaviTestPodatke() + return + } + defer rows.Close() + + p := map[string]string{} + for rows.Next() { + var k2, v string + if err := rows.Scan(&k2, &v); err == nil { + p[k2] = v + } + } + + naziv := p["naziv_firme"] + if naziv == "" { + naziv = "Test Company DOO" + } + pib := p["pib"] + if pib == "" { + pib = "123456789" + } + + k.Name = naziv + k.TINPlain = pib + k.TIN = "RS" + pib + k.Address = p["adresa"] + if k.Address == "" { + k.Address = "Test Adresa 1" + } + k.City = p["grad"] + if k.City == "" { + k.City = "Beograd" + } + k.District = p["opstina"] + if k.District == "" { + k.District = "Savski Venac" + } + k.LocationName = p["poslovna_jedinica_naziv"] + if k.LocationName == "" { + k.LocationName = naziv + } + k.BusinessUnitID = p["poslovna_jedinica_oznaka"] + if k.BusinessUnitID == "" { + k.BusinessUnitID = "BU-001" + } + + // PIN i limit iz baze — samo ako env var nije postavljen + if os.Getenv("BE_PIN") == "" { + if pin := p["be_pin"]; pin != "" { + k.PIN = pin + } + } + if os.Getenv("BE_LIMIT") == "" { + if lim := p["be_limit"]; lim != "" { + if f, err := strconv.ParseFloat(lim, 64); err == nil && f > 0 { + k.Limit = f + } + } + } +} + +func (k *Kartica) postaviTestPodatke() { + k.Name = "Test Company DOO" + k.TINPlain = "123456789" + k.TIN = "RS123456789" + k.Address = "Test Adresa 1" + k.City = "Beograd" + k.District = "Savski Venac" + k.LocationName = "Test Company DOO" + k.BusinessUnitID = "BU-001" +} + +// ── Komande ──────────────────────────────────────────────── + +func (k *Kartica) cmdStatus() map[string]any { + k.mu.Lock() + defer k.mu.Unlock() + counters := map[string]int{} + for key, val := range k.counters { + counters[key] = val + } + return map[string]any{ + "status": "ok", + "total_counter": k.totalCounter, + "counters": counters, + "limit": k.Limit, + "unread_amount": k.unreadAmount, + "pin_required": !k.pinUnesen, + } +} + +func (k *Kartica) cmdCertificate() map[string]any { + k.mu.Lock() + defer k.mu.Unlock() + return map[string]any{ + "status": "ok", + "jid": k.JID, + "tin": k.TIN, + "tin_plain": k.TINPlain, + "name": k.Name, + "address": k.Address, + "city": k.City, + "district": k.District, + "business_unit_id": k.BusinessUnitID, + "location_name": k.LocationName, + "valid_from": k.ValidFrom, + "valid_to": k.ValidTo, + "issuer": k.Issuer, + } +} + +func (k *Kartica) cmdVerifyPin(pin string) map[string]any { + k.mu.Lock() + defer k.mu.Unlock() + if pin != k.PIN { + return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"} + } + k.pinUnesen = true + return map[string]any{"status": "ok"} +} + +func (k *Kartica) cmdResetAudit() map[string]any { + k.mu.Lock() + defer k.mu.Unlock() + k.unreadAmount = 0 + return map[string]any{"status": "ok", "unread_amount": 0} +} + +func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float64) map[string]any { + k.mu.Lock() + defer k.mu.Unlock() + + // blokada limite + if k.unreadAmount >= k.Limit { + return map[string]any{ + "status": "blocked", + "message": fmt.Sprintf("Limit iščitavanja dostignut (%.2f / %.2f)", k.unreadAmount, k.Limit), + } + } + + // određi tip i ext + ext, tipKey := "", "pp" + if v, ok := tipExt[[2]string{invoiceType, transactionType}]; ok { + ext = v[0] + tipKey = v[1] + } else { + ext = "ПП" + tipKey = "pp" + } + + k.totalCounter++ + k.counters[tipKey]++ + typCounter := k.counters[tipKey] + + // za fiskalne račune akumuliraj neisčitani iznos + nonFiscal := strings.HasPrefix(tipKey, "k") || strings.HasPrefix(tipKey, "o") || strings.HasPrefix(tipKey, "r") + if !nonFiscal && transactionType == "Sale" { + k.unreadAmount += totalAmount + } else if !nonFiscal && transactionType == "Refund" { + k.unreadAmount -= totalAmount + if k.unreadAmount < 0 { + k.unreadAmount = 0 + } + } + + // lažni potpis — random base64 dovoljno dug da izgleda realistično + sig := mockPotpis() + + return map[string]any{ + "status": "ok", + "counter": k.totalCounter, + "counter_extension": ext, + "type_counter": typCounter, + "signature": sig, + "blocked": false, + } +} + +// mockPotpis generiše 64-bajtni nasumični base64 string koji imitira RSA potpis. +func mockPotpis() string { + b := make([]byte, 64) + for i := range b { + b[i] = byte(rand.IntN(256)) + } + return base64.StdEncoding.EncodeToString(b) +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envFloat(key string, def float64) float64 { + if v := os.Getenv(key); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + } + return def +} diff --git a/internal/be/server.go b/internal/be/server.go new file mode 100644 index 0000000..08181e4 --- /dev/null +++ b/internal/be/server.go @@ -0,0 +1,103 @@ +package be + +import ( + "bufio" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net" + "os" +) + +// JeUkljucen vraća true ako emulator treba da se pokrene. +// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true. +func JeUkljucen(db *sql.DB) bool { + if v := os.Getenv("BE_ENABLED"); v != "" { + return v != "false" && v != "0" + } + var vrednost string + _ = db.QueryRow("SELECT vrednost FROM podesavanja WHERE kljuc='be_enabled'").Scan(&vrednost) + return vrednost != "false" && vrednost != "0" +} + +// Pokreni startuje TCP listener za kartica emulator. +// Blokira dok listener ne padne — pozivati kao goroutine. +func Pokreni(db *sql.DB) { + port := env("BE_PORT", "4567") + addr := fmt.Sprintf("0.0.0.0:%s", port) + + ln, err := net.Listen("tcp", addr) + if err != nil { + slog.Error("be: ne mogu da pokrenem listener", "addr", addr, "error", err) + os.Exit(1) + } + slog.Info("kartica emulator sluša", "addr", addr) + + k := novaKartica(db) + + for { + conn, err := ln.Accept() + if err != nil { + slog.Warn("be: greška pri Accept()", "error", err) + continue + } + go obradiKonekciju(conn, k) + } +} + +func obradiKonekciju(conn net.Conn, k *Kartica) { + defer conn.Close() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req map[string]any + if err := json.Unmarshal(line, &req); err != nil { + odgovori(conn, map[string]any{"status": "error", "message": "invalid JSON"}) + return + } + + cmd, _ := req["command"].(string) + var resp map[string]any + + switch cmd { + case "status": + resp = k.cmdStatus() + + case "certificate": + resp = k.cmdCertificate() + + case "verify_pin": + pin, _ := req["pin"].(string) + resp = k.cmdVerifyPin(pin) + + case "reset_audit": + resp = k.cmdResetAudit() + + case "sign": + invType, _ := req["invoice_type"].(string) + txType, _ := req["transaction_type"].(string) + totalAmount, _ := req["total_amount"].(float64) + resp = k.cmdSign(invType, txType, totalAmount) + + default: + resp = map[string]any{"status": "error", "message": fmt.Sprintf("nepoznata komanda: %q", cmd)} + } + + odgovori(conn, resp) + } +} + +func odgovori(conn net.Conn, resp map[string]any) { + b, err := json.Marshal(resp) + if err != nil { + return + } + b = append(b, '\n') + conn.Write(b) +} diff --git a/internal/db/repository.go b/internal/db/repository.go index 6b41af6..0d13869 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -277,6 +277,7 @@ type KorisniciRepository interface { Lista(ctx context.Context) ([]model.Korisnik, error) AzurirajUlogu(ctx context.Context, id int64, uloga string) error AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error + AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error PromeniLozinku(ctx context.Context, id int64, hash string) error SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index a2f7a8c..824bfa8 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -33,6 +33,8 @@ type korisnikOpcije struct { lokalnaAnimacija sql.NullString lokalniHover sql.NullString lokalnaBrzinaAnimacije sql.NullString + ime sql.NullString + prezime sql.NullString } // dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik @@ -50,6 +52,8 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) { k.LokalnaAnimacija = o.lokalnaAnimacija.String k.LokalniHover = o.lokalniHover.String k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String + k.Ime = o.ime.String + k.Prezime = o.prezime.String } // skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik @@ -62,6 +66,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije, + &o.ime, &o.prezime, ); err != nil { return nil, err } @@ -107,7 +112,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), - COALESCE(lokalna_brzina_animacije, '') + COALESCE(lokalna_brzina_animacije, ''), + COALESCE(ime, ''), COALESCE(prezime, '') FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme) k, err := skeniraiKorisnika(row) if err != nil { @@ -125,7 +131,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), - COALESCE(lokalna_brzina_animacije, '') + COALESCE(lokalna_brzina_animacije, ''), + COALESCE(ime, ''), COALESCE(prezime, '') FROM korisnici WHERE id = ?`, id) k, err := skeniraiKorisnika(row) if err != nil { @@ -143,7 +150,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), - COALESCE(lokalna_brzina_animacije, '') + COALESCE(lokalna_brzina_animacije, ''), + COALESCE(ime, ''), COALESCE(prezime, '') FROM korisnici ORDER BY datum_kreiranja ASC`) if err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) @@ -159,6 +167,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije, + &o.ime, &o.prezime, ); err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) } @@ -169,6 +178,15 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro return lista, nil } +func (r *sqliteKorisniciRepo) AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE korisnici SET ime = ?, prezime = ? WHERE id = ?`, ime, prezime, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.AzurirajImePrezime: %w", err) + } + return nil +} + func (r *sqliteKorisniciRepo) SacuvajAvatar(ctx context.Context, id int64, putanja string) error { _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET avatar_putanja = ? WHERE id = ?`, putanja, id) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 6c289af..9535d69 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -36,6 +36,8 @@ type podaciAdminProfil struct { LokalnaTema string KoristiLokalnuTemu bool JelDemo bool + Ime string + Prezime string } type podaciProfilTema struct { @@ -310,6 +312,8 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) { LokalnaTema: svezi.LokalnaTema, KoristiLokalnuTemu: svezi.KoristiLokalnuTemu, JelDemo: h.JelDemo, + Ime: svezi.Ime, + Prezime: svezi.Prezime, }) } @@ -405,6 +409,29 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther) } +// AdminSacuvajImePrezime snima ime i prezime prijavljenog korisnika +func (h *Handler) AdminSacuvajImePrezime(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) + return + } + ime := r.FormValue("ime") + prezime := r.FormValue("prezime") + if err := h.KorisniciRepo.AzurirajImePrezime(r.Context(), k.ID, ime, prezime); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "Ime i prezime su sačuvani.") + http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) +} + // AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) { k := middleware.KorisnikIzKonteksta(r.Context()) diff --git a/internal/handler/fiskal_verifikacija.go b/internal/handler/fiskal_verifikacija.go new file mode 100644 index 0000000..b21ffdd --- /dev/null +++ b/internal/handler/fiskal_verifikacija.go @@ -0,0 +1,92 @@ +package handler + +import ( + "encoding/base64" + "encoding/json" + "net/http" +) + +// FiskalVlPodaci su podaci dekodirani iz vl parametra QR koda. +type FiskalVlPodaci struct { + InvoiceNumber string `json:"n"` + InvoiceCounter string `json:"ic"` + DateTime string `json:"t"` + TotalAmount float64 `json:"a"` + TIN string `json:"c"` + Company string `json:"co"` + Store string `json:"lo"` + Address string `json:"ad"` + City string `json:"g"` + District string `json:"di"` + InvoiceType string `json:"it"` + TransactionType string `json:"tr"` + TaxItems []FiskalTaxItem `json:"tx"` + Payments []FiskalPayment `json:"pm"` + Cashier string `json:"ca"` + BuyerID string `json:"bi"` + Items []FiskalItem `json:"items"` +} + +type FiskalTaxItem struct { + Label string `json:"label"` + CategoryName string `json:"categoryName"` + Rate float64 `json:"rate"` + Base float64 `json:"base"` + Amount float64 `json:"amount"` +} + +type FiskalPayment struct { + Type string `json:"type"` + Amount float64 `json:"amount"` +} + +type FiskalItem struct { + Name string `json:"name"` + Quantity float64 `json:"quantity"` + UnitPrice float64 `json:"unitPrice"` + TotalAmount float64 `json:"totalAmount"` + Labels []string `json:"labels"` +} + +type fiskalVerifikacijaPodaci struct { + Podaci *FiskalVlPodaci + Greska string + JeFiskalni bool +} + +func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) { + vl := r.URL.Query().Get("vl") + + podaci := &fiskalVerifikacijaPodaci{} + + if vl == "" { + podaci.Greska = "Nedostaje vl parametar." + h.renderujStandalone(w, "fiskal_verifikacija", podaci) + return + } + + decoded, err := base64.StdEncoding.DecodeString(vl) + if err != nil { + // proba RawStdEncoding (bez paddinga) i URLEncoding + decoded, err = base64.RawStdEncoding.DecodeString(vl) + if err != nil { + podaci.Greska = "Neispravan QR kod." + h.renderujStandalone(w, "fiskal_verifikacija", podaci) + return + } + } + + var vlPodaci FiskalVlPodaci + if err := json.Unmarshal(decoded, &vlPodaci); err != nil { + podaci.Greska = "Neispravan format podataka." + h.renderujStandalone(w, "fiskal_verifikacija", podaci) + return + } + + podaci.Podaci = &vlPodaci + podaci.JeFiskalni = vlPodaci.InvoiceType != "Copy" && + vlPodaci.InvoiceType != "Training" && + vlPodaci.InvoiceType != "Proforma" + + h.renderujStandalone(w, "fiskal_verifikacija", podaci) +} diff --git a/internal/handler/kes.go b/internal/handler/kes.go index ba07b86..6b1e062 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -43,7 +43,7 @@ var saSidebar = []string{ // standalone su šabloni bez base layouta var standaloneIme = []string{ - "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", + "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "fiskal_verifikacija", } // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima. diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 7a827dd..2acb010 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -76,6 +76,12 @@ type PodaciPodesavanja struct { QrBazniUrl string PfrUrl string PfrTip string + PfrKasir string + VerifyHost string + BePin string + BeLimit string + BeEnabled string + FiskalPismo string LoginPozadina string LoginPozadinaOpacity string LoginPozadinaBlurPozadine string @@ -331,9 +337,15 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) { "firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"), "firma_fiskalizacija": r.FormValue("firma_fiskalizacija"), "firma_rezim": r.FormValue("firma_rezim"), - // fiskalizacija — L-PFR podešavanja - "pfr_url": r.FormValue("pfr_url"), - "pfr_tip": r.FormValue("pfr_tip"), + // fiskalizacija — L-PFR i kartica emulator podešavanja + "pfr_url": r.FormValue("pfr_url"), + "pfr_tip": r.FormValue("pfr_tip"), + "pfr_kasir": r.FormValue("pfr_kasir"), + "verify_host": r.FormValue("verify_host"), + "be_pin": r.FormValue("be_pin"), + "be_limit": r.FormValue("be_limit"), + "be_enabled": r.FormValue("be_enabled"), + "fiskalni_pismo": r.FormValue("fiskalni_pismo"), } for kljuc, vrednost := range polja { @@ -865,6 +877,12 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac QrBazniUrl: podesavanja["qr_bazni_url"], PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"), PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"), + PfrKasir: podesavanja["pfr_kasir"], + VerifyHost: podesavanja["verify_host"], + BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"), + BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"), + BeEnabled: vrednostIliDefault(podesavanja, "be_enabled", "true"), + FiskalPismo: vrednostIliDefault(podesavanja, "fiskalni_pismo", "latin"), }, nil } @@ -968,6 +986,95 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { `, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime)) } +// BeStatus se spaja na lokalni kartica emulator (TCP :4567) i vraća HTMX fragment sa statusom. +func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { + return + } + + bePort := os.Getenv("BE_PORT") + if bePort == "" { + bePort = "4567" + } + addr := "127.0.0.1:" + bePort + + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
✗ Emulator nije dostupan na %s — %s
`, + html.EscapeString(addr), html.EscapeString(err.Error())) + return + } + defer conn.Close() + + cmd := `{"command":"status"}` + "\n" + conn.Write([]byte(cmd)) + conn.SetDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + conn.Close() + + var st map[string]any + if err := json.Unmarshal(buf[:n], &st); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
✗ Neispravan odgovor
`) + return + } + + total, _ := st["total_counter"].(float64) + unread, _ := st["unread_amount"].(float64) + limit, _ := st["limit"].(float64) + pinReq, _ := st["pin_required"].(bool) + + pinTekst := "PIN unesen" + if pinReq { + pinTekst = "Čeka PIN" + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
+ ✓ Kartica emulator aktivan +
+ Ukupno računa: %d + Neisčitano: %s / %s din + %s +
+
`, + int(total), + html.EscapeString(formatirajDinare(unread, 0)), + html.EscapeString(formatirajDinare(limit, 0)), + html.EscapeString(pinTekst), + ) +} + +// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment. +func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok { + return + } + bePort := os.Getenv("BE_PORT") + if bePort == "" { + bePort = "4567" + } + conn, err := net.DialTimeout("tcp", "127.0.0.1:"+bePort, 2*time.Second) + if err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
✗ Emulator nije dostupan
`) + return + } + conn.Write([]byte(`{"command":"reset_audit"}` + "\n")) + conn.SetDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 512) + n, _ := conn.Read(buf) + conn.Close() + + var resp map[string]any + json.Unmarshal(buf[:n], &resp) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
✓ Neisčitani iznos resetovan na 0 (Proof of Audit simuliran)
`) +} + // jePrivatnaAdresa proverava da li je hostname u opsegu privatnih/lokalnih mreža. // Dozvoljeni: 127.x.x.x, localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x. func jePrivatnaAdresa(hostname string) bool { diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 689f449..fecade2 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -526,23 +526,37 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { // self-heal: ako u međuvremenu ima stanja na magacinu za potraživane artikle, // povuci ih (delimično ili u celosti) i otključaj nalog ako je sve pokriveno. // Hvata sve načine na koje stanje poraste, ne samo nabavku/izmenu/popis. - if potrazivani, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil && len(potrazivani) > 0 { - vidjeni := map[int64]bool{} - for _, p := range potrazivani { - if vidjeni[p.ArtikalID] { - continue + // Samo predlozeno=false redovi blokiraju nalog — predloženi se ignorišu. + if potrazivaniSvi, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil { + var potrazivani []model.ServisniPotrazivaniDeo + for _, p := range potrazivaniSvi { + if !p.Predlozeno { + potrazivani = append(potrazivani, p) } - vidjeni[p.ArtikalID] = true - otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID) - if err != nil { - slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err) - continue + } + if len(potrazivani) == 0 && nalog.Status == model.StatusCekaDelove { + // nema predlozeno=0 redova koji blokiraju — resetuj status odmah + if err := h.ServisRepo.AzurirajStatus(r.Context(), id, model.StatusPrimljeno); err != nil { + slog.Error("self-heal reset statusa (nema predlozeno=0) nije uspeo", "nalog_id", id, "error", err) } - for _, nalogID := range otkljucani { - // reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd. - if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove { - if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil { - slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err) + } else if len(potrazivani) > 0 { + vidjeni := map[int64]bool{} + for _, p := range potrazivani { + if vidjeni[p.ArtikalID] { + continue + } + vidjeni[p.ArtikalID] = true + otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID) + if err != nil { + slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err) + continue + } + for _, nalogID := range otkljucani { + // reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd. + if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove { + if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil { + slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err) + } } } } @@ -1929,11 +1943,19 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { return } - // zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi — - // prvo nabaviti delove, pa obrisati iz potraživanih (ili kroz nabavku) + // zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi (predlozeno=false) — + // predloženi delovi (predlozeno=true) ne blokiraju nalog if e == nil && trenutni.Status == model.StatusCekaDelove && noviStatus != model.StatusCekaDelove { - potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id) - if err == nil && len(potrazivani) > 0 { + sviPotrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id) + var blokirajuci int + if err == nil { + for _, p := range sviPotrazivani { + if !p.Predlozeno { + blokirajuci++ + } + } + } + if blokirajuci > 0 { middleware.SetFlash(w, r, h.DB, "greska", "Nalog ne može da napusti „Čeka delove\" dok ima delova koji nedostaju. Obrišite ih iz tabele ili dopunite zalihe.") http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) @@ -2096,7 +2118,17 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent } } - kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") + kasir := "" + if kor := middleware.KorisnikIzKonteksta(ctx); kor != nil { + if kor.Ime != "" || kor.Prezime != "" { + kasir = strings.TrimSpace(kor.Ime + " " + kor.Prezime) + } else { + kasir = kor.KorisnickoIme + } + } + if kasir == "" { + kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") + } if kasir == "" { kasir = "NTech" } @@ -2159,11 +2191,6 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) { return } nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, `Fiskalni račun -`) - journal := "" if fr.SiroviOdgovor != "" { var raw map[string]any @@ -2173,17 +2200,35 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) { } } } - - // zameni QR placeholder pravim QR kodom - if fr.QRKod != "" && strings.Contains(journal, "{{{{QR-KOD}}}}") { - qrImg := `` - journal = strings.ReplaceAll(journal, "{{{{QR-KOD}}}}", qrImg) - } - - fmt.Fprint(w, journal) if nalog != nil { - fmt.Fprintf(w, "\n\n--- NTech servisni nalog: %s ---\n", nalog.BrojNaloga) + journal += "\n\n--- NTech servisni nalog: " + nalog.BrojNaloga + " ---\n" } + + // Podeli journal na deo pre i posle QR placeholder-a + const qrPlaceholder = "{{{{QR-KOD}}}}" + pre, post, hasQR := strings.Cut(journal, qrPlaceholder) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `Fiskalni račun +`) + + fmt.Fprint(w, `
`)
+	fmt.Fprint(w, pre)
+	fmt.Fprint(w, `
`) + + if hasQR && fr.QRKod != "" { + fmt.Fprintf(w, `
`, fr.QRKod) + } + + fmt.Fprint(w, `
`)
+	fmt.Fprint(w, post)
+	fmt.Fprint(w, `
`) + fmt.Fprint(w, `

`) } func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/korisnik.go b/internal/model/korisnik.go index e1d1b05..be1b714 100644 --- a/internal/model/korisnik.go +++ b/internal/model/korisnik.go @@ -6,6 +6,8 @@ import "time" type Korisnik struct { ID int64 KorisnickoIme string + Ime string + Prezime string LozinkaHash string Uloga string // "superadmin" | "admin" | "radnik" Aktivan bool diff --git a/migrations/086_korisnik_ime_prezime.sql b/migrations/086_korisnik_ime_prezime.sql new file mode 100644 index 0000000..0fbdd7d --- /dev/null +++ b/migrations/086_korisnik_ime_prezime.sql @@ -0,0 +1,2 @@ +ALTER TABLE korisnici ADD COLUMN ime TEXT NOT NULL DEFAULT ''; +ALTER TABLE korisnici ADD COLUMN prezime TEXT NOT NULL DEFAULT ''; diff --git a/web/templates/stranice/admin_profil.html b/web/templates/stranice/admin_profil.html index 55bea2c..1ade530 100644 --- a/web/templates/stranice/admin_profil.html +++ b/web/templates/stranice/admin_profil.html @@ -1,6 +1,27 @@ {{ template "base" . }} {{ define "naslov" }}Moj profil — NTech{{ end }} {{ define "sadrzaj" }}
+ +
+
Ime i prezime
+
+
+
+ + +
+
+ + +
+
Koristi se kao „Kasir" na fiskalnom računu.
+
+ +
+
+
+
+
Promena lozinke
diff --git a/web/templates/stranice/fiskal_verifikacija.html b/web/templates/stranice/fiskal_verifikacija.html new file mode 100644 index 0000000..fe1f6fd --- /dev/null +++ b/web/templates/stranice/fiskal_verifikacija.html @@ -0,0 +1,232 @@ + + + + + + Verifikacija fiskalnog računa + + + +
+ +{{if .Greska}} +
+
+
Greška pri čitanju računa
+
{{.Greska}}
+
+{{else}} + +{{if .JeFiskalni}} +
+
+
Račun je verifikovan
+
Fiskalni račun je evidentiran u sistemu
+
+{{else}} +
+
+
Ovo nije fiskalni račun
+
{{.Podaci.InvoiceType}}
+
+{{end}} + +{{with .Podaci}} + + +
+
{{.Company}}
+
+ {{if .Store}}{{.Store}}
{{end}} + {{if .Address}}{{.Address}}{{if .City}}, {{.City}}{{end}}
{{end}} + PIB: {{.TIN}} +
+
+ + +
+
Podaci o računu
+
+ Broj računa + {{.InvoiceNumber}} +
+
+ Redni broj + {{.InvoiceCounter}} +
+
+ Datum i vreme + {{.DateTime}} +
+ {{if .Cashier}} +
+ Kasir + {{.Cashier}} +
+ {{end}} + {{if .BuyerID}} +
+ ID kupca + {{.BuyerID}} +
+ {{end}} +
+ Tip + + {{if eq .InvoiceType "Normal"}} + Promet + {{else if eq .InvoiceType "Advance"}} + Avans + {{else if eq .InvoiceType "Copy"}} + Kopija + {{else if eq .InvoiceType "Training"}} + Obuka + {{else if eq .InvoiceType "Proforma"}} + Predračun + {{else}} + {{.InvoiceType}} + {{end}} + {{if eq .TransactionType "Refund"}} +  Refundacija + {{end}} + +
+
+ + +{{if .Items}} +
+
Stavke
+
+ Naziv + Kol. + Iznos +
+ {{range .Items}} +
+ {{.Name}} + {{printf "%.0f" .Quantity}} + {{dinari .TotalAmount}} +
+ {{end}} +
+ Ukupno za plaćanje + {{dinari .TotalAmount}} din +
+
+{{else}} +
+
+ Ukupno za plaćanje + {{dinari .TotalAmount}} din +
+
+{{end}} + + +{{if .Payments}} +
+
Plaćanje
+ {{range .Payments}} +
+ + {{if eq .Type "Cash"}}Gotovina + {{else if eq .Type "Card"}}Kartica + {{else if eq .Type "Check"}}Ček + {{else if eq .Type "WireTransfer"}}Prenos + {{else if eq .Type "Voucher"}}Vaučer + {{else if eq .Type "MobileMoney"}}Mobilno plaćanje + {{else}}{{.Type}}{{end}} + + {{dinari .Amount}} din +
+ {{end}} +
+{{end}} + + +{{if .TaxItems}} +
+
Porez
+ {{range .TaxItems}} +
+ {{if .CategoryName}}{{.CategoryName}} ({{.Label}}){{else}}{{.Label}}{{end}} {{printf "%.0f" .Rate}}% + Osnovica: {{dinari .Base}} + PDV: {{dinari .Amount}} din +
+ {{end}} +
+{{end}} + +{{end}}{{/* with .Podaci */}} + +
+ Fiskalizacija — NTech sistem
+ Poreski identifikacioni broj se proverava kod Poreske uprave RS +
+ +{{end}}{{/* if .Greska */}} + +
+ + diff --git a/web/templates/stranice/podesavanja_fiskalizacija.html b/web/templates/stranice/podesavanja_fiskalizacija.html index 779f877..ef00a78 100644 --- a/web/templates/stranice/podesavanja_fiskalizacija.html +++ b/web/templates/stranice/podesavanja_fiskalizacija.html @@ -25,6 +25,24 @@
Trenutno podržan samo Teron L-PFR.
+
+ + +
Pismo kojim Fisk štampa tekst računa. Stupi na snagu posle restarta Fisk servera. Env var FISKALNI_PISMO ima prednost.
+
+ +
+ + +
Ime koje se štampa u redu „Kasir" na fiskalnom računu. Prazno = prikazuje se „NTech".
+
+
+ +
+
+ + Kartica emulator (BE) +
+
+ Bezbednosni element — drži PIN, limite i brojače. Radi kao goroutine unutar NTech-a (port 4567). + Promene PIN-a i limita stupaju na snagu tek posle restarta servera. +
+ +
+ + +
+ +
+ + +
Isključi kad koristiš pravi Teron/fizički L-PFR. Stupi na snagu posle restarta servera. Env var BE_ENABLED=false ima prednost.
+
+ +
+ + +
PIN koji Fisk šalje pri verify_pin komandi. Podrazumevano: 1234. Env var BE_PIN ima prednost.
+
+ +
+ + +
Maksimalni neisčitani promet pre blokade kartice. Podrazumevano: 500 000. Env var BE_LIMIT ima prednost.
+
+ +
+ + +
+ Npr. 192.168.1.10:3000 ili ntech.firma.rs. + Prazno = QR vodi na sandbox.suf.purs.gov.rs. + Env var VERIFY_HOST ima prednost nad ovim poljem. + Fisk čita ovu vrednost pri pokretanju. +
+
+ +
+ +
+ +
+
+ + +
+
+ Status kartice +
+ + +
+
+
+ Učitavanje... +
+
+ +
+