Files
GoNtech/internal/handler/handler.go
T
Dasko 3a0f98622b Knjigovodstvo i fiskalizacija: dashboard upozorenje za praznine, KPO dozvole, pojednostavljen storno, ispravke verifikacije i kasira
- Dashboard kartica prikazuje naloge bez KIR/KPO/fiskalnog upisa i sumnjive duplikate (double-submit), sa zaštitom od automatskog dupliranja prihoda pri bulk dopuni
- Registrovane nedostajuće KPO dozvole (kpo.pregled/dodaj/obrisi) — modul je bio potpuno nedostupan
- Uklonjen suvišan/nefunkcionalan 'Ukloni nalog' (duplirao Storno bez stvarne razlike); Storno u listi prodaje sad vodi direktno na formu
- Kasir na fiskalnom računu koristi ime prijavljenog korisnika umesto fiksnog podešavanja
- Fiskalni računi za nalog uvek prikazuju najnoviji zapis (original vs. refund posle storna)
- Verifikaciona stranica (/v/) jasno označena kao lokalna test simulacija, ne prava provera kod PURS-a; dodat prikaz kusura
- Mock brojač fiskalnih računa (kartica emulator) sad preživljava restart servera
2026-07-01 23:01:26 +02:00

283 lines
12 KiB
Go

package handler
import (
"context"
"database/sql"
"html/template"
"io/fs"
"net/http"
"strings"
"sync"
"ntech/internal/config"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
)
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
ServisniRadoviRepo db.ServisniRadoviRepository
ServisniPotrazivaniDeloviRepo db.ServisniPotrazivaniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
FiskalRepo db.FiskalRepository
KpoRepo db.KpoRepository
ServisniLogRepo db.ServisniLogRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
// mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova
// uzima ekskluzivno (write) zaključavanje pre zamene konekcije.
mu sync.RWMutex
}
// ZakljucajCitanje je middleware koji za vreme obrade zahteva drži deljeno
// zaključavanje baze. Više zahteva se izvršava paralelno (RLock ne blokira druge
// čitaoce), ali obnova baze (VratiBackup), koja traži ekskluzivno zaključavanje,
// sačeka da svi tekući zahtevi završe pre zamene konekcije. Time se sprečava
// data race nad Handler.DB / repozitorijumima i upit nad zatvorenom konekcijom.
func (h *Handler) ZakljucajCitanje(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
next.ServeHTTP(w, r)
})
}
// SaBazom izvršava fn sa TRENUTNOM konekcijom baze, pod deljenim zaključavanjem.
// Namenjeno pozadinskim gorutinama (auto-backup, čišćenje): posle obnove backupa
// h.DB se menja, pa gorutine moraju da čitaju aktuelni handle, a ne zatvoreni.
// Zaključavanje se drži samo za vreme fn — ne pozivaj iz njega operacije koje
// dugo blokiraju (npr. time.Sleep), da ne bi nepotrebno odlagao obnovu.
func (h *Handler) SaBazom(fn func(*sql.DB)) {
h.mu.RLock()
defer h.mu.RUnlock()
fn(h.DB)
}
// Novi kreira novi Handler sa datom bazom.
// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
return &Handler{
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
ServisniRadoviRepo: sqlite.NoviServisniRadoviRepo(baza),
ServisniPotrazivaniDeloviRepo: sqlite.NoviServisniPotrazivaniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
FiskalRepo: sqlite.NoviFiskalRepo(baza),
KpoRepo: sqlite.NoviKpoRepo(baza),
ServisniLogRepo: sqlite.NoviServisniLogRepo(baza),
}
}
// reinicijalizujRepozitorijume zamenjuje sve repozitorijume posle obnove baze.
// MORA se pozivati dok je h.mu ekskluzivno zaključan (vidi VratiBackup), inače
// nastaje data race sa zahtevima koji čitaju repozitorijume.
func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.DB = novaDB
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
h.UslugeRepo = sqlite.NoviUslugaRepo(novaDB)
h.TroskoviRepo = sqlite.NoviTrosakRepo(novaDB)
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB)
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB)
h.ServisniRadoviRepo = sqlite.NoviServisniRadoviRepo(novaDB)
h.ServisniPotrazivaniDeloviRepo = sqlite.NoviServisniPotrazivaniDeloviRepo(novaDB)
h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc)
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
h.RezervniKodoviRepo = sqlite.NoviRezervniKodoviRepo(novaDB)
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB)
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
h.FiskalRepo = sqlite.NoviFiskalRepo(novaDB)
h.KpoRepo = sqlite.NoviKpoRepo(novaDB)
h.ServisniLogRepo = sqlite.NoviServisniLogRepo(novaDB)
}
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
// Koristi se pri automatskom punjenju KIR/KPR iz prodaje/nabavke.
func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB)
if err != nil {
return false
}
return config.ModulUkljucen(podesavanja, modul)
}
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje).
func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, akcija) {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return nil, false
}
return k, true
}
// popuniPodaciStranice popunjava zajednička polja stranice uključujući prijavljenog korisnika
func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]string) model.PodaciStranice {
// podrazumevana tema je tamna; korisnik može imati svoju lokalnu temu
tema := "tamna"
ps := model.PodaciStranice{
Tema: tema,
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoPutanja: podesavanja["logo_putanja"],
TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1",
Korisnik: "Admin",
}
var korisnik *model.Korisnik
if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil {
korisnik = k
ps.Korisnik = k.KorisnickoIme
ps.KorisnikIme = k.KorisnickoIme
ps.KorisnikUloga = k.Uloga
ps.AvatarPutanja = k.AvatarPutanja
ps.Dozvole = h.DozvoleRepo.SveDozvole(r.Context(), k.Uloga)
// lokalna tema korisnika — primenjuje se uvek kada je postavljena, bez obzira na KoristiLokalnuTemu
if k.LokalnaTema != "" {
ps.Tema = k.LokalnaTema
}
}
ps.CsrfToken = middleware.CsrfToken(r.Context())
ps.AssetV = h.AssetV
ps.Flash = middleware.GetFlash(r, h.DB)
// uključeni zakonski moduli prema profilu firme — šabloni ih koriste za uslovni meni
ps.Moduli = config.SviModuli(podesavanja)
// logika pozadine:
// - lična pozadina → uvek se prikazuje i forsira tamnu temu, bez obzira na KoristiLokalnuTemu
// - globalna pozadina → prikazuje se svima koji nemaju ličnu
// KoristiLokalnuTemu i LokalnaTema važe samo kada nema lične pozadine
if korisnik != nil && korisnik.LokalnaPozadina != "" {
ps.AppPozadina = korisnik.LokalnaPozadina
ps.Tema = "tamna"
ps.AppPozadinaOpacity = korisnik.LokalnaPozadinaOpacity
if ps.AppPozadinaOpacity == "" {
ps.AppPozadinaOpacity = "50"
}
ps.AppPozadinaBlur = korisnik.LokalnaPozadinaBlur
if ps.AppPozadinaBlur == "" {
ps.AppPozadinaBlur = "12"
}
ps.AppPozadinaBlurPozadine = korisnik.LokalnaPozadinaBlurPozadine
if ps.AppPozadinaBlurPozadine == "" {
ps.AppPozadinaBlurPozadine = "0"
}
ps.AppPozadinaGlassOpacity = korisnik.LokalnaPozadinaGlassOpacity
if ps.AppPozadinaGlassOpacity == "" {
ps.AppPozadinaGlassOpacity = "10"
}
}
if korisnik != nil {
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
ps.LokalniHover = korisnik.LokalniHover
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
}
return ps
}
// 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")
if url == "" {
return nil
}
if key == "" {
key = "test"
}
return fiskal.NoviKlijent(url, key)
}
// 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
// "NTech" ako ni to nije uneto.
func (h *Handler) imeKasira(ctx context.Context) string {
kasir := ""
if k := middleware.KorisnikIzKonteksta(ctx); k != nil {
if k.Ime != "" || k.Prezime != "" {
kasir = strings.TrimSpace(k.Ime + " " + k.Prezime)
} else {
kasir = k.KorisnickoIme
}
}
if kasir == "" {
kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
}
if kasir == "" {
kasir = "NTech"
}
return kasir
}