Ograničenje konekcija BE emulatora, sopstveni kontekst fiskalizacije nezavisan od prekida zahteva, validacija porta na setup stranici, serverska optimizacija kratke pretrage bez eval-a, testovi

This commit is contained in:
2026-07-13 15:10:02 +02:00
parent bc60288a0d
commit 9cf4e66f00
24 changed files with 619 additions and 40 deletions
+26
View File
@@ -31,6 +31,7 @@ ESIR_ID = "NTECH001" # naš 8-char ESIR identifikator
# Kartica emulator (NTech goroutine) # Kartica emulator (NTech goroutine)
BE_HOST = os.environ.get("BE_HOST", "127.0.0.1") BE_HOST = os.environ.get("BE_HOST", "127.0.0.1")
BE_PORT = int(os.environ.get("BE_PORT", "4567")) BE_PORT = int(os.environ.get("BE_PORT", "4567"))
BE_PIN = os.environ.get("BE_PIN", "1234")
DATA_DIR = Path(__file__).parent / "data" DATA_DIR = Path(__file__).parent / "data"
INVOICES_DIR = DATA_DIR / "invoices" INVOICES_DIR = DATA_DIR / "invoices"
@@ -86,6 +87,25 @@ def _ucitaj_fiskalni_pismo():
FISKALNI_PISMO = _ucitaj_fiskalni_pismo() FISKALNI_PISMO = _ucitaj_fiskalni_pismo()
_be_pin_verifikovan = False
def _osiguraj_be_pin() -> bool:
"""Verifikuje PIN kod kartica emulatora ako to još nije učinjeno u ovom
procesu — emulator (NTech, internal/be/kartica.go) posle be#34 odbija
"sign" dok se prethodno ne pozove "verify_pin". pinUnesen je stanje na
deljenoj Kartica instanci (globalno za sve TCP konekcije), pa je dovoljno
da se verifikacija uspešno izvrši jednom po životnom veku NTech procesa."""
global _be_pin_verifikovan
if _be_pin_verifikovan:
return True
resp = be_command({"command": "verify_pin", "pin": BE_PIN})
_be_pin_verifikovan = resp.get("status") == "ok"
if not _be_pin_verifikovan:
log(f" ⚠️ automatska PIN verifikacija ka kartica emulatoru nije uspela: {resp}")
return _be_pin_verifikovan
def be_command(cmd: dict) -> dict: def be_command(cmd: dict) -> dict:
"""Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor.""" """Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor."""
try: try:
@@ -338,6 +358,7 @@ def _build_invoice_response(req, request_id):
# Kartica emulator: podatke firme i potpis/brojače # Kartica emulator: podatke firme i potpis/brojače
cert = be_command({"command": "certificate"}) cert = be_command({"command": "certificate"})
_osiguraj_be_pin()
sign = be_command({ sign = be_command({
"command": "sign", "command": "sign",
"invoice_type": invoice_type, "invoice_type": invoice_type,
@@ -347,6 +368,11 @@ def _build_invoice_response(req, request_id):
if sign.get("status") == "blocked": if sign.get("status") == "blocked":
raise RuntimeError(f"Kartica blokirana: {sign.get('message')}") 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) jid = cert.get("jid", ESIR_ID)
total_cnt = sign.get("counter", 1) total_cnt = sign.get("counter", 1)
+9
View File
@@ -262,6 +262,11 @@ func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
return map[string]any{"status": "ok"} return map[string]any{"status": "ok"}
} }
// cmdResetAudit namerno NE zahteva pinUnesen: koristi ga isključivo admin panel
// (BeResetAudit, već iza "podesavanja.izmeni" RBAC provere) preko sopstvene TCP
// konekcije koja nikad ne zove verify_pin — zahtevanje PIN-a ovde bi trajno
// pokvarilo dugme "Resetuj audit" u Podešavanjima. cmdSign ostaje PIN-zaštićen
// jer njega poziva eksterni fiskalni klijent ("Fisk") koji prethodno zove verify_pin.
func (k *Kartica) cmdResetAudit() map[string]any { func (k *Kartica) cmdResetAudit() map[string]any {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
@@ -274,6 +279,10 @@ func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
if !k.pinUnesen {
return map[string]any{"status": "error", "code": "2101", "message": "PIN nije verifikovan"}
}
// blokada limite // blokada limite
if k.unreadAmount >= k.Limit { if k.unreadAmount >= k.Limit {
return map[string]any{ return map[string]any{
+126
View File
@@ -0,0 +1,126 @@
package be
import "testing"
func novaTestnaKartica() *Kartica {
return &Kartica{
PIN: "1234",
Limit: 500000,
counters: map[string]int{
"pp": 0, "pr": 0, "ap": 0, "ar": 0,
"kp": 0, "kr": 0, "op": 0, "or": 0,
"rp": 0, "rr": 0,
},
}
}
func TestCmdSignBezPinaOdbija(t *testing.T) {
k := novaTestnaKartica()
resp := k.cmdSign("Normal", "Sale", 100)
if resp["status"] != "error" {
t.Fatalf("cmdSign bez verifikovanog PIN-a mora vratiti status=error, dobijeno: %v", resp)
}
if resp["code"] != "2101" {
t.Errorf("code = %v, očekivano 2101", resp["code"])
}
if k.totalCounter != 0 {
t.Errorf("totalCounter se povećao iako PIN nije verifikovan: %d", k.totalCounter)
}
}
func TestCmdSignPogresanPinOdbija(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("0000"); resp["status"] != "error" {
t.Fatalf("cmdVerifyPin sa pogrešnim PIN-om mora vratiti error, dobijeno: %v", resp)
}
if k.pinUnesen {
t.Fatal("pinUnesen ne sme biti true posle pogrešnog PIN-a")
}
if resp := k.cmdSign("Normal", "Sale", 100); resp["status"] != "error" {
t.Fatalf("cmdSign posle pogrešnog PIN-a mora ostati blokiran, dobijeno: %v", resp)
}
}
func TestCmdSignPoslePravogPinaUspeva(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("1234"); resp["status"] != "ok" {
t.Fatalf("cmdVerifyPin sa ispravnim PIN-om mora vratiti ok, dobijeno: %v", resp)
}
resp := k.cmdSign("Normal", "Sale", 100)
if resp["status"] != "ok" {
t.Fatalf("cmdSign posle ispravnog PIN-a mora uspeti, dobijeno: %v", resp)
}
if k.totalCounter != 1 {
t.Errorf("totalCounter = %d, očekivano 1", k.totalCounter)
}
if k.unreadAmount != 100 {
t.Errorf("unreadAmount = %v, očekivano 100", k.unreadAmount)
}
}
func TestCmdSignBlokiraNaLimitu(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = k.Limit
resp := k.cmdSign("Normal", "Sale", 1)
if resp["status"] != "blocked" {
t.Fatalf("cmdSign na limitu mora vratiti status=blocked, dobijeno: %v", resp)
}
}
func TestCmdSignRefundSmanjujeUnreadAmount(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = 500
k.cmdSign("Normal", "Refund", 200)
if k.unreadAmount != 300 {
t.Errorf("unreadAmount posle refunda = %v, očekivano 300", k.unreadAmount)
}
}
func TestCmdSignRefundNeIdeUNegativu(t *testing.T) {
k := novaTestnaKartica()
k.pinUnesen = true
k.unreadAmount = 50
k.cmdSign("Normal", "Refund", 200)
if k.unreadAmount != 0 {
t.Errorf("unreadAmount ne sme biti negativan, dobijeno %v", k.unreadAmount)
}
}
func TestCmdResetAuditRadiBezPina(t *testing.T) {
// namerno: admin panel (BeResetAudit) nikad ne zove verify_pin, v. komentar
// uz cmdResetAudit u kartica.go — reset ne sme zahtevati PIN.
k := novaTestnaKartica()
k.unreadAmount = 12345
resp := k.cmdResetAudit()
if resp["status"] != "ok" {
t.Fatalf("cmdResetAudit mora uspeti i bez PIN-a, dobijeno: %v", resp)
}
if k.unreadAmount != 0 {
t.Errorf("unreadAmount posle reset_audit = %v, očekivano 0", k.unreadAmount)
}
}
func TestCmdVerifyPinKonstantnoVreme(t *testing.T) {
k := novaTestnaKartica()
if resp := k.cmdVerifyPin("1234"); resp["status"] != "ok" {
t.Fatalf("očekivan uspeh sa ispravnim PIN-om, dobijeno: %v", resp)
}
if !k.pinUnesen {
t.Fatal("pinUnesen mora biti true posle ispravnog PIN-a")
}
}
func TestCmdStatusPrijavljujePinRequired(t *testing.T) {
k := novaTestnaKartica()
resp := k.cmdStatus()
if resp["pin_required"] != true {
t.Fatalf("pin_required mora biti true pre verifikacije, dobijeno: %v", resp)
}
k.cmdVerifyPin("1234")
resp = k.cmdStatus()
if resp["pin_required"] != false {
t.Fatalf("pin_required mora biti false posle verifikacije, dobijeno: %v", resp)
}
}
+30 -2
View File
@@ -8,8 +8,19 @@ import (
"log/slog" "log/slog"
"net" "net"
"os" "os"
"time"
) )
// 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).
const maxKonekcija = 32
// citanjeTimeout je rok po redu (komandi) koji se osvežava pre svakog čitanja —
// klijent koji ćuti duže od ovoga se prekida umesto da drži konekciju zauvek.
// var (ne const) da bi testovi mogli da ga privremeno skrate.
var citanjeTimeout = 30 * time.Second
// JeUkljucen vraća true ako emulator treba da se pokrene. // JeUkljucen vraća true ako emulator treba da se pokrene.
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true. // Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
func JeUkljucen(db *sql.DB) bool { func JeUkljucen(db *sql.DB) bool {
@@ -35,6 +46,7 @@ func Pokreni(db *sql.DB) {
slog.Info("kartica emulator sluša", "addr", addr) slog.Info("kartica emulator sluša", "addr", addr)
k := novaKartica(db) k := novaKartica(db)
sem := make(chan struct{}, maxKonekcija)
for { for {
conn, err := ln.Accept() conn, err := ln.Accept()
@@ -42,7 +54,16 @@ func Pokreni(db *sql.DB) {
slog.Warn("be: greška pri Accept()", "error", err) slog.Warn("be: greška pri Accept()", "error", err)
continue continue
} }
go obradiKonekciju(conn, k) select {
case sem <- struct{}{}:
go func() {
defer func() { <-sem }()
obradiKonekciju(conn, k)
}()
default:
slog.Warn("be: dostignut limit konekcija, odbijam klijenta", "limit", maxKonekcija)
conn.Close()
}
} }
} }
@@ -50,7 +71,14 @@ func obradiKonekciju(conn net.Conn, k *Kartica) {
defer conn.Close() defer conn.Close()
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
for scanner.Scan() { for {
// SetDeadline (ne samo SetReadDeadline) da rok pokrije i odgovori() Write
// koji sledi posle čitanja — klijent koji šalje komande a nikad ne čita
// odgovore bi inače mogao da blokira Write zauvek i iscrpi semafor konekcija.
conn.SetDeadline(time.Now().Add(citanjeTimeout))
if !scanner.Scan() {
return
}
line := scanner.Bytes() line := scanner.Bytes()
if len(line) == 0 { if len(line) == 0 {
continue continue
+87
View File
@@ -0,0 +1,87 @@
package be
import (
"bufio"
"encoding/json"
"net"
"testing"
"time"
)
// TestObradiKonekcijaZatvaraNakonTisine proverava ispravku BUG.md #36 — konekcija
// koja ništa ne pošalje mora biti zatvorena posle citanjeTimeout-a, ne držana
// zauvek.
func TestObradiKonekcijaZatvaraNakonTisine(t *testing.T) {
stariTimeout := citanjeTimeout
citanjeTimeout = 100 * time.Millisecond
defer func() { citanjeTimeout = stariTimeout }()
klijentska, serverska := net.Pipe()
defer klijentska.Close()
gotovo := make(chan struct{})
go func() {
obradiKonekciju(serverska, novaTestnaKartica())
close(gotovo)
}()
select {
case <-gotovo:
// očekivano: server je zatvorio konekciju posle isteka citanjeTimeout-a
case <-time.After(2 * time.Second):
t.Fatal("obradiKonekciju nije prekinuta ni posle 2s tišine klijenta — deadline ne radi")
}
}
// TestObradiKonekcijaOdgovaraNaKomandu proverava da server i dalje ispravno
// odgovara na komandu pre nego što istekne deadline (da fix #36 ne pokvari
// normalan tok status → verify_pin → sign preko realnog TCP-a).
func TestObradiKonekcijaOdgovaraNaKomandu(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("ne mogu da otvorim listener: %v", err)
}
defer ln.Close()
k := novaTestnaKartica()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
obradiKonekciju(conn, k)
}()
conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatalf("ne mogu da se povežem: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte(`{"command":"status"}` + "\n")); err != nil {
t.Fatalf("greška pri slanju komande: %v", err)
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
t.Fatalf("greška pri čitanju odgovora: %v", err)
}
var resp map[string]any
if err := json.Unmarshal([]byte(line), &resp); err != nil {
t.Fatalf("odgovor nije validan JSON: %v (%q)", err, line)
}
if resp["status"] != "ok" {
t.Fatalf("status = %v, očekivano ok", resp["status"])
}
}
func TestPokreniOdbijaKadJeLimitDostignut(t *testing.T) {
// Konceptualna provera semafora u Pokreni je pokrivena kroz kod (kanal
// kapaciteta maxKonekcija); ovde samo proveravamo da konstanta ima razuman,
// pozitivan limit umesto neograničenog broja gorutina.
if maxKonekcija <= 0 {
t.Fatalf("maxKonekcija mora biti pozitivan broj, dobijeno: %d", maxKonekcija)
}
}
+24 -13
View File
@@ -45,6 +45,29 @@ func nadjiLokalneAdrese() []string {
return adrese return adrese
} }
// 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
// tiho gutala i upisivala port 0).
func obradiSetupPotvrdi(envFajl string, gotov chan<- struct{}) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
var telo struct {
Port int `json:"port"`
}
if err := json.NewDecoder(req.Body).Decode(&telo); err != nil || telo.Port <= 0 || telo.Port > 65535 {
http.Error(w, "Neispravan port", http.StatusBadRequest)
return
}
if err := SacuvajEnv(telo.Port, envFajl); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
close(gotov)
}
}
// PokreniSetup pokreće HTTP server za prvo podešavanje i čeka da korisnik završi // PokreniSetup pokreće HTTP server za prvo podešavanje i čeka da korisnik završi
func PokreniSetup(fsys fs.FS, envFajl string) { func PokreniSetup(fsys fs.FS, envFajl string) {
port := NadjiSlobodanPort() port := NadjiSlobodanPort()
@@ -86,19 +109,7 @@ func PokreniSetup(fsys fs.FS, envFajl string) {
}) })
// prima izabrani port, upisuje ntech.env i signalizira završetak // prima izabrani port, upisuje ntech.env i signalizira završetak
r.Post("/setup/potvrdi", func(w http.ResponseWriter, req *http.Request) { r.Post("/setup/potvrdi", obradiSetupPotvrdi(envFajl, gotov))
var telo struct {
Port int `json:"port"`
}
json.NewDecoder(req.Body).Decode(&telo)
if err := SacuvajEnv(telo.Port, envFajl); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
close(gotov)
})
srv := &http.Server{Addr: adresa, Handler: r} srv := &http.Server{Addr: adresa, Handler: r}
+81
View File
@@ -0,0 +1,81 @@
package config
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestObradiSetupPotvrdiNevazeciJsonOdbija(t *testing.T) {
envFajl := filepath.Join(t.TempDir(), "ntech.env")
gotov := make(chan struct{})
req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader("nije json"))
w := httptest.NewRecorder()
obradiSetupPotvrdi(envFajl, gotov)(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, očekivano 400 za nevažeći JSON", w.Code)
}
if _, err := os.Stat(envFajl); err == nil {
t.Fatal("ntech.env ne sme biti upisan kad je JSON nevažeći")
}
select {
case <-gotov:
t.Fatal("gotov kanal ne sme biti zatvoren kad zahtev nije uspeo")
default:
}
}
func TestObradiSetupPotvrdiPortNulaOdbija(t *testing.T) {
envFajl := filepath.Join(t.TempDir(), "ntech.env")
gotov := make(chan struct{})
req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":0}`))
w := httptest.NewRecorder()
obradiSetupPotvrdi(envFajl, gotov)(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, očekivano 400 za port 0", w.Code)
}
}
func TestObradiSetupPotvrdiPortVanOpsegaOdbija(t *testing.T) {
envFajl := filepath.Join(t.TempDir(), "ntech.env")
gotov := make(chan struct{})
req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":99999}`))
w := httptest.NewRecorder()
obradiSetupPotvrdi(envFajl, gotov)(w, req)
if w.Code != 400 {
t.Fatalf("status = %d, očekivano 400 za port van opsega", w.Code)
}
}
func TestObradiSetupPotvrdiValidanPortUpisuje(t *testing.T) {
envFajl := filepath.Join(t.TempDir(), "ntech.env")
gotov := make(chan struct{})
req := httptest.NewRequest("POST", "/setup/potvrdi", strings.NewReader(`{"port":8080}`))
w := httptest.NewRecorder()
obradiSetupPotvrdi(envFajl, gotov)(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, očekivano 200 za validan port", w.Code)
}
sadrzaj, err := os.ReadFile(envFajl)
if err != nil {
t.Fatalf("ntech.env nije upisan: %v", err)
}
if !strings.Contains(string(sadrzaj), "NTECH_PORT=8080") {
t.Errorf("ntech.env sadržaj = %q, očekivano NTECH_PORT=8080", sadrzaj)
}
select {
case <-gotov:
default:
t.Fatal("gotov kanal mora biti zatvoren posle uspešnog zahteva")
}
}
+160
View File
@@ -0,0 +1,160 @@
package fiskal
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func testniZahtev() InvoiceRequest {
return InvoiceRequest{
InvoiceRequest: InvoiceRequestBody{
InvoiceType: "Normal",
TransactionType: "Sale",
Payment: []PaymentItem{{Amount: 100, PaymentType: "Cash"}},
Items: []InvoiceItem{{Name: "Test", TotalAmount: 100, UnitPrice: 100, Quantity: 1}},
Cashier: "Test Kasir",
},
}
}
func TestIzdajRacunUspeh(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/invoices" {
t.Fatalf("neočekivan zahtev: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("Authorization header = %q, očekivano Bearer test-key", got)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"invoiceNumber":"ПП123","totalAmount":100,"totalTax":16.67}`))
}))
defer srv.Close()
k := NoviKlijent(srv.URL, "test-key")
odgovor, err := k.IzdajRacun(context.Background(), testniZahtev())
if err != nil {
t.Fatalf("IzdajRacun greška: %v", err)
}
if odgovor.InvoiceNumber != "ПП123" {
t.Errorf("InvoiceNumber = %q, očekivano ПП123", odgovor.InvoiceNumber)
}
if odgovor.TotalAmount != 100 {
t.Errorf("TotalAmount = %v, očekivano 100", odgovor.TotalAmount)
}
}
func TestIzdajRacunHttpGreska(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"message":"nevažeći zahtev"}`))
}))
defer srv.Close()
k := NoviKlijent(srv.URL, "test-key")
if _, err := k.IzdajRacun(context.Background(), testniZahtev()); err == nil {
t.Fatal("očekivana greška za HTTP 400, dobijeno nil")
}
}
func TestIzdajRacunServerNedostupan(t *testing.T) {
// port na kome sigurno niko ne sluša (zatvoren odmah posle otvaranja)
ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := ln.URL
ln.Close()
k := NoviKlijent(url, "test-key")
if _, err := k.IzdajRacun(context.Background(), testniZahtev()); err == nil {
t.Fatal("očekivana greška kad server nije dostupan, dobijeno nil")
}
}
func TestIzdajRacunKontekstIstekao(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
}))
defer srv.Close()
k := NoviKlijent(srv.URL, "test-key")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
if _, err := k.IzdajRacun(ctx, testniZahtev()); err == nil {
t.Fatal("očekivana greška kad kontekst istekne pre odgovora, dobijeno nil")
}
}
func TestIzdajRacunPrezivljavaOtkazivanjeOriginalnogKonteksta(t *testing.T) {
// simulira #35 iz BUG.md: 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)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"invoiceNumber":"ПП999"}`))
}))
defer srv.Close()
originalni, otkazi := context.WithCancel(context.Background())
odvojeni := context.WithoutCancel(originalni)
odvojeni, cancel := context.WithTimeout(odvojeni, time.Second)
defer cancel()
// otkaži originalni kontekst odmah — kao da je browser konekcija prekinuta
otkazi()
k := NoviKlijent(srv.URL, "test-key")
odgovor, err := k.IzdajRacun(odvojeni, testniZahtev())
if err != nil {
t.Fatalf("fiskalni poziv nije smeo da pukne posle otkazivanja originalnog konteksta: %v", err)
}
if odgovor.InvoiceNumber != "ПП999" {
t.Errorf("InvoiceNumber = %q, očekivano ПП999", odgovor.InvoiceNumber)
}
}
func TestStatusUspeh(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/status" {
t.Fatalf("neočekivana putanja: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}))
defer srv.Close()
k := NoviKlijent(srv.URL, "")
rezultat, err := k.Status(context.Background())
if err != nil {
t.Fatalf("Status greška: %v", err)
}
if rezultat["status"] != "ok" {
t.Errorf("status = %v, očekivano ok", rezultat["status"])
}
}
func TestZakljuciDanNoContent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("očekivan DELETE, dobijeno %s", r.Method)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
k := NoviKlijent(srv.URL, "")
if err := k.ZakljuciDan(context.Background()); err != nil {
t.Fatalf("ZakljuciDan greška: %v", err)
}
}
func TestNoviKlijentSkidaKrajnjuKosuCrtu(t *testing.T) {
k := NoviKlijent("http://127.0.0.1:4566/", "kljuc")
if k.BaseURL != "http://127.0.0.1:4566" {
t.Errorf("BaseURL = %q, očekivano bez krajnje kose crte", k.BaseURL)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func (h *Handler) Dobavljaci(w http.ResponseWriter, r *http.Request) {
return return
} }
pretraga := r.URL.Query().Get("pretraga") pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga"))
dobavljaci, err := h.DobavljaciRepo.Lista(r.Context(), pretraga) dobavljaci, err := h.DobavljaciRepo.Lista(r.Context(), pretraga)
if err != nil { if err != nil {
+3 -3
View File
@@ -63,7 +63,7 @@ func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
return return
} }
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent == nil { if klijent == nil {
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija." podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
h.renderujTemplate(w, "fiskal_pazar", podaci) h.renderujTemplate(w, "fiskal_pazar", podaci)
@@ -94,7 +94,7 @@ func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return return
} }
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent == nil { if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
@@ -127,7 +127,7 @@ func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
return return
} }
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent == nil { if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther) http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
+15 -3
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
"ntech/internal/config" "ntech/internal/config"
"ntech/internal/db" "ntech/internal/db"
@@ -276,9 +277,9 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
// fiskalKlijent vraća inicijalizovan fiskalni HTTP klijent na osnovu trenutnih // fiskalKlijent vraća inicijalizovan fiskalni HTTP klijent na osnovu trenutnih
// podešavanja. Vraća nil ako pfr_url nije podešen. URL se čita dinamički pri // podešavanja. Vraća nil ako pfr_url nije podešen. URL se čita dinamički pri
// svakom pozivu — nema keširanja, pa promena podešavanja stupa na snagu odmah. // svakom pozivu — nema keširanja, pa promena podešavanja stupa na snagu odmah.
func (h *Handler) fiskalKlijent() *fiskal.Klijent { func (h *Handler) fiskalKlijent(ctx context.Context) *fiskal.Klijent {
url, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_url") url, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_url")
key, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_api_key") key, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_api_key")
if url == "" { if url == "" {
return nil return nil
} }
@@ -288,6 +289,17 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent {
return fiskal.NoviKlijent(url, key) return fiskal.NoviKlijent(url, key)
} }
// odvojenKontekstFiskalizacije vraća kontekst koji preživljava otkazivanje/prekid
// originalnog HTTP zahteva (npr. korisnik zatvori tab, proxy timeout) — fiskalni
// poziv ka PFR serveru i upis lokalnog zapisa moraju da se završe i kad browser
// konekcija padne, jer je fiskalizacija nepovratna radnja (ponovni pokušaj bez
// ovoga bi mogao izdati duplirani fiskalni račun). Vrednosti iz ctx (npr. korisnik
// iz middleware) ostaju dostupne; samo se otkazivanje/deadline originalnog ctx
// ignoriše u korist sopstvenog, ograničenog roka.
func odvojenKontekstFiskalizacije(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
}
// imeKasira vraća ime i prezime prijavljenog korisnika za polje "kasir" na fiskalnom // imeKasira vraća ime i prezime prijavljenog korisnika za polje "kasir" na fiskalnom
// računu (korisničko ime kao rezerva ako ime/prezime nisu uneti). Pada nazad na // računu (korisničko ime kao rezerva ako ime/prezime nisu uneti). Pada nazad na
// podešavanje pfr_kasir (npr. za pozadinske pozive bez korisnika u kontekstu), pa na // podešavanje pfr_kasir (npr. za pozadinske pozive bez korisnika u kontekstu), pa na
+1 -1
View File
@@ -53,7 +53,7 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str
} }
filter := db.ArtikalFilter{ filter := db.ArtikalFilter{
Pretraga: r.URL.Query().Get("pretraga"), Pretraga: ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")),
Tip: tip, Tip: tip,
SamoKriticni: r.URL.Query().Get("kriticni") == "1", SamoKriticni: r.URL.Query().Get("kriticni") == "1",
Arhivirani: r.URL.Query().Get("arhivirani") == "1", Arhivirani: r.URL.Query().Get("arhivirani") == "1",
+7 -3
View File
@@ -320,7 +320,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
// Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije) // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije)
racunKreiran := false racunKreiran := false
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
if klijent := h.fiskalKlijent(); klijent != nil { if klijent := h.fiskalKlijent(r.Context()); klijent != nil {
primljeno, _ := strconv.ParseFloat(strings.TrimSpace(r.FormValue("primljeno")), 64) primljeno, _ := strconv.ParseFloat(strings.TrimSpace(r.FormValue("primljeno")), 64)
h.fiskalizujProdaju(r.Context(), id, klijent, primljeno) h.fiskalizujProdaju(r.Context(), id, klijent, primljeno)
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil { if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil {
@@ -729,6 +729,8 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
// stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi // stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi
// pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici. // pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici.
func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error { func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id) fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
if err != nil { if err != nil {
return err return err
@@ -736,7 +738,7 @@ func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, po
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" { if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
return nil return nil
} }
fk := h.fiskalKlijent() fk := h.fiskalKlijent(ctx)
if fk == nil { if fk == nil {
return errors.New("fiskalizacija nije podešena") return errors.New("fiskalizacija nije podešena")
} }
@@ -827,6 +829,8 @@ func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) {
// primljeno je iznos koji je kupac predao (za tačan povraćaj na računu) — 0 ako // primljeno je iznos koji je kupac predao (za tačan povraćaj na računu) — 0 ako
// nije poznat (npr. ručni retry), tada se šalje tačan dug bez povraćaja. // nije poznat (npr. ručni retry), tada se šalje tačan dug bez povraćaja.
func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijent *fiskal.Klijent, primljeno float64) { func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijent *fiskal.Klijent, primljeno float64) {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID) nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID)
if err != nil { if err != nil {
slog.Error("fiskalizujProdaju: nije pronađen nalog", "prodaja_id", prodajaID, "error", err) slog.Error("fiskalizujProdaju: nije pronađen nalog", "prodaja_id", prodajaID, "error", err)
@@ -905,7 +909,7 @@ func (h *Handler) RetryFiskalizacijaProdaje(w http.ResponseWriter, r *http.Reque
return return
} }
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent == nil { if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.") middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+11 -5
View File
@@ -656,7 +656,7 @@ func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) {
// fiskalni refund — best-effort // fiskalni refund — best-effort
if originalniFiskal != nil { if originalniFiskal != nil {
if fk := h.fiskalKlijent(); fk != nil { if fk := h.fiskalKlijent(ctx); fk != nil {
if err := h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca); err != nil { if err := h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca); err != nil {
slog.Error("fiskalni refund servisa nije uspeo", "servis_id", id, "error", err) slog.Error("fiskalni refund servisa nije uspeo", "servis_id", id, "error", err)
} }
@@ -691,7 +691,7 @@ func (h *Handler) PokusajRefundServisa(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return return
} }
fk := h.fiskalKlijent() fk := h.fiskalKlijent(ctx)
if fk == nil { if fk == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.") middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
@@ -2447,7 +2447,7 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
// konačan račun da zatvori avans i pokrene eventualni povraćaj viška. // konačan račun da zatvori avans i pokrene eventualni povraćaj viška.
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr == nil { if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr == nil {
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent != nil { if klijent != nil {
h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos, primljeno) h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos, primljeno)
} }
@@ -2512,6 +2512,8 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
// avansa ne fiskalizuje ceo iznos ponovo. Bez efekta ako razlika nije pozitivna // avansa ne fiskalizuje ceo iznos ponovo. Bez efekta ako razlika nije pozitivna
// (smanjenje avansa se ne fiskalizuje automatski — v. razgovor o povraćaju). // (smanjenje avansa se ne fiskalizuje automatski — v. razgovor o povraćaju).
func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, noviAvans float64, nacinPlacanja string) { func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, noviAvans float64, nacinPlacanja string) {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
if noviAvans <= 0 { if noviAvans <= 0 {
return return
} }
@@ -2527,7 +2529,7 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no
if delta <= 0 { if delta <= 0 {
return return
} }
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(ctx)
if klijent == nil { if klijent == nil {
return return
} }
@@ -2580,6 +2582,8 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no
// na računu se iskazuje stvarno primljen iznos i PFR sam izračunava povraćaj // na računu se iskazuje stvarno primljen iznos i PFR sam izračunava povraćaj
// (isto kao fiskal.NapraviZahtev za Prodaju); 0 ili manje od duga → bez povraćaja. // (isto kao fiskal.NapraviZahtev za Prodaju); 0 ili manje od duga → bez povraćaja.
func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, nacinPlacanja string, iznos, primljeno float64) { func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, nacinPlacanja string, iznos, primljeno float64) {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID) nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
if err != nil { if err != nil {
slog.Error("fiskalizujServis: nije pronađen nalog", "id", servisID, "error", err) slog.Error("fiskalizujServis: nije pronađen nalog", "id", servisID, "error", err)
@@ -2782,6 +2786,8 @@ func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniD
// refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda). Vraća // refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda). Vraća
// grešku i pozivaocu (npr. PokusajRefundServisa) da bi ručni retry mogao da je prikaže. // grešku i pozivaocu (npr. PokusajRefundServisa) da bi ručni retry mogao da je prikaže.
func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) error { func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) error {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID) nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
if err != nil { if err != nil {
slog.Error("refundujServis: nije pronađen nalog", "id", servisID, "error", err) slog.Error("refundujServis: nije pronađen nalog", "id", servisID, "error", err)
@@ -2902,7 +2908,7 @@ func (h *Handler) RetryFiskalizacija(w http.ResponseWriter, r *http.Request) {
// Naplaceno==0 je dozvoljeno kad avans u potpunosti pokriva cenu — konačan // Naplaceno==0 je dozvoljeno kad avans u potpunosti pokriva cenu — konačan
// račun se i tada mora izdati da zatvori avans (i eventualno vrati višak) // račun se i tada mora izdati da zatvori avans (i eventualno vrati višak)
klijent := h.fiskalKlijent() klijent := h.fiskalKlijent(r.Context())
if klijent == nil { if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.") middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
+1 -1
View File
@@ -38,7 +38,7 @@ func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
return return
} }
pretraga := r.URL.Query().Get("pretraga") pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga"))
troskovi, err := h.TroskoviRepo.Lista(r.Context(), db.TrosakFilter{Pretraga: pretraga}) troskovi, err := h.TroskoviRepo.Lista(r.Context(), db.TrosakFilter{Pretraga: pretraga})
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError)
+1 -1
View File
@@ -39,7 +39,7 @@ func (h *Handler) Usluge(w http.ResponseWriter, r *http.Request) {
return return
} }
pretraga := r.URL.Query().Get("pretraga") pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga"))
usluge, err := h.UslugeRepo.Lista(r.Context(), db.UslugaFilter{Pretraga: pretraga}) usluge, err := h.UslugeRepo.Lista(r.Context(), db.UslugaFilter{Pretraga: pretraga})
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju usluga", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju usluga", http.StatusInternalServerError)
+13
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
) )
// parseID parsira string ID iz URL parametra u int64 // parseID parsira string ID iz URL parametra u int64
@@ -13,3 +14,15 @@ func parseID(s string) (int64, error) {
} }
return id, nil return id, nil
} }
// ignorisiKratkuPretragu vraća pretragu nepromenjenu ako je prazna ili ima 3+
// 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.
func ignorisiKratkuPretragu(pretraga string) string {
if l := len([]rune(strings.TrimSpace(pretraga))); l > 0 && l < 3 {
return ""
}
return pretraga
}
+12
View File
@@ -21,6 +21,18 @@ window.addEventListener('pageshow', function(e) {
}); });
}); });
// otvara nativni date/time picker na BILO KOJI klik unutar polja, ne samo na
// malu kalendar/sat ikonicu koju browser crta — u Firefoxu (i na nekim
// sistemima/rezolucijama) ta ikonica ima vrlo uzak klik-hitbox, pa korisnik
// klikne tik pored nje i pomisli da "klik na kalendar ne radi ništa".
// showPicker() zahteva user-gesture kontekst — click handler to ispunjava.
document.addEventListener('click', function(e) {
var el = e.target.closest('input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"]');
if (el && typeof el.showPicker === 'function') {
try { el.showPicker(); } catch (err) { /* readonly/disabled i sl. — ignoriši */ }
}
});
// otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen // otvara/zatvara podmeni u sidebaru — radi i kad je sidebar skupljen i kad je proširen
// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni. // (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni.
function ntechTogglePodmeni(btn) { function ntechTogglePodmeni(btn) {
+1 -1
View File
@@ -20,7 +20,7 @@
<input type="text" name="pretraga" value="{{.Pretraga}}" <input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži dobavljače..." placeholder="Pretraži dobavljače..."
style="flex:1;" style="flex:1;"
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search" hx-trigger="keyup changed delay:150ms, search"
hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-get="/dobavljaci" hx-target="#dobavljaci-rezultati" hx-select="#dobavljaci-rezultati" hx-swap="innerHTML" hx-push-url="true">
</form> </form>
</div> </div>
+6 -2
View File
@@ -46,7 +46,7 @@
<canvas id="grafikon-prihod"></canvas> <canvas id="grafikon-prihod"></canvas>
</div> </div>
<div style="overflow-x:auto;"> <div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
@@ -98,7 +98,7 @@
<div class="kartica izv-sekcija animiraj"> <div class="kartica izv-sekcija animiraj">
<div class="izv-naslov">Stari otvoreni nalozi — bez završetka duže od 14 dana</div> <div class="izv-naslov">Stari otvoreni nalozi — bez završetka duže od 14 dana</div>
{{if .StariNalozi}} {{if .StariNalozi}}
<div style="overflow-x:auto;"> <div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
@@ -143,6 +143,7 @@
<div class="kartica izv-grid-kartica animiraj"> <div class="kartica izv-grid-kartica animiraj">
<div class="izv-naslov">Najprodavaniji artikli — top 10</div> <div class="izv-naslov">Najprodavaniji artikli — top 10</div>
{{if .TopArtikli}} {{if .TopArtikli}}
<div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
@@ -168,6 +169,7 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o prodaji.</div> <div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o prodaji.</div>
{{end}} {{end}}
@@ -177,6 +179,7 @@
<div class="kartica izv-grid-kartica animiraj"> <div class="kartica izv-grid-kartica animiraj">
<div class="izv-naslov">Najvažniji klijenti — top 10</div> <div class="izv-naslov">Najvažniji klijenti — top 10</div>
{{if .TopKlijenti}} {{if .TopKlijenti}}
<div class="tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr style="border-bottom:0.5px solid var(--ivica);"> <tr style="border-bottom:0.5px solid var(--ivica);">
@@ -199,6 +202,7 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o klijentima.</div> <div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o klijentima.</div>
{{end}} {{end}}
+1 -1
View File
@@ -56,7 +56,7 @@
<div class="kartica animiraj"> <div class="kartica animiraj">
{{if .Zapisi}} {{if .Zapisi}}
<div class="tabela-omotac"> <div class="tabela-omotac tabela-skrol">
<table class="tabela"> <table class="tabela">
<thead> <thead>
<tr> <tr>
+1 -1
View File
@@ -40,7 +40,7 @@
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}" <input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..." placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
style="width:100%;" style="width:100%;"
hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search" hx-trigger="keyup changed delay:150ms, search"
hx-get="{{.OsnovniUrl}}" hx-include="closest form" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-get="{{.OsnovniUrl}}" hx-include="closest form" hx-target="#magacin-rezultati" hx-select="#magacin-rezultati" hx-swap="innerHTML" hx-push-url="true">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;"> <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<select name="kategorija" style="flex:1;min-width:140px;" <select name="kategorija" style="flex:1;min-width:140px;"
+1 -1
View File
@@ -28,7 +28,7 @@
<input type="text" name="pretraga" value="{{.Pretraga}}" <input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži troškove po nazivu ili šifri..." placeholder="Pretraži troškove po nazivu ili šifri..."
style="width:100%;" style="width:100%;"
hx-get="/troskovi" hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search" hx-get="/troskovi" hx-trigger="keyup changed delay:150ms, search"
hx-target="#troskovi-rezultati" hx-select="#troskovi-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-target="#troskovi-rezultati" hx-select="#troskovi-rezultati" hx-swap="innerHTML" hx-push-url="true">
</form> </form>
+1 -1
View File
@@ -24,7 +24,7 @@
<input type="text" name="pretraga" value="{{.Pretraga}}" <input type="text" name="pretraga" value="{{.Pretraga}}"
placeholder="Pretraži usluge po nazivu ili šifri..." placeholder="Pretraži usluge po nazivu ili šifri..."
style="width:100%;" style="width:100%;"
hx-get="/usluge" hx-trigger="keyup[target.value.length==0||target.value.length>=3] changed delay:150ms, search" hx-get="/usluge" hx-trigger="keyup changed delay:150ms, search"
hx-target="#usluge-rezultati" hx-select="#usluge-rezultati" hx-swap="innerHTML" hx-push-url="true"> hx-target="#usluge-rezultati" hx-select="#usluge-rezultati" hx-swap="innerHTML" hx-push-url="true">
</form> </form>