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) } // podrazumevanaPdvStopa vraća opštu PDV stopu iz šifarnika (Podešavanja → Kalkulacija // i PDV → Šifarnik PDV stopa) — jedini izvor „koja je opšta stopa" u programu. // 20.0 se koristi samo kao krajnji fallback ako šifarnik nema nijednu aktivnu // opštu stopu (npr. korisnik ih sve arhivirao) — ne kao podrazumevana poslovna // pretpostavka, već da se izbegne prekid rada. func (h *Handler) podrazumevanaPdvStopa(ctx context.Context) float64 { stopa, err := h.PdvStopeRepo.PodrazumevanaOpsta(ctx) if err != nil || stopa == nil { return 20.0 } return stopa.Stopa } // validnePdvStope vraća skup PDV stopa koje korisnik sme da izabere na stavci — // aktivne stope iz šifarnika (Podešavanja → Kalkulacija i PDV), plus 0 koje je // uvek dozvoljeno (van sistema PDV/oslobođeno). Zamenjuje hardkodovanu proveru // „0, 10 ili 20" — šifarnik je jedini izvor važećih stopa. func (h *Handler) validnePdvStope(ctx context.Context) map[float64]bool { validne := map[float64]bool{0: true} stope, err := h.PdvStopeRepo.Lista(ctx, true) if err != nil { return validne } for _, s := range stope { validne[s.Stopa] = true } return validne } // 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 }