From e6215a43fe678533d86847ddd56d97c2aef33db9 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] Kartica emulator (BE) + fiskalna verifikacija + ime/prezime korisnika MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 - Koncept.md, Magacin.md: dokumentacija --- Fisk/data/locale_cyrillic.properties | 24 +- Fisk/data/locale_latin.properties | 24 +- Fisk/receipt.py | 53 +- Fisk/server.py | 220 +++- Fisk/stop.sh | 2 +- Koncept.md | 1009 +++++++++++++++++ Magacin.md | 344 ++++++ 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 +- 23 files changed, 2778 insertions(+), 116 deletions(-) create mode 100644 Koncept.md create mode 100644 Magacin.md 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/Koncept.md b/Koncept.md new file mode 100644 index 0000000..adfdfae --- /dev/null +++ b/Koncept.md @@ -0,0 +1,1009 @@ +# Koncept fiskalizacije — NTech + L-PFR + +Dokument opisuje arhitekturu fiskalizacije prema Tehničkom vodiču za L-PFR/ESIR +i plan implementacije za NTech + Fisk mock server. + +--- + +## 1. Učesnici u sistemu + +``` +┌──────────┐ zahtev za ┌──────────┐ potpisivanje ┌──────────┐ +│ NTech │─── fiskalizaciju ───→│ L-PFR │─── (APDU komande)──→│ Kartica │ +│ (ESIR) │ │ (mock) │ │ (BE) │ +│ │←── fiskalni račun ──│ │←── potpisani podaci─│ │ +└──────────┘ └──────────┘ └──────────┘ + │ + │ paketi za iščitavanje + ↓ + ┌──────────┐ + │ SUF │ + │ (PU RS) │ + └──────────┘ +``` + +| Komponenta | Šta je | Gde se nalazi | +|---|---|---| +| **ESIR** (NTech) | Elektronski sistem za izdavanje računa — kasa, POS | Kod obveznika | +| **L-PFR** (mock) | Lokalni procesor fiskalnih računa — "crna kutija" | Kod obveznika | +| **BE** (kartica) | Bezbednosni element na pametnoj kartici | Kod obveznika (fizički) | +| **SUF** | Sistem Uprave za Fiskalizaciju — Poreska uprava | Cloud PU RS | + +## 2. Bezbednosni element — pametna kartica (BE) + +Kartica je **jedini izvor identiteta** obveznika u sistemu fiskalizacije. +Nju izdaje Poreska uprava, personalizovana je za konkretnog obveznika +i poslovni prostor. + +### 2.1 Šta se nalazi na kartici + +| Podatak | Opis | Primer | +|---|---|---| +| **JID** | Jedinstveni identifikator — 8 alfanumeričkih znakova | `TRNMOCK1` | +| **PIB** | Poreski identifikacioni broj obveznika | `123456789` | +| **Naziv firme** | Poslovno ime obveznika | `Dalibor DOO` | +| **Poslovni prostor** | Adresa, grad, opština | `Beograd, Savski Venac` | +| **Sertifikat** | Digitalni sertifikat (validan ~3 godine) | `2024-01-01 → 2027-01-01` | +| **PIN** | Lični identifikacioni broj za pristup kartici | `1234` | +| **Brojači** | Ukupan broj računa + po tipu transakcije | `total, pp, pr, ap, kp...` | +| **Limit iščitavanja** | Maksimalni neisčitani iznos pre blokade | definiše PU | +| **Trenutni neisčitani iznos** | Akumulirani iznos od poslednjeg iščitavanja | resetuje se dokazom | +| **Ključevi** | Kriptografski ključevi za digitalno potpisivanje | par privatni/javni | + +### 2.2 Životni ciklus kartice + +1. Obveznik se uvodi u eFiskalizaciju preko portala ePorezi +2. Obveznik zahteva izdavanje BE preko ESF (Elektronski servisi za fiskalizaciju) +3. PU personalizuje karticu (upisuje sertifikat, PIB, JID, limit) +4. Kartica se fizički dostavlja obvezniku +5. Obveznik ubacuje karticu u L-PFR i unosi PIN +6. Kartica potpisuje račune dok ne dostigne limit → potrebno iščitavanje +7. Promena adrese poslovnog prostora → nova kartica +8. Odjava poslovnog prostora → opoziv svih kartica za taj prostor + +### 2.3 Ograničenja kartice + +- Limit iščitavanja — kad neisčitani iznos dostigne limit, kartica **blokira** potpisivanje +- Dokaz iščitavanja (Proof of Audit) sa SUF-a resetuje limit +- Bez interneta: radi u oflajn režimu, akumulira neisčitane račune +- Kartica NE može da se čita direktno — samo L-PFR komunicira sa njom + +## 3. L-PFR — Lokalni procesor fiskalnih računa + +L-PFR je softver/hardver koji radi po principu "crne kutije". +Komunicira samo sa: +- ESIR-om (prima zahteve, vraća fiskalne račune) +- BE karticom (potpisivanje, brojači) +- SUF-om (iščitavanje — internet ili lokalno) + +### 3.1 Odgovornosti L-PFR-a + +| Funkcija | Opis | +|---|---| +| Prijem zahteva | Prima podatke o transakciji od ESIR-a (JSON) | +| PDV obračun | Računa poresku obavezu po stopama (Ж=20%, Ђ=10%, А=0%) | +| Potpisivanje | Prosleđuje podatke kartici, dobija digitalni potpis | +| Generisanje broja | Formira jedinstveni broj računa: `{JID_ESIR}-{JID_BE}-{brojač}` | +| QR kod | Generiše verifikacioni URL i QR kod (40×40mm do 50×50mm) | +| Čuvanje | Pamti sve račune u internoj memoriji (neizbrisivo) | +| Iščitavanje | Šalje pakete za iščitavanje u SUF (internet ili USB/SD) | +| Blokada | Kad BE dostigne limit, blokira izdavanje novih računa | + +### 3.2 Vrste L-PFR-a + +| Vrsta | Opis | +|---|---| +| **Hardverski L-PFR** | Fizički uređaj sa čitačem kartica, ekranom, štampačem | +| **Softverski L-PFR** | Softver instaliran na računaru obveznika | +| **Razvojni L-PFR** | Softverska simulacija za development i testiranje ESIR-a | + +Naš Fisk mock je **Razvojni L-PFR**. + +### 3.3 Tipovi računa i transakcija + +| Tip računa | Oznaka | Transakcija | Brojač | Fiskalni? | +|---|---|---|---|---| +| Normal | ПП | Sale (Prodaja) | pp | Da | +| Normal | ПР | Refund (Refundacija) | pr | Da | +| Advance | АП | Sale | ap | Da | +| Advance | АР | Refund | ar | Da | +| Copy | КП | Sale | kp | Ne | +| Copy | КР | Refund | kr | Ne | +| Training | ОП | Sale | op | Ne | +| Training | ОР | Refund | or | Ne | + +## 4. ESIR — Elektronski sistem za izdavanje računa (NTech) + +ESIR je ono što koristi kasir. Njegove odgovornosti: + +1. Prikuplja podatke o transakciji (artikli, količine, cene, plaćanje) +2. Formira zahtev za fiskalizaciju i šalje ga L-PFR-u +3. Prima fiskalni račun od L-PFR-a +4. Prikazuje/štampa račun kupcu (sa QR kodom) +5. NE potpisuje račun — to radi kartica preko L-PFR-a + +### 4.1 Šta ESIR šalje L-PFR-u (InvoiceRequest) + +```json +{ + "invoiceRequest": { + "invoiceType": "Normal", + "transactionType": "Sale", + "cashier": "Dalibor", + "buyerId": "10:123456789", + "items": [ + { + "name": "RAM 16GB DDR4", + "labels": ["Ж"], + "totalAmount": 7200.00, + "unitPrice": 7200.00, + "quantity": 1.000 + } + ], + "payment": [ + { + "amount": 7200.00, + "paymentType": "Cash" + } + ] + } +} +``` + +**Napomena:** Podaci o firmi (PIB, naziv, adresa) su NA KARTICI (BE), ne u ESIR-u. +L-PFR ih čita sa kartice i stavlja u fiskalni račun. ESIR ih NE šalje. + +### 4.2 Šta L-PFR vraća ESIR-u (InvoiceResponse) + +```json +{ + "requestedBy": "NTECH001", + "signedBy": "TRNMOCK1", + "sdcDateTime": "2026-06-26T13:00:00.000+02:00", + "invoiceNumber": "NTECH001-TRNMOCK1-42", + "invoiceCounter": "3/42ПП", + "invoiceCounterExtension": "ПП", + "verificationUrl": "https://sandbox.suf.purs.gov.rs/v/?vl=NTECH001-TRNMOCK1-42", + "verificationQRCode": "iVBORw0KG...", + "totalAmount": 7200.00, + "totalTax": 1200.00, + "taxItems": [ + {"label": "Ж", "categoryName": "PDV", "rate": 20.0, "amount": 1200.00} + ], + "messages": "Success", + "journal": "========= FISKALNI RAČUN =========\n..." +} +``` + +## 5. Tok fiskalizacije — korak po korak + +``` + ESIR (NTech) L-PFR (mock) Kartica (BE) + │ │ │ + │ 1. POST /api/invoices │ │ + │ {invoiceRequest: {...}} │ │ + │────────────────────────────────→│ │ + │ │ │ + │ │ 2. Čita firmu sa kartice │ + │ │ (PIB, naziv, adresa) │ + │ │─────────────────────────────→│ + │ │ │ + │ │ 3. Provera PIN-a (ako treba)│ + │ │─────────────────────────────→│ + │ │←─────────────────────────────│ + │ │ │ + │ │ 4. Potpisivanje podataka │ + │ │ (APDU komanda) │ + │ │─────────────────────────────→│ + │ │←── potpis + novi brojač ─────│ + │ │ │ + │ │ 5. L-PFR računa PDV, │ + │ │ generiše QR, broj računa │ + │ │ │ + │ 6. Fiskalni račun + QR │ │ + │←────────────────────────────────│ │ + │ │ │ + │ 7. NTech čuva fiskalni račun │ 8. Paket za iščitavanje │ + │ prikazuje/štampa kupcu │ čeka slanje u SUF │ + │ │ │ +``` + +## 6. Komunikacija L-PFR ↔ kartica (BE) + +### Kako to radi u stvarnosti + +U pravom sistemu L-PFR ima **fizički čitač kartica**. Kartica je **pasivna** — +ne inicira ništa, ne "zove" nikoga. L-PFR je gospodar: šalje APDU komande +(ISO 7816) kartici kroz čitač, kartica odgovara. + +``` +Pravi sistem: +L-PFR ──APDU komanda──→ čitač kartica → kartica (BE) +L-PFR ←────────── odgovor (bajtovi) ────── kartica (BE) +``` + +Kartica je u suštini **server koji čeka komande** — samo ne sluša na mreži +nego na fizičkom interfejsu čitača. + +### Naš mock preslikava isti smer + +Kartica emulator **sluša** na TCP portu. Fisk (L-PFR mock) se **spaja** na nju +i šalje JSON komande — isti smer kao u stvarnosti, samo TCP+JSON umesto +fizički+APDU. + +Kartica emulator je implementirana unutar NTech Go binarnog fajla (goroutine) +jer tako ima direktan pristup bazi podataka za podatke o firmi, bez deljenja +fajlova između kontejnera. + +``` +┌──────────────────────────┐ HTTP :3000 ┌──────────────┐ +│ NTech (Go) │←─────────────→│ Fisk │ +│ ├── ESIR logika │ │ (L-PFR mock)│ +│ └── kartica emulator │←──────────────│ :4566 │ +│ sluša na :4567 │ TCP :4567 │ │ +└──────────────────────────┘ JSON komande └──────────────┘ +``` + +Fisk se spaja na `ntech:4567`, šalje komandu, čeka odgovor, zatvara konekciju. +NTech kartica emulator nikad ne inicira — samo odgovara. + +### 6.1 API kartice — 4 komande + +Sva komunikacija: JSON linija (`\n` terminated), request-response. + +#### `status` — pročitaj brojače, limit, status + +``` +→ {"command":"status"} +← { + "status":"ok", + "total_counter": 42, + "counters": {"pp":3,"pr":1,"ap":0,"ar":0,"kp":0,"kr":0,"op":0,"or":0}, + "limit": 500000, + "unread_amount": 150000 + } +``` + +#### `certificate` — identitet kartice (firma, PIB, JID) + +``` +→ {"command":"certificate"} +← { + "status":"ok", + "jid": "TRNMOCK1", + "tin": "RS123456789", + "tin_plain": "123456789", + "name": "Dalibor DOO", + "address": "Test Adresa 1", + "city": "Beograd", + "district": "Savski Venac", + "business_unit_id": "BU-001", + "location_name": "Dalibor DOO", + "valid_from": "2024-01-01T00:00:00+01:00", + "valid_to": "2027-01-01T00:00:00+01:00", + "issuer": "Poreska uprava RS" + } +``` + +#### `verify_pin` — provera PIN-a pre pristupa + +``` +→ {"command":"verify_pin", "pin":"1234"} +← {"status":"ok"} + // pogrešan PIN: +← {"status":"error", "code":"2100", "message":"Pogrešan PIN"} +``` + +#### `sign` — potpiši račun, inkrementiraj brojač + +``` +→ { + "command": "sign", + "invoice_type": "Normal", + "transaction_type": "Sale", + "total_amount": 7200.00 + } +← { + "status": "ok", + "counter": 42, + "counter_extension": "ПП", + "type_counter": 3, + "signature": "base64...", + "blocked": false + } + // ako je limit dostignut: +← {"status":"blocked", "message":"Limit iščitavanja dostignut"} +``` + +### 6.2 Šta izlazi iz mocka u emulator kartice + +| Podatak | Gde je sad (mock) | Gde treba (kartica emulator) | +|---|---|---| +| `PIN_BE = "1234"` | server.py:40 | kartica emulator | +| `BE_ID = "TRNMOCK1"` | server.py:27 | kartica emulator — `jid` | +| `ucitaj_firmu()` (naziv, PIB, adresa...) | server.py:47-77 | kartica emulator — `certificate` | +| `get_counter()` / `peek_counter()` | server.py:81-92 | kartica emulator — `sign` / `status` | +| `counter_ext()` | server.py:94-107 | kartica emulator — `sign` | +| `resp_certificate()` | server.py:221-230 | kartica emulator — `certificate` | +| `resp_verify_pin()` | server.py:194-204 | kartica emulator — `verify_pin` | + +### 6.3 Šta ostaje u mocku + +| Funkcija | Ostaje u mocku? | +|---|---| +| Prijem HTTP zahteva od ESIR-a (`/api/invoices`, ...) | ✅ da | +| `TAX_RATES`, `izracunaj_pdv()` | ✅ da | +| Generisanje QR koda | ✅ da | +| Generisanje `invoiceNumber` (`{ESIR_ID}-{JID}-{counter}`) | ✅ da — JID dobija od kartice | +| Snimanje računa (JSON, txt, html, QR PNG) | ✅ da | +| `generate_receipt()`, `generate_receipt_html()` | ✅ da | +| CORS, logging, rutiranje | ✅ da | +| `ucitaj_firmu()` | ❌ zamenjuje se pozivom `certificate` ka kartici | +| Brojači | ❌ idu na karticu | +| PIN verifikacija | ❌ ide na karticu | + +### 6.4 Kako mock poziva karticu (Python) + +```python +import socket, json + +SOCKET_PATH = "/tmp/ntech-be.sock" + +def be_command(cmd: dict) -> dict: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(SOCKET_PATH) + s.sendall((json.dumps(cmd) + "\n").encode()) + resp = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + resp += chunk + if b"\n" in resp: + break + s.close() + return json.loads(resp.decode()) +``` + +### 6.5 Novi tok fiskalizacije sa emulatorom + +``` + ESIR (NTech) L-PFR (mock) Kartica (Go) + │ │ │ + │ POST /api/invoices │ │ + │────────────────────→│ │ + │ │ │ + │ │ 1. be_command({ │ + │ │ "command":"certificate"│ + │ │ }) │ + │ │─────────────────────────→│ + │ │←── firma, PIB, JID ──────│ + │ │ │ + │ │ 2. be_command({ │ + │ │ "command":"sign", │ + │ │ "invoice_type":"Normal│ + │ │ "transaction_type":...│ + │ │ "total_amount":7200 │ + │ │ }) │ + │ │─────────────────────────→│ + │ │←── counter, ext, potpis ─│ + │ │ │ + │ │ 3. računa PDV, generiše │ + │ │ QR, broj računa │ + │ │ │ + │←── fiskalni račun ──│ │ + │ │ │ +``` + +**Napomena — `verify_pin`:** PIN se proverava **jednom pri pokretanju** kartice (ili kad `isPinRequired=True` u `/api/status`), ne per-invoice. Komanda `verify_pin` postoji u API-ju kartice radi inicijalizacije i ponovnog otključavanja, ali je ne pozivamo u toku normalnog izdavanja računa. + +### 6.6 Šta radimo u NTech-u (Go) — emulator kartice + +Novi paket: `internal/be/` ili `cmd/karticab`: + +``` +internal/be/ +├── server.go # Unix socket listener, JSON parser +├── kartica.go # stanje kartice: brojači, limit, PIN, firma +└── config.go # env varijable +``` + +Tipovi podataka za karticu (JSON wire format): + +```go +type StatusResponse struct { + Status string `json:"status"` + TotalCounter int `json:"total_counter"` + Counters map[string]int `json:"counters"` + Limit float64 `json:"limit"` + UnreadAmount float64 `json:"unread_amount"` +} + +type CertificateResponse struct { + Status string `json:"status"` + JID string `json:"jid"` + TIN string `json:"tin"` + TINPlain string `json:"tin_plain"` + Name string `json:"name"` + Address string `json:"address"` + City string `json:"city"` + District string `json:"district"` + BusinessUnitID string `json:"business_unit_id"` + LocationName string `json:"location_name"` + ValidFrom string `json:"valid_from"` + ValidTo string `json:"valid_to"` + Issuer string `json:"issuer"` +} + +type SignResponse struct { + Status string `json:"status"` + Counter int `json:"counter"` + CounterExtension string `json:"counter_extension"` + TypeCounter int `json:"type_counter"` + Signature string `json:"signature"` + Blocked bool `json:"blocked"` +} +``` + +Pokretanje kartice (zaseban proces): + +```bash +NTECH_BE_SOCKET=/tmp/ntech-be.sock \ +NTECH_BE_PIN=1234 \ +NTECH_BE_JID=TRNMOCK1 \ +NTECH_BE_FIRMA_NAZIV="Dalibor DOO" \ +NTECH_BE_PIB=123456789 \ +NTECH_BE_ADRESA="Test Adresa 1" \ +NTECH_BE_GRAD=Beograd \ +go run ./cmd/kartica/ +``` + +### 6.7 Docker + +Kartica emulator je goroutine unutar NTech-a — nema zasebnog kontejnera. +NTech expose-uje dva porta: `3000` za ESIR HTTP i `4567` za karticu. + +```yaml +services: + ntech: + build: . + ports: + - "3000:3000" + - "4567:4567" # kartica emulator (goroutine unutar NTech-a) + environment: + - BE_PORT=4567 + - BE_PIN=1234 + - BE_JID=TRNMOCK1 + + fisk: + build: ./Fisk + ports: + - "4566:4566" + environment: + - BE_HOST=ntech # hostname NTech kontejnera u Docker mreži + - BE_PORT=4567 + - ESIR_ID=NTECH001 +``` + +## 7. Poznati bugovi u trenutnom mocku (pre refaktora) + +### 7.1 QR kod u HTML računu je prazan — `receipt.py:370` + +```python +# Trenutno (pogrešno): +qr_src = f"data:image/png;base64,{inv.get('qrCode', '')}" + +# Ispravno — polje se zove verificationQRCode: +qr_src = f"data:image/png;base64,{inv.get('verificationQRCode', '')}" +``` + +### 7.2 `invoiceCounter` prikazuje `invoiceNumber` — `receipt.py:252-253` + +```python +# Trenutno (oba reda prikazuju invoiceNumber): +lines.append(layout(..., str(inv.get("invoiceNumber", "")), W)) +lines.append(layout(..., str(inv.get("invoiceNumber", "")), W)) + +# Ispravno — drugi red: +lines.append(layout(m.get("sdc-invoice-counter", "Brojač računa"), str(inv.get("invoiceCounter", "")), W)) +``` + +## 8. Napomena — podaci firme + +Kartica emulator je goroutine unutar NTech procesa, pa ima direktan pristup +NTech bazi podataka. Pri pokretanju čita podatke firme iz `podesavanja` tabele +(naziv, PIB, adresa...) — isto što `ucitaj_firmu()` radi u Python mocku danas, +samo direktno bez deljenja fajlova između kontejnera. + +Ovo je ispravno modelovanje: prava kartica ima zamrznute podatke upisane pri +personalizaciji. Ako se adresa ili naziv firme promeni u NTech podešavanjima, +treba restartovati NTech (i kartica emulator se pokreće ponovo sa novim podacima). + +--- + +## 9. Funkcije u Fisk/ Python mocku + +### 9.1 server.py — L-PFR server + +| Funkcija | Linija | Opis | +|---|---|---| +| `ucitaj_firmu()` | ~47 | Čita naziv, PIB, adresu iz NTech SQLite-a (env `NTECH_SQLITE`); fallback na test vrednosti | +| `get_counter(tip)` | ~81 | Inkrementira i vraća brojač iz fajla u `COUNTER_DIR`; tip = "pp", "pr", "ap"... | +| `peek_counter(tip)` | ~87 | Čita brojač bez inkrementiranja | +| `counter_ext(invoice_type, transaction_type)` | ~94 | Vraća ćirilični sufiks (ПП, ПР, АП...) i ključ za brojač | +| `izracunaj_pdv(items)` | ~110 | Grupiše stavke po poreskoj oznaci; formula: `pdv = bruto * stopa / (100 + stopa)` | +| `_build_invoice_response(req, request_id)` | ~130 | **Glavni builder** — računa PDV, poziva karticu, generiše invoiceNumber, QR, snima JSON/txt/html/PNG | +| `resp_certificate()` | ~221 | Vraća identitet (firma, PIB, JID) — u budućnosti delegira kartici | +| `resp_verify_pin(body)` | ~194 | Proverava PIN — u budućnosti delegira kartici | +| `resp_status()` | ~170 | Vraća L-PFR status: `isPinRequired`, `lastInvoiceNumber`, TIN | +| `resp_attention()` | ~165 | Health check — vraća `"status":"ok"` | +| `resp_settings_get/post()` | ~240 | Teron podešavanja (poreske stope, locale) | +| `resp_invoice_final()` | ~260 | Konačni račun koji zatvara avansne transakcije | +| `resp_invoice_last/by_request/by_number/search()` | ~280+ | Pregled ranije izdatih računa | + +**Konstante:** +- `TAX_RATES`: `{"Ж":20, "Ђ":10, "Е":10, "А":0, "Г":0, "З":0}` + generičke oznake +- `BE_ID = "TRNMOCK1"` — JID bezb. elementa +- `ESIR_ID = "NTECH001"` — ESIR identifikator +- `PIN_BE = "1234"` — PIN kartice +- `ROUTES` — 12 HTTP ruta, Teron API kompatibilne + +### 9.2 receipt.py — generator fiskalnih računa + +| Funkcija | Opis | +|---|---| +| `generate_receipt(invoice_data, lang)` | Tekstualni račun širine 48 znakova, prati `agent-invoice.vm` template | +| `generate_receipt_html(invoice_data, lang)` | HTML za A4 štampu, monospace font, `@page size A4` | +| `generate_report(report_data, lang)` | Dnevni/periodični izveštaj (standard-report.vm) | +| `load_locale(lang)` | Učitava `locale_latin.properties` ili `locale_cyrillic.properties` iz `data/` | +| `price()`, `qty()`, `amount()` | Formatiranje brojeva (2 decimale, hiljade sa tačkom) | +| `center(text, width)` | Centriranje teksta na širini | +| `layout(levo, desno, width)` | Red sa levim i desnim tekstom, popunjen razmacima | +| `wrap(text, width)` | Prelom dugog teksta u više redova | +| `separator(width)` | Crtana linija razdvajača | +| `title(text, width)` | Naslov uokviren u `== tekst ==` | + +**Mapiranje tipova:** +- `TRANSACTION_TYPES_CYR` i `TRANSACTION_TYPES_LAT` — prevod `"Normal/Sale"` → `"ПРОМЕТ ПРОДАЈЕ"` / `"PROMET PRODAJE"` + +--- + +## 10. Detaljan tok dobijanja fiskalnog računa + +### 10.1 Normalni fiskalni račun (Promet Prodaja — ПП) + +**Preduslov:** Kartica ubačena, PIN prihvaćen (jednom pri pokretanju). + +``` +ESIR (NTech) L-PFR (Fisk) BE (Kartica) SUF + │ │ │ │ + │ 1. Kasir završava │ │ │ + │ transakciju u NTech │ │ │ + │ │ │ │ + │ 2. POST /api/invoices │ │ │ + │ {invoiceRequest} │ │ │ + │─────────────────────────→│ │ │ + │ │ │ │ + │ │ 3. certificate │ │ + │ │─────────────────────────→│ │ + │ │←── PIB, naziv, adresa ───│ │ + │ │ │ │ + │ │ 4. sign {type, amount} │ │ + │ │─────────────────────────→│ │ + │ │ BE: proverava limit │ │ + │ │ BE: ažurira brojač │ │ + │ │ BE: generiše potpis │ │ + │ │←── {counter, ext, sig} ──│ │ + │ │ │ │ + │ │ 5. računa PDV po stopama │ │ + │ │ generiše invoiceNumber│ │ + │ │ generiše QR kod │ │ + │ │ snima račun interno │ │ + │ │ │ │ + │ 6. {invoiceResponse} │ │ │ + │←─────────────────────────│ │ │ + │ │ │ │ + │ 7. prikazuje/štampa │ │ │ + │ kupcu (tekst + QR) │ │ │ + │ │ │ │ + │ │ 8. asinhro — kad ima net:│ │ + │ │ šalje paket ──────────┼────────────────→│ + │ │←──────────────────────── Proof of Audit ───│ +``` + +### 10.2 Format ključnih polja u odgovoru + +| Polje | Format | Primer | Ko generiše | +|---|---|---|---| +| `invoiceNumber` | `{ESIR_JID}-{BE_JID}-{ukupan_br}` | `NTECH001-TRNMOCK1-42` | L-PFR | +| `invoiceCounter` | `{br_ove_vrste}/{ukupan_br}{ext}` | `3/42ПП` | L-PFR + BE | +| `sdcDateTime` | ISO 8601 sa zonom | `2026-06-26T13:00:00.000+02:00` | L-PFR | +| `verificationUrl` | URL ka SUF portalu | `https://suf.purs.gov.rs/v/?vl=...` | L-PFR | +| `verificationQRCode` | base64 PNG | `iVBORw0KG...` | L-PFR (qrcode lib) | +| `totalTax` | float, zaokružen | `1200.00` | L-PFR | + +**ПФР broj (invoiceNumber) za L-PFR:** oba JID su isti (ESIR = BE su isti uređaj). +Za V-PFR su različiti jer V-PFR ima sopstveni BE u cloudu. + +### 10.3 Avansni tok (AP → PP) + +``` +1. AP (Avanс Prodaja) — plaćen avanс +2. AP može se ponavljati (više avansnih uplata) — svaki referencira prethodni AP +3. PP (Promet Prodaja) — zatvaranje transakcije, referencira poslednji AP + - PP MORA sadržati referentni broj poslednjeg AP + - PP iznos = ukupno - plaćeni avans +``` + +**Lanac referenci:** AP1 ← AP2 ← PP (ili AP1 ← AR1 ← AP2 ← PP itd.) + +### 10.4 Refundacija (PR) + +``` +PR (Promet Refundacija) — MORA imati: + - referenceDocumentNumber: PFR broj originalnog PP računa + - buyerId: OBAVEZNO (identifikacija kupca za refundaciju) +``` + +--- + +## 11. Na šta treba voditi računa + +### 11.1 Podaci firme dolaze sa kartice, NE iz ESIR-a + +``` +❌ Pogrešno: ESIR šalje naziv/PIB/adresu u invoiceRequest +✅ Ispravno: L-PFR čita sa kartice (certificate komanda), ESIR to ne zna +``` + +U mocku: `ucitaj_firmu()` čita iz SQLite. U emulatoru: Go goroutine čita iz iste baze. + +### 11.2 PDV se računa iz bruto iznosa (ne iz neto) + +``` +pdv = bruto * stopa / (100 + stopa) +# za Ж (20%): pdv = 1000 * 20 / 120 = 166.67 (ne 200!) +``` + +Mock formula je ispravna. Greška nastaje ako se uzme `bruto * stopa / 100`. + +### 11.3 PIN se proverava jednom — ne per-invoice + +- Pin se unosi pri **ubacivanju kartice** (pokretanje L-PFR-a) +- Ako je `isPinRequired=True` u `/api/status` — znači kartica je izvučena i ponovo ubačena +- `verify_pin` komanda postoji ali se NE poziva za svaki račun + +### 11.4 Kartica se blokira kad dostigne limit iščitavanja + +- Limit je iznos (ne broj računa) +- Kad se dostigne: `sign` vraća `{"status":"blocked"}` +- Rešenje: iščitavanje (internet → SUF šalje Proof of Audit → resetuje brojač) +- Bez interneta: lokalno iščitavanje (USB/SD → portal PU) +- Za vreme iščitavanja: L-PFR može i dalje da izdaje račune (iščitavanje nije bloker) + +### 11.5 QR kod NE SME sadržati logo ni sliku + +Standard propisuje: QR kod za verifikaciju ne sme biti ispisан na slici ili logu, +niti sadržati sliku ili logo unutar sebe. Minimalna veličina: 40×40mm, maksimalna: 50×50mm. + +### 11.6 Fiskalni dokumenti koji NISU fiskalni računi + +Sledeći tipovi NE registruju promet i moraju nositi napomenu **"OVO NIJE FISKALNI RAČUN"** +(napisano duplo većim fontom): + +| Tip | Šta je | +|---|---| +| **Kopija (КП/КР)** | Kopija već izdatog računa | +| **Obuka (ОП/ОР)** | Za trening kasira, testiranje ESIR-a | +| **Predračun (РП/РР)** | Obaveštavanje kupca o budućem prometu | + +### 11.7 Referentni broj — kada je obavezan + +| Situacija | Ref. broj obavezan? | +|---|---| +| Promet Refundacija (PR) | **DA** — PFR broj originalnog PP | +| Kopija | **DA** — PFR broj originalnog računa | +| Promet Prodaja zatvaranje avansa | **DA** — PFR broj poslednjeg AP | +| Avanс Prodaja (2. i naredni AP) | **DA** — PFR broj prethodnog AP | +| Obični Promet Prodaja | NE | +| Predračun | NE | + +### 11.8 Ime kasira dolazi iz ESIR-a, ne sa kartice + +```json +"invoiceRequest": { + "cashier": "Dalibor" // NTech šalje ovo +} +``` + +Kartica ne zna ko je kasir — to je podatak transakcije. L-PFR kopira polje `cashier` +iz zahteva direktno u odgovor/račun. Nije obavezno po standardu, ali korisno za evidenciju. + +### 11.9 ESIR broj vs PFR broj + +- **ESIR broj** (`buyerCostCenterId`, `referenceNumber`) — opcioni interni broj iz ESIR-a, npr. broj porudžbine +- **PFR broj** (`invoiceNumber`) — jedinstven, nepromenljiv, kreira ga L-PFR; format: `{JID_ESIR}-{JID_BE}-{sekvenca}` +- Referentni broj pri refundaciji = **PFR broj** originalnog, ne ESIR broj + +### 11.10 Bugovi u receipt.py — ✅ ispravljeni + +```python +# BUG 1 — receipt.py:370 — QR kod je prazan u HTML prikazu — ISPRAVLJENO +# Polje se zove verificationQRCode, ne qrCode +qr_src = f"data:image/png;base64,{inv.get('verificationQRCode', '')}" + +# BUG 2 — receipt.py:252 — oba reda prikazuju invoiceNumber — ISPRAVLJENO +str(inv.get("invoiceCounter", "")) # treći red sada koristi invoiceCounter +``` + +### 11.11 Docker — cross-container komunikacija + +Unix socket NE radi između Docker kontejnera bez deljenog volumena. +Rešenje: kartica emulator sluša na TCP portu `4567` unutar NTech kontejnera. + +``` +✅ Fisk env: BE_HOST=ntech BE_PORT=4567 +✅ NTech expose-uje: "4567:4567" +❌ Nemoj koristiti /tmp socket između kontejnera +``` + +--- + +## 12. Izgled fiskalnog računa — razlike i ispravke + +> **Status pregleda:** Sve GR greške su ispravljene. Videti §12.2 za detalje. +> Dodatne ispravke (kasir, servis modal, Čeka delove) u §12.5. + +### 12.1 Referentni račun (OMV) + +Pravi fiskalni račun koji generiše Teron L-PFR (`Dokumenta/Račun.png`): + +``` +============ ФИСКАЛНИ РАЧУН ============ + 101987198 + OMV SRBIJA DOO BEOGRAD + 1083363-Огранак БС Краљево + ДУШАНА ПОПОВИЋА + Краљево +Касир: Radojka Vuković +ЕСИР број: 700/11.71.0.0 +-----------ПРОМЕТ ПРОДАЈА----------- + Артикли +======================================== +Назив Цена Кол. Укупно +OMV EP BMB 95 / l (Ђ) + 179,07 16,570 2.967,17 +---------------------------------------- +Укупан износ: 2.967,17 +Платна картица: 2.967,17 +======================================== +Ознака Име Стопа Порез +Ђ О-ПДВ 20,00% 494,53 +---------------------------------------- +Укупан износос пореза: 494,53 +======================================== +ПФР време: 18.12.2025. 11:28:25 +ПФР број рачуна: U92D83M9-U92D83M9-195100 +Бројач рачуна: 192838/195100ПП +======================================== +[QR KOD — centriran, ~60mm x 60mm] +======== КРАЈ ФИСKАЛНОГ РАЧУНА ======== +``` + +### 12.2 Grešake i ispravke + +#### GR-1: Formatiranje brojeva — ✅ ISPRAVLJENO + +**Problem:** Python `f"{n:,.2f}"` daje američki format: `2,967.17` +Srpski/evropski standard: `2.967,17` (tačka za hiljade, zarez za decimale) + +**Gde:** `receipt.py` — funkcije `price()`, `qty()`, `amount()`, `number()` + +**Ispravka:** +```python +def _sr(n, decimals=2): + s = f"{n:,.{decimals}f}" + return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".") +``` +Sve funkcije `price()`, `qty()`, `amount()`, `number()` sada koriste `_sr()`. + +--- + +#### GR-2: Pogrešno polje za Brojač računa — ✅ ISPRAVLJENO + +**Problem:** `receipt.py` linija 252 koristila `inv.get("invoiceNumber")` umesto `inv.get("invoiceCounter")`. +Oba reda (PFR broj + Brojač) prikazivala isti `invoiceNumber`. + +**Ispravka:** Treći red sada koristi `inv.get("invoiceCounter", "")`. + +--- + +#### GR-3: Labele ne odgovaraju stvarnom računu — ✅ ISPRAVLJENO + +**Problem:** Locale fajlovi su imali pogrešne ili engleske labele. + +| Šta se prikazuje | Teron (stvarno) | Pre ispravke | Posle ispravke | +|---|---|---|---| +| Ukupan iznos | `Ukupan iznos` | `Za uplatu` | ✅ `Ukupan iznos` | +| Platna kartica | `Platna kartica` | `Card` | ✅ `Platna kartica` | +| Gotovina | `Gotovina` | `Cash` | ✅ `Gotovina` | +| Čekovi | `Čekovi` | `Check` | ✅ `Čekovi` | +| Vaučer | `Vaučer` | `Voucher` | ✅ `Vaučer` | +| Instant plaćanje | `Instant plaćanje` | `MobileMoney` | ✅ `Instant plaćanje` | +| Prenos na račun | `Prenos na račun` | `WireTransfer` | ✅ `Prenos na račun` | +| Ostalo | `Ostalo` | `Drugo` | ✅ `Ostalo` | +| PFR vreme | `PFR vreme` | `Vreme` | ✅ `PFR vreme` | +| PFR broj računa | `PFR broj računa` | `Broj računa` | ✅ `PFR broj računa` | +| Brojač računa | `Brojač računa` | `Brojač` | ✅ `Brojač računa` | + +**Ispravka:** +- `Fisk/data/locale_latin.properties` i `locale_cyrillic.properties` — dodati `Cash=Gotovina`, `Card=Platna kartica` itd., ispraviti PFR labele +- `receipt.py` — lookup za payment type: `p.get("paymentType", p.get("type", ""))` pa `m.get(pt, pt or "Ostalo")` (bez "Drugo" fallback-a) + +--- + +#### GR-4: QR kod nije centriran na računu — ✅ ISPRAVLJENO + +**Problem:** `StampaFiskalnog` renderovao ceo journal kao jedan `white-space:pre` blok. `` ugrađen unutar `
` ne može se centrirati CSS-om jer `white-space:pre` tretira razmake doslovno.
+
+**Gde:** `internal/handler/servis.go` — `StampaFiskalnog`
+
+**Ispravka:** Koristi se `strings.Cut(journal, "{{{{QR-KOD}}}}")` koji deli journal na deo pre i posle QR-a. Renderuje se kao:
+```html
+
…tekst pre QR…
+
+ +
+
…tekst posle QR…
+``` +`body` ima `max-width:max-content;margin:0 auto` da se tekst i QR centriraju kao celina. + +--- + +#### GR-5: QR veličina u HTML verziji receipt.py — ✅ ISPRAVLJENO + +**Problem:** `.qr img { width:25mm; height:25mm; }` — premalo za skeniranje. + +**Ispravka:** Promenjena veličina na `width:60mm; height:60mm;` u `generate_receipt_html`. + +--- + +#### GR-6: Jezik hardkodovan na latinicu — ✅ ISPRAVLJENO + +**Problem:** `server.py` uvek pozivao `generate_receipt(full_data, "latin")`. + +**Ispravka:** +- Fisk čita `fiskalni_pismo` iz env var `FISKALNI_PISMO` (prioritet) ili iz NTech SQLite (`podesavanja` tabela, ključ `fiskalni_pismo`) +- Dodana funkcija `_ucitaj_fiskalni_pismo()` pri pokretanju servera +- Oba poziva `generate_receipt(full_data, FISKALNI_PISMO)` i `generate_receipt_html(full_data, FISKALNI_PISMO)` +- **Restart Fisk servera obavezan** posle promene podešavanja + +--- + +#### GR-7: UI za izbor pisma nedostajao — ✅ ISPRAVLJENO + +**Ispravka:** +- `web/templates/stranice/podesavanja_fiskalizacija.html` — dodat ` + +
+ + +
+ + +
+ + +
+``` + +#### 5b. ZAMENA — novi blok cena sa PDV dropdown-om i trećim poljem: + +```html + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+``` + +#### 5c. JavaScript blok (linije ~187–207) — dodati logiku za `cena_sa_pdv` i PDV stopa + +```javascript +// NOVI JS — zamenjuje stari blok kalkulacije cena: +(function() { + var nabavna = document.getElementById('nabavna-cena'); + var prodajna = document.getElementById('prodajna-cena'); + var saPdv = document.getElementById('cena-sa-pdv'); + var pdvSel = document.getElementById('pdv-stopa'); + var marza = document.querySelector('[name="marza"]'); + + function pdv() { return parseFloat(pdvSel.value) || 0; } + function br(el) { return parseFloat(el.value) || 0; } + + // prodajna → cena sa PDV + function izProdajne() { + var p = br(prodajna); + if (p > 0) saPdv.value = (p * (1 + pdv() / 100)).toFixed(2); + izMarze(); + } + // cena sa PDV → prodajna + function izSaPdv() { + var s = br(saPdv); + if (s > 0) prodajna.value = (s / (1 + pdv() / 100)).toFixed(2); + izMarze(); + } + // nabavna + marža → prodajna (i onda prodajna → sa PDV) + function izMarzeKalc() { + var n = br(nabavna), m = br(marza); + if (n > 0 && marza.value !== '') { + prodajna.value = (n * (1 + m / 100)).toFixed(2); + izProdajne(); + } + } + // prodajna + nabavna → marža + function izMarze() { + var n = br(nabavna), p = br(prodajna); + if (n > 0) marza.value = ((p / n - 1) * 100).toFixed(2); + } + // pri promeni PDV stope — preračunaj cena sa PDV iz prodajne + function izPdvStope() { + if (prodajna.value !== '') izProdajne(); + } + + prodajna.addEventListener('input', izProdajne); + saPdv.addEventListener('input', izSaPdv); + nabavna.addEventListener('input', izMarzeKalc); + marza.addEventListener('input', izMarzeKalc); + pdvSel.addEventListener('change', izPdvStope); +})(); +``` + +--- + +### 6. Napomene — šta NE menjamo u ovoj fazi + +| Fajl | Zašto ne diramo | +|---|---| +| `internal/handler/prodaja.go` linija 74 | POS i dalje koristi `a.ProdajnaCena` kao neto cenu; korisnik vidi neto pri prodaji. Ako treba prikazati bruto → posebna izmena. | +| `internal/fiskal/mapiranje.go` linija 66 | Koristi `s.CenaBezPdv` iz `model.StavkaProdaje` (ne iz `model.Artikal`) — to je poseban struct, nije zahvaćen. | +| `internal/db/sqlite/izvestaj.go` | Koristi `ProdajnaCena` za vrednost zaliha — neto ostaje logična osnova za to. | +| `magacin.html` (lista) | Prikazuje `ProdajnaCena` — može ostati neto, ili se doda kolona `CenaSaPdv`. Nije hitan. | + +--- + +## Redosled implementacije + +1. **Migracija** `087_artikal_cena_sa_pdv.sql` — nova kolona + popunjavanje postojećih +2. **Model** `internal/model/artikal.go` — novo polje + ispravka metoda +3. **SQLite repo** `internal/db/sqlite/artikal.go` — SELECT, Scan, INSERT, UPDATE (4 mesta) +4. **Handler parser** `internal/handler/magacin_forma.go` — čitanje `pdv_stopa` + kalkulacija `cena_sa_pdv` +5. **Šablon** `web/templates/stranice/magacin_forma.html` — PDV dropdown + treće polje + novi JS 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... +
+
+ +
+