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