Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb9fe1e37
|
||
|
|
ee265f81a9
|
||
|
|
24845f7aa0
|
||
|
|
292492f309
|
||
|
|
4c75aa2c75
|
||
|
|
80abfd583d
|
||
|
|
9cf4e66f00
|
+40
-1
@@ -31,6 +31,7 @@ ESIR_ID = "NTECH001" # naš 8-char ESIR identifikator
|
||||
# Kartica emulator (NTech goroutine)
|
||||
BE_HOST = os.environ.get("BE_HOST", "127.0.0.1")
|
||||
BE_PORT = int(os.environ.get("BE_PORT", "4567"))
|
||||
BE_PIN = os.environ.get("BE_PIN", "1234")
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
INVOICES_DIR = DATA_DIR / "invoices"
|
||||
@@ -86,6 +87,25 @@ def _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:
|
||||
"""Šalje JSON komandu kartica emulatoru (NTech TCP :4567) i vraća odgovor."""
|
||||
try:
|
||||
@@ -207,7 +227,10 @@ def log(msg):
|
||||
# ── QR ──────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
img.save(buf, format="PNG")
|
||||
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
|
||||
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({
|
||||
"command": "sign",
|
||||
"invoice_type": invoice_type,
|
||||
@@ -347,6 +384,8 @@ def _build_invoice_response(req, request_id):
|
||||
|
||||
if sign.get("status") == "blocked":
|
||||
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)
|
||||
total_cnt = sign.get("counter", 1)
|
||||
|
||||
@@ -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"), 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("prodaja.dodaj")).Post("/fiskal/batch-fiskalizuj", h.BatchFiskalizacija)
|
||||
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/{id}/izmeni", h.IzmeniPdvStopu)
|
||||
|
||||
@@ -262,6 +262,11 @@ func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
|
||||
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 {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
@@ -274,6 +279,10 @@ func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
if !k.pinUnesen {
|
||||
return map[string]any{"status": "error", "code": "2101", "message": "PIN nije verifikovan"}
|
||||
}
|
||||
|
||||
// blokada limite
|
||||
if k.unreadAmount >= k.Limit {
|
||||
return map[string]any{
|
||||
|
||||
@@ -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
@@ -8,8 +8,19 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"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.
|
||||
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
|
||||
func JeUkljucen(db *sql.DB) bool {
|
||||
@@ -35,6 +46,7 @@ func Pokreni(db *sql.DB) {
|
||||
slog.Info("kartica emulator sluša", "addr", addr)
|
||||
|
||||
k := novaKartica(db)
|
||||
sem := make(chan struct{}, maxKonekcija)
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
@@ -42,7 +54,16 @@ func Pokreni(db *sql.DB) {
|
||||
slog.Warn("be: greška pri Accept()", "error", err)
|
||||
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()
|
||||
|
||||
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()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
|
||||
@@ -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
@@ -45,6 +45,29 @@ func nadjiLokalneAdrese() []string {
|
||||
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
|
||||
func PokreniSetup(fsys fs.FS, envFajl string) {
|
||||
port := NadjiSlobodanPort()
|
||||
@@ -86,19 +109,7 @@ func PokreniSetup(fsys fs.FS, envFajl string) {
|
||||
})
|
||||
|
||||
// prima izabrani port, upisuje ntech.env i signalizira završetak
|
||||
r.Post("/setup/potvrdi", func(w http.ResponseWriter, req *http.Request) {
|
||||
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)
|
||||
})
|
||||
r.Post("/setup/potvrdi", obradiSetupPotvrdi(envFajl, gotov))
|
||||
|
||||
srv := &http.Server{Addr: adresa, Handler: r}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ type ErrNedovoljnoKolicine struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ var ErrArtikalDuplikatSifre = errors.New("ntech: šifra ili barkod artikla već
|
||||
var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge 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
|
||||
type ArtikalRepository interface {
|
||||
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestIzvestajPraznaBaza(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-01: stornirani prodajni nalozi ne smeju ući u prihod ───────────────
|
||||
// ─── stornirani prodajni nalozi ne smeju ući u prihod ────────────────────────
|
||||
|
||||
func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -160,7 +160,7 @@ func TestTopKlijenti_IgnorisuSeStornirani(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-02/03: servis prihod koristi naplaceno (bruto, uključuje delove) ───
|
||||
// ─── servis prihod koristi naplaceno (bruto, uključuje delove) ────────────────
|
||||
|
||||
func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -221,7 +221,7 @@ func TestMesecniPrihodServis_KoristiNaplaceno(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-10: avans se uračunava u prihod (naplaceno + avans) ─────────────────
|
||||
// ─── avans se uračunava u prihod (naplaceno + avans) ──────────────────────────
|
||||
|
||||
func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -280,7 +280,7 @@ func TestPrihodTekuciMesec_GarancijaNeUlazi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-08: TopKlijenti broji samo preuzete naloge sa naplatom/avansom ──────
|
||||
// ─── TopKlijenti broji samo preuzete naloge sa naplatom/avansom ───────────────
|
||||
|
||||
func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -343,7 +343,7 @@ func TestPrihodTekuciMesec_Kombinovani(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-04: auto-izračun cena_konacna ne sme da uključuje delove ────────────
|
||||
// ─── auto-izračun cena_konacna ne sme da uključuje delove ─────────────────────
|
||||
//
|
||||
// Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u
|
||||
// Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa
|
||||
@@ -381,7 +381,7 @@ func TestServisAutoIzracunCeneKonacne_BezDuplogDela(t *testing.T) {
|
||||
`INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
|
||||
VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID))
|
||||
|
||||
// simuliramo ispravnu handler logiku (BUG-04 fix):
|
||||
// simuliramo ispravnu handler logiku:
|
||||
// cena_konacna = dijagnostika + radovi (BEZ delova)
|
||||
cenaKonacna := 200.0 + 200.0 // dijagnostika + rad
|
||||
if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
@@ -20,9 +21,6 @@ func NoviNivelacijaRepo(db *sql.DB) *NivelacijaRepo {
|
||||
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.
|
||||
// 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) {
|
||||
@@ -35,7 +33,7 @@ func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaC
|
||||
var stara float64
|
||||
err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrArtikalNePostoji
|
||||
return nil, db.ErrArtikalNePostoji
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
@@ -62,7 +63,7 @@ func TestPromeniCenu_NepostojeciArtikal(t *testing.T) {
|
||||
nivRepo := NoviNivelacijaRepo(baza)
|
||||
|
||||
_, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -36,7 +36,7 @@ func (h *Handler) Dobavljaci(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
pretraga := r.URL.Query().Get("pretraga")
|
||||
pretraga := ignorisiKratkuPretragu(r.URL.Query().Get("pretraga"))
|
||||
|
||||
dobavljaci, err := h.DobavljaciRepo.Lista(r.Context(), pretraga)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"ntech/internal/config"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/fiskal"
|
||||
"ntech/internal/middleware"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(r.Context())
|
||||
if klijent == nil {
|
||||
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
|
||||
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)
|
||||
return
|
||||
}
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(r.Context())
|
||||
if klijent == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
|
||||
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)
|
||||
return
|
||||
}
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(r.Context())
|
||||
if klijent == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ntech/internal/config"
|
||||
"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
|
||||
// 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.
|
||||
func (h *Handler) fiskalKlijent() *fiskal.Klijent {
|
||||
url, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_url")
|
||||
key, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_api_key")
|
||||
func (h *Handler) fiskalKlijent(ctx context.Context) *fiskal.Klijent {
|
||||
url, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_url")
|
||||
key, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_api_key")
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -288,6 +289,17 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -53,7 +53,7 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str
|
||||
}
|
||||
|
||||
filter := db.ArtikalFilter{
|
||||
Pretraga: r.URL.Query().Get("pretraga"),
|
||||
Pretraga: ignorisiKratkuPretragu(r.URL.Query().Get("pretraga")),
|
||||
Tip: tip,
|
||||
SamoKriticni: r.URL.Query().Get("kriticni") == "1",
|
||||
Arhivirani: r.URL.Query().Get("arhivirani") == "1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
@@ -91,7 +92,7 @@ func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
korisnikID := &k.ID
|
||||
_, err = h.NivelacijaRepo.PromeniCenu(r.Context(), id, novaCena, razlog, korisnikID)
|
||||
switch {
|
||||
case errors.Is(err, sqlite.ErrArtikalNePostoji):
|
||||
case errors.Is(err, db.ErrArtikalNePostoji):
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.")
|
||||
http.Redirect(w, r, "/magacin", http.StatusSeeOther)
|
||||
case err != nil:
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
"ntech/internal/auth"
|
||||
"ntech/internal/db"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -287,6 +287,7 @@ type PodaciPodesavanja struct {
|
||||
PfrUrl string
|
||||
PfrTip string
|
||||
PfrKasir string
|
||||
PfrApiKey string
|
||||
VerifyHost string
|
||||
BePin 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 {
|
||||
return
|
||||
}
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -450,7 +451,7 @@ func (h *Handler) VratiBackup(w http.ResponseWriter, r *http.Request) {
|
||||
os.Remove(h.PutanjaBaze + "-wal")
|
||||
os.Remove(h.PutanjaBaze + "-shm")
|
||||
|
||||
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze)
|
||||
novaDB, err := sqlite.OtvoriDB(h.PutanjaBaze)
|
||||
if err != nil {
|
||||
slog.Error("vrati backup: greška pri otvaranju nove baze (potreban restart)", "error", err)
|
||||
return
|
||||
@@ -567,7 +568,7 @@ func (h *Handler) UvezizBazu(w http.ResponseWriter, r *http.Request) {
|
||||
os.Remove(h.PutanjaBaze + "-wal")
|
||||
os.Remove(h.PutanjaBaze + "-shm")
|
||||
|
||||
novaDB, err := ntechsqlite.OtvoriDB(h.PutanjaBaze)
|
||||
novaDB, err := sqlite.OtvoriDB(h.PutanjaBaze)
|
||||
if err != nil {
|
||||
slog.Error("uvezi bazu: greška pri otvaranju nove baze (potreban restart)", "error", err)
|
||||
return
|
||||
@@ -689,6 +690,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
"pfr_tip": r.FormValue("pfr_tip"),
|
||||
"pfr_kasir": r.FormValue("pfr_kasir"),
|
||||
"verify_host": r.FormValue("verify_host"),
|
||||
"pfr_api_key": r.FormValue("pfr_api_key"),
|
||||
"be_pin": r.FormValue("be_pin"),
|
||||
"be_limit": r.FormValue("be_limit"),
|
||||
"be_enabled": r.FormValue("be_enabled"),
|
||||
@@ -699,7 +701,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
if vrednost == "" {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -728,7 +730,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -740,7 +742,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -754,7 +756,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -768,7 +770,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -782,7 +784,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -796,7 +798,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -809,7 +811,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
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)
|
||||
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
|
||||
if _, ima := r.Form["servis_uslovi"]; ima {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -828,7 +830,7 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
// klauzula na predračunu (slobodan tekst); čuva se uvek
|
||||
if _, ima := r.Form["servis_klauzula_predracuna"]; ima {
|
||||
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)
|
||||
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
|
||||
if _, ima := r.Form["qr_bazni_url"]; ima {
|
||||
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)
|
||||
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
|
||||
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)
|
||||
http.Redirect(w, r, "/admin/podesavanja/opste?logo_greska=Greška+pri+čuvanju+podešavanja", http.StatusSeeOther)
|
||||
return
|
||||
@@ -961,7 +963,7 @@ func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) {
|
||||
for _, s := range stari {
|
||||
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)
|
||||
}
|
||||
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
|
||||
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
staraPodesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if stara := staraPodesavanja["login_pozadina"]; stara != "" {
|
||||
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
|
||||
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())
|
||||
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)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
|
||||
http.Redirect(w, r, "/admin/podesavanja/izgled", http.StatusSeeOther)
|
||||
@@ -1098,7 +1100,7 @@ func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err == nil {
|
||||
if stara := podesavanja["login_pozadina"]; 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)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.")
|
||||
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_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)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
|
||||
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
|
||||
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 {
|
||||
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"),
|
||||
PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"),
|
||||
PfrKasir: podesavanja["pfr_kasir"],
|
||||
PfrApiKey: podesavanja["pfr_api_key"],
|
||||
VerifyHost: podesavanja["verify_host"],
|
||||
BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"),
|
||||
BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"),
|
||||
@@ -1282,7 +1285,7 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
pfrURL := strings.TrimSpace(r.FormValue("pfr_url"))
|
||||
if pfrURL == "" {
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"ntech/internal/auth"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
|
||||
loginBlurPozadine := "0"
|
||||
loginBlurKartice := "12"
|
||||
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"]
|
||||
if v := podesavanja["login_pozadina_opacity"]; v != "" {
|
||||
loginOpacity = v
|
||||
|
||||
+15
-14
@@ -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)
|
||||
racunKreiran := false
|
||||
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)
|
||||
h.fiskalizujProdaju(r.Context(), id, klijent, primljeno)
|
||||
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>
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
|
||||
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
|
||||
@media print{body{font-size:11px;padding:10px;}}
|
||||
body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
|
||||
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
|
||||
pre img{width:53.5mm;height:auto;}
|
||||
@media screen{pre img{width:289px;}}
|
||||
@media print{body{font-size:7px;padding:10px;}}
|
||||
</style></head><body>`)
|
||||
|
||||
fmt.Fprint(w, `<pre>`)
|
||||
fmt.Fprint(w, pre)
|
||||
fmt.Fprint(w, `</pre>`)
|
||||
|
||||
if hasQR && fr.QRKod != "" {
|
||||
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
|
||||
fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, `<pre>`)
|
||||
fmt.Fprint(w, post)
|
||||
fmt.Fprint(w, `</pre>`)
|
||||
|
||||
@@ -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
|
||||
func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") {
|
||||
http.Error(w, "Nemate dozvolu za storniranje prodaje.", http.StatusForbidden)
|
||||
k, ok := h.zahtevajDozvolu(w, r, "prodaja.storno")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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
|
||||
// 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 {
|
||||
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
|
||||
defer cancel()
|
||||
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -736,7 +735,7 @@ func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, po
|
||||
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
|
||||
return nil
|
||||
}
|
||||
fk := h.fiskalKlijent()
|
||||
fk := h.fiskalKlijent(ctx)
|
||||
if fk == nil {
|
||||
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
|
||||
// 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) {
|
||||
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
|
||||
defer cancel()
|
||||
nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(r.Context())
|
||||
if klijent == nil {
|
||||
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)
|
||||
|
||||
+17
-14
@@ -656,7 +656,7 @@ func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// fiskalni refund — best-effort
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
fk := h.fiskalKlijent()
|
||||
fk := h.fiskalKlijent(ctx)
|
||||
if fk == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
|
||||
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.
|
||||
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr == nil {
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(r.Context())
|
||||
if klijent != nil {
|
||||
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
|
||||
// (smanjenje avansa se ne fiskalizuje automatski — v. razgovor o povraćaju).
|
||||
func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, noviAvans float64, nacinPlacanja string) {
|
||||
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
|
||||
defer cancel()
|
||||
if noviAvans <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -2527,7 +2529,7 @@ func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, no
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
klijent := h.fiskalKlijent()
|
||||
klijent := h.fiskalKlijent(ctx)
|
||||
if klijent == nil {
|
||||
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
|
||||
// (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) {
|
||||
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
|
||||
defer cancel()
|
||||
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
|
||||
if err != nil {
|
||||
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
|
||||
// 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 {
|
||||
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
|
||||
defer cancel()
|
||||
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
|
||||
if err != nil {
|
||||
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
|
||||
// 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 {
|
||||
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)
|
||||
@@ -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>
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
|
||||
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
|
||||
.ntech-razdvajac{margin:24px 0;border-top:2px dashed #999;padding-top:24px;}
|
||||
@media print{body{font-size:11px;padding:10px;}.ntech-razdvajac{page-break-before:always;}}
|
||||
body{font-family:monospace;font-size:10px;padding:20px;max-width:max-content;margin:0 auto;}
|
||||
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;}
|
||||
pre img{width:53.5mm;height:auto;}
|
||||
@media screen{pre img{width:289px;}}
|
||||
@media print{body{font-size:7px;padding:10px;}.ntech-razdvajac{page-break-before:always;}}
|
||||
</style></head><body>`)
|
||||
|
||||
const qrPlaceholder = "{{{{QR-KOD}}}}"
|
||||
@@ -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>`)
|
||||
|
||||
if hasQR && fr.QRKod != "" {
|
||||
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
|
||||
fmt.Fprintf(w, `<img src="data:image/png;base64,%s">`, fr.QRKod)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, `<pre>`)
|
||||
fmt.Fprint(w, post)
|
||||
fmt.Fprint(w, `</pre>`)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError)
|
||||
|
||||
@@ -2,9 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
appdb "ntech/internal/db"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
@@ -174,12 +177,14 @@ func (h *Handler) kirKandidatiServisa(ctx context.Context) ([]model.ServisniNalo
|
||||
// (double-submit) pa ih automatski backfill preskače.
|
||||
func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) {
|
||||
var p model.PrazninaKnjigovodstva
|
||||
var kandidatiP []kandidatUskladjivanja
|
||||
|
||||
if h.modulUkljucen(ctx, "pdv") {
|
||||
_, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx)
|
||||
_, postojiMapP, kp, err := h.kirKandidatiProdaje(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
kandidatiP = kp
|
||||
_, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
@@ -211,10 +216,11 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
|
||||
}
|
||||
|
||||
if h.modulUkljucen(ctx, "kpo") {
|
||||
_, postojiMapP, kandidatiP, err := h.kpoKandidatiProdaje(ctx)
|
||||
_, postojiMapP, kp, err := h.kpoKandidatiProdaje(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
kandidatiP = kp
|
||||
_, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
@@ -258,6 +264,17 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
|
||||
}
|
||||
p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
|
||||
}
|
||||
|
||||
// sumnjivi duplikati za fiskal — isti kandidati kao za KPO/PDV,
|
||||
// ako su već učitani; ako ne, učitavamo iz prodaje (samo brojimo,
|
||||
// preskaču se u automatskoj batch fiskalizaciji)
|
||||
if len(kandidatiP) > 0 {
|
||||
dupli := sumnjiviDuplikati(kandidatiP)
|
||||
for id := range dupli {
|
||||
p.SumnjiviFiskalID = append(p.SumnjiviFiskalID, id)
|
||||
}
|
||||
}
|
||||
|
||||
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
|
||||
p.BezRefundaProdajaID = idsP
|
||||
p.BezRefundaServisID = idsS
|
||||
@@ -330,3 +347,36 @@ func istKlijent(a, b *int64) bool {
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// BatchFiskalizacija pokreće fiskalizaciju svih prodaja i servisa koji nemaju
|
||||
// fiskalni račun. Best-effort: ako neka fiskalizacija padne, nastavlja se.
|
||||
func (h *Handler) BatchFiskalizacija(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
klijent := h.fiskalKlijent(ctx)
|
||||
if klijent == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
izdato := 0
|
||||
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
|
||||
for id := range m {
|
||||
h.fiskalizujProdaju(ctx, id, klijent, 0)
|
||||
izdato++
|
||||
}
|
||||
}
|
||||
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
|
||||
for id := range m {
|
||||
h.fiskalizujServis(ctx, id, klijent, "Gotovina", 0, 0)
|
||||
izdato++
|
||||
}
|
||||
}
|
||||
|
||||
if izdato == 0 {
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Svi nalozi već imaju fiskalni račun.")
|
||||
} else {
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Pokrenuta fiskalizacija za "+strconv.Itoa(izdato)+" naloga.")
|
||||
}
|
||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (h *Handler) Usluge(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju usluga", http.StatusInternalServerError)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseID parsira string ID iz URL parametra u int64
|
||||
@@ -13,3 +14,15 @@ func parseID(s string) (int64, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type PrazninaKnjigovodstva struct {
|
||||
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
|
||||
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
|
||||
SumnjiviDupli int
|
||||
SumnjiviFiskalID []int64 // ID-jevi sumnjivih duplikata u fiskalizaciji — preskaču se u automatskoj batch fiskalizaciji
|
||||
}
|
||||
|
||||
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
|
||||
|
||||
@@ -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
|
||||
// (sidebar ostaje u zatečenom stanju). U isto vreme sme biti otvoren samo jedan podmeni.
|
||||
function ntechTogglePodmeni(btn) {
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
<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.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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||
placeholder="Pretraži dobavljače..."
|
||||
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">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<canvas id="grafikon-prihod"></canvas>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto;">
|
||||
<div class="tabela-skrol">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
@@ -98,7 +98,7 @@
|
||||
<div class="kartica izv-sekcija animiraj">
|
||||
<div class="izv-naslov">Stari otvoreni nalozi — bez završetka duže od 14 dana</div>
|
||||
{{if .StariNalozi}}
|
||||
<div style="overflow-x:auto;">
|
||||
<div class="tabela-skrol">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
@@ -143,6 +143,7 @@
|
||||
<div class="kartica izv-grid-kartica animiraj">
|
||||
<div class="izv-naslov">Najprodavaniji artikli — top 10</div>
|
||||
{{if .TopArtikli}}
|
||||
<div class="tabela-skrol">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
@@ -168,6 +169,7 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o prodaji.</div>
|
||||
{{end}}
|
||||
@@ -177,6 +179,7 @@
|
||||
<div class="kartica izv-grid-kartica animiraj">
|
||||
<div class="izv-naslov">Najvažniji klijenti — top 10</div>
|
||||
{{if .TopKlijenti}}
|
||||
<div class="tabela-skrol">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
@@ -199,6 +202,7 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="font-size:13px;color:var(--tekst-sporedni);padding:8px 0;">Nema podataka o klijentima.</div>
|
||||
{{end}}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<div class="kartica animiraj">
|
||||
{{if .Zapisi}}
|
||||
<div class="tabela-omotac">
|
||||
<div class="tabela-omotac tabela-skrol">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<input type="text" name="pretraga" value="{{.Filter.Pretraga}}"
|
||||
placeholder="Pretraži po nazivu, šifri, barkodu, lokaciji..."
|
||||
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">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
|
||||
<select name="kategorija" style="flex:1;min-width:140px;"
|
||||
@@ -317,7 +317,7 @@ function toggleCenaPdv() {
|
||||
<h3>Promeni prodajnu cenu</h3>
|
||||
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
||||
</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>
|
||||
<label class="polje-labela">Nova cena (din)</label>
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
</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;">
|
||||
<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;">
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="kanban-kartice">
|
||||
{{range $.Nalozi}}
|
||||
{{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;">
|
||||
{{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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||
placeholder="Pretraži troškove po nazivu ili šifri..."
|
||||
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">
|
||||
</form>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<input type="text" name="pretraga" value="{{.Pretraga}}"
|
||||
placeholder="Pretraži usluge po nazivu ili šifri..."
|
||||
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">
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user