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:
@@ -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=Остало
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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..."
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
</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">✗ 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">✗ 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">
|
||||
✓ 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">✗ 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">✓ 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 {
|
||||
|
||||
+79
-34
@@ -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, `<!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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE korisnici ADD COLUMN ime TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE korisnici ADD COLUMN prezime TEXT NOT NULL DEFAULT '';
|
||||
@@ -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"}}
|
||||
<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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user