Files
GoNtech/Koncept.md
T
Dasko e6215a43fe 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
- Koncept.md, Magacin.md: dokumentacija
2026-06-26 23:17:52 +02:00

1010 lines
42 KiB
Markdown
Raw Blame History

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