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
-, : dokumentacija
This commit is contained in:
2026-06-26 23:17:52 +02:00
parent 985a2e7410
commit 97fd4490ea
21 changed files with 1425 additions and 116 deletions
+27
View File
@@ -36,6 +36,8 @@ type podaciAdminProfil struct {
LokalnaTema string
KoristiLokalnuTemu bool
JelDemo bool
Ime string
Prezime string
}
type podaciProfilTema struct {
@@ -310,6 +312,8 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
LokalnaTema: svezi.LokalnaTema,
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu,
JelDemo: h.JelDemo,
Ime: svezi.Ime,
Prezime: svezi.Prezime,
})
}
@@ -405,6 +409,29 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
}
// AdminSacuvajImePrezime snima ime i prezime prijavljenog korisnika
func (h *Handler) AdminSacuvajImePrezime(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
ime := r.FormValue("ime")
prezime := r.FormValue("prezime")
if err := h.KorisniciRepo.AzurirajImePrezime(r.Context(), k.ID, ime, prezime); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Ime i prezime su sačuvani.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
}
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
+92
View File
@@ -0,0 +1,92 @@
package handler
import (
"encoding/base64"
"encoding/json"
"net/http"
)
// FiskalVlPodaci su podaci dekodirani iz vl parametra QR koda.
type FiskalVlPodaci struct {
InvoiceNumber string `json:"n"`
InvoiceCounter string `json:"ic"`
DateTime string `json:"t"`
TotalAmount float64 `json:"a"`
TIN string `json:"c"`
Company string `json:"co"`
Store string `json:"lo"`
Address string `json:"ad"`
City string `json:"g"`
District string `json:"di"`
InvoiceType string `json:"it"`
TransactionType string `json:"tr"`
TaxItems []FiskalTaxItem `json:"tx"`
Payments []FiskalPayment `json:"pm"`
Cashier string `json:"ca"`
BuyerID string `json:"bi"`
Items []FiskalItem `json:"items"`
}
type FiskalTaxItem struct {
Label string `json:"label"`
CategoryName string `json:"categoryName"`
Rate float64 `json:"rate"`
Base float64 `json:"base"`
Amount float64 `json:"amount"`
}
type FiskalPayment struct {
Type string `json:"type"`
Amount float64 `json:"amount"`
}
type FiskalItem struct {
Name string `json:"name"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
TotalAmount float64 `json:"totalAmount"`
Labels []string `json:"labels"`
}
type fiskalVerifikacijaPodaci struct {
Podaci *FiskalVlPodaci
Greska string
JeFiskalni bool
}
func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
vl := r.URL.Query().Get("vl")
podaci := &fiskalVerifikacijaPodaci{}
if vl == "" {
podaci.Greska = "Nedostaje vl parametar."
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
return
}
decoded, err := base64.StdEncoding.DecodeString(vl)
if err != nil {
// proba RawStdEncoding (bez paddinga) i URLEncoding
decoded, err = base64.RawStdEncoding.DecodeString(vl)
if err != nil {
podaci.Greska = "Neispravan QR kod."
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
return
}
}
var vlPodaci FiskalVlPodaci
if err := json.Unmarshal(decoded, &vlPodaci); err != nil {
podaci.Greska = "Neispravan format podataka."
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
return
}
podaci.Podaci = &vlPodaci
podaci.JeFiskalni = vlPodaci.InvoiceType != "Copy" &&
vlPodaci.InvoiceType != "Training" &&
vlPodaci.InvoiceType != "Proforma"
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
}
+1 -1
View File
@@ -43,7 +43,7 @@ var saSidebar = []string{
// standalone su šabloni bez base layouta
var standaloneIme = []string{
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list",
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "fiskal_verifikacija",
}
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
+110 -3
View File
@@ -76,6 +76,12 @@ type PodaciPodesavanja struct {
QrBazniUrl string
PfrUrl string
PfrTip string
PfrKasir string
VerifyHost string
BePin string
BeLimit string
BeEnabled string
FiskalPismo string
LoginPozadina string
LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string
@@ -331,9 +337,15 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
"firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"),
"firma_fiskalizacija": r.FormValue("firma_fiskalizacija"),
"firma_rezim": r.FormValue("firma_rezim"),
// fiskalizacija — L-PFR podešavanja
"pfr_url": r.FormValue("pfr_url"),
"pfr_tip": r.FormValue("pfr_tip"),
// fiskalizacija — L-PFR i kartica emulator podešavanja
"pfr_url": r.FormValue("pfr_url"),
"pfr_tip": r.FormValue("pfr_tip"),
"pfr_kasir": r.FormValue("pfr_kasir"),
"verify_host": r.FormValue("verify_host"),
"be_pin": r.FormValue("be_pin"),
"be_limit": r.FormValue("be_limit"),
"be_enabled": r.FormValue("be_enabled"),
"fiskalni_pismo": r.FormValue("fiskalni_pismo"),
}
for kljuc, vrednost := range polja {
@@ -865,6 +877,12 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
QrBazniUrl: podesavanja["qr_bazni_url"],
PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"),
PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"),
PfrKasir: podesavanja["pfr_kasir"],
VerifyHost: podesavanja["verify_host"],
BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"),
BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"),
BeEnabled: vrednostIliDefault(podesavanja, "be_enabled", "true"),
FiskalPismo: vrednostIliDefault(podesavanja, "fiskalni_pismo", "latin"),
}, nil
}
@@ -968,6 +986,95 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
</div>`, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime))
}
// BeStatus se spaja na lokalni kartica emulator (TCP :4567) i vraća HTMX fragment sa statusom.
func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
return
}
bePort := os.Getenv("BE_PORT")
if bePort == "" {
bePort = "4567"
}
addr := "127.0.0.1:" + bePort
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`,
html.EscapeString(addr), html.EscapeString(err.Error()))
return
}
defer conn.Close()
cmd := `{"command":"status"}` + "\n"
conn.Write([]byte(cmd))
conn.SetDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
conn.Close()
var st map[string]any
if err := json.Unmarshal(buf[:n], &st); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor</div>`)
return
}
total, _ := st["total_counter"].(float64)
unread, _ := st["unread_amount"].(float64)
limit, _ := st["limit"].(float64)
pinReq, _ := st["pin_required"].(bool)
pinTekst := "PIN unesen"
if pinReq {
pinTekst = "Čeka PIN"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status uspeh">
&#10003; Kartica emulator aktivan
<div class="fisk-status-detalji">
<span>Ukupno računa: %d</span>
<span>Neisčitano: %s / %s din</span>
<span>%s</span>
</div>
</div>`,
int(total),
html.EscapeString(formatirajDinare(unread, 0)),
html.EscapeString(formatirajDinare(limit, 0)),
html.EscapeString(pinTekst),
)
}
// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok {
return
}
bePort := os.Getenv("BE_PORT")
if bePort == "" {
bePort = "4567"
}
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+bePort, 2*time.Second)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan</div>`)
return
}
conn.Write([]byte(`{"command":"reset_audit"}` + "\n"))
conn.SetDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 512)
n, _ := conn.Read(buf)
conn.Close()
var resp map[string]any
json.Unmarshal(buf[:n], &resp)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status uspeh">&#10003; Neisčitani iznos resetovan na 0 (Proof of Audit simuliran)</div>`)
}
// jePrivatnaAdresa proverava da li je hostname u opsegu privatnih/lokalnih mreža.
// Dozvoljeni: 127.x.x.x, localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x.
func jePrivatnaAdresa(hostname string) bool {
+79 -34
View File
@@ -526,23 +526,37 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
// self-heal: ako u međuvremenu ima stanja na magacinu za potraživane artikle,
// povuci ih (delimično ili u celosti) i otključaj nalog ako je sve pokriveno.
// Hvata sve načine na koje stanje poraste, ne samo nabavku/izmenu/popis.
if potrazivani, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil && len(potrazivani) > 0 {
vidjeni := map[int64]bool{}
for _, p := range potrazivani {
if vidjeni[p.ArtikalID] {
continue
// Samo predlozeno=false redovi blokiraju nalog — predloženi se ignorišu.
if potrazivaniSvi, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil {
var potrazivani []model.ServisniPotrazivaniDeo
for _, p := range potrazivaniSvi {
if !p.Predlozeno {
potrazivani = append(potrazivani, p)
}
vidjeni[p.ArtikalID] = true
otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID)
if err != nil {
slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err)
continue
}
if len(potrazivani) == 0 && nalog.Status == model.StatusCekaDelove {
// nema predlozeno=0 redova koji blokiraju — resetuj status odmah
if err := h.ServisRepo.AzurirajStatus(r.Context(), id, model.StatusPrimljeno); err != nil {
slog.Error("self-heal reset statusa (nema predlozeno=0) nije uspeo", "nalog_id", id, "error", err)
}
for _, nalogID := range otkljucani {
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
} else if len(potrazivani) > 0 {
vidjeni := map[int64]bool{}
for _, p := range potrazivani {
if vidjeni[p.ArtikalID] {
continue
}
vidjeni[p.ArtikalID] = true
otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID)
if err != nil {
slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err)
continue
}
for _, nalogID := range otkljucani {
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
}
}
}
}
@@ -1929,11 +1943,19 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
return
}
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi —
// prvo nabaviti delove, pa obrisati iz potraživanih (ili kroz nabavku)
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi (predlozeno=false)
// predloženi delovi (predlozeno=true) ne blokiraju nalog
if e == nil && trenutni.Status == model.StatusCekaDelove && noviStatus != model.StatusCekaDelove {
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err == nil && len(potrazivani) > 0 {
sviPotrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
var blokirajuci int
if err == nil {
for _, p := range sviPotrazivani {
if !p.Predlozeno {
blokirajuci++
}
}
}
if blokirajuci > 0 {
middleware.SetFlash(w, r, h.DB, "greska",
"Nalog ne može da napusti „Čeka delove\" dok ima delova koji nedostaju. Obrišite ih iz tabele ili dopunite zalihe.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
@@ -2096,7 +2118,17 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
}
}
kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
kasir := ""
if kor := middleware.KorisnikIzKonteksta(ctx); kor != nil {
if kor.Ime != "" || kor.Prezime != "" {
kasir = strings.TrimSpace(kor.Ime + " " + kor.Prezime)
} else {
kasir = kor.KorisnickoIme
}
}
if kasir == "" {
kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
}
if kasir == "" {
kasir = "NTech"
}
@@ -2159,11 +2191,6 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
return
}
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style>body{font-family:monospace;font-size:12px;white-space:pre;padding:20px;max-width:400px;margin:0 auto;}
@media print{body{font-size:11px;padding:10px}}</style></head><body>`)
journal := ""
if fr.SiroviOdgovor != "" {
var raw map[string]any
@@ -2173,17 +2200,35 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
}
}
}
// zameni QR placeholder pravim QR kodom
if fr.QRKod != "" && strings.Contains(journal, "{{{{QR-KOD}}}}") {
qrImg := `<img src="data:image/png;base64,` + fr.QRKod + `" style="width:25mm;height:25mm;display:block;margin:8px auto;">`
journal = strings.ReplaceAll(journal, "{{{{QR-KOD}}}}", qrImg)
}
fmt.Fprint(w, journal)
if nalog != nil {
fmt.Fprintf(w, "\n\n--- NTech servisni nalog: %s ---\n", nalog.BrojNaloga)
journal += "\n\n--- NTech servisni nalog: " + nalog.BrojNaloga + " ---\n"
}
// Podeli journal na deo pre i posle QR placeholder-a
const qrPlaceholder = "{{{{QR-KOD}}}}"
pre, post, hasQR := strings.Cut(journal, qrPlaceholder)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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;}}
</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.Fprint(w, `<pre>`)
fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`)
fmt.Fprint(w, `<p style="margin-top:16px;"><button onclick="window.print()" style="padding:8px 16px;">Štampaj</button></p></body></html>`)
}
func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {