7 Commits
Author SHA1 Message Date
Dasko 0fb9fe1e37 PFR API ključ, fiskalni račun formatiranje, popravke
- PFR API ključ (pfr_api_key) u podešavanjima i fiskalizaciji
- Formatiranje fiskalnog računa: veličina fonta, QR kod, layout
- PFR mock: QR box_size/border, automatski PIN retry posle BE restarta
- Servis: animirane kanban kartice, formatiranje računa
- Dashboard i testovi: sitne ispravke
2026-07-30 02:25:12 +02:00
Dasko ee265f81a9 Merge fix/nedoslednosti u develop 2026-07-16 13:59:33 +02:00
Dasko 24845f7aa0 Magacin: data-refresh umesto full-reload na promeni cene — modal se zatvara, osvežava se samo tabela 2026-07-16 13:58:58 +02:00
Dasko 292492f309 Import aliasi: ntechsqlite → sqlite u 4 handler fajla 2026-07-16 13:18:39 +02:00
Dasko 4c75aa2c75 Error prefiks: ErrArtikalNePostoji u db/repository.go, ntech: prefiks na ErrNedovoljnoKolicine 2026-07-16 13:16:29 +02:00
Dasko 80abfd583d StornoProdaje: zahtevajDozvolu umesto direktnog ImaDozvolu (kao servis/nabavka) 2026-07-16 13:13:21 +02:00
Dasko 9cf4e66f00 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 2026-07-13 15:10:02 +02:00
39 changed files with 773 additions and 109 deletions
+40 -1
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:
@@ -207,7 +227,10 @@ def log(msg):
# ── QR ────────────────────────────────────────────────────── # ── QR ──────────────────────────────────────────────────────
def generate_qr(url): def generate_qr(url):
img = qrcode.make(url) qr = qrcode.QRCode(box_size=4, border=2)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="PNG") img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("utf-8") return base64.b64encode(buf.getvalue()).decode("utf-8")
@@ -338,6 +361,20 @@ 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({
"command": "sign",
"invoice_type": invoice_type,
"transaction_type": transaction_type,
"total_amount": total_amount,
})
# Ako je BE restovan posle naše PIN verifikacije, pinUnesen je opet False —
# resetuj flag, ponovo verifikuj PIN, pa probaj sign još jednom.
if sign.get("status") != "ok" and "PIN" in (sign.get("message") or ""):
global _be_pin_verifikovan
_be_pin_verifikovan = False
_osiguraj_be_pin()
sign = be_command({ sign = be_command({
"command": "sign", "command": "sign",
"invoice_type": invoice_type, "invoice_type": invoice_type,
@@ -347,6 +384,8 @@ 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":
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)
+1
View File
@@ -324,6 +324,7 @@ func main() {
r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
r.With(modul("fiskalizacija"), doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) r.With(modul("fiskalizacija"), doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan)
r.With(modul("fiskalizacija"), doz("prodaja.dodaj")).Post("/fiskal/batch-fiskalizuj", h.BatchFiskalizacija)
r.With(pregledPodesavanja).Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.With(pregledPodesavanja).Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
+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.
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 — 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 (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")
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ type ErrNedovoljnoKolicine struct {
} }
func (e *ErrNedovoljnoKolicine) Error() string { func (e *ErrNedovoljnoKolicine) Error() string {
return "Nedovoljno količine na stanju za artikal: " + e.ArtikalNaziv return "ntech: nedovoljno količine na stanju za artikal: " + e.ArtikalNaziv
} }
+3
View File
@@ -29,6 +29,9 @@ var ErrArtikalDuplikatSifre = errors.New("ntech: šifra ili barkod artikla već
var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge već postoji") var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge već postoji")
var ErrTrosakDuplikatSifre = errors.New("ntech: šifra troška već postoji") var ErrTrosakDuplikatSifre = errors.New("ntech: šifra troška već postoji")
// ErrArtikalNePostoji se vraća kada se menja cena nepostojećeg artikla
var ErrArtikalNePostoji = errors.New("ntech: artikal ne postoji")
// ArtikalRepository definiše operacije nad artiklima // ArtikalRepository definiše operacije nad artiklima
type ArtikalRepository interface { type ArtikalRepository interface {
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
+6 -6
View File
@@ -65,7 +65,7 @@ func TestIzvestajPraznaBaza(t *testing.T) {
} }
} }
// ─── BUG-01: stornirani prodajni nalozi ne smeju ući u prihod ─────────────── // ─── stornirani prodajni nalozi ne smeju ući u prihod ────────────────────────
func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) { func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -160,7 +160,7 @@ func TestTopKlijenti_IgnorisuSeStornirani(t *testing.T) {
} }
} }
// ─── BUG-02/03: servis prihod koristi naplaceno (bruto, uključuje delove) ─── // ─── servis prihod koristi naplaceno (bruto, uključuje delove) ────────────────
func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) { func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -221,7 +221,7 @@ func TestMesecniPrihodServis_KoristiNaplaceno(t *testing.T) {
} }
} }
// ─── BUG-10: avans se uračunava u prihod (naplaceno + avans) ───────────────── // ─── avans se uračunava u prihod (naplaceno + avans) ──────────────────────────
func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) { func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -280,7 +280,7 @@ func TestPrihodTekuciMesec_GarancijaNeUlazi(t *testing.T) {
} }
} }
// ─── BUG-08: TopKlijenti broji samo preuzete naloge sa naplatom/avansom ────── // ─── TopKlijenti broji samo preuzete naloge sa naplatom/avansom ───────────────
func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) { func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -343,7 +343,7 @@ func TestPrihodTekuciMesec_Kombinovani(t *testing.T) {
} }
} }
// ─── BUG-04: auto-izračun cena_konacna ne sme da uključuje delove ──────────── // ─── auto-izračun cena_konacna ne sme da uključuje delove ─────────────────────
// //
// Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u // Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u
// Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa // Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa
@@ -381,7 +381,7 @@ func TestServisAutoIzracunCeneKonacne_BezDuplogDela(t *testing.T) {
`INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) `INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID)) VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID))
// simuliramo ispravnu handler logiku (BUG-04 fix): // simuliramo ispravnu handler logiku:
// cena_konacna = dijagnostika + radovi (BEZ delova) // cena_konacna = dijagnostika + radovi (BEZ delova)
cenaKonacna := 200.0 + 200.0 // dijagnostika + rad cenaKonacna := 200.0 + 200.0 // dijagnostika + rad
if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil { if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil {
+2 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -20,9 +21,6 @@ func NoviNivelacijaRepo(db *sql.DB) *NivelacijaRepo {
return &NivelacijaRepo{db: db} return &NivelacijaRepo{db: db}
} }
// ErrArtikalNePostoji se vraća kada se menja cena nepostojećeg artikla
var ErrArtikalNePostoji = errors.New("artikal ne postoji")
// PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis. // PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis.
// Stara cena se čita iz baze unutar transakcije; izvor je "rucno". // Stara cena se čita iz baze unutar transakcije; izvor je "rucno".
func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) { func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) {
@@ -35,7 +33,7 @@ func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaC
var stara float64 var stara float64
err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara) err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrArtikalNePostoji return nil, db.ErrArtikalNePostoji
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err) return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err)
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"testing" "testing"
"ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -62,7 +63,7 @@ func TestPromeniCenu_NepostojeciArtikal(t *testing.T) {
nivRepo := NoviNivelacijaRepo(baza) nivRepo := NoviNivelacijaRepo(baza)
_, err := nivRepo.PromeniCenu(ctx, 9999, 500, "test", nil) _, err := nivRepo.PromeniCenu(ctx, 9999, 500, "test", nil)
if !errors.Is(err, ErrArtikalNePostoji) { if !errors.Is(err, db.ErrArtikalNePostoji) {
t.Errorf("greška = %v, očekivano ErrArtikalNePostoji", err) t.Errorf("greška = %v, očekivano ErrArtikalNePostoji", err)
} }
+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: 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)
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"time" "time"
ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -28,7 +28,7 @@ func (h *Handler) AdminBlokiraneIP(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok {
return return
} }
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
+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 {
+5 -5
View File
@@ -10,7 +10,7 @@ import (
"unicode" "unicode"
"ntech/internal/config" "ntech/internal/config"
ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/fiskal" "ntech/internal/fiskal"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
@@ -46,7 +46,7 @@ func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
return return
} }
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
@@ -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",
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
@@ -91,7 +92,7 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
korisnikID := &k.ID korisnikID := &k.ID
_, err = h.NivelacijaRepo.PromeniCenu(r.Context(), id, novaCena, razlog, korisnikID) _, err = h.NivelacijaRepo.PromeniCenu(r.Context(), id, novaCena, razlog, korisnikID)
switch { switch {
case errors.Is(err, sqlite.ErrArtikalNePostoji): case errors.Is(err, db.ErrArtikalNePostoji):
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.") middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
http.Redirect(w, r, "/magacin", http.StatusSeeOther) http.Redirect(w, r, "/magacin", http.StatusSeeOther)
case err != nil: case err != nil:
+27 -24
View File
@@ -24,7 +24,7 @@ import (
"ntech/internal/auth" "ntech/internal/auth"
"ntech/internal/db" "ntech/internal/db"
ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -287,6 +287,7 @@ type PodaciPodesavanja struct {
PfrUrl string PfrUrl string
PfrTip string PfrTip string
PfrKasir string PfrKasir string
PfrApiKey string
VerifyHost string VerifyHost string
BePin string BePin string
BeLimit string BeLimit string
@@ -314,7 +315,7 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok { if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
return return
} }
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return return
@@ -450,7 +451,7 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
os.Remove(h.PutanjaBaze + "-wal") os.Remove(h.PutanjaBaze + "-wal")
os.Remove(h.PutanjaBaze + "-shm") os.Remove(h.PutanjaBaze + "-shm")
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) novaDB, err := sqlite.OtvoriDB(h.PutanjaBaze)
if err != nil { if err != nil {
slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err) slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err)
return return
@@ -567,7 +568,7 @@ func (h *Handler) UvezizBazu(w http.ResponseWriter, r *http.Request) {
os.Remove(h.PutanjaBaze + "-wal") os.Remove(h.PutanjaBaze + "-wal")
os.Remove(h.PutanjaBaze + "-shm") os.Remove(h.PutanjaBaze + "-shm")
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze) novaDB, err := sqlite.OtvoriDB(h.PutanjaBaze)
if err != nil { if err != nil {
slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err) slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err)
return return
@@ -689,6 +690,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
"pfr_tip": r.FormValue("pfr_tip"), "pfr_tip": r.FormValue("pfr_tip"),
"pfr_kasir": r.FormValue("pfr_kasir"), "pfr_kasir": r.FormValue("pfr_kasir"),
"verify_host": r.FormValue("verify_host"), "verify_host": r.FormValue("verify_host"),
"pfr_api_key": r.FormValue("pfr_api_key"),
"be_pin": r.FormValue("be_pin"), "be_pin": r.FormValue("be_pin"),
"be_limit": r.FormValue("be_limit"), "be_limit": r.FormValue("be_limit"),
"be_enabled": r.FormValue("be_enabled"), "be_enabled": r.FormValue("be_enabled"),
@@ -699,7 +701,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
if vrednost == "" { if vrednost == "" {
continue continue
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -728,7 +730,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_interval_sati", strconv.Itoa(n)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_interval_sati", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -740,7 +742,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_broj_kopija", strconv.Itoa(n)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "backup_broj_kopija", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -754,7 +756,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "kalkulacija_marza", strconv.FormatFloat(marza, 'f', -1, 64)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "kalkulacija_marza", strconv.FormatFloat(marza, 'f', -1, 64)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -768,7 +770,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_dana", strconv.Itoa(n)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_garancija_dana", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -782,7 +784,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_cena_dijagnostike", strconv.FormatFloat(cena, 'f', -1, 64)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_cena_dijagnostike", strconv.FormatFloat(cena, 'f', -1, 64)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -796,7 +798,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "predracun_rok_dana", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -809,7 +811,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sledeci, http.StatusSeeOther) http.Redirect(w, r, sledeci, http.StatusSeeOther)
return return
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "predvidjen_rok_dana", strconv.Itoa(n)); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "predvidjen_rok_dana", strconv.Itoa(n)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -819,7 +821,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// pa korisnik može i da skrati ili isprazni tekst // pa korisnik može i da skrati ili isprazni tekst
if _, ima := r.Form["servis_uslovi"]; ima { if _, ima := r.Form["servis_uslovi"]; ima {
uslovi := strings.TrimSpace(r.FormValue("servis_uslovi")) uslovi := strings.TrimSpace(r.FormValue("servis_uslovi"))
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_uslovi", uslovi); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_uslovi", uslovi); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -828,7 +830,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// klauzula na predračunu (slobodan tekst); čuva se uvek // klauzula na predračunu (slobodan tekst); čuva se uvek
if _, ima := r.Form["servis_klauzula_predracuna"]; ima { if _, ima := r.Form["servis_klauzula_predracuna"]; ima {
klauzula := strings.TrimSpace(r.FormValue("servis_klauzula_predracuna")) klauzula := strings.TrimSpace(r.FormValue("servis_klauzula_predracuna"))
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_klauzula_predracuna", klauzula); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "servis_klauzula_predracuna", klauzula); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -837,7 +839,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
// bazna adresa za QR kod (npr. http://192.168.1.25:3000); prazno → koristi se host iz zahteva // bazna adresa za QR kod (npr. http://192.168.1.25:3000); prazno → koristi se host iz zahteva
if _, ima := r.Form["qr_bazni_url"]; ima { if _, ima := r.Form["qr_bazni_url"]; ima {
bazni := strings.TrimSpace(r.FormValue("qr_bazni_url")) bazni := strings.TrimSpace(r.FormValue("qr_bazni_url"))
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "qr_bazni_url", bazni); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "qr_bazni_url", bazni); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return return
} }
@@ -943,7 +945,7 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
// timestamp u URL-u sprečava browser da koristi staru keširanu sliku // timestamp u URL-u sprečava browser da koristi staru keširanu sliku
putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix()) putanja := fmt.Sprintf("/static/uploads/logo%s?v=%d", ext, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", putanja); err != nil {
slog.Error("upload loga: greška pri čuvanju putanje", "error", err) slog.Error("upload loga: greška pri čuvanju putanje", "error", err)
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther) http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
return return
@@ -961,7 +963,7 @@ func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) {
for _, s := range stari { for _, s := range stari {
os.Remove(s) os.Remove(s)
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", ""); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", ""); err != nil {
slog.Error("ukloni logo: greška pri čuvanju", "error", err) slog.Error("ukloni logo: greška pri čuvanju", "error", err)
} }
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther) http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
@@ -1047,7 +1049,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
} }
// briše staru pozadinu sa diska ako postoji // briše staru pozadinu sa diska ako postoji
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) staraPodesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["login_pozadina"]; stara != "" { if stara := staraPodesavanja["login_pozadina"]; stara != "" {
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla // putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
deoBezverzije, _, _ := strings.Cut(stara, "?") deoBezverzije, _, _ := strings.Cut(stara, "?")
@@ -1081,7 +1083,7 @@ func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
} }
putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix()) putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", putanja); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", putanja); err != nil {
slog.Error("upload login pozadine: greška pri čuvanju putanje", "error", err) slog.Error("upload login pozadine: greška pri čuvanju putanje", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
@@ -1098,7 +1100,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
return return
} }
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil { if err == nil {
if stara := podesavanja["login_pozadina"]; stara != "" { if stara := podesavanja["login_pozadina"]; stara != "" {
deoBezverzije, _, _ := strings.Cut(stara, "?") deoBezverzije, _, _ := strings.Cut(stara, "?")
@@ -1107,7 +1109,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", ""); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", ""); err != nil {
slog.Error("ukloni login pozadinu: greška pri čuvanju", "error", err) slog.Error("ukloni login pozadinu: greška pri čuvanju", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
@@ -1164,7 +1166,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
"login_pozadina_opacity": opacityStr, "login_pozadina_opacity": opacityStr,
"login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr, "login_pozadina_zatamnjenje_kartice": zatamnjenjeKarticeStr,
} { } {
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil { if err := sqlite.SacuvajPodesavanje(r.Context(), h.DB, kljuc, vrednost); err != nil {
slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err) slog.Error("greška pri čuvanju stila login pozadine", "kljuc", kljuc, "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther) http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
@@ -1177,7 +1179,7 @@ func (h *Handler) SacuvajLoginPozadinaStilove(w http.ResponseWriter, r *http.Req
// napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template // napuniPodaciPodesavanja učitava sva podešavanja i kreira strukturu za template
func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (PodaciPodesavanja, error) { func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (PodaciPodesavanja, error) {
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
return PodaciPodesavanja{}, err return PodaciPodesavanja{}, err
} }
@@ -1225,6 +1227,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"), PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"),
PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"), PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"),
PfrKasir: podesavanja["pfr_kasir"], PfrKasir: podesavanja["pfr_kasir"],
PfrApiKey: podesavanja["pfr_api_key"],
VerifyHost: podesavanja["verify_host"], VerifyHost: podesavanja["verify_host"],
BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"), BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"),
BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"), BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"),
@@ -1282,7 +1285,7 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
} }
pfrURL := strings.TrimSpace(r.FormValue("pfr_url")) pfrURL := strings.TrimSpace(r.FormValue("pfr_url"))
if pfrURL == "" { if pfrURL == "" {
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil { if err != nil {
http.Error(w, "Greška", http.StatusInternalServerError) http.Error(w, "Greška", http.StatusInternalServerError)
return return
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
"ntech/internal/auth" "ntech/internal/auth"
ntechsqlite "ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware" "ntech/internal/middleware"
) )
@@ -39,7 +39,7 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
loginBlurPozadine := "0" loginBlurPozadine := "0"
loginBlurKartice := "12" loginBlurKartice := "12"
loginZatamnjenjeKartice := "0" loginZatamnjenjeKartice := "0"
if podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(context.Background(), h.DB); err == nil { if podesavanja, err := sqlite.DohvatiSvaPodesavanja(context.Background(), h.DB); err == nil {
loginPozadina = podesavanja["login_pozadina"] loginPozadina = podesavanja["login_pozadina"]
if v := podesavanja["login_pozadina_opacity"]; v != "" { if v := podesavanja["login_pozadina_opacity"]; v != "" {
loginOpacity = v loginOpacity = v
+15 -14
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 {
@@ -508,20 +508,18 @@ func (h *Handler) StampaFiskalnogProdaje(w http.ResponseWriter, r *http.Request)
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title> fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style> <style>
*{box-sizing:border-box;} *{box-sizing:border-box;}
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;} body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;} pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
@media print{body{font-size:11px;padding:10px;}} pre img{width:53.5mm;height:auto;}
@media screen{pre img{width:289px;}}
@media print{body{font-size:7px;padding:10px;}}
</style></head><body>`) </style></head><body>`)
fmt.Fprint(w, `<pre>`) fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, pre) fmt.Fprint(w, pre)
fmt.Fprint(w, `</pre>`)
if hasQR && fr.QRKod != "" { if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod) fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
} }
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, post) fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`) fmt.Fprint(w, `</pre>`)
@@ -613,9 +611,8 @@ func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.Pr
// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i označava nalog kao storniran // StornoProdaje stornira prodajni nalog: vraća artikle na stanje i označava nalog kao storniran
func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context()) k, ok := h.zahtevajDozvolu(w, r, "prodaja.storno")
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") { if !ok {
http.Error(w, "Nemate dozvolu za storniranje prodaje.", http.StatusForbidden)
return return
} }
id, err := parseID(chi.URLParam(r, "id")) id, err := parseID(chi.URLParam(r, "id"))
@@ -729,6 +726,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 +735,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 +826,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 +906,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)
+17 -14
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)
@@ -2974,10 +2980,11 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title> fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
<style> <style>
*{box-sizing:border-box;} *{box-sizing:border-box;}
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;} body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;} pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
.ntech-razdvajac{margin:24px 0;border-top:2px dashed #999;padding-top:24px;} pre img{width:53.5mm;height:auto;}
@media print{body{font-size:11px;padding:10px;}.ntech-razdvajac{page-break-before:always;}} @media screen{pre img{width:289px;}}
@media print{body{font-size:7px;padding:10px;}.ntech-razdvajac{page-break-before:always;}}
</style></head><body>`) </style></head><body>`)
const qrPlaceholder = "{{{{QR-KOD}}}}" const qrPlaceholder = "{{{{QR-KOD}}}}"
@@ -3001,13 +3008,9 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis
fmt.Fprint(w, `<pre>`) fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, pre) fmt.Fprint(w, pre)
fmt.Fprint(w, `</pre>`)
if hasQR && fr.QRKod != "" { if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod) fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
} }
fmt.Fprint(w, `<pre>`)
fmt.Fprint(w, post) fmt.Fprint(w, post)
fmt.Fprint(w, `</pre>`) fmt.Fprint(w, `</pre>`)
} }
+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)
+52 -2
View File
@@ -2,9 +2,12 @@ package handler
import ( import (
"context" "context"
"net/http"
"strconv"
"time" "time"
appdb "ntech/internal/db" appdb "ntech/internal/db"
"ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
) )
@@ -174,12 +177,14 @@ func (h *Handler) kirKandidatiServisa(ctx context.Context) ([]model.ServisniNalo
// (double-submit) pa ih automatski backfill preskače. // (double-submit) pa ih automatski backfill preskače.
func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) { func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) {
var p model.PrazninaKnjigovodstva var p model.PrazninaKnjigovodstva
var kandidatiP []kandidatUskladjivanja
if h.modulUkljucen(ctx, "pdv") { if h.modulUkljucen(ctx, "pdv") {
_, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx) _, postojiMapP, kp, err := h.kirKandidatiProdaje(ctx)
if err != nil { if err != nil {
return p, err return p, err
} }
kandidatiP = kp
_, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx) _, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
if err != nil { if err != nil {
return p, err return p, err
@@ -211,10 +216,11 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
} }
if h.modulUkljucen(ctx, "kpo") { if h.modulUkljucen(ctx, "kpo") {
_, postojiMapP, kandidatiP, err := h.kpoKandidatiProdaje(ctx) _, postojiMapP, kp, err := h.kpoKandidatiProdaje(ctx)
if err != nil { if err != nil {
return p, err return p, err
} }
kandidatiP = kp
_, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx) _, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx)
if err != nil { if err != nil {
return p, err return p, err
@@ -258,6 +264,17 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
} }
p.BezFiskalnogServis = len(p.BezFiskalnogServisID) p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
} }
// sumnjivi duplikati za fiskal — isti kandidati kao za KPO/PDV,
// ako su već učitani; ako ne, učitavamo iz prodaje (samo brojimo,
// preskaču se u automatskoj batch fiskalizaciji)
if len(kandidatiP) > 0 {
dupli := sumnjiviDuplikati(kandidatiP)
for id := range dupli {
p.SumnjiviFiskalID = append(p.SumnjiviFiskalID, id)
}
}
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil { if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
p.BezRefundaProdajaID = idsP p.BezRefundaProdajaID = idsP
p.BezRefundaServisID = idsS p.BezRefundaServisID = idsS
@@ -330,3 +347,36 @@ func istKlijent(a, b *int64) bool {
} }
return *a == *b return *a == *b
} }
// BatchFiskalizacija pokreće fiskalizaciju svih prodaja i servisa koji nemaju
// fiskalni račun. Best-effort: ako neka fiskalizacija padne, nastavlja se.
func (h *Handler) BatchFiskalizacija(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
klijent := h.fiskalKlijent(ctx)
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
izdato := 0
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
for id := range m {
h.fiskalizujProdaju(ctx, id, klijent, 0)
izdato++
}
}
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
for id := range m {
h.fiskalizujServis(ctx, id, klijent, "Gotovina", 0, 0)
izdato++
}
}
if izdato == 0 {
middleware.SetFlash(w, r, h.DB, "uspeh", "Svi nalozi već imaju fiskalni račun.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Pokrenuta fiskalizacija za "+strconv.Itoa(izdato)+" naloga.")
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
+1 -1
View File
@@ -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
// 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
}
+1
View File
@@ -95,6 +95,7 @@ type PrazninaKnjigovodstva struct {
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
SumnjiviDupli int SumnjiviDupli int
SumnjiviFiskalID []int64 // ID-jevi sumnjivih duplikata u fiskalizaciji — preskaču se u automatskoj batch fiskalizaciji
} }
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa. // BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
+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) {
+12
View File
@@ -51,6 +51,18 @@
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }} {{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }}
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }} {{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }}
{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) }}
<form method="POST" action="/fiskal/batch-fiskalizuj" style="margin:0;">
<input type="hidden" name="_csrf" value="{{ $.CsrfToken }}">
<button type="submit" class="btn-primarno" style="font-size:13px;">Fiskalizuj sve</button>
</form>
{{ end }}
{{ if and .PrazninaKnjige.SumnjiviFiskalID (gt (len .PrazninaKnjige.SumnjiviFiskalID) 0) }}
<div style="margin-top:4px;font-size:12px;opacity:0.7;">
⚠ Sumnjivi duplikati (preskaču se):
{{ range $i, $id := .PrazninaKnjige.SumnjiviFiskalID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#b45309;text-decoration:line-through;">Prodaja #{{ $id }}</a>{{ end }}
</div>
{{ end }}
</div> </div>
</div> </div>
</div> </div>
+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>
+2 -2
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;"
@@ -317,7 +317,7 @@ function toggleCenaPdv() {
<h3>Promeni prodajnu cenu</h3> <h3>Promeni prodajnu cenu</h3>
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button> <button type="submit" class="premesti-zatvori" aria-label="Zatvori">&times;</button>
</form> </form>
<form method="POST" action="/magacin/promeni-cenu/{{.ID}}" style="display:flex;flex-direction:column;gap:12px;padding:16px;"> <form method="POST" action="/magacin/promeni-cenu/{{.ID}}" data-refresh="#magacin-rezultati" style="display:flex;flex-direction:column;gap:12px;padding:16px;">
<div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{dinariCeli .Cena}} din</strong></div> <div style="font-size:13px;color:var(--tekst-sporedni);">Trenutna cena: <strong>{{dinariCeli .Cena}} din</strong></div>
<div> <div>
<label class="polje-labela">Nova cena (din)</label> <label class="polje-labela">Nova cena (din)</label>
@@ -54,6 +54,14 @@
</div> </div>
</div> </div>
<div>
<label for="pfr_api_key" class="polje-labela">API ključ</label>
<input type="text" id="pfr_api_key" name="pfr_api_key" value="{{.PfrApiKey}}"
placeholder="mock-api-key-0000"
style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;font-family:monospace;">
<div class="pomocni-tekst" style="margin-top:4px;">Bearer token koji NTech šalje PFR serveru. Mock podrazumevano: <code>mock-api-key-0000</code>.</div>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<button type="submit" <button type="submit"
style="padding:9px 18px;background:var(--sb-akcent);border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;"> style="padding:9px 18px;background:var(--sb-akcent);border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;">
+1 -1
View File
@@ -48,7 +48,7 @@
<div class="kanban-kartice"> <div class="kanban-kartice">
{{range $.Nalozi}} {{range $.Nalozi}}
{{if eq .Status $status}} {{if eq .Status $status}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta{{if .ImaPredlog}} kanban-ceka-odobrenje{{end}}"> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{.ID}}" class="kartica kanban-karta animiraj{{if .ImaPredlog}} kanban-ceka-odobrenje{{end}}">
<div style="display:flex;align-items:center;gap:6px;"> <div style="display:flex;align-items:center;gap:6px;">
{{if .ImaPredlog}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;" title="Čeka odobrenje klijenta"></span>{{end}} {{if .ImaPredlog}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;" title="Čeka odobrenje klijenta"></span>{{end}}
<span style="font-size:14px;font-weight:500;color:var(--sb-akcent);font-family:monospace;">{{.BrojNaloga}}</span> <span style="font-size:14px;font-weight:500;color:var(--sb-akcent);font-family:monospace;">{{.BrojNaloga}}</span>
+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>