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
+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.