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
This commit is contained in:
2026-06-26 23:17:52 +02:00
parent 9197404db0
commit e6215a43fe
23 changed files with 2778 additions and 116 deletions
+299
View File
@@ -0,0 +1,299 @@
// Package be implementira emulator bezbednosnog elementa (pametne kartice).
// Sluša na TCP portu i obrađuje 4 JSON komande: status, certificate, verify_pin, sign.
// Podatke o firmi čita iz NTech SQLite baze pri pokretanju.
package be
import (
"database/sql"
"encoding/base64"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strconv"
"strings"
"sync"
"time"
)
// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača)
var tipExt = map[[2]string][2]string{
{"Normal", "Sale"}: {"ПП", "pp"},
{"Normal", "Refund"}: {"ПР", "pr"},
{"Advance", "Sale"}: {"АП", "ap"},
{"Advance", "Refund"}: {"АР", "ar"},
{"Copy", "Sale"}: {"КП", "kp"},
{"Copy", "Refund"}: {"КР", "kr"},
{"Training", "Sale"}: {"ОП", "op"},
{"Training", "Refund"}: {"ОР", "or"},
{"Proforma", "Sale"}: {"РП", "rp"},
{"Proforma", "Refund"}: {"РР", "rr"},
}
// Kartica drži stanje emuliranog bezbednosnog elementa.
type Kartica struct {
mu sync.Mutex
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
JID string
PIN string
TIN string // RS+PIB
TINPlain string // samo PIB
Name string
Address string
City string
District string
BusinessUnitID string
LocationName string
ValidFrom string
ValidTo string
Issuer string
// stanje u toku rada
pinUnesen bool
totalCounter int
counters map[string]int
Limit float64
unreadAmount float64
}
func novaKartica(db *sql.DB) *Kartica {
k := &Kartica{
JID: env("BE_JID", "TRNMOCK1"),
PIN: env("BE_PIN", "1234"),
Limit: envFloat("BE_LIMIT", 500000),
Issuer: "Poreska uprava RS",
counters: map[string]int{
"pp": 0, "pr": 0, "ap": 0, "ar": 0,
"kp": 0, "kr": 0, "op": 0, "or": 0,
"rp": 0, "rr": 0,
},
}
now := time.Now()
k.ValidFrom = now.Format("2006-01-02T00:00:00+01:00")
k.ValidTo = now.AddDate(3, 0, 0).Format("2006-01-02T00:00:00+01:00")
// čitaj podatke firme, PIN i limit iz baze (env var ima prednost)
k.ucitajFirmu(db)
slog.Info("kartica emulator inicijalizovan",
"jid", k.JID,
"tin", k.TINPlain,
"firma", k.Name,
"limit", k.Limit,
)
return k
}
func (k *Kartica) ucitajFirmu(db *sql.DB) {
rows, err := db.Query(
"SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " +
"('naziv_firme','pib','maticni_broj','adresa','telefon'," +
"'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," +
"'be_pin','be_limit')",
)
if err != nil {
slog.Warn("be: ne mogu da čitam podesavanja", "error", err)
k.postaviTestPodatke()
return
}
defer rows.Close()
p := map[string]string{}
for rows.Next() {
var k2, v string
if err := rows.Scan(&k2, &v); err == nil {
p[k2] = v
}
}
naziv := p["naziv_firme"]
if naziv == "" {
naziv = "Test Company DOO"
}
pib := p["pib"]
if pib == "" {
pib = "123456789"
}
k.Name = naziv
k.TINPlain = pib
k.TIN = "RS" + pib
k.Address = p["adresa"]
if k.Address == "" {
k.Address = "Test Adresa 1"
}
k.City = p["grad"]
if k.City == "" {
k.City = "Beograd"
}
k.District = p["opstina"]
if k.District == "" {
k.District = "Savski Venac"
}
k.LocationName = p["poslovna_jedinica_naziv"]
if k.LocationName == "" {
k.LocationName = naziv
}
k.BusinessUnitID = p["poslovna_jedinica_oznaka"]
if k.BusinessUnitID == "" {
k.BusinessUnitID = "BU-001"
}
// PIN i limit iz baze — samo ako env var nije postavljen
if os.Getenv("BE_PIN") == "" {
if pin := p["be_pin"]; pin != "" {
k.PIN = pin
}
}
if os.Getenv("BE_LIMIT") == "" {
if lim := p["be_limit"]; lim != "" {
if f, err := strconv.ParseFloat(lim, 64); err == nil && f > 0 {
k.Limit = f
}
}
}
}
func (k *Kartica) postaviTestPodatke() {
k.Name = "Test Company DOO"
k.TINPlain = "123456789"
k.TIN = "RS123456789"
k.Address = "Test Adresa 1"
k.City = "Beograd"
k.District = "Savski Venac"
k.LocationName = "Test Company DOO"
k.BusinessUnitID = "BU-001"
}
// ── Komande ────────────────────────────────────────────────
func (k *Kartica) cmdStatus() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
counters := map[string]int{}
for key, val := range k.counters {
counters[key] = val
}
return map[string]any{
"status": "ok",
"total_counter": k.totalCounter,
"counters": counters,
"limit": k.Limit,
"unread_amount": k.unreadAmount,
"pin_required": !k.pinUnesen,
}
}
func (k *Kartica) cmdCertificate() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
return map[string]any{
"status": "ok",
"jid": k.JID,
"tin": k.TIN,
"tin_plain": k.TINPlain,
"name": k.Name,
"address": k.Address,
"city": k.City,
"district": k.District,
"business_unit_id": k.BusinessUnitID,
"location_name": k.LocationName,
"valid_from": k.ValidFrom,
"valid_to": k.ValidTo,
"issuer": k.Issuer,
}
}
func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
if pin != k.PIN {
return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"}
}
k.pinUnesen = true
return map[string]any{"status": "ok"}
}
func (k *Kartica) cmdResetAudit() map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
k.unreadAmount = 0
return map[string]any{"status": "ok", "unread_amount": 0}
}
func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float64) map[string]any {
k.mu.Lock()
defer k.mu.Unlock()
// blokada limite
if k.unreadAmount >= k.Limit {
return map[string]any{
"status": "blocked",
"message": fmt.Sprintf("Limit iščitavanja dostignut (%.2f / %.2f)", k.unreadAmount, k.Limit),
}
}
// određi tip i ext
ext, tipKey := "", "pp"
if v, ok := tipExt[[2]string{invoiceType, transactionType}]; ok {
ext = v[0]
tipKey = v[1]
} else {
ext = "ПП"
tipKey = "pp"
}
k.totalCounter++
k.counters[tipKey]++
typCounter := k.counters[tipKey]
// za fiskalne račune akumuliraj neisčitani iznos
nonFiscal := strings.HasPrefix(tipKey, "k") || strings.HasPrefix(tipKey, "o") || strings.HasPrefix(tipKey, "r")
if !nonFiscal && transactionType == "Sale" {
k.unreadAmount += totalAmount
} else if !nonFiscal && transactionType == "Refund" {
k.unreadAmount -= totalAmount
if k.unreadAmount < 0 {
k.unreadAmount = 0
}
}
// lažni potpis — random base64 dovoljno dug da izgleda realistično
sig := mockPotpis()
return map[string]any{
"status": "ok",
"counter": k.totalCounter,
"counter_extension": ext,
"type_counter": typCounter,
"signature": sig,
"blocked": false,
}
}
// mockPotpis generiše 64-bajtni nasumični base64 string koji imitira RSA potpis.
func mockPotpis() string {
b := make([]byte, 64)
for i := range b {
b[i] = byte(rand.IntN(256))
}
return base64.StdEncoding.EncodeToString(b)
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
+103
View File
@@ -0,0 +1,103 @@
package be
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
)
// JeUkljucen vraća true ako emulator treba da se pokrene.
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
func JeUkljucen(db *sql.DB) bool {
if v := os.Getenv("BE_ENABLED"); v != "" {
return v != "false" && v != "0"
}
var vrednost string
_ = db.QueryRow("SELECT vrednost FROM podesavanja WHERE kljuc='be_enabled'").Scan(&vrednost)
return vrednost != "false" && vrednost != "0"
}
// Pokreni startuje TCP listener za kartica emulator.
// Blokira dok listener ne padne — pozivati kao goroutine.
func Pokreni(db *sql.DB) {
port := env("BE_PORT", "4567")
addr := fmt.Sprintf("0.0.0.0:%s", port)
ln, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("be: ne mogu da pokrenem listener", "addr", addr, "error", err)
os.Exit(1)
}
slog.Info("kartica emulator sluša", "addr", addr)
k := novaKartica(db)
for {
conn, err := ln.Accept()
if err != nil {
slog.Warn("be: greška pri Accept()", "error", err)
continue
}
go obradiKonekciju(conn, k)
}
}
func obradiKonekciju(conn net.Conn, k *Kartica) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req map[string]any
if err := json.Unmarshal(line, &req); err != nil {
odgovori(conn, map[string]any{"status": "error", "message": "invalid JSON"})
return
}
cmd, _ := req["command"].(string)
var resp map[string]any
switch cmd {
case "status":
resp = k.cmdStatus()
case "certificate":
resp = k.cmdCertificate()
case "verify_pin":
pin, _ := req["pin"].(string)
resp = k.cmdVerifyPin(pin)
case "reset_audit":
resp = k.cmdResetAudit()
case "sign":
invType, _ := req["invoice_type"].(string)
txType, _ := req["transaction_type"].(string)
totalAmount, _ := req["total_amount"].(float64)
resp = k.cmdSign(invType, txType, totalAmount)
default:
resp = map[string]any{"status": "error", "message": fmt.Sprintf("nepoznata komanda: %q", cmd)}
}
odgovori(conn, resp)
}
}
func odgovori(conn net.Conn, resp map[string]any) {
b, err := json.Marshal(resp)
if err != nil {
return
}
b = append(b, '\n')
conn.Write(b)
}
+1
View File
@@ -277,6 +277,7 @@ type KorisniciRepository interface {
Lista(ctx context.Context) ([]model.Korisnik, error)
AzurirajUlogu(ctx context.Context, id int64, uloga string) error
AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error
AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error
PromeniLozinku(ctx context.Context, id int64, hash string) error
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error
+21 -3
View File
@@ -33,6 +33,8 @@ type korisnikOpcije struct {
lokalnaAnimacija sql.NullString
lokalniHover sql.NullString
lokalnaBrzinaAnimacije sql.NullString
ime sql.NullString
prezime sql.NullString
}
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik
@@ -50,6 +52,8 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) {
k.LokalnaAnimacija = o.lokalnaAnimacija.String
k.LokalniHover = o.lokalniHover.String
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
k.Ime = o.ime.String
k.Prezime = o.prezime.String
}
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
@@ -62,6 +66,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
&o.ime, &o.prezime,
); err != nil {
return nil, err
}
@@ -107,7 +112,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
COALESCE(lokalna_brzina_animacije, ''),
COALESCE(ime, ''), COALESCE(prezime, '')
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -125,7 +131,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
COALESCE(lokalna_brzina_animacije, ''),
COALESCE(ime, ''), COALESCE(prezime, '')
FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -143,7 +150,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
COALESCE(lokalna_brzina_animacije, '')
COALESCE(lokalna_brzina_animacije, ''),
COALESCE(ime, ''), COALESCE(prezime, '')
FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
@@ -159,6 +167,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
&o.ime, &o.prezime,
); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
}
@@ -169,6 +178,15 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
return lista, nil
}
func (r *sqliteKorisniciRepo) AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET ime = ?, prezime = ? WHERE id = ?`, ime, prezime, id)
if err != nil {
return fmt.Errorf("ntech: korisnici.AzurirajImePrezime: %w", err)
}
return nil
}
func (r *sqliteKorisniciRepo) SacuvajAvatar(ctx context.Context, id int64, putanja string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET avatar_putanja = ? WHERE id = ?`, putanja, id)
+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) {
+2
View File
@@ -6,6 +6,8 @@ import "time"
type Korisnik struct {
ID int64
KorisnickoIme string
Ime string
Prezime string
LozinkaHash string
Uloga string // "superadmin" | "admin" | "radnik"
Aktivan bool