Compare commits
10
Commits
37801fca2a
...
263aefe9fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263aefe9fc
|
||
|
|
07666b30c9
|
||
|
|
65dd976bb4
|
||
|
|
0fb573552d
|
||
|
|
23c4c7ded0
|
||
|
|
c30d4cb34a
|
||
|
|
18b4199d0a
|
||
|
|
1a93fc739d
|
||
|
|
550750fe88
|
||
|
|
23173e79b2
|
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-alpine
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server.py receipt.py ./
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,2 @@
|
||||
qrcode
|
||||
pillow
|
||||
+365
-504
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
myLPFR Mock Server — Kompletan fiskalni server za testiranje
|
||||
================================================================
|
||||
Podržava sve /agent/v3, /api/v3 i /extension/v3 endpoint-e.
|
||||
Automatski generiše QR kodove, snima račune, vodi log.
|
||||
Pokreće se preko start.sh, gasi preko stop.sh.
|
||||
Teron L-PFR Mock Server — glumi Teron fiskalni server za testiranje NTech-a.
|
||||
Endpoint-i i format odgovora usklađeni sa Teron API dokumentacijom.
|
||||
Port: 4566 (Teron standard)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -23,35 +21,31 @@ import qrcode
|
||||
from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale
|
||||
|
||||
# ── Konfiguracija ──────────────────────────────────────────
|
||||
PORT = 8989
|
||||
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
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
INVOICES_DIR = DATA_DIR / "invoices"
|
||||
QR_DIR = DATA_DIR / "qr"
|
||||
RECEIPTS_DIR = DATA_DIR / "receipts"
|
||||
LOG_FILE = DATA_DIR / "server.log"
|
||||
COUNTER_DIR = DATA_DIR / "counters"
|
||||
|
||||
# Inicijalizuj foldere
|
||||
for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR]:
|
||||
for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prefix računa (čitaj iz fajla, ili kreni od 1)
|
||||
COUNTER_FILE = DATA_DIR / "counter.txt"
|
||||
|
||||
# ── Bezbednosni element (mock kartica) ─────────────────────
|
||||
# Fiksni test PIN — pravu karticu otključava korisnik svojim PIN-om,
|
||||
# ovde je samo test vrednost kojom glumimo otključavanje.
|
||||
# Test PIN (pravi Teron traži PIN za BE karticu)
|
||||
PIN_BE = "1234"
|
||||
|
||||
# Putanja do NTech SQLite baze — čita se read-only. Podrazumevano ../ntech.db
|
||||
# (koren repozitorijuma), može se promeniti preko NTECH_SQLITE.
|
||||
# NTech SQLite baza (read-only) — čita podatke o firmi
|
||||
NTECH_DB = os.environ.get("NTECH_SQLITE") or str(Path(__file__).parent.parent / "ntech.db")
|
||||
|
||||
# ── Firma ───────────────────────────────────────────────────
|
||||
|
||||
def ucitaj_firmu():
|
||||
"""Čita podatke o firmi iz NTech baze (read-only) i vraća ih kao dict.
|
||||
Bezbednosni element 'već zna' identitet poreskog obveznika — ovde to glumimo
|
||||
čitanjem profila firme iz tabele podesavanja. Ako baza ili ključ nedostaje,
|
||||
vraćamo test vrednosti da server i dalje radi."""
|
||||
"""Čita podatke o firmi iz NTech baze."""
|
||||
podaci = {}
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{NTECH_DB}?mode=ro", uri=True)
|
||||
@@ -68,9 +62,11 @@ def ucitaj_firmu():
|
||||
log(f" ⚠️ Ne mogu da pročitam firmu iz baze ({NTECH_DB}): {e}")
|
||||
|
||||
naziv = podaci.get("naziv_firme") or "Test Company DOO"
|
||||
pib = podaci.get("pib") or "123456789"
|
||||
return {
|
||||
"name": naziv,
|
||||
"tin": podaci.get("pib") or "123456789",
|
||||
"tin": f"RS{pib}", # Teron koristi RS prefiks
|
||||
"tinPlain": pib,
|
||||
"mb": podaci.get("maticni_broj") or "12345678",
|
||||
"address": podaci.get("adresa") or "Test Address 1",
|
||||
"telefon": podaci.get("telefon") or "",
|
||||
@@ -80,270 +76,292 @@ def ucitaj_firmu():
|
||||
"city": podaci.get("grad") or "Beograd",
|
||||
}
|
||||
|
||||
def get_next_invoice_number():
|
||||
"""Vraća i inkrementira broj računa."""
|
||||
if COUNTER_FILE.exists():
|
||||
num = int(COUNTER_FILE.read_text().strip())
|
||||
else:
|
||||
num = 1
|
||||
COUNTER_FILE.write_text(str(num + 1))
|
||||
return f"{num:06d}"
|
||||
# ── Brojači ─────────────────────────────────────────────────
|
||||
|
||||
def get_counter(tip="total"):
|
||||
"""Čita i inkrementira brojač za dati tip (total, pp, pr, ap, ar, kp, op, itd.)."""
|
||||
f = COUNTER_DIR / f"{tip}.txt"
|
||||
num = int(f.read_text().strip()) if f.exists() else 1
|
||||
f.write_text(str(num + 1))
|
||||
return num
|
||||
|
||||
def peek_counter(tip="total"):
|
||||
"""Čita brojač bez inkrementiranja."""
|
||||
f = COUNTER_DIR / f"{tip}.txt"
|
||||
num = int(f.read_text().strip()) if f.exists() else 1
|
||||
return max(1, num - 1)
|
||||
|
||||
def counter_ext(invoice_type, transaction_type):
|
||||
"""Vraća sufiks tipa transakcije (ПП, ПР, АП...) i ključ brojača."""
|
||||
t = (str(invoice_type).lower(), str(transaction_type).lower())
|
||||
mapping = {
|
||||
("normal", "sale"): ("ПП", "pp"),
|
||||
("normal", "refund"): ("ПР", "pr"),
|
||||
("advance", "sale"): ("АП", "ap"),
|
||||
("advance", "refund"): ("АР", "ar"),
|
||||
("copy", "sale"): ("КП", "kp"),
|
||||
("copy", "refund"): ("КР", "kr"),
|
||||
("training", "sale"): ("ОП", "op"),
|
||||
("training", "refund"): ("ОР", "or"),
|
||||
}
|
||||
return mapping.get(t, ("НН", "other"))
|
||||
|
||||
# ── Logging ─────────────────────────────────────────────────
|
||||
|
||||
def log(msg):
|
||||
"""Upisuje poruku u log fajl i na stdout."""
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{now}] {msg}"
|
||||
print(line, flush=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
# ── QR ──────────────────────────────────────────────────────
|
||||
|
||||
def generate_qr(url):
|
||||
"""Pravi QR kod PNG i vraća base64 string."""
|
||||
img = qrcode.make(url)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
# ── Gradi rute iz OpenAPI speca ─────────────────────────────
|
||||
|
||||
# Ako postoji myLPFR-api-docs.json, koristi ga.
|
||||
# Ako ne, koristi hardkodovane rute.
|
||||
SPEC_FILE = Path(__file__).parent.parent / "myLPFR-api-docs.json"
|
||||
|
||||
# Default rute (pale su iz Swagger speca)
|
||||
DEFAULT_ROUTES = [
|
||||
# Agent API
|
||||
("GET", "agent/v3/attention", "attention"),
|
||||
("GET", "agent/v3/environment-parameters", "environment"),
|
||||
("POST", "agent/v3/invoices", "invoice"),
|
||||
("GET", "agent/v3/invoices/:requestId", "invoice_lookup"),
|
||||
("POST", "agent/v3/open-drawer", "open_drawer"),
|
||||
("POST", "agent/v3/pin", "verify_pin"),
|
||||
("POST", "agent/v3/print-text", "print_text"),
|
||||
("GET", "agent/v3/receipts/:requestId", "receipt"),
|
||||
("GET", "agent/v3/receipts/:requestId/text","receipt_text"),
|
||||
("GET", "agent/v3/receipts/:requestId/html","receipt_html"),
|
||||
("GET", "agent/v3/reports/daily", "daily_report"),
|
||||
("GET", "agent/v3/reports/daily/text", "daily_report_text"),
|
||||
("GET", "agent/v3/reports/periodic", "periodic_report"),
|
||||
("GET", "agent/v3/reports/periodic/text", "periodic_report_text"),
|
||||
("GET", "agent/v3/status", "status"),
|
||||
("GET", "agent/v3/subject", "subject"),
|
||||
# E-SDC API
|
||||
("GET", "api/v3/attention", "attention"),
|
||||
("GET", "api/v3/environment-parameters", "environment"),
|
||||
("POST", "api/v3/invoices", "invoice"),
|
||||
("GET", "api/v3/invoices/:requestId", "invoice_lookup"),
|
||||
("POST", "api/v3/pin", "verify_pin"),
|
||||
("GET", "api/v3/status", "status"),
|
||||
# Extension API
|
||||
("GET", "extension/v3/notifications", "notifications"),
|
||||
("GET", "extension/v3/reports/daily", "daily_report"),
|
||||
("GET", "extension/v3/reports/periodic", "periodic_report"),
|
||||
("GET", "extension/v3/status-codes", "status_codes"),
|
||||
("GET", "extension/v3/subject", "subject"),
|
||||
]
|
||||
|
||||
# ── Response handler-i ──────────────────────────────────────
|
||||
# ── Vreme ───────────────────────────────────────────────────
|
||||
|
||||
def sada():
|
||||
"""Trenutno vreme u ISO formatu sa +02:00."""
|
||||
tz = timezone(timedelta(hours=2))
|
||||
return datetime.now(tz).strftime("%Y-%m-%dT%H:%M:%S.000+02:00")
|
||||
|
||||
# ── PDV obračun ─────────────────────────────────────────────
|
||||
|
||||
# Teron koristi: Ж (20%), Ђ (10%), Е (posebna), А (0% neobveznici),
|
||||
# Г (oslobođen), З (0% bez prava odbitka)
|
||||
# + starije oznake generičkog L-PFR-a za kompatibilnost
|
||||
TAX_RATES = {
|
||||
# Teron oznake
|
||||
"Ж": 20.0, # opšta stopa 20%
|
||||
"Ђ": 10.0, # snižena stopa 10%
|
||||
"Е": 10.0, # posebna snižena stopa
|
||||
"А": 0.0, # neobveznici PDV-a
|
||||
"Г": 0.0, # oslobođen bez prava na odbitak
|
||||
"З": 0.0, # nije predmet oporezivanja
|
||||
# Generičke oznake (kompatibilnost)
|
||||
"Б": 20.0, "B": 20.0,
|
||||
"В": 0.0, "V": 0.0,
|
||||
"Д": 0.0, "D": 0.0,
|
||||
"A": 0.0, "G": 0.0, "E": 10.0,
|
||||
}
|
||||
|
||||
def izracunaj_pdv(items):
|
||||
"""Grupiše stavke po poreskoj oznaci i izračunava PDV iz bruto iznosa.
|
||||
Formula: pdv = bruto * stopa / (100 + stopa)"""
|
||||
grupe = {}
|
||||
for item in items:
|
||||
total = float(item.get("totalAmount", 0))
|
||||
for label in item.get("labels", []):
|
||||
rate = TAX_RATES.get(label, 0.0)
|
||||
if label not in grupe:
|
||||
grupe[label] = {"label": label, "rate": rate, "amount": 0.0}
|
||||
if rate > 0:
|
||||
grupe[label]["amount"] += total * rate / (100 + rate)
|
||||
return [
|
||||
{
|
||||
"label": d["label"],
|
||||
"categoryName": "PDV",
|
||||
"categoryType": 0,
|
||||
"rate": d["rate"],
|
||||
"amount": round(d["amount"], 4),
|
||||
}
|
||||
for d in grupe.values()
|
||||
]
|
||||
|
||||
# ── Response handleri ───────────────────────────────────────
|
||||
|
||||
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 ""
|
||||
return {
|
||||
"isPinRequired": True,
|
||||
"isPinRequired": False,
|
||||
"auditRequired": False,
|
||||
"sdcDateTime": sada(),
|
||||
"lastInvoiceNumber": get_last_invoice_number(),
|
||||
"protocolVersion": "1.0.0.0",
|
||||
"secureElementVersion": "1.0",
|
||||
"hardwareVersion": "1.0",
|
||||
"softwareVersion": "0.3.18",
|
||||
"deviceSerialNumber": "50-0002-NX6LC40XR3TQ",
|
||||
"make": "MyOffice DOO",
|
||||
"model": "myLPFR",
|
||||
"mssc": [],
|
||||
"gsc": ["1300", "0210"],
|
||||
"supportedLanguages": ["sr-Cyrl-RS", "sr-Latin-RS"],
|
||||
"uid": "",
|
||||
"taxCoreApi": "https://suf-sandbox.purs.gov.rs",
|
||||
"currentTaxRates": None,
|
||||
"allTaxRates": [],
|
||||
}
|
||||
|
||||
def resp_environment():
|
||||
f = ucitaj_firmu()
|
||||
return {
|
||||
"lastInvoiceNumber": last_num,
|
||||
"protocolVersion": "1.0.0",
|
||||
"serialNumber": ESIR_ID,
|
||||
"tin": f["tin"],
|
||||
"uid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"taxCoreApi": "https://suf-sandbox.purs.gov.rs",
|
||||
"sufVersion": "3.0",
|
||||
"supportedLanguages": ["sr-Cyrl-RS", "sr-Latin-RS"],
|
||||
"taxRates": [{
|
||||
"validFrom": "2026-01-01",
|
||||
"groupId": 1,
|
||||
"taxCategories": [{
|
||||
"categoryId": 1,
|
||||
"name": "PDV",
|
||||
"categoryType": "0",
|
||||
"orderId": 1,
|
||||
"taxRates": [
|
||||
{"rateId": 1, "rate": 20.0, "label": "S"},
|
||||
{"rateId": 2, "rate": 10.0, "label": "P"},
|
||||
],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
def resp_subject():
|
||||
f = ucitaj_firmu()
|
||||
return {
|
||||
"tin": f["tin"],
|
||||
"mb": f["mb"],
|
||||
"uid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": f["name"],
|
||||
"address": f["address"],
|
||||
"city": f["city"],
|
||||
"country": "RS",
|
||||
"district": f["district"],
|
||||
"locationName": f["locationName"],
|
||||
"businessUnitId": f["businessUnitId"],
|
||||
}
|
||||
|
||||
def resp_invoice(request_id, request_body=None):
|
||||
invoice_number = get_next_invoice_number()
|
||||
verification_url = f"https://suf-sandbox.purs.gov.rs/verify/{request_id}"
|
||||
qr_b64 = generate_qr(verification_url)
|
||||
uid = f"550e8400-e29b-41d4-a716-{invoice_number.zfill(12)}"
|
||||
|
||||
invoice_data = {
|
||||
"uid": uid,
|
||||
"requestId": request_id,
|
||||
"signedXml": f"<Invoice><UID>{uid}</UID><Number>{invoice_number}</Number><RequestId>{request_id}</RequestId><SignedAt>{sada()}</SignedAt></Invoice>",
|
||||
"sdcDateTime": sada(),
|
||||
"invoiceNumber": invoice_number,
|
||||
"verificationUrl": verification_url,
|
||||
"qrCode": qr_b64,
|
||||
"encryptedInternalData": f"ENC_{uid}",
|
||||
"signature": f"SIG_{invoice_number}_{request_id[:8]}",
|
||||
}
|
||||
|
||||
# Proširi podatke iz tela zahteva (za štampu)
|
||||
full_data = dict(invoice_data)
|
||||
if request_body:
|
||||
full_data.update(request_body)
|
||||
full_data["invoiceNumber"] = invoice_number
|
||||
full_data["sdcDateTime"] = invoice_data["sdcDateTime"]
|
||||
full_data["qrCode"] = qr_b64
|
||||
full_data["isFiscal"] = full_data.get("isFiscal", True)
|
||||
f = ucitaj_firmu()
|
||||
full_data.setdefault("tin", f["tin"])
|
||||
full_data.setdefault("company", f["name"])
|
||||
full_data.setdefault("store", f["locationName"])
|
||||
full_data.setdefault("address", f["address"])
|
||||
full_data.setdefault("district", f["district"])
|
||||
full_data.setdefault("cashier", "Marko Marković")
|
||||
full_data.setdefault("transactionType", "NSX")
|
||||
full_data.setdefault("totalAmount", sum(item.get("amount", item.get("unitPrice", 0) * item.get("quantity", 0)) for item in full_data.get("items", [])))
|
||||
full_data.setdefault("payments", [{"type": "Cash", "amount": full_data["totalAmount"]}])
|
||||
full_data.setdefault("taxItems", [])
|
||||
full_data.setdefault("totalTax", sum(t.get("amount", 0) for t in full_data.get("taxItems", [])))
|
||||
full_data.setdefault("refund", 0)
|
||||
full_data.setdefault("invoiceType", "Normal")
|
||||
|
||||
# Snimi kompletan račun
|
||||
invoice_path = INVOICES_DIR / f"{invoice_number}_{request_id}.json"
|
||||
with open(invoice_path, "w", encoding="utf-8") as f:
|
||||
json.dump(full_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Snimi QR kod
|
||||
qr_path = QR_DIR / f"{invoice_number}_{request_id}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(base64.b64decode(qr_b64))
|
||||
|
||||
# Generiši i snimi tekst računa (latinica)
|
||||
receipt_text = generate_receipt(full_data, "latin")
|
||||
receipt_path = RECEIPTS_DIR / f"{invoice_number}_{request_id}.txt"
|
||||
receipt_path.write_text(receipt_text, encoding="utf-8")
|
||||
|
||||
# Generiši HTML račun
|
||||
receipt_html = generate_receipt_html(full_data, "latin")
|
||||
html_path = RECEIPTS_DIR / f"{invoice_number}_{request_id}.html"
|
||||
html_path.write_text(receipt_html, encoding="utf-8")
|
||||
|
||||
log(f" 🧾 RAČUN {invoice_number} | requestId={request_id} | QR={qr_path.name} | Račun={receipt_path.name} | HTML={html_path.name}")
|
||||
return invoice_data
|
||||
|
||||
def resp_invoice_lookup(request_id):
|
||||
"""Pronađi postojeći račun po requestId."""
|
||||
for f in INVOICES_DIR.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if data.get("requestId") == request_id:
|
||||
log(f" 🔍 Pronađen račun: {f.name}")
|
||||
return data
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def get_last_invoice_number():
|
||||
"""Poslednji broj računa (bez inkrementiranja)."""
|
||||
if COUNTER_FILE.exists():
|
||||
num = int(COUNTER_FILE.read_text().strip()) - 1
|
||||
return f"{num:06d}" if num >= 1 else ""
|
||||
return ""
|
||||
|
||||
def resp_verify_pin(request_body=None):
|
||||
"""Glumi otključavanje kartice PIN-om. Prihvata telo kao JSON {"pin": "..."}
|
||||
ili kao goli string. Poredi sa fiksnim test PIN-om PIN_BE."""
|
||||
uneti = ""
|
||||
if isinstance(request_body, dict):
|
||||
uneti = str(request_body.get("pin", "")).strip()
|
||||
elif isinstance(request_body, str):
|
||||
uneti = request_body.strip().strip('"')
|
||||
if uneti == PIN_BE:
|
||||
log(" 🔓 PIN ispravan — kartica otključana")
|
||||
log(" 🔓 PIN ispravan")
|
||||
return {"status": "OK", "message": "PIN verifikovan"}
|
||||
log(" 🔒 Pogrešan PIN")
|
||||
return {"status": "ERROR", "code": "E003", "message": "Pogrešan PIN"}
|
||||
return {"status": "ERROR", "code": "2100", "message": "Pogrešan PIN"}
|
||||
|
||||
def resp_open_drawer():
|
||||
return {"status": "OK", "message": "Fioka otvorena"}
|
||||
|
||||
def resp_print_text():
|
||||
return {"status": "OK", "message": "Tekst odštampan"}
|
||||
|
||||
def resp_receipt(request_id):
|
||||
"""Vraća sačuvani račun u tekst formatu (za štampu)."""
|
||||
# Prvo probaj da nađeš po requestId
|
||||
for f in sorted(RECEIPTS_DIR.glob("*.txt"), reverse=True):
|
||||
if request_id in f.stem:
|
||||
def resp_settings_get():
|
||||
return {
|
||||
"contentType": "text/plain; charset=utf-8",
|
||||
"receiptText": f.read_text(encoding="utf-8"),
|
||||
"requestId": request_id,
|
||||
}
|
||||
return {
|
||||
"contentType": "text/plain; charset=utf-8",
|
||||
"receiptText": "Račun nije pronađen.",
|
||||
"requestId": request_id,
|
||||
"printerType": "Thermal",
|
||||
"printerInterface": "None",
|
||||
"lpfrEnabled": False,
|
||||
"vpfrEnabled": False,
|
||||
"authorizeLocalClients": False,
|
||||
"authorizeRemoteClients": False,
|
||||
"apiKey": "mock-api-key-0000",
|
||||
"webserverAddress": f"http://127.0.0.1:{PORT}/",
|
||||
}
|
||||
|
||||
def resp_receipt_html(request_id):
|
||||
"""Vraća sačuvani račun u HTML formatu (za A4 štampu iz browsera)."""
|
||||
for f in sorted(RECEIPTS_DIR.glob("*.html"), reverse=True):
|
||||
if request_id in f.stem:
|
||||
return f.read_text(encoding="utf-8")
|
||||
return "<h1>Račun nije pronađen</h1>"
|
||||
def resp_settings_post():
|
||||
return {"status": "OK", "message": "Podešavanja sačuvana"}
|
||||
|
||||
def _build_report(title, start_date=None, end_date=None):
|
||||
"""Pravi izveštaj iz snimljenih računa."""
|
||||
_firma = ucitaj_firmu()
|
||||
def resp_certificate():
|
||||
f = ucitaj_firmu()
|
||||
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",
|
||||
}
|
||||
|
||||
def _build_invoice_response(req, request_id):
|
||||
"""Gradi Teron odgovor za fiskalni račun."""
|
||||
# Teron zahtev dolazi unutar invoiceRequest omotača
|
||||
inv_req = req.get("invoiceRequest", req)
|
||||
|
||||
invoice_type = inv_req.get("invoiceType", "Normal")
|
||||
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()
|
||||
|
||||
# Odgovor koji ide ka NTech-u (ESIR-u)
|
||||
odgovor = {
|
||||
"requestedBy": ESIR_ID,
|
||||
"signedBy": BE_ID,
|
||||
"sdcDateTime": sada(),
|
||||
"invoiceCounter": invoice_counter,
|
||||
"invoiceCounterExtension": ext,
|
||||
"invoiceNumber": invoice_number,
|
||||
"verificationUrl": verification_url,
|
||||
"verificationQRCode": qr_b64,
|
||||
"taxItems": tax_items,
|
||||
"totalAmount": total_amount,
|
||||
"totalTax": total_tax,
|
||||
"messages": "Success",
|
||||
}
|
||||
|
||||
# Puni podaci za snimanje i generisanje računa
|
||||
full_data = {
|
||||
**odgovor,
|
||||
"requestId": request_id,
|
||||
"invoiceType": invoice_type,
|
||||
"transactionType": transaction_type,
|
||||
"items": items,
|
||||
"payments": inv_req.get("payment", [{"type": "Cash", "amount": total_amount}]),
|
||||
"cashier": inv_req.get("cashier", "Kasir"),
|
||||
"buyerId": inv_req.get("buyerId", ""),
|
||||
"referentDocumentNumber": inv_req.get("referentDocumentNumber", ""),
|
||||
"isFiscal": invoice_type not in ("Copy", "Training", "Proforma"),
|
||||
"tin": firma["tinPlain"],
|
||||
"company": firma["name"],
|
||||
"store": firma["locationName"],
|
||||
"address": firma["address"],
|
||||
"district": firma["district"],
|
||||
"refund": 0,
|
||||
# za avansni konačni
|
||||
"advancePaid": req.get("advancePaid", 0),
|
||||
"advanceTax": req.get("advanceTax", 0),
|
||||
}
|
||||
|
||||
# Snimi JSON
|
||||
inv_path = INVOICES_DIR / f"{total_cnt:06d}_{request_id}.json"
|
||||
with open(inv_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(full_data, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
# Snimi QR PNG
|
||||
qr_path = QR_DIR / f"{total_cnt:06d}_{request_id}.png"
|
||||
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")
|
||||
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_path = RECEIPTS_DIR / f"{total_cnt:06d}_{request_id}.html"
|
||||
html_path.write_text(html_txt, encoding="utf-8")
|
||||
|
||||
# Dodaj journal (tekst računa) u odgovor
|
||||
odgovor["journal"] = receipt_text
|
||||
|
||||
log(f" 🧾 {invoice_number} | {ext} | {total_amount:.2f} din | PDV {total_tax:.2f}")
|
||||
return odgovor
|
||||
|
||||
def resp_invoice(request_id, request_body=None):
|
||||
if not request_body:
|
||||
return {"error": "Telo zahteva je obavezno"}, 400
|
||||
return _build_invoice_response(request_body, request_id)
|
||||
|
||||
def resp_invoice_final(request_id, request_body=None):
|
||||
"""Konačni račun koji zatvara avanse (/api/invoices/final)."""
|
||||
if not request_body:
|
||||
return {"error": "Telo zahteva je obavezno"}, 400
|
||||
return _build_invoice_response(request_body, request_id)
|
||||
|
||||
def resp_invoice_last():
|
||||
"""Vraća poslednji sačuvani račun."""
|
||||
files = sorted(INVOICES_DIR.glob("*.json"), reverse=True)
|
||||
if not files:
|
||||
return {"error": "Nema računa"}, 404
|
||||
try:
|
||||
return json.loads(files[0].read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"error": "Greška pri čitanju računa"}, 500
|
||||
|
||||
def resp_invoice_by_request(request_id):
|
||||
for f in INVOICES_DIR.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if data.get("requestId") == request_id:
|
||||
return data
|
||||
except Exception:
|
||||
continue
|
||||
return {"error": f"Račun {request_id} nije pronađen"}, 404
|
||||
|
||||
def resp_invoice_by_number(invoice_number):
|
||||
for f in INVOICES_DIR.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
if data.get("invoiceNumber") == invoice_number:
|
||||
return data
|
||||
except Exception:
|
||||
continue
|
||||
return {"error": f"Račun {invoice_number} nije pronađen"}, 404
|
||||
|
||||
def resp_invoice_search(request_body=None):
|
||||
"""Osnovna pretraga — vraća CSV."""
|
||||
invoices = []
|
||||
for f in sorted(INVOICES_DIR.glob("*.json")):
|
||||
try:
|
||||
@@ -351,291 +369,135 @@ def _build_report(title, start_date=None, end_date=None):
|
||||
invoices.append(data)
|
||||
except Exception:
|
||||
continue
|
||||
lines = [
|
||||
f"{d['invoiceNumber']},{d.get('invoiceType','Normal')},{d.get('transactionType','Sale')},{d.get('sdcDateTime','')},{d.get('totalAmount',0)}"
|
||||
for d in invoices
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
total_payments_by_type = {}
|
||||
total_tax_by_label = {}
|
||||
per_tx_data = {}
|
||||
invoice_count = 0
|
||||
grand_total = 0.0
|
||||
grand_tax = 0.0
|
||||
# ── Rute ────────────────────────────────────────────────────
|
||||
|
||||
for inv in invoices:
|
||||
invoice_count += 1
|
||||
# Plaćanja
|
||||
for p in inv.get("payments", []):
|
||||
ptype = p.get("type", "Other")
|
||||
amt = float(p.get("amount", 0))
|
||||
total_payments_by_type[ptype] = total_payments_by_type.get(ptype, 0.0) + amt
|
||||
grand_total += amt
|
||||
# Porezi
|
||||
for t in inv.get("taxItems", []):
|
||||
lbl = t.get("label", "")
|
||||
rate = float(t.get("rate", 0))
|
||||
amt = float(t.get("amount", 0))
|
||||
key = f"{lbl}_{rate}"
|
||||
if key not in total_tax_by_label:
|
||||
total_tax_by_label[key] = {"label": lbl, "rate": rate, "total": 0.0, "amount": 0.0}
|
||||
total_tax_by_label[key]["total"] += float(inv.get("totalAmount", 0))
|
||||
total_tax_by_label[key]["amount"] += amt
|
||||
grand_tax += amt
|
||||
# Po tipu transakcije
|
||||
tx = inv.get("transactionType", "NSX")
|
||||
if tx not in per_tx_data:
|
||||
per_tx_data[tx] = {"transactionTypeExt": tx, "invoiceCount": 0, "payments": {}, "taxItems": {}}
|
||||
per_tx_data[tx]["invoiceCount"] += 1
|
||||
for p in inv.get("payments", []):
|
||||
ptype = p.get("type", "Other")
|
||||
amt = float(p.get("amount", 0))
|
||||
per_tx_data[tx]["payments"][ptype] = per_tx_data[tx]["payments"].get(ptype, 0.0) + amt
|
||||
for t in inv.get("taxItems", []):
|
||||
lbl = t.get("label", "")
|
||||
rate = float(t.get("rate", 0))
|
||||
amt = float(t.get("amount", 0))
|
||||
key = f"{lbl}_{rate}"
|
||||
if key not in per_tx_data[tx]["taxItems"]:
|
||||
per_tx_data[tx]["taxItems"][key] = {"label": lbl, "rate": rate, "total": 0.0, "amount": 0.0}
|
||||
per_tx_data[tx]["taxItems"][key]["total"] += float(inv.get("totalAmount", 0))
|
||||
per_tx_data[tx]["taxItems"][key]["amount"] += amt
|
||||
ROUTES = [
|
||||
("GET", "api/attention", "attention"),
|
||||
("GET", "api/status", "status"),
|
||||
("POST", "api/pin", "verify_pin"),
|
||||
("GET", "api/settings", "settings_get"),
|
||||
("POST", "api/settings", "settings_post"),
|
||||
("GET", "api/certificate", "certificate"),
|
||||
("POST", "api/invoices/final", "invoice_final"),
|
||||
("GET", "api/invoices/last", "invoice_last"),
|
||||
("GET", "api/invoices/request/:requestId", "invoice_by_request"),
|
||||
("GET", "api/invoices/:invoiceNumber", "invoice_by_number"),
|
||||
("POST", "api/invoices/search", "invoice_search"),
|
||||
("POST", "api/invoices", "invoice"),
|
||||
]
|
||||
|
||||
# Formatiraj
|
||||
payments_list = [{"paymentType": k, "amount": v} for k, v in total_payments_by_type.items()]
|
||||
tax_list = list(total_tax_by_label.values())
|
||||
per_tx_list = []
|
||||
for tx, data in per_tx_data.items():
|
||||
tx_payments = [{"paymentType": k, "amount": v} for k, v in data["payments"].items()]
|
||||
tx_taxes = list(data["taxItems"].values())
|
||||
tx_total_pmts = sum(p["amount"] for p in tx_payments)
|
||||
tx_total_taxes = sum(t["amount"] for t in tx_taxes)
|
||||
per_tx_list.append({
|
||||
"transactionTypeExt": tx,
|
||||
"invoiceCount": data["invoiceCount"],
|
||||
"payments": tx_payments,
|
||||
"totalPayments": tx_total_pmts,
|
||||
"taxItems": tx_taxes,
|
||||
"totalTax": tx_total_taxes,
|
||||
})
|
||||
|
||||
report_data = {
|
||||
"title": title,
|
||||
"number": 1,
|
||||
"dateTime": sada(),
|
||||
"tin": _firma["tin"],
|
||||
"businessName": _firma["name"],
|
||||
"locationName": _firma["locationName"],
|
||||
"address": _firma["address"],
|
||||
"district": _firma["district"],
|
||||
"uid": "550e8400-e29b-41d4-a716-000000000001",
|
||||
"startDate": start_date or datetime.now().strftime("%Y-%m-%d"),
|
||||
"endDate": end_date or datetime.now().strftime("%Y-%m-%d"),
|
||||
"total": {
|
||||
"invoiceCount": invoice_count,
|
||||
"payments": payments_list,
|
||||
"totalPayments": grand_total,
|
||||
"taxItems": tax_list,
|
||||
"totalTax": grand_tax,
|
||||
},
|
||||
"perTransactionType": per_tx_list,
|
||||
}
|
||||
return report_data
|
||||
|
||||
def resp_daily_report():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
locale = load_locale("latin")
|
||||
report_data = _build_report(locale.get("daily-report", "DNEVNI IZVEŠTAJ"), today, today)
|
||||
|
||||
# Snimi izveštaj
|
||||
report_path = RECEIPTS_DIR / f"daily-report-{today}.json"
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Generiši tekst izveštaj
|
||||
report_text = generate_report(report_data, "latin")
|
||||
text_path = RECEIPTS_DIR / f"daily-report-{today}.txt"
|
||||
text_path.write_text(report_text, encoding="utf-8")
|
||||
|
||||
log(f" 📊 DNEVNI IZVEŠTAJ | računa: {report_data['total']['invoiceCount']} | ukupno: {report_data['total']['totalPayments']:.2f}")
|
||||
return report_data
|
||||
|
||||
def resp_periodic_report():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
locale = load_locale("latin")
|
||||
report_data = _build_report(locale.get("periodic-report", "PERIODIČNI IZVEŠTAJ"), "2026-01-01", today)
|
||||
|
||||
report_path = RECEIPTS_DIR / f"periodic-report-{today}.json"
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
report_text = generate_report(report_data, "latin")
|
||||
text_path = RECEIPTS_DIR / f"periodic-report-{today}.txt"
|
||||
text_path.write_text(report_text, encoding="utf-8")
|
||||
|
||||
log(f" 📊 PERIODIČNI IZVEŠTAJ | računa: {report_data['total']['invoiceCount']} | ukupno: {report_data['total']['totalPayments']:.2f}")
|
||||
return report_data
|
||||
|
||||
def resp_notifications():
|
||||
return [{
|
||||
"id": "1",
|
||||
"type": "INFO",
|
||||
"message": "Sistem funkcioniše ispravno",
|
||||
"timestamp": sada(),
|
||||
}]
|
||||
|
||||
def resp_status_codes():
|
||||
return {
|
||||
"codes": [
|
||||
{"code": "S001", "description": "Uspešno potpisan račun"},
|
||||
{"code": "E001", "description": "Greška pri potpisivanju"},
|
||||
{"code": "E002", "description": "Kartica nije prisutna"},
|
||||
{"code": "E003", "description": "Pogrešan PIN"},
|
||||
{"code": "E004", "description": "Nema konekcije ka SUF serveru"},
|
||||
],
|
||||
}
|
||||
|
||||
# Mapiranje handler-a
|
||||
HANDLERS = {
|
||||
"attention": resp_attention,
|
||||
"status": resp_status,
|
||||
"environment": resp_environment,
|
||||
"subject": resp_subject,
|
||||
"invoice": resp_invoice,
|
||||
"invoice_lookup": resp_invoice_lookup,
|
||||
"verify_pin": resp_verify_pin,
|
||||
"open_drawer": resp_open_drawer,
|
||||
"print_text": resp_print_text,
|
||||
"receipt": resp_receipt,
|
||||
"receipt_text": resp_receipt,
|
||||
"receipt_html": resp_receipt_html,
|
||||
"daily_report": resp_daily_report,
|
||||
"daily_report_text": resp_daily_report,
|
||||
"periodic_report": resp_periodic_report,
|
||||
"periodic_report_text": resp_periodic_report,
|
||||
"notifications": resp_notifications,
|
||||
"status_codes": resp_status_codes,
|
||||
"settings_get": resp_settings_get,
|
||||
"settings_post": resp_settings_post,
|
||||
"certificate": resp_certificate,
|
||||
"invoice": resp_invoice,
|
||||
"invoice_final": resp_invoice_final,
|
||||
"invoice_last": resp_invoice_last,
|
||||
"invoice_by_request": resp_invoice_by_request,
|
||||
"invoice_by_number": resp_invoice_by_number,
|
||||
"invoice_search": resp_invoice_search,
|
||||
}
|
||||
|
||||
# ── HTTP Handler ─────────────────────────────────────────────
|
||||
|
||||
class FiscalHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Glavni handler za sve fiskalne endpoint-e."""
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
"""Override — koristi naš log umesto default stderr."""
|
||||
pass # Logujemo ručno u _handle
|
||||
pass # koristimo naš log
|
||||
|
||||
def do_GET(self):
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._handle("POST")
|
||||
def do_GET(self): self._handle("GET")
|
||||
def do_POST(self): self._handle("POST")
|
||||
def do_DELETE(self): self._handle("DELETE")
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""CORS preflight."""
|
||||
self.send_response(200)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept-Language, RequestId")
|
||||
self._cors()
|
||||
self.end_headers()
|
||||
|
||||
def _handle(self, method):
|
||||
path = self.path.split("?")[0]
|
||||
hdrs = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Accept-Language, RequestId",
|
||||
}
|
||||
status = 404
|
||||
body = None
|
||||
matched_route = None
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
# Nađi rutu
|
||||
r_handler_name = None
|
||||
for r_method, r_pattern, r_handler_name in DEFAULT_ROUTES:
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length == 0:
|
||||
return None
|
||||
raw = self.rfile.read(length).decode("utf-8")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
def _handle(self, method):
|
||||
path = self.path.split("?")[0].strip("/")
|
||||
status = 404
|
||||
body = {"error": "Not Found", "path": path}
|
||||
content_type = "application/json; charset=utf-8"
|
||||
matched = None
|
||||
|
||||
for r_method, r_pattern, r_name in ROUTES:
|
||||
if r_method != method:
|
||||
continue
|
||||
# Konvertuj :param u regex
|
||||
regex = re.sub(r":(\w+)", r"(?P<\1>[^/]+)", r_pattern)
|
||||
regex = f"^{regex}$"
|
||||
m = re.match(regex, path.strip("/"))
|
||||
if m:
|
||||
matched_route = r_pattern
|
||||
handler = HANDLERS.get(r_handler_name)
|
||||
if handler:
|
||||
request_id = self.headers.get("RequestId", "unknown")
|
||||
params = m.groupdict()
|
||||
regex = "^" + re.sub(r":(\w+)", r"(?P<\1>[^/]+)", r_pattern) + "$"
|
||||
m = re.match(regex, path)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
if r_handler_name == "invoice":
|
||||
# Pročitaj telo zahteva
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
request_body = None
|
||||
if content_len > 0:
|
||||
try:
|
||||
raw = self.rfile.read(content_len)
|
||||
request_body = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
request_body = None
|
||||
body = handler(request_id, request_body)
|
||||
elif r_handler_name == "verify_pin":
|
||||
# Pročitaj telo (PIN) — JSON {"pin": "..."} ili goli string
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
request_body = None
|
||||
if content_len > 0:
|
||||
try:
|
||||
raw = self.rfile.read(content_len).decode("utf-8")
|
||||
try:
|
||||
request_body = json.loads(raw)
|
||||
except Exception:
|
||||
request_body = raw
|
||||
except Exception:
|
||||
request_body = None
|
||||
body = handler(request_body)
|
||||
elif r_handler_name == "invoice_lookup":
|
||||
rid = params.get("requestId", "unknown")
|
||||
result = handler(rid)
|
||||
if result:
|
||||
matched = r_name
|
||||
handler = HANDLERS.get(r_name)
|
||||
params = m.groupdict()
|
||||
request_id = self.headers.get("RequestId", f"mock-{int(time.time())}")
|
||||
|
||||
# Poziv handlera
|
||||
if r_name in ("invoice", "invoice_final"):
|
||||
result = handler(request_id, self._read_body())
|
||||
elif r_name == "verify_pin":
|
||||
result = handler(self._read_body())
|
||||
elif r_name == "invoice_by_request":
|
||||
result = handler(params.get("requestId", ""))
|
||||
elif r_name == "invoice_by_number":
|
||||
result = handler(params.get("invoiceNumber", ""))
|
||||
elif r_name == "invoice_search":
|
||||
result = handler(self._read_body())
|
||||
elif r_name in ("settings_post",):
|
||||
self._read_body()
|
||||
result = handler()
|
||||
else:
|
||||
result = handler()
|
||||
|
||||
# Razdvoji (body, status) ako handler vratio tuple
|
||||
if isinstance(result, tuple):
|
||||
body, status = result
|
||||
else:
|
||||
body = result
|
||||
else:
|
||||
status = 404
|
||||
body = {"error": f"Račun {rid} nije pronađen"}
|
||||
elif r_handler_name == "receipt":
|
||||
body = handler(params.get("requestId", "unknown"))
|
||||
elif r_handler_name == "receipt_text":
|
||||
result = handler(params.get("requestId", "unknown"))
|
||||
body = result.get("receiptText", "Račun nije pronađen.")
|
||||
elif r_handler_name == "receipt_html":
|
||||
body = handler(params.get("requestId", "unknown"))
|
||||
else:
|
||||
body = handler()
|
||||
status = 200
|
||||
break
|
||||
|
||||
# Log
|
||||
emoji = "✅" if status == 200 else "❌"
|
||||
client = self.client_address[0]
|
||||
log(f" {emoji} {method} {path} → {matched_route or '404'} | {client}")
|
||||
|
||||
# Pošalji odgovor
|
||||
if r_handler_name in ("daily_report_text", "periodic_report_text") and body:
|
||||
report_text = generate_report(body, "latin")
|
||||
resp_bytes = report_text.encode("utf-8")
|
||||
hdrs["Content-Type"] = "text/plain; charset=utf-8"
|
||||
elif r_handler_name == "receipt_html" and body:
|
||||
# HTML odgovor
|
||||
resp_bytes = body.encode("utf-8") if isinstance(body, str) else body
|
||||
hdrs["Content-Type"] = "text/html; charset=utf-8"
|
||||
elif isinstance(body, str) and r_handler_name == "receipt_text":
|
||||
# Tekst odgovor
|
||||
resp_bytes = body.encode("utf-8") if isinstance(body, str) else body
|
||||
hdrs["Content-Type"] = "text/plain; charset=utf-8"
|
||||
elif body is not None:
|
||||
# Serializacija
|
||||
if r_name == "invoice_search" and isinstance(body, str):
|
||||
resp_bytes = body.encode("utf-8")
|
||||
content_type = "text/csv; charset=utf-8"
|
||||
elif isinstance(body, (dict, list)):
|
||||
resp_bytes = json.dumps(body, indent=2, ensure_ascii=False).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json; charset=utf-8"
|
||||
else:
|
||||
resp_bytes = json.dumps({"error": "Not Found", "path": path}, ensure_ascii=False).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json; charset=utf-8"
|
||||
resp_bytes = str(body).encode("utf-8")
|
||||
|
||||
emoji = "✅" if status == 200 else "❌"
|
||||
log(f" {emoji} {method} /{path} → {matched or '404'} [{status}]")
|
||||
|
||||
self.send_response(status)
|
||||
for k, v in hdrs.items():
|
||||
self.send_header(k, v)
|
||||
self._cors()
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(resp_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(resp_bytes)
|
||||
@@ -643,19 +505,18 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
|
||||
# ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
log("╔══════════════════════════════════════════════╗")
|
||||
log("║ 🧾 myLPFR Mock Server — Fiskalni server ║")
|
||||
log("║ http://{}:{}/ ║".format(HOST, PORT))
|
||||
log("║ {} ruta | QR: AUTO | Snimanje: UKLJUČENO ║".format(len(DEFAULT_ROUTES)))
|
||||
log("╚══════════════════════════════════════════════╝")
|
||||
firma = ucitaj_firmu()
|
||||
log("╔══════════════════════════════════════════════════╗")
|
||||
log("║ 🧾 Teron L-PFR Mock Server ║")
|
||||
log(f"║ http://{HOST}:{PORT}/ ║")
|
||||
log("╚══════════════════════════════════════════════════╝")
|
||||
log(f" 📁 Podaci: {DATA_DIR}")
|
||||
log(f" 🧾 Računi: {INVOICES_DIR}")
|
||||
log(f" 📱 QR PNG: {QR_DIR}")
|
||||
log(f" 📝 Log: {LOG_FILE}")
|
||||
f = ucitaj_firmu()
|
||||
log(f" 💳 Kartica (BE) — baza: {NTECH_DB}")
|
||||
log(f" Firma: {f['name']} | PIB: {f['tin']} | MB: {f['mb']}")
|
||||
log(f" Adresa: {f['address']} | Test PIN: {PIN_BE}")
|
||||
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(" ▶ Server pokrenut. Ctrl+C za gašenje.")
|
||||
|
||||
server = http.server.HTTPServer((HOST, PORT), FiscalHandler)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PID_FILE="$SCRIPT_DIR/data/server.pid"
|
||||
PORT=8989
|
||||
PORT=4566
|
||||
|
||||
# Proveri da li server već radi
|
||||
check_running() {
|
||||
|
||||
@@ -38,9 +38,19 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
- Security HTTP headers (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
||||
- Login attempt logging — history by user, IP, reason, date
|
||||
- Users and roles — admin panel, user management
|
||||
- Inventory — items, categories, filtering, critical stock levels
|
||||
- Service orders — intake, status bar, costs, receipt
|
||||
- Inventory — items, categories, filtering, critical stock levels, per-item stock card, supplier links, item transfers
|
||||
- Service orders:
|
||||
- Intake form, status bar, archive
|
||||
- Diagnostic workflow — fault description, technician notes, work done, diagnostic fee
|
||||
- Parts and services — used items deducted from stock; suggested items (proposal to client)
|
||||
- Client proposal approval — client receives a public link (QR code) to accept or reject a parts/service proposal with a comment
|
||||
- Public status page — client can check order status and receive notifications via a unique link
|
||||
- Documents — work order, pre-invoice (estimate), dispatch note, return slip, device label (QR + Code128 barcode)
|
||||
- Pickup with payment — tracks payment method and advance amount
|
||||
- Guarantee period, expected completion date, technician assignment, client notes
|
||||
- Sales orders — items, calculation, receipt with company and client details
|
||||
- Services catalog — service price list used for billing in service orders
|
||||
- Expenses — expense records with category and amount
|
||||
- Procurement — records of purchases from suppliers
|
||||
- Sales price calculation on procurement — markup (global, per category, per item), landed costs (customs, shipping...) allocated across items, two-way markup↔price computation; respects VAT-payer status
|
||||
- Price revaluation (nivelacija) — sales price changes with an audit trail (old→new, reason, source, user)
|
||||
@@ -50,7 +60,7 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
- VAT rate code list
|
||||
- Clients and suppliers — contact database
|
||||
- Reminders — records with deadlines
|
||||
- Reports — revenue overview, inventory status
|
||||
- Reports — revenue overview, inventory status, inventory value report, stock movement list, stocktake (physical count)
|
||||
- Settings — company name, address, Tax ID (PIB), logo; theme toggle
|
||||
- Background images — login page and app, with blur, transparency and glass effect
|
||||
- Personal theme and background — each user can set their own theme and background image
|
||||
@@ -62,9 +72,12 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
- Automated tests — unit and integration over a SQLite database (crypto, RBAC, login flows, form validators, reports)
|
||||
- **Demo mode** (`NTECH_ENV=demo`) — auto-created demo user, pre-filled login form, restricted backup count, blocked password/2FA changes
|
||||
|
||||
### In Progress
|
||||
|
||||
- **Fiscalization (ESIR/PFR)** — Teron L-PFR mock server included in `Fisk/`; Go client integration planned
|
||||
|
||||
### Planned
|
||||
|
||||
- Fiscalization (ESIR/PFR)
|
||||
- KPO book and double-entry bookkeeping (optional, later phase)
|
||||
- PostgreSQL support (for multi-user environments)
|
||||
- WebAuthn / Passkey login (database schema is already prepared)
|
||||
@@ -80,6 +93,7 @@ The goal is simple: everything the repair shop needs to track is located in one
|
||||
| [Go](https://go.dev) | backend language |
|
||||
| [chi](https://github.com/go-chi/chi) | HTTP router |
|
||||
| [html/template](https://pkg.go.dev/html/template) | server-side templates |
|
||||
| [HTMX](https://htmx.org) | dynamic HTML over HTTP |
|
||||
| [Alpine.js](https://alpinejs.dev) | client-side UI logic |
|
||||
| [SQLite](https://sqlite.org) + [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | main database (pure Go, no CGO) |
|
||||
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | optional production database |
|
||||
@@ -188,6 +202,78 @@ your.domain.com {
|
||||
}
|
||||
```
|
||||
|
||||
### With Teron L-PFR Mock (Fiscalization Testing)
|
||||
|
||||
The `Fisk/` directory contains a Python mock server that simulates a [Teron](https://teron.rs) L-PFR fiscal device (port 4566). It implements the Teron HTTP API — invoice signing, PDV calculation, QR code generation, and per-type counters — without requiring real hardware or a certificate.
|
||||
|
||||
Use it alongside NTech for fiscalization development and testing:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ntech:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
container_name: ntech
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
NTECH_ENV: production
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/web/static/uploads
|
||||
- ./logs:/var/log/ntech
|
||||
- ./backups:/app/backups
|
||||
networks:
|
||||
- ntech-net
|
||||
|
||||
teron-mock:
|
||||
image: ghcr.io/dalibor31/ntech-fisk:latest
|
||||
container_name: teron_mock
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- teron-data:/app/data
|
||||
networks:
|
||||
- ntech-net
|
||||
|
||||
volumes:
|
||||
teron-data:
|
||||
|
||||
networks:
|
||||
ntech-net:
|
||||
```
|
||||
|
||||
The `teron-mock` service is reachable from `ntech` at `http://teron-mock:4566` over the internal Docker network — the port is not exposed to the host.
|
||||
|
||||
To run the mock server locally (without Docker):
|
||||
|
||||
```bash
|
||||
cd Fisk
|
||||
pip install -r requirements.txt
|
||||
python server.py
|
||||
# or: ./start.sh
|
||||
```
|
||||
|
||||
#### Teron Mock Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/status` | Device status and last invoice number |
|
||||
| GET | `/api/attention` | Active alerts |
|
||||
| POST | `/api/pin` | PIN verification (BE unlock) |
|
||||
| GET | `/api/settings` | Device settings |
|
||||
| POST | `/api/invoices` | Issue a fiscal invoice |
|
||||
| POST | `/api/invoices/final` | Finalize an advance invoice |
|
||||
| GET | `/api/invoices/last` | Last issued invoice |
|
||||
| GET | `/api/invoices/:invoiceNumber` | Invoice by number |
|
||||
| POST | `/api/invoices/search` | Search invoices |
|
||||
|
||||
Counters, signed receipts, QR codes, and JSON invoice data are persisted in `Fisk/data/`.
|
||||
|
||||
---
|
||||
|
||||
### Demo Mode
|
||||
|
||||
Demo mode runs a fully functional copy with a pre-created `Demo` / `Demo1234` admin account. Password and 2FA changes are blocked. Backup is limited to 2 copies.
|
||||
|
||||
+90
-4
@@ -38,9 +38,19 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
- Bezbednosni HTTP headeri (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
||||
- Evidencija pokušaja prijave — istorija po korisniku, IP, razlog, datum
|
||||
- Korisnici i uloge — admin panel, upravljanje korisnicima
|
||||
- Magacin — artikli, kategorije, filtriranje, kritični nivoi zaliha
|
||||
- Servisni nalozi — prijem, statusna traka, troškovi, priznanica
|
||||
- Magacin — artikli, kategorije, filtriranje, kritični nivoi zaliha, magacinska kartica po artiklu, veza sa dobavljačima, premeštanje artikala
|
||||
- Servisni nalozi:
|
||||
- Forma prijema, statusna traka, arhiva
|
||||
- Tok dijagnostike — opis kvara, napomene servisera, urađeno, cena dijagnostike
|
||||
- Delovi i radovi — ugrađeni artikli se skidaju sa lagera; predloženi artikli (ponuda klijentu)
|
||||
- Odobravanje predloga — klijent dobija javni link (QR kod) da prihvati ili odbije predlog sa komentarom
|
||||
- Javna statusna stranica — klijent prati status naloga putem jedinstvenog linka
|
||||
- Dokumenti — radni nalog, predračun, otpremnica, revers, nalepnica za uređaj (QR + Code128 barkod)
|
||||
- Preuzimanje sa naplatom — način plaćanja i iznos avansa
|
||||
- Garancija, predviđen datum završetka, serviser, napomena klijentu
|
||||
- Prodajni nalozi — stavke, obračun, priznanica sa podacima firme i klijenta
|
||||
- Cenovnik usluga — šifarnik usluga za obračun u servisnim nalozima
|
||||
- Troškovi — evidencija troškova po kategoriji i iznosu
|
||||
- Nabavke — evidencija nabavki od dobavljača
|
||||
- Kalkulacija prodajne cene pri nabavci — marža (globalna, po kategoriji i po artiklu), zavisni troškovi (carina, prevoz...) sa raspodelom na stavke, dvosmerni izračun marža↔prodajna; poštuje status PDV obveznika
|
||||
- Nivelacija — promena prodajne cene uz trag (istorija promena: stara→nova, razlog, izvor, korisnik)
|
||||
@@ -50,7 +60,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
- Šifarnik PDV stopa
|
||||
- Klijenti i dobavljači — baza kontakata
|
||||
- Podsetnici — evidencija sa rokom
|
||||
- Izveštaji — pregled prihoda, stanje magacina
|
||||
- Izveštaji — pregled prihoda, stanje magacina, vrednost zaliha, prometni list, popis (inventura)
|
||||
- Podešavanja — naziv, adresa, PIB, logo firme; promena teme
|
||||
- Pozadinske slike — login stranica i aplikacija, sa zamućenjem, providnošću i glass efektom
|
||||
- Lična tema i pozadina — svaki korisnik može svoju temu i pozadinsku sliku
|
||||
@@ -62,9 +72,12 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
- Automatski testovi — jedinični i integracioni nad SQLite bazom (kripto, RBAC, tokovi prijave, validatori forme, izveštaji)
|
||||
- **Demo mod** (`NTECH_ENV=demo`) — automatski kreiran demo korisnik, pre-popunjeni login, ograničen bekap, blokirana promena lozinke i 2FA
|
||||
|
||||
### U toku
|
||||
|
||||
- **Fiskalizacija (ESIR/PFR)** — Teron L-PFR mock server dostupan u `Fisk/`; integracija Go klijenta u planu
|
||||
|
||||
### Planirano
|
||||
|
||||
- Fiskalizacija (ESIR/PFR)
|
||||
- KPO knjiga i dvojno knjigovodstvo (opciono, kasnija faza)
|
||||
- Podrška za PostgreSQL (za višekorisničko okruženje)
|
||||
- WebAuthn / Passkey prijava (šema baze je pripremljena)
|
||||
@@ -80,6 +93,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
||||
| [Go](https://go.dev) | backend jezik |
|
||||
| [chi](https://github.com/go-chi/chi) | HTTP ruter |
|
||||
| [html/template](https://pkg.go.dev/html/template) | serverski šabloni |
|
||||
| [HTMX](https://htmx.org) | dinamički HTML preko HTTP-a |
|
||||
| [Alpine.js](https://alpinejs.dev) | UI logika na strani klijenta |
|
||||
| [SQLite](https://sqlite.org) + [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | glavna baza (čisti Go, bez CGO) |
|
||||
| [PostgreSQL](https://www.postgresql.org) + [pgx/v5](https://github.com/jackc/pgx) | opciona baza za produkciju |
|
||||
@@ -188,6 +202,78 @@ tvoj.domen.com {
|
||||
}
|
||||
```
|
||||
|
||||
### Sa Teron L-PFR mokom (testiranje fiskalizacije)
|
||||
|
||||
Folder `Fisk/` sadrži Python mock server koji simulira [Teron](https://teron.rs) L-PFR fiskalni uređaj (port 4566). Implementira Teron HTTP API — potpisivanje računa, PDV obračun, generisanje QR koda i brojače po tipu računa — bez potrebe za pravim hardverom ili sertifikatom.
|
||||
|
||||
Koristi se uz NTech za razvoj i testiranje fiskalizacije:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ntech:
|
||||
image: ghcr.io/dalibor31/ntech:latest
|
||||
container_name: ntech
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
NTECH_ENV: production
|
||||
NTECH_PORT: "8000"
|
||||
NTECH_SQLITE: /app/data/ntech.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/web/static/uploads
|
||||
- ./logs:/var/log/ntech
|
||||
- ./backups:/app/backups
|
||||
networks:
|
||||
- ntech-net
|
||||
|
||||
teron-mock:
|
||||
image: ghcr.io/dalibor31/ntech-fisk:latest
|
||||
container_name: teron_mock
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- teron-data:/app/data
|
||||
networks:
|
||||
- ntech-net
|
||||
|
||||
volumes:
|
||||
teron-data:
|
||||
|
||||
networks:
|
||||
ntech-net:
|
||||
```
|
||||
|
||||
Servis `teron-mock` je dostupan iz `ntech` kontejnera na adresi `http://teron-mock:4566` preko interne Docker mreže — port nije izložen spolja.
|
||||
|
||||
Za lokalno pokretanje moka (bez Dockera):
|
||||
|
||||
```bash
|
||||
cd Fisk
|
||||
pip install -r requirements.txt
|
||||
python server.py
|
||||
# ili: ./start.sh
|
||||
```
|
||||
|
||||
#### Teron Mock endpointi
|
||||
|
||||
| Metod | Putanja | Opis |
|
||||
|-------|---------|------|
|
||||
| GET | `/api/status` | Status uređaja i poslednji broj računa |
|
||||
| GET | `/api/attention` | Aktivna upozorenja |
|
||||
| POST | `/api/pin` | Verifikacija PIN-a (otključavanje BE) |
|
||||
| GET | `/api/settings` | Podešavanja uređaja |
|
||||
| POST | `/api/invoices` | Izdavanje fiskalnog računa |
|
||||
| POST | `/api/invoices/final` | Finalizacija avansnog računa |
|
||||
| GET | `/api/invoices/last` | Poslednji izdati račun |
|
||||
| GET | `/api/invoices/:invoiceNumber` | Račun po broju |
|
||||
| POST | `/api/invoices/search` | Pretraga računa |
|
||||
|
||||
Brojači, potpisane priznanice, QR kodovi i JSON podaci o računima čuvaju se u `Fisk/data/`.
|
||||
|
||||
---
|
||||
|
||||
### Demo mod
|
||||
|
||||
Demo mod pokreće potpuno funkcionalnu kopiju sa pre-kreiranim nalogom `Demo` / `Demo1234` (admin). Promena lozinke i 2FA su blokirani. Bekap je ograničen na 2 kopije.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
teron-mock:
|
||||
image: ghcr.io/dalibor31/ntech-fisk:latest
|
||||
container_name: teron_mock
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4566:4566"
|
||||
volumes:
|
||||
- teron-data:/app/data
|
||||
|
||||
volumes:
|
||||
teron-data:
|
||||
Reference in New Issue
Block a user