PFR API ključ, fiskalni račun formatiranje, popravke

- PFR API ključ (pfr_api_key) u podešavanjima i fiskalizaciji
- Formatiranje fiskalnog računa: veličina fonta, QR kod, layout
- PFR mock: QR box_size/border, automatski PIN retry posle BE restarta
- Servis: animirane kanban kartice, formatiranje računa
- Dashboard i testovi: sitne ispravke
This commit is contained in:
2026-07-30 02:25:12 +02:00
parent ee265f81a9
commit 0fb9fe1e37
16 changed files with 119 additions and 36 deletions
+17 -4
View File
@@ -227,7 +227,10 @@ def log(msg):
# ── QR ──────────────────────────────────────────────────────
def generate_qr(url):
img = qrcode.make(url)
qr = qrcode.QRCode(box_size=4, border=2)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8")
@@ -366,12 +369,22 @@ def _build_invoice_response(req, request_id):
"total_amount": total_amount,
})
# Ako je BE restovan posle naše PIN verifikacije, pinUnesen je opet False —
# resetuj flag, ponovo verifikuj PIN, pa probaj sign još jednom.
if sign.get("status") != "ok" and "PIN" in (sign.get("message") or ""):
global _be_pin_verifikovan
_be_pin_verifikovan = False
_osiguraj_be_pin()
sign = be_command({
"command": "sign",
"invoice_type": invoice_type,
"transaction_type": transaction_type,
"total_amount": total_amount,
})
if sign.get("status") == "blocked":
raise RuntimeError(f"Kartica blokirana: {sign.get('message')}")
if sign.get("status") != "ok":
# npr. "error"/2101 (PIN nije verifikovan) — ne sme tiho da propadne u
# podrazumevane vrednosti counter=1 (v. BUG.md #34), jer bi to izdalo
# fiskalni broj koji se ponavlja iz računa u račun.
raise RuntimeError(f"Kartica potpis nije uspeo: {sign.get('message', sign.get('status'))}")
jid = cert.get("jid", ESIR_ID)
+1
View File
@@ -324,6 +324,7 @@ func main() {
r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
r.With(modul("fiskalizacija"), doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan)
r.With(modul("fiskalizacija"), doz("prodaja.dodaj")).Post("/fiskal/batch-fiskalizuj", h.BatchFiskalizacija)
r.With(pregledPodesavanja).Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// maxKonekcija ograničava broj istovremenih TCP konekcija — bez ovoga bi klijent
// koji samo otvori konekciju i ništa ne pošalje mogao da drži neograničen broj
// gorutina/file descriptor-a (v. BUG.md #36).
// gorutina/file descriptor-a.
const maxKonekcija = 32
// citanjeTimeout je rok po redu (komandi) koji se osvežava pre svakog čitanja —
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"time"
)
// TestObradiKonekcijaZatvaraNakonTisine proverava ispravku BUG.md #36 — konekcija
// TestObradiKonekcijaZatvaraNakonTisine proverava — konekcija
// koja ništa ne pošalje mora biti zatvorena posle citanjeTimeout-a, ne držana
// zauvek.
func TestObradiKonekcijaZatvaraNakonTisine(t *testing.T) {
+1 -1
View File
@@ -47,7 +47,7 @@ func nadjiLokalneAdrese() []string {
// obradiSetupPotvrdi prima izabrani port sa setup stranice, upisuje ntech.env i
// signalizira gotov kanalu da server može da se ugasi. Odbija nevažeći JSON i
// portove van dozvoljenog opsega (v. BUG.md #38 — ranije se greška dekodiranja
// portove van dozvoljenog opsega (ranije se greška dekodiranja
// tiho gutala i upisivala port 0).
func obradiSetupPotvrdi(envFajl string, gotov chan<- struct{}) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
+6 -6
View File
@@ -65,7 +65,7 @@ func TestIzvestajPraznaBaza(t *testing.T) {
}
}
// ─── BUG-01: stornirani prodajni nalozi ne smeju ući u prihod ───────────────
// ─── stornirani prodajni nalozi ne smeju ući u prihod ────────────────────────
func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background()
@@ -160,7 +160,7 @@ func TestTopKlijenti_IgnorisuSeStornirani(t *testing.T) {
}
}
// ─── BUG-02/03: servis prihod koristi naplaceno (bruto, uključuje delove) ───
// ─── servis prihod koristi naplaceno (bruto, uključuje delove) ────────────────
func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) {
ctx := context.Background()
@@ -221,7 +221,7 @@ func TestMesecniPrihodServis_KoristiNaplaceno(t *testing.T) {
}
}
// ─── BUG-10: avans se uračunava u prihod (naplaceno + avans) ─────────────────
// ─── avans se uračunava u prihod (naplaceno + avans) ──────────────────────────
func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) {
ctx := context.Background()
@@ -280,7 +280,7 @@ func TestPrihodTekuciMesec_GarancijaNeUlazi(t *testing.T) {
}
}
// ─── BUG-08: TopKlijenti broji samo preuzete naloge sa naplatom/avansom ──────
// ─── TopKlijenti broji samo preuzete naloge sa naplatom/avansom ───────────────
func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) {
ctx := context.Background()
@@ -343,7 +343,7 @@ func TestPrihodTekuciMesec_Kombinovani(t *testing.T) {
}
}
// ─── BUG-04: auto-izračun cena_konacna ne sme da uključuje delove ────────────
// ─── auto-izračun cena_konacna ne sme da uključuje delove ─────────────────────
//
// Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u
// Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa
@@ -381,7 +381,7 @@ func TestServisAutoIzracunCeneKonacne_BezDuplogDela(t *testing.T) {
`INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID))
// simuliramo ispravnu handler logiku (BUG-04 fix):
// simuliramo ispravnu handler logiku:
// cena_konacna = dijagnostika + radovi (BEZ delova)
cenaKonacna := 200.0 + 200.0 // dijagnostika + rad
if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil {
+1 -1
View File
@@ -89,7 +89,7 @@ func TestIzdajRacunKontekstIstekao(t *testing.T) {
}
func TestIzdajRacunPrezivljavaOtkazivanjeOriginalnogKonteksta(t *testing.T) {
// simulira #35 iz BUG.md: HTTP handler-ov r.Context() se otkaže (npr. klijent
// simulira: HTTP handler-ov r.Context() se otkaže (npr. klijent
// zatvori konekciju), ali fiskalni poziv nastavlja preko odvojenog konteksta.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(30 * time.Millisecond)
+3
View File
@@ -287,6 +287,7 @@ type PodaciPodesavanja struct {
PfrUrl string
PfrTip string
PfrKasir string
PfrApiKey string
VerifyHost string
BePin string
BeLimit string
@@ -689,6 +690,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
"pfr_tip": r.FormValue("pfr_tip"),
"pfr_kasir": r.FormValue("pfr_kasir"),
"verify_host": r.FormValue("verify_host"),
"pfr_api_key": r.FormValue("pfr_api_key"),
"be_pin": r.FormValue("be_pin"),
"be_limit": r.FormValue("be_limit"),
"be_enabled": r.FormValue("be_enabled"),
@@ -1225,6 +1227,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"),
PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"),
PfrKasir: podesavanja["pfr_kasir"],
PfrApiKey: podesavanja["pfr_api_key"],
VerifyHost: podesavanja["verify_host"],
BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"),
BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"),
+6 -8
View File
@@ -508,20 +508,18 @@ func (h *Handler) StampaFiskalnogProdaje(w http.ResponseWriter, r *http.Request)
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style>
*{box-sizing:border-box;}
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
@media print{body{font-size:11px;padding:10px;}}
body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
pre img{width:53.5mm;height:auto;}
@media screen{pre img{width:289px;}}
@media print{body{font-size:7px;padding:10px;}}
</style></head><body>`)
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, pre)
fmt.Fprint(w, `</pre>`)
if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
}
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`)
+6 -9
View File
@@ -2980,10 +2980,11 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style>
*{box-sizing:border-box;}
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
.ntech-razdvajac{margin:24px 0;border-top:2px dashed #999;padding-top:24px;}
@media print{body{font-size:11px;padding:10px;}.ntech-razdvajac{page-break-before:always;}}
body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
pre img{width:53.5mm;height:auto;}
@media screen{pre img{width:289px;}}
@media print{body{font-size:7px;padding:10px;}.ntech-razdvajac{page-break-before:always;}}
</style></head><body>`)
const qrPlaceholder = "{{{{QR-KOD}}}}"
@@ -3007,13 +3008,9 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, pre)
fmt.Fprint(w, `</pre>`)
if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
}
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`)
}
+52 -2
View File
@@ -2,9 +2,12 @@ package handler
import (
"context"
"net/http"
"strconv"
"time"
appdb "ntech/internal/db"
"ntech/internal/middleware"
"ntech/internal/model"
)
@@ -174,12 +177,14 @@ func (h *Handler) kirKandidatiServisa(ctx context.Context) ([]model.ServisniNalo
// (double-submit) pa ih automatski backfill preskače.
func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) {
var p model.PrazninaKnjigovodstva
var kandidatiP []kandidatUskladjivanja
if h.modulUkljucen(ctx, "pdv") {
_, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx)
_, postojiMapP, kp, err := h.kirKandidatiProdaje(ctx)
if err != nil {
return p, err
}
kandidatiP = kp
_, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
if err != nil {
return p, err
@@ -211,10 +216,11 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
}
if h.modulUkljucen(ctx, "kpo") {
_, postojiMapP, kandidatiP, err := h.kpoKandidatiProdaje(ctx)
_, postojiMapP, kp, err := h.kpoKandidatiProdaje(ctx)
if err != nil {
return p, err
}
kandidatiP = kp
_, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx)
if err != nil {
return p, err
@@ -258,6 +264,17 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
}
p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
}
// sumnjivi duplikati za fiskal — isti kandidati kao za KPO/PDV,
// ako su već učitani; ako ne, učitavamo iz prodaje (samo brojimo,
// preskaču se u automatskoj batch fiskalizaciji)
if len(kandidatiP) > 0 {
dupli := sumnjiviDuplikati(kandidatiP)
for id := range dupli {
p.SumnjiviFiskalID = append(p.SumnjiviFiskalID, id)
}
}
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefundaProdajaID = idsP
p.BezRefundaServisID = idsS
@@ -330,3 +347,36 @@ func istKlijent(a, b *int64) bool {
}
return *a == *b
}
// BatchFiskalizacija pokreće fiskalizaciju svih prodaja i servisa koji nemaju
// fiskalni račun. Best-effort: ako neka fiskalizacija padne, nastavlja se.
func (h *Handler) BatchFiskalizacija(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
klijent := h.fiskalKlijent(ctx)
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
izdato := 0
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
for id := range m {
h.fiskalizujProdaju(ctx, id, klijent, 0)
izdato++
}
}
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
for id := range m {
h.fiskalizujServis(ctx, id, klijent, "Gotovina", 0, 0)
izdato++
}
}
if izdato == 0 {
middleware.SetFlash(w, r, h.DB, "uspeh", "Svi nalozi već imaju fiskalni račun.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Pokrenuta fiskalizacija za "+strconv.Itoa(izdato)+" naloga.")
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
+1 -1
View File
@@ -19,7 +19,7 @@ func parseID(s string) (int64, error) {
// znaka, a inače "" (1-2 znaka se ignorišu). Ranije je ovo bio klijentski
// hx-trigger uslov (keyup[length==0||length>=3]) — uklonjen jer ga htmx
// evaluira preko eval()/new Function(), što CSP bez 'unsafe-eval' blokira
// (v. BUG.md #41). Ovde se ista optimizacija radi na serveru, bez eval-a.
// Ovde se ista optimizacija radi na serveru, bez eval-a.
func ignorisiKratkuPretragu(pretraga string) string {
if l := len([]rune(strings.TrimSpace(pretraga))); l > 0 && l < 3 {
return ""
+1
View File
@@ -95,6 +95,7 @@ type PrazninaKnjigovodstva struct {
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
SumnjiviDupli int
SumnjiviFiskalID []int64 // ID-jevi sumnjivih duplikata u fiskalizaciji — preskaču se u automatskoj batch fiskalizaciji
}
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
+13 -1
View File
@@ -51,7 +51,19 @@
<div style="display:flex;gap:8px;flex-wrap:wrap;">
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }}
</div>
{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) }}
<form method="POST" action="/fiskal/batch-fiskalizuj" style="margin:0;">
<input type="hidden" name="_csrf" value="{{ $.CsrfToken }}">
<button type="submit" class="btn-primarno" style="font-size:13px;">Fiskalizuj sve</button>
</form>
{{ end }}
{{ if and .PrazninaKnjige.SumnjiviFiskalID (gt (len .PrazninaKnjige.SumnjiviFiskalID) 0) }}
<div style="margin-top:4px;font-size:12px;opacity:0.7;">
⚠ Sumnjivi duplikati (preskaču se):
{{ range $i, $id := .PrazninaKnjige.SumnjiviFiskalID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#b45309;text-decoration:line-through;">Prodaja #{{ $id }}</a>{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
{{ end }}
@@ -54,6 +54,14 @@
</div>
</div>
<div>
<label for="pfr_api_key" class="polje-labela">API ključ</label>
<input type="text" id="pfr_api_key" name="pfr_api_key" value="{{.PfrApiKey}}"
placeholder="mock-api-key-0000"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;font-family:monospace;">
<div class="pomocni-tekst" style="margin-top:4px;">Bearer token koji NTech šalje PFR serveru. Mock podrazumevano: <code>mock-api-key-0000</code>.</div>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<button type="submit"
style="padding:9px 18px;background:var(--sb-akcent);border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;">
+1 -1
View File
@@ -48,7 +48,7 @@
<div class="kanban-kartice">
{{range $.Nalozi}}
{{if eq .Status $status}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta{{if .ImaPredlog}} kanban-ceka-odobrenje{{end}}">
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta animiraj{{if .ImaPredlog}} kanban-ceka-odobrenje{{end}}">
<div style="display:flex;align-items:center;gap:6px;">
{{if .ImaPredlog}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;" title="Čeka odobrenje klijenta"></span>{{end}}
<span style="font-size:14px;font-weight:500;color:var(--sb-akcent);font-family:monospace;">{{.BrojNaloga}}</span>