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

- internal/be: emulator kartice (TCP listener za L-PFR mock)
- /v/: javna stranica za verifikaciju fiskalnog računa (QR kod)
- /servis/{id}/fiskalni-racun: štampa fiskalnog računa
- Podešavanja fiskalizacije: BE status/reset, proširen UI
- Korisnik: polja ime i prezime (model, DB, handler, admin profil)
- Fisk server: ažuriran receipt.py i server.py
-, : dokumentacija
This commit is contained in:
2026-06-26 23:17:52 +02:00
parent 985a2e7410
commit 97fd4490ea
21 changed files with 1425 additions and 116 deletions
+16 -8
View File
@@ -8,9 +8,9 @@ pos-invoice-number=ЕСИР број
pos-time=ЕСИР време
ref-doc-number=Реф. број
ref-doc-dt=Реф. време
sdc-invoice-counter=Бројач
sdc-invoice-number=Број рачуна
sdc-time=Време
sdc-invoice-counter=Бројач рачуна
sdc-invoice-number=ПФР број рачуна
sdc-time=ПФР време
uid=УИД
items=Артикли
item-name=Назив
@@ -25,17 +25,17 @@ quantity=Количина
price=Цена
amount=Износ
unitPrice=Јед. цена
tax-label=ПДВ
tax-name=Порез
tax-label=Ознака
tax-name=Име
tax-rate=Стопа
tax-amount=Порез
tax=Порез
total-tax=Укупан порез
total-tax=Укупан износ пореза
advance-tax=Авансни порез
to-pay=За уплату
to-pay=Укупан износ
paid-in-advance=Плаћено авансно
remaining=Преостало
refund=Рефундација
refund=Повраћај
total-refund=Укупна рефундација
total-payment=Укупно уплаћено
payment=Уплаћено
@@ -46,3 +46,11 @@ report-number=Број извештаја
per-transaction-type=ПРОМЕТ ПО ВРСТИ
customer-signature=Потпис купца
end-of-fiscal-invoice=КРАЈ ФИСКАЛНОГ РАЧУНА
# Типови плаћања
Cash=Готовина
Card=Платна картица
Check=Чекови
Voucher=Ваучер
MobileMoney=Инстант плаћање
WireTransfer=Пренос на рачун
Other=Остало
+16 -8
View File
@@ -8,9 +8,9 @@ pos-invoice-number=ESIR broj
pos-time=ESIR vreme
ref-doc-number=Ref. broj
ref-doc-dt=Ref. vreme
sdc-invoice-counter=Brojač
sdc-invoice-number=Broj računa
sdc-time=Vreme
sdc-invoice-counter=Brojač računa
sdc-invoice-number=PFR broj računa
sdc-time=PFR vreme
uid=UID
items=Artikli
item-name=Naziv
@@ -25,17 +25,17 @@ quantity=Količina
price=Cena
amount=Iznos
unitPrice=Jed. cena
tax-label=PDV
tax-name=Porez
tax-label=Oznaka
tax-name=Ime
tax-rate=Stopa
tax-amount=Porez
tax=Porez
total-tax=Ukupan porez
total-tax=Ukupan iznos poreza
advance-tax=Avansni porez
to-pay=Za uplatu
to-pay=Ukupan iznos
paid-in-advance=Plaćeno avansno
remaining=Preostalo
refund=Refundacija
refund=Povraćaj
total-refund=Ukupna refundacija
total-payment=Ukupno uplaćeno
payment=Uplaćeno
@@ -46,3 +46,11 @@ report-number=Broj izveštaja
per-transaction-type=PROMET PO VRSTI
customer-signature=Potpis kupca
end-of-fiscal-invoice=KRAJ FISKALNOG RAČUNA
# Tipovi plaćanja
Cash=Gotovina
Card=Platna kartica
Check=Čekovi
Voucher=Vaučer
MobileMoney=Instant plaćanje
WireTransfer=Prenos na račun
Other=Ostalo
+36 -17
View File
@@ -25,21 +25,26 @@ def load_locale(lang="latin"):
locale[key.strip()] = value.strip()
return locale
def _sr(n, decimals=2):
"""Srpski format: tačka za hiljade, zarez za decimale — 2.967,17"""
s = f"{n:,.{decimals}f}"
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
def price(n):
"""Formatira cenu: ###,###.00"""
return f"{n:,.2f}"
"""Formatira cenu: ###.###,00"""
return _sr(n, 2)
def qty(n):
"""Formatira količinu: ###,###.000"""
return f"{n:,.3f}"
"""Formatira količinu: ###.###,000"""
return _sr(n, 3)
def amount(n):
"""Formatira iznos: ###,###.00"""
return f"{n:,.2f}"
"""Formatira iznos: ###.###,00"""
return _sr(n, 2)
def number(n):
"""Formatira broj: ###,###"""
return f"{n:,.0f}"
"""Formatira broj: ###.###"""
return _sr(n, 0)
def dt(iso_string):
"""Konvertuje ISO datetime u format: dd.MM.yyyy. HH:mm:ss"""
@@ -103,6 +108,20 @@ TRANSACTION_TYPES_LAT = {
"PSX": "PREDRAČUN - PRODAJA", "PRX": "PREDRAČUN - REFUNDACIJA",
"TSX": "OBUKA - PRODAJA", "TRX": "OBUKA - REFUNDACIJA",
}
# NTech šalje invoiceType+transactionType kao reči (Normal/Sale) umesto koda (NSX)
_INV_TX_TO_CODE = {
("Normal", "Sale"): "NSX", ("Normal", "Refund"): "NRX",
("Advance", "Sale"): "ASX", ("Advance", "Refund"): "ARX",
("Copy", "Sale"): "CSX", ("Copy", "Refund"): "CRX",
("Training", "Sale"): "TSX", ("Training", "Refund"): "TRX",
("Proforma", "Sale"): "PSX", ("Proforma", "Refund"): "PRX",
}
def _tx_code(inv):
"""Vraća 3-slovni kod transakcije (NSX, NRX...) iz full_data rečnika."""
it = inv.get("invoiceType", "Normal")
tt = inv.get("transactionType", "Sale")
return _INV_TX_TO_CODE.get((it, tt), tt)
# ── Glavna funkcija ─────────────────────────────────────────
@@ -118,6 +137,7 @@ def generate_receipt(invoice_data, lang="latin"):
tx_types = TRANSACTION_TYPES_LAT if lang == "latin" else TRANSACTION_TYPES_CYR
W = 48
inv = invoice_data
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
lines = []
# ── PREAMBLE ──
@@ -160,8 +180,6 @@ def generate_receipt(invoice_data, lang="latin"):
lines.append(layout(m.get("ref-doc-dt", "Ref. vreme"), dt(inv["referentDocumentDT"]), W))
# ── TIP TRANSAKCIJE ──
tx_code = inv.get("transactionType", "NSX")
tx_label = tx_types.get(tx_code, tx_code)
lines.append(title(tx_label, "-", W))
# ── ARTIKLI ──
@@ -215,7 +233,8 @@ def generate_receipt(invoice_data, lang="latin"):
if not is_covered_by_advance:
for p in inv.get("payments", []):
ptype = m.get(p.get("type", ""), p.get("type", "Drugo"))
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
lines.append(layout(ptype, amount(float(p.get("amount", 0))), W))
if inv.get("invoiceType") == "Proforma":
lines.append(layout(m.get("refund", "Povraćaj"), amount(0), W))
@@ -249,7 +268,7 @@ def generate_receipt(invoice_data, lang="latin"):
# ── PFR VREDNOSTI ──
lines.append(layout(m.get("sdc-time", "PFR vreme"), dt(inv.get("sdcDateTime", "")), W))
lines.append(layout(m.get("sdc-invoice-number", "PFR broj računa"), str(inv.get("invoiceNumber", "")), W))
lines.append(layout(m.get("sdc-invoice-counter", "Brojač računa"), str(inv.get("invoiceNumber", "")), W))
lines.append(layout(m.get("sdc-invoice-counter", "Brojač računa"), str(inv.get("invoiceCounter", "")), W))
lines.append(separator("=", W))
# ── QR KOD ──
@@ -290,8 +309,7 @@ def generate_receipt_html(invoice_data, lang="latin"):
tx_types = TRANSACTION_TYPES_LAT if lang == "latin" else TRANSACTION_TYPES_CYR
inv = invoice_data
tx_code = inv.get("transactionType", "NSX")
tx_label = tx_types.get(tx_code, tx_code)
tx_label = tx_types.get(_tx_code(inv), _tx_code(inv))
is_fiscal = inv.get("isFiscal", True)
is_refund = inv.get("transactionType") == "Refund"
inv_type = inv.get("invoiceType", "Normal")
@@ -331,7 +349,8 @@ def generate_receipt_html(invoice_data, lang="latin"):
payments_rows += f'<tr><td class="l">{m.get("advance-tax", "PDV na avans")}</td><td class="r">{amount(advance_tax)}</td></tr>'
if not covered:
for p in inv.get("payments", []):
ptype = m.get(p.get("type", ""), p.get("type", "Drugo"))
pt = p.get("paymentType", p.get("type", ""))
ptype = m.get(pt, pt or "Drugo")
payments_rows += f'<tr><td class="l">{ptype}</td><td class="r">{amount(float(p.get("amount", 0)))}</td></tr>'
if inv_type == "Proforma":
payments_rows += f'<tr><td class="l">{m.get("refund", "Povraćaj")}</td><td class="r">{amount(0)}</td></tr>'
@@ -404,7 +423,7 @@ def generate_receipt_html(invoice_data, lang="latin"):
.hdr-sub {{ font-size: 11pt; font-weight: bold; }}
.title {{ font-weight: bold; font-size: 12pt; margin: 1.5mm 0; }}
.qr {{ text-align: center; margin: 2mm 0; }}
.qr img {{ width: 25mm; height: 25mm; }}
.qr img {{ width: 60mm; height: 60mm; }}
.preamble {{ font-style: italic; margin: 1mm 0; font-size: 7pt; }}
.row {{ display: flex; justify-content: space-between; }}
.col-left {{ width: 72%; }}
@@ -472,7 +491,7 @@ def generate_receipt_html(invoice_data, lang="latin"):
<table>
<tr><td class="l">{m.get('sdc-time', 'PFR vreme')}</td><td class="r">{dt(inv.get('sdcDateTime', ''))}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-number', 'PFR broj računa')}</td><td class="r">{inv.get('invoiceNumber', '')}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-counter', 'Brojač računa')}</td><td class="r">{inv.get('invoiceNumber', '')}</td></tr>
<tr><td class="l">{m.get('sdc-invoice-counter', 'Brojač računa')}</td><td class="r">{inv.get('invoiceCounter', '')}</td></tr>
</table>
<div class="sep-double"></div>
+182 -38
View File
@@ -17,6 +17,9 @@ import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
import socket
import urllib.parse
import qrcode
from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale
@@ -24,7 +27,10 @@ from receipt import generate_receipt, generate_receipt_html, generate_report, lo
PORT = 4566 # Teron standard port
HOST = "0.0.0.0"
ESIR_ID = "NTECH001" # naš 8-char ESIR identifikator
BE_ID = "TRNMOCK1" # simulirani BE/LPFR identifikator
# Kartica emulator (NTech goroutine)
BE_HOST = os.environ.get("BE_HOST", "127.0.0.1")
BE_PORT = int(os.environ.get("BE_PORT", "4567"))
DATA_DIR = Path(__file__).parent / "data"
INVOICES_DIR = DATA_DIR / "invoices"
@@ -36,12 +42,94 @@ COUNTER_DIR = DATA_DIR / "counters"
for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]:
d.mkdir(parents=True, exist_ok=True)
# Test PIN (pravi Teron traži PIN za BE karticu)
PIN_BE = "1234"
# NTech SQLite baza (read-only) — čita podatke o firmi
# NTech SQLite baza (read-only) — fallback kad kartica emulator nije dostupan
NTECH_DB = os.environ.get("NTECH_SQLITE") or str(Path(__file__).parent.parent / "ntech.db")
def _ucitaj_verify_host():
"""Čita verify_host iz env, pa iz NTech SQLite baze."""
if v := os.environ.get("VERIFY_HOST", ""):
return v
try:
con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True)
try:
cur = con.execute("SELECT vrednost FROM podesavanja WHERE kljuc='verify_host'")
row = cur.fetchone()
return row[0] if row and row[0] else ""
finally:
con.close()
except Exception:
return ""
# Host za verifikacioni link na QR kodu (npr. "ntech.moja-firma.rs:3000").
# Ako je prazno, koristi se sandbox.suf.purs.gov.rs.
VERIFY_HOST = _ucitaj_verify_host()
def _ucitaj_fiskalni_pismo():
"""Čita fiskalni_pismo iz env var FISKALNI_PISMO ili iz NTech SQLite baze.
Vrednosti: 'latin' (podrazumevano) ili 'cyrillic'."""
if v := os.environ.get("FISKALNI_PISMO", ""):
return v if v in ("latin", "cyrillic") else "latin"
try:
con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True)
try:
cur = con.execute("SELECT vrednost FROM podesavanja WHERE kljuc='fiskalni_pismo'")
row = cur.fetchone()
v = row[0] if row and row[0] else "latin"
return v if v in ("latin", "cyrillic") else "latin"
finally:
con.close()
except Exception:
return "latin"
# Pismo fiskalnog računa: 'latin' ili 'cyrillic'
FISKALNI_PISMO = _ucitaj_fiskalni_pismo()
def be_command(cmd: dict) -> dict:
"""Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect((BE_HOST, BE_PORT))
s.sendall((json.dumps(cmd) + "\n").encode("utf-8"))
buf = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
if b"\n" in buf:
break
return json.loads(buf.decode("utf-8").strip())
except Exception as e:
log(f" ⚠️ be_command({cmd.get('command')}) greška: {e}")
return {"status": "error", "message": str(e)}
def build_vl(full_data):
"""Gradi base64-kodirani payload za vl parametar verifikacionog URL-a."""
payload = {
"n": full_data.get("invoiceNumber", ""),
"ic": full_data.get("invoiceCounter", ""),
"t": full_data.get("sdcDateTime", ""),
"a": full_data.get("totalAmount", 0),
"c": full_data.get("tin", ""),
"co": full_data.get("company", ""),
"lo": full_data.get("store", ""),
"ad": full_data.get("address", ""),
"g": full_data.get("city", ""),
"di": full_data.get("district", ""),
"it": full_data.get("invoiceType", "Normal"),
"tr": full_data.get("transactionType", "Sale"),
"tx": full_data.get("taxItems", []),
"pm": full_data.get("payments", []),
"ca": full_data.get("cashier", ""),
"bi": full_data.get("buyerId", ""),
"items": full_data.get("items", []),
}
j = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return base64.b64encode(j.encode("utf-8")).decode("ascii")
# ── Firma ───────────────────────────────────────────────────
def ucitaj_firmu():
@@ -178,17 +266,20 @@ def resp_attention():
return {"sdcDateTime": sada(), "status": "OK"}
def resp_status():
f = ucitaj_firmu()
last = peek_counter("total")
last_num = f"{ESIR_ID}-{BE_ID}-{last}" if last >= 1 else ""
st = be_command({"command": "status"})
cert = be_command({"command": "certificate"})
total = st.get("total_counter", 0)
jid = cert.get("jid", "UNKNOWN")
tin = cert.get("tin", "RS000000000")
last_num = f"{ESIR_ID}-{jid}-{total}" if total >= 1 else ""
return {
"isPinRequired": False,
"isPinRequired": st.get("pin_required", False),
"auditRequired": False,
"sdcDateTime": sada(),
"lastInvoiceNumber": last_num,
"protocolVersion": "1.0.0",
"serialNumber": ESIR_ID,
"tin": f["tin"],
"tin": tin,
}
def resp_verify_pin(request_body=None):
@@ -197,11 +288,12 @@ def resp_verify_pin(request_body=None):
uneti = str(request_body.get("pin", "")).strip()
elif isinstance(request_body, str):
uneti = request_body.strip().strip('"')
if uneti == PIN_BE:
resp = be_command({"command": "verify_pin", "pin": uneti})
if resp.get("status") == "ok":
log(" 🔓 PIN ispravan")
return {"status": "OK", "message": "PIN verifikovan"}
log(" 🔒 Pogrešan PIN")
return {"status": "ERROR", "code": "2100", "message": "Pogrešan PIN"}
return {"status": "ERROR", "code": resp.get("code", "2100"), "message": resp.get("message", "Pogrešan PIN")}
def resp_settings_get():
return {
@@ -219,14 +311,14 @@ def resp_settings_post():
return {"status": "OK", "message": "Podešavanja sačuvana"}
def resp_certificate():
f = ucitaj_firmu()
c = be_command({"command": "certificate"})
return {
"serialNumber": BE_ID,
"tin": f["tin"],
"name": f["name"],
"validFrom": "2024-01-01T00:00:00+01:00",
"validTo": "2027-01-01T00:00:00+01:00",
"issuer": "Poreska uprava RS",
"serialNumber": c.get("jid", ESIR_ID),
"tin": c.get("tin", "RS000000000"),
"name": c.get("name", ""),
"validFrom": c.get("valid_from", "2024-01-01T00:00:00+01:00"),
"validTo": c.get("valid_to", "2027-01-01T00:00:00+01:00"),
"issuer": c.get("issuer", "Poreska uprava RS"),
}
def _build_invoice_response(req, request_id):
@@ -238,27 +330,77 @@ def _build_invoice_response(req, request_id):
transaction_type = inv_req.get("transactionType", "Sale")
items = inv_req.get("items", [])
# Brojači
total_cnt = get_counter("total")
ext, tip_key = counter_ext(invoice_type, transaction_type)
type_cnt = get_counter(tip_key)
invoice_number = f"{ESIR_ID}-{BE_ID}-{total_cnt}"
invoice_counter = f"{type_cnt}/{total_cnt}{ext}"
verification_url = f"https://sandbox.suf.purs.gov.rs/v/?vl={invoice_number}"
qr_b64 = generate_qr(verification_url)
# PDV i ukupan iznos
tax_items = izracunaj_pdv(items)
total_amount = round(sum(float(i.get("totalAmount", 0)) for i in items), 2)
total_tax = round(sum(t["amount"] for t in tax_items), 2)
firma = ucitaj_firmu()
# Kartica emulator: podatke firme i potpis/brojače
cert = be_command({"command": "certificate"})
sign = be_command({
"command": "sign",
"invoice_type": invoice_type,
"transaction_type": transaction_type,
"total_amount": total_amount,
})
if sign.get("status") == "blocked":
raise RuntimeError(f"Kartica blokirana: {sign.get('message')}")
jid = cert.get("jid", ESIR_ID)
total_cnt = sign.get("counter", 1)
type_cnt = sign.get("type_counter", 1)
ext = sign.get("counter_extension", "ПП")
invoice_number = f"{ESIR_ID}-{jid}-{total_cnt}"
invoice_counter = f"{type_cnt}/{total_cnt}{ext}"
# firma podaci sa kartice
firma = {
"tinPlain": cert.get("tin_plain", "000000000"),
"tin": cert.get("tin", "RS000000000"),
"name": cert.get("name", "Test Company DOO"),
"locationName": cert.get("location_name", cert.get("name", "Test Company DOO")),
"address": cert.get("address", "Test Adresa 1"),
"city": cert.get("city", "Beograd"),
"district": cert.get("district", "Savski Venac"),
}
# Verifikacioni URL i QR kod
if VERIFY_HOST:
vl_payload = {
"n": invoice_number,
"ic": invoice_counter,
"t": sada(),
"a": total_amount,
"c": firma["tinPlain"],
"co": firma["name"],
"lo": firma["locationName"],
"ad": firma["address"],
"g": firma["city"],
"di": firma["district"],
"it": invoice_type,
"tr": transaction_type,
"tx": tax_items,
"pm": inv_req.get("payment", [{"type": "Cash", "amount": total_amount}]),
"ca": inv_req.get("cashier", "Kasir"),
"bi": inv_req.get("buyerId", ""),
"items": items,
}
vl = base64.b64encode(
json.dumps(vl_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
).decode("ascii")
scheme = "https" if VERIFY_HOST.startswith("https://") else "http"
host = VERIFY_HOST.removeprefix("https://").removeprefix("http://")
verification_url = f"{scheme}://{host}/v/?vl={urllib.parse.quote(vl, safe='')}"
else:
verification_url = f"https://sandbox.suf.purs.gov.rs/v/?vl={invoice_number}"
qr_b64 = generate_qr(verification_url)
# Odgovor koji ide ka NTech-u (ESIR-u)
odgovor = {
"requestedBy": ESIR_ID,
"signedBy": BE_ID,
"signedBy": jid,
"sdcDateTime": sada(),
"invoiceCounter": invoice_counter,
"invoiceCounterExtension": ext,
@@ -304,12 +446,12 @@ def _build_invoice_response(req, request_id):
with open(qr_path, "wb") as fh:
fh.write(base64.b64decode(qr_b64))
# Generiši tekst i HTML račun
receipt_text = generate_receipt(full_data, "latin")
# Generiši tekst i HTML račun (pismo određuje podešavanje fiskalni_pismo)
receipt_text = generate_receipt(full_data, FISKALNI_PISMO)
txt_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.txt"
txt_path.write_text(receipt_text, encoding="utf-8")
html_txt = generate_receipt_html(full_data, "latin")
html_txt = generate_receipt_html(full_data, FISKALNI_PISMO)
html_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.html"
html_path.write_text(html_txt, encoding="utf-8")
@@ -505,7 +647,10 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
# ── Main ─────────────────────────────────────────────────────
def main():
firma = ucitaj_firmu()
cert = be_command({"command": "certificate"})
jid = cert.get("jid", "?")
tin = cert.get("tin_plain", "?")
name = cert.get("name", "?")
log("╔══════════════════════════════════════════════════╗")
log("║ 🧾 Teron L-PFR Mock Server ║")
log(f"║ http://{HOST}:{PORT}/ ║")
@@ -514,9 +659,8 @@ def main():
log(f" 🧾 Računi: {INVOICES_DIR}")
log(f" 📱 QR PNG: {QR_DIR}")
log(f" 📝 Log: {LOG_FILE}")
log(f" 🏢 Firma: {firma['name']} | PIB: {firma['tinPlain']}")
log(f" 🆔 ESIR ID: {ESIR_ID} | BE ID: {BE_ID}")
log(f" 🔑 Test PIN: {PIN_BE}")
log(f" 🏢 Firma: {name} | PIB: {tin}")
log(f" 🆔 ESIR ID: {ESIR_ID} | BE JID: {jid} (kartica: {BE_HOST}:{BE_PORT})")
log(" ▶ Server pokrenut. Ctrl+C za gašenje.")
server = http.server.HTTPServer((HOST, PORT), FiscalHandler)
+1 -1
View File
@@ -3,7 +3,7 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PID_FILE="$SCRIPT_DIR/data/server.pid"
PORT=8989
PORT=4566
echo "⏹ Zaustavljam fiskalni mock server..."
+13
View File
@@ -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)
+299
View File
@@ -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
}
+103
View File
@@ -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)
}
+1
View File
@@ -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
+21 -3
View File
@@ -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)
+27
View File
@@ -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())
+92
View File
@@ -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)
}
+1 -1
View File
@@ -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.
+108 -1
View File
@@ -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
// 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) {
</div>`, 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, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`,
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, `<div class="fisk-status greska">&#10007; Neispravan odgovor</div>`)
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, `<div class="fisk-status uspeh">
&#10003; Kartica emulator aktivan
<div class="fisk-status-detalji">
<span>Ukupno računa: %d</span>
<span>Neisčitano: %s / %s din</span>
<span>%s</span>
</div>
</div>`,
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, `<div class="fisk-status greska">&#10007; Emulator nije dostupan</div>`)
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, `<div class="fisk-status uspeh">&#10003; Neisčitani iznos resetovan na 0 (Proof of Audit simuliran)</div>`)
}
// 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 {
+65 -20
View File
@@ -526,7 +526,20 @@ 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 {
// 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)
}
}
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)
}
} else if len(potrazivani) > 0 {
vidjeni := map[int64]bool{}
for _, p := range potrazivani {
if vidjeni[p.ArtikalID] {
@@ -547,6 +560,7 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
}
}
}
}
// status naloga se možda promenio — ponovo ga učitaj za prikaz
if osvezen, e := h.ServisRepo.DohvatiID(r.Context(), id); e == nil && osvezen != nil {
nalog = osvezen
@@ -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, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style>body{font-family:monospace;font-size:12px;white-space:pre;padding:20px;max-width:400px;margin:0 auto;}
@media print{body{font-size:11px;padding:10px}}</style></head><body>`)
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 := `<img src="data:image/png;base64,` + fr.QRKod + `" style="width:25mm;height:25mm;display:block;margin:8px auto;">`
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, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style>
*{box-sizing:border-box;}
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
@media print{body{font-size:11px;padding:10px;}}
</style></head><body>`)
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, pre)
fmt.Fprint(w, `</pre>`)
if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
}
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`)
fmt.Fprint(w, `<p style="margin-top:16px;"><button onclick="window.print()" style="padding:8px 16px;">Štampaj</button></p></body></html>`)
}
func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE korisnici ADD COLUMN ime TEXT NOT NULL DEFAULT '';
ALTER TABLE korisnici ADD COLUMN prezime TEXT NOT NULL DEFAULT '';
+21
View File
@@ -1,6 +1,27 @@
{{ template "base" . }} {{ define "naslov" }}Moj profil — NTech{{ end }}
{{ define "sadrzaj" }}
<div class="stranica-stack" style="display: flex; flex-direction: column; gap: 16px; max-width: 560px">
<!-- ime i prezime -->
<div class="kartica animiraj">
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); margin-bottom: 16px; padding-bottom: 12px; border-bottom: 0.5px solid var(--ivica)">Ime i prezime</div>
<form method="POST" action="/admin/profil/ime-prezime">
<div style="display: flex; flex-direction: column; gap: 12px">
<div>
<label class="polje-labela">Ime</label>
<input type="text" name="ime" value="{{.Ime}}" placeholder="npr. Petar" style="width: 100%; box-sizing: border-box" />
</div>
<div>
<label class="polje-labela">Prezime</label>
<input type="text" name="prezime" value="{{.Prezime}}" placeholder="npr. Petrović" style="width: 100%; box-sizing: border-box" />
</div>
<div class="pomocni-tekst">Koristi se kao „Kasir" na fiskalnom računu.</div>
<div>
<button type="submit" class="btn-primarno">Sačuvaj</button>
</div>
</div>
</form>
</div>
<!-- promena lozinke -->
<div class="kartica animiraj">
<div style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni); margin-bottom: 16px; padding-bottom: 12px; border-bottom: 0.5px solid var(--ivica)">Promena lozinke</div>
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="sr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verifikacija fiskalnog računa</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 14px; color: #1e293b; background: #f1f5f9; min-height: 100vh; }
.omotac { max-width: 520px; margin: 0 auto; padding: 0 0 48px; }
/* baner */
.baner { padding: 20px 20px 16px; text-align: center; }
.baner.ok { background: #16a34a; color: #fff; }
.baner.greska { background: #dc2626; color: #fff; }
.baner-ikona { font-size: 36px; line-height: 1; margin-bottom: 6px; }
.baner-tekst { font-size: 18px; font-weight: 700; }
.baner-podnaslov { font-size: 13px; margin-top: 4px; opacity: 0.85; }
/* zaglavlje firme */
.zaglavlje { background: #fff; padding: 16px 20px; border-bottom: 1px solid #e2e8f0; text-align: center; }
.firma-naziv { font-size: 16px; font-weight: 700; color: #0f172a; }
.firma-info { font-size: 12px; color: #64748b; margin-top: 4px; line-height: 1.6; }
/* kartica */
.kartica { background: #fff; margin: 12px 12px 0; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
.kartica-naslov { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.7px; color: #64748b; padding: 12px 16px 8px; border-bottom: 1px solid #f1f5f9; }
/* red podataka */
.red { display: flex; justify-content: space-between; align-items: flex-start; padding: 9px 16px; border-bottom: 1px solid #f1f5f9; gap: 12px; }
.red:last-child { border-bottom: none; }
.red-labela { font-size: 12px; color: #64748b; }
.red-vrednost { font-size: 13px; color: #0f172a; font-weight: 500; text-align: right; }
.red-vrednost.mono { font-family: monospace; font-size: 12px; word-break: break-all; }
/* stavke */
.stavke-zaglavlje { display: flex; justify-content: space-between; padding: 7px 16px; background: #f8fafc; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
.stavka { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 16px; border-bottom: 1px solid #f1f5f9; gap: 8px; }
.stavka:last-child { border-bottom: none; }
.stavka-naziv { font-size: 13px; color: #1e293b; flex: 1; }
.stavka-kolicina { font-size: 12px; color: #64748b; text-align: center; min-width: 40px; }
.stavka-iznos { font-size: 13px; color: #0f172a; font-weight: 500; text-align: right; min-width: 80px; }
/* PDV tabela */
.pdv-red { display: flex; justify-content: space-between; padding: 7px 16px; border-bottom: 1px solid #f1f5f9; font-size: 12px; color: #374151; }
.pdv-red:last-child { border-bottom: none; }
.pdv-label { color: #64748b; }
/* ukupno */
.ukupno-blok { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; background: #0f172a; }
.ukupno-labela { font-size: 13px; color: #94a3b8; }
.ukupno-iznos { font-size: 22px; font-weight: 700; color: #fff; }
/* načini plaćanja */
.placanje-red { display: flex; justify-content: space-between; padding: 7px 16px; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
.placanje-red:last-child { border-bottom: none; }
/* tip računa bedž */
.tip-bedz { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.tip-fiskalni { background: #dcfce7; color: #166534; }
.tip-nefiskalni { background: #fef3c7; color: #92400e; }
/* podnožje */
.podnozje { text-align: center; padding: 20px 16px 8px; font-size: 11px; color: #94a3b8; line-height: 1.6; }
.podnozje a { color: #64748b; text-decoration: none; }
</style>
</head>
<body>
<div class="omotac">
{{if .Greska}}
<div class="baner greska">
<div class="baner-ikona"></div>
<div class="baner-tekst">Greška pri čitanju računa</div>
<div class="baner-podnaslov">{{.Greska}}</div>
</div>
{{else}}
{{if .JeFiskalni}}
<div class="baner ok">
<div class="baner-ikona"></div>
<div class="baner-tekst">Račun je verifikovan</div>
<div class="baner-podnaslov">Fiskalni račun je evidentiran u sistemu</div>
</div>
{{else}}
<div class="baner" style="background:#f59e0b;color:#1c1917;">
<div class="baner-ikona"></div>
<div class="baner-tekst">Ovo nije fiskalni račun</div>
<div class="baner-podnaslov">{{.Podaci.InvoiceType}}</div>
</div>
{{end}}
{{with .Podaci}}
<!-- firma -->
<div class="zaglavlje">
<div class="firma-naziv">{{.Company}}</div>
<div class="firma-info">
{{if .Store}}{{.Store}}<br>{{end}}
{{if .Address}}{{.Address}}{{if .City}}, {{.City}}{{end}}<br>{{end}}
PIB: {{.TIN}}
</div>
</div>
<!-- osnovno -->
<div class="kartica">
<div class="kartica-naslov">Podaci o računu</div>
<div class="red">
<span class="red-labela">Broj računa</span>
<span class="red-vrednost mono">{{.InvoiceNumber}}</span>
</div>
<div class="red">
<span class="red-labela">Redni broj</span>
<span class="red-vrednost mono">{{.InvoiceCounter}}</span>
</div>
<div class="red">
<span class="red-labela">Datum i vreme</span>
<span class="red-vrednost">{{.DateTime}}</span>
</div>
{{if .Cashier}}
<div class="red">
<span class="red-labela">Kasir</span>
<span class="red-vrednost">{{.Cashier}}</span>
</div>
{{end}}
{{if .BuyerID}}
<div class="red">
<span class="red-labela">ID kupca</span>
<span class="red-vrednost mono">{{.BuyerID}}</span>
</div>
{{end}}
<div class="red">
<span class="red-labela">Tip</span>
<span class="red-vrednost">
{{if eq .InvoiceType "Normal"}}
<span class="tip-bedz tip-fiskalni">Promet</span>
{{else if eq .InvoiceType "Advance"}}
<span class="tip-bedz tip-fiskalni">Avans</span>
{{else if eq .InvoiceType "Copy"}}
<span class="tip-bedz tip-nefiskalni">Kopija</span>
{{else if eq .InvoiceType "Training"}}
<span class="tip-bedz tip-nefiskalni">Obuka</span>
{{else if eq .InvoiceType "Proforma"}}
<span class="tip-bedz tip-nefiskalni">Predračun</span>
{{else}}
<span class="tip-bedz tip-nefiskalni">{{.InvoiceType}}</span>
{{end}}
{{if eq .TransactionType "Refund"}}
&nbsp;<span class="tip-bedz tip-nefiskalni">Refundacija</span>
{{end}}
</span>
</div>
</div>
<!-- stavke -->
{{if .Items}}
<div class="kartica">
<div class="kartica-naslov">Stavke</div>
<div class="stavke-zaglavlje">
<span>Naziv</span>
<span>Kol.</span>
<span style="text-align:right;">Iznos</span>
</div>
{{range .Items}}
<div class="stavka">
<span class="stavka-naziv">{{.Name}}</span>
<span class="stavka-kolicina">{{printf "%.0f" .Quantity}}</span>
<span class="stavka-iznos">{{dinari .TotalAmount}}</span>
</div>
{{end}}
<div class="ukupno-blok">
<span class="ukupno-labela">Ukupno za plaćanje</span>
<span class="ukupno-iznos">{{dinari .TotalAmount}} din</span>
</div>
</div>
{{else}}
<div class="kartica">
<div class="ukupno-blok">
<span class="ukupno-labela">Ukupno za plaćanje</span>
<span class="ukupno-iznos">{{dinari .TotalAmount}} din</span>
</div>
</div>
{{end}}
<!-- načini plaćanja -->
{{if .Payments}}
<div class="kartica">
<div class="kartica-naslov">Plaćanje</div>
{{range .Payments}}
<div class="placanje-red">
<span style="color:#374151;">
{{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}}
</span>
<span style="font-weight:600;">{{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
<!-- PDV -->
{{if .TaxItems}}
<div class="kartica">
<div class="kartica-naslov">Porez</div>
{{range .TaxItems}}
<div class="pdv-red">
<span class="pdv-label">{{if .CategoryName}}{{.CategoryName}} ({{.Label}}){{else}}{{.Label}}{{end}} {{printf "%.0f" .Rate}}%</span>
<span>Osnovica: {{dinari .Base}}</span>
<span style="font-weight:600;">PDV: {{dinari .Amount}} din</span>
</div>
{{end}}
</div>
{{end}}
{{end}}{{/* with .Podaci */}}
<div class="podnozje">
Fiskalizacija — NTech sistem<br>
<span style="font-size:10px;color:#cbd5e1;">Poreski identifikacioni broj se proverava kod Poreske uprave RS</span>
</div>
{{end}}{{/* if .Greska */}}
</div>
</body>
</html>
@@ -25,6 +25,24 @@
<div class="pomocni-tekst" style="margin-top:4px;">Trenutno podržan samo Teron L-PFR.</div>
</div>
<div>
<label for="fiskalni_pismo" class="polje-labela">Pismo fiskalnog računa</label>
<select id="fiskalni_pismo" name="fiskalni_pismo"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;">
<option value="latin" {{if ne .FiskalPismo "cyrillic"}}selected{{end}}>Latinica</option>
<option value="cyrillic" {{if eq .FiskalPismo "cyrillic"}}selected{{end}}>Ćirilica</option>
</select>
<div class="pomocni-tekst" style="margin-top:4px;">Pismo kojim Fisk štampa tekst računa. Stupi na snagu posle restarta Fisk servera. Env var <code>FISKALNI_PISMO</code> ima prednost.</div>
</div>
<div>
<label for="pfr_kasir" class="polje-labela">Kasir (ime na računu)</label>
<input type="text" id="pfr_kasir" name="pfr_kasir" value="{{.PfrKasir}}"
placeholder="npr. Petar Petrović"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;">
<div class="pomocni-tekst" style="margin-top:4px;">Ime koje se štampa u redu „Kasir" na fiskalnom računu. Prazno = prikazuje se „NTech".</div>
</div>
<div>
<label for="pfr_url" class="polje-labela">URL fiskalnog servera</label>
<input type="text" id="pfr_url" name="pfr_url" value="{{.PfrUrl}}"
@@ -60,6 +78,103 @@
</div>
<!-- Kartica emulator -->
<div class="kartica animiraj">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/><path d="M7 15h.01"/><path d="M11 15h2"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Kartica emulator (BE)</span>
</div>
<div class="pomocni-tekst" style="margin-bottom:16px;">
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.
</div>
<form method="POST" action="/podesavanja/sacuvaj">
<input type="hidden" name="_next" value="/admin/podesavanja/fiskalizacija">
<div style="display:flex;flex-direction:column;gap:16px;max-width:480px;">
<div>
<label for="be_enabled" class="polje-labela">Kartica emulator</label>
<select id="be_enabled" name="be_enabled"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;">
<option value="true" {{if ne .BeEnabled "false"}}selected{{end}}>Uključen (mock kartica)</option>
<option value="false" {{if eq .BeEnabled "false"}}selected{{end}}>Isključen (pravi Teron uređaj)</option>
</select>
<div class="pomocni-tekst" style="margin-top:4px;">Isključi kad koristiš pravi Teron/fizički L-PFR. Stupi na snagu posle restarta servera. Env var <code>BE_ENABLED=false</code> ima prednost.</div>
</div>
<div>
<label for="be_pin" class="polje-labela">PIN kartice</label>
<input type="text" id="be_pin" name="be_pin" value="{{.BePin}}"
maxlength="8" placeholder="1234"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;font-family:monospace;">
<div class="pomocni-tekst" style="margin-top:4px;">PIN koji Fisk šalje pri verify_pin komandi. Podrazumevano: <code>1234</code>. Env var <code>BE_PIN</code> ima prednost.</div>
</div>
<div>
<label for="be_limit" class="polje-labela">Limit iščitavanja (din)</label>
<input type="number" id="be_limit" name="be_limit" value="{{.BeLimit}}"
min="1000" step="1000" placeholder="500000"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;">
<div class="pomocni-tekst" style="margin-top:4px;">Maksimalni neisčitani promet pre blokade kartice. Podrazumevano: <code>500 000</code>. Env var <code>BE_LIMIT</code> ima prednost.</div>
</div>
<div>
<label for="verify_host" class="polje-labela">Domen za verifikacioni QR link</label>
<input type="text" id="verify_host" name="verify_host" value="{{.VerifyHost}}"
placeholder="ntech.moja-firma.rs:3000"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;">
<div class="pomocni-tekst" style="margin-top:4px;">
Npr. <code>192.168.1.10:3000</code> ili <code>ntech.firma.rs</code>.
Prazno = QR vodi na <code>sandbox.suf.purs.gov.rs</code>.
Env var <code>VERIFY_HOST</code> ima prednost nad ovim poljem.
Fisk čita ovu vrednost pri pokretanju.
</div>
</div>
<div>
<button type="submit"
style="padding:9px 18px;background:var(--sb-akcent);border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;">
Sačuvaj
</button>
</div>
</div>
</form>
<!-- Status kartice (live) -->
<div style="margin-top:20px;padding-top:16px;border-top:0.5px solid var(--ivica);">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;gap:8px;flex-wrap:wrap;">
<span style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--tekst-sporedni);">Status kartice</span>
<div style="display:flex;gap:8px;">
<button type="button"
hx-post="/podesavanja/fiskalizacija/be-reset-audit"
hx-target="#be-status-panel"
hx-swap="innerHTML"
hx-confirm="Simulirati Proof of Audit? Ovo će resetovati neisčitani iznos na 0."
style="padding:5px 12px;background:transparent;border:0.5px solid var(--ivica);border-radius:6px;color:var(--tekst-sporedni);font-size:12px;cursor:pointer;">
Resetuj limit
</button>
<button type="button"
hx-get="/podesavanja/fiskalizacija/be-status"
hx-target="#be-status-panel"
hx-swap="innerHTML"
style="padding:5px 12px;background:transparent;border:0.5px solid var(--ivica);border-radius:6px;color:var(--tekst-sporedni);font-size:12px;cursor:pointer;">
Osveži
</button>
</div>
</div>
<div id="be-status-panel"
hx-get="/podesavanja/fiskalizacija/be-status"
hx-trigger="load"
hx-swap="innerHTML">
<span style="font-size:12px;color:var(--tekst-sporedni);">Učitavanje...</span>
</div>
</div>
</div>
</div>
<style>
+56 -3
View File
@@ -149,7 +149,7 @@
</div>
<div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);">
<label class="polje-labela">Primljeno (din)</label>
<input type="number" id="primljeno" name="primljeno" min="0" step="0.01" value="0" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;" oninput="var t=document.getElementById('za-naplatu-iznos').textContent;var zn=t?parseFloat(t.replace(/\./g,'').replace(',','.').replace(/[^0-9.]/g,''))||0:0;var p=parseFloat(this.value)||0;var k=p-zn;document.getElementById('kusur').textContent=(k>=0?k.toFixed(2):'0.00')+' din';document.getElementById('kusur-blok').style.display=k>=0?'flex':'none'">
<input type="number" id="primljeno" name="primljeno" min="0" step="0.01" placeholder="Unesite primljeni iznos" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;" oninput="ntechPrimljenoUpdate(this)">
</div>
<div id="kusur-blok" style="display:none;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,var(--uspeh) 10%,transparent);border:0.5px solid var(--uspeh);border-radius:8px;">
<span style="color:var(--uspeh);font-weight:500;">Kusur (vratiti)</span>
@@ -161,14 +161,67 @@
<input type="hidden" name="naplaceno" value="{{printf "%.2f" .PreostaloSve}}">
<div style="margin-bottom:10px;">
<label class="polje-labela">Način plaćanja</label>
<select name="nacin_placanja" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:13px;">
<select name="nacin_placanja" id="nacin-placanja-sel" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:13px;" onchange="ntechNacinPlacanjaBroj(this)">
<option value="Gotovina">Gotovina</option>
<option value="Kartica">Kartica</option>
<option value="Virman">Virman</option>
</select>
</div>
<button type="submit" class="btn-primarno" style="width:100%;">Naplati i preuzmi</button>
<label style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;margin-bottom:10px;">
<input type="checkbox" id="prikazi-racun" name="prikazi_racun" value="1" checked
style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;">
<span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span>
</label>
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;opacity:0.4;cursor:not-allowed;" disabled>Naplati i preuzmi</button>
</form>
<script>
(function(){
function zaNaplatu() {
var t = document.getElementById('za-naplatu-iznos');
return t ? parseFloat(t.textContent.replace(/\./g,'').replace(',','.').replace(/[^0-9.]/g,'')) || 0 : 0;
}
window.ntechPrimljenoUpdate = function(inp) {
var zn = zaNaplatu();
var p = parseFloat(inp.value) || 0;
var k = p - zn;
var kusurBlok = document.getElementById('kusur-blok');
if (k >= 0 && inp.value !== '') {
document.getElementById('kusur').textContent = k.toFixed(2).replace('.',',') + ' din';
kusurBlok.style.display = 'flex';
} else {
kusurBlok.style.display = 'none';
}
var btn = document.getElementById('btn-naplati');
var ok = inp.value !== '' && p >= zn;
btn.disabled = !ok;
btn.style.opacity = ok ? '1' : '0.4';
btn.style.cursor = ok ? 'pointer' : 'not-allowed';
};
window.ntechNacinPlacanjaBroj = function(sel) {
if (sel.value !== 'Gotovina') {
var inp = document.getElementById('primljeno');
if (inp && inp.value === '') {
inp.value = zaNaplatu().toFixed(2);
ntechPrimljenoUpdate(inp);
}
}
};
var dlg = document.currentScript.closest('dialog');
var f = dlg && dlg.querySelector('form[method="POST"]');
if (!f) return;
f.addEventListener('submit', function(){
var cb = f.querySelector('#prikazi-racun');
if (!cb || !cb.checked) return;
var sadrzaj = document.getElementById('sadrzaj');
if (!sadrzaj) return;
var observer = new MutationObserver(function(){
observer.disconnect();
window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank');
});
observer.observe(sadrzaj.parentElement, {childList: true});
});
})();
</script>
</div>
</dialog>
{{end}}