refactor(izvestaji): direktan SQL dashboard/izveštaja u IzvestajRepository

dashboard.go i izvestaji.go više ne sadrže direktan SQL — svih 12 upita
prebačeno u novi IzvestajRepository (internal/db/sqlite/izvestaj.go). Repo vraća
sirove redove (model.*Red tipovi), a handleri zadržavaju prezentaciju
(formatiranje datuma, boje tačaka, rang, sklapanje niza 12 meseci). Žičenje kroz
Handler.IzvestajRepo (+ reinicijalizuj).

Dobici: testabilnost (dodati integracioni testovi izvestaj_test.go) i put ka
Postgres-u bez prepravke handlera. dashboard.prihod provera ostaje u handleru.

Van obima: middleware/flash.go i backup VACUUM INTO (ne pripadaju repo sloju).
This commit is contained in:
2026-06-12 22:53:15 +02:00
parent 53c06b6db4
commit 9aaafa2358
7 changed files with 463 additions and 164 deletions
+47 -74
View File
@@ -3,7 +3,6 @@ package handler
import (
"log/slog"
"net/http"
"time"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
@@ -36,34 +35,32 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
var brojArtikala, aktivniServisi, kriticnaZaliha, aktivniPodsetnici int
var prihodOvogMeseca float64
if err := h.DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM artikli",
).Scan(&brojArtikala); err != nil {
if n, err := h.IzvestajRepo.BrojArtikala(ctx); err != nil {
slog.Error("dashboard: broj artikala", "error", err)
} else {
brojArtikala = n
}
if err := h.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM servisni_nalozi
WHERE status != 'Završeno'`,
).Scan(&aktivniServisi); err != nil {
if n, err := h.IzvestajRepo.BrojAktivnihServisa(ctx); err != nil {
slog.Error("dashboard: aktivni servisi", "error", err)
} else {
aktivniServisi = n
}
// prihod se dohvata samo ako korisnik ima dozvolu dashboard.prihod
korisnikDash := middleware.KorisnikIzKonteksta(ctx)
if h.DozvoleRepo.ImaDozvolu(ctx, korisnikDash.Uloga, "dashboard.prihod") {
if err := h.DB.QueryRowContext(ctx, `
SELECT COALESCE(SUM(ukupno), 0) FROM prodajni_nalozi
WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')`,
).Scan(&prihodOvogMeseca); err != nil {
if v, err := h.IzvestajRepo.PrihodTekuciMesec(ctx); err != nil {
slog.Error("dashboard: prihod ovog meseca", "error", err)
} else {
prihodOvogMeseca = v
}
}
if err := h.DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min",
).Scan(&kriticnaZaliha); err != nil {
if n, err := h.IzvestajRepo.BrojKriticnihZaliha(ctx); err != nil {
slog.Error("dashboard: kriticna zaliha", "error", err)
} else {
kriticnaZaliha = n
}
korisnikFilter := appdb.PodsetnikFilter{}
@@ -76,75 +73,51 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
aktivniPodsetnici = n
}
// poslednjih 5 servisnih naloga sa datumom prijema
servisRedovi, err := h.DB.QueryContext(ctx, `
SELECT uredjaj, status, datum_prijema FROM servisni_nalozi
ORDER BY datum_prijema DESC LIMIT 5`)
if err != nil {
slog.Error("dashboard: poslednji servisi", "error", err)
}
// poslednjih 5 servisnih naloga — repo vraća sirove redove, ovde formatiramo
var poslednjiServisi []model.StavkaServisa
if servisRedovi != nil {
defer servisRedovi.Close()
for servisRedovi.Next() {
var s model.StavkaServisa
var datum time.Time
if err := servisRedovi.Scan(&s.Uredjaj, &s.Status, &datum); err == nil {
s.BojaTacke = bojaTackeServisa(s.Status)
s.DatumPrijema = datum.Format("02.01.")
poslednjiServisi = append(poslednjiServisi, s)
}
if redovi, err := h.IzvestajRepo.PoslednjiServisi(ctx, 5); err != nil {
slog.Error("dashboard: poslednji servisi", "error", err)
} else {
for _, s := range redovi {
poslednjiServisi = append(poslednjiServisi, model.StavkaServisa{
Uredjaj: s.Uredjaj,
Status: s.Status,
BojaTacke: bojaTackeServisa(s.Status),
DatumPrijema: s.DatumPrijema.Format("02.01."),
})
}
}
// artikli sa kritičnom zalihom, sortirani po količini rastuće
zaliheRedovi, err := h.DB.QueryContext(ctx, `
SELECT naziv, kolicina FROM artikli
WHERE kolicina <= kolicina_min
ORDER BY kolicina ASC LIMIT 5`)
if err != nil {
slog.Error("dashboard: kriticne zalihe", "error", err)
}
// artikli sa kritičnom zalihom
var kriticneZalihe []model.StavkaZalihe
if zaliheRedovi != nil {
defer zaliheRedovi.Close()
for zaliheRedovi.Next() {
var z model.StavkaZalihe
if err := zaliheRedovi.Scan(&z.Naziv, &z.Kolicina); err == nil {
if z.Kolicina == 0 {
z.BojaTacke = "#dc2626"
} else {
z.BojaTacke = "#f97316"
}
kriticneZalihe = append(kriticneZalihe, z)
if redovi, err := h.IzvestajRepo.KriticneZalihe(ctx, 5); err != nil {
slog.Error("dashboard: kriticne zalihe", "error", err)
} else {
for _, z := range redovi {
boja := "#f97316"
if z.Kolicina == 0 {
boja = "#dc2626"
}
kriticneZalihe = append(kriticneZalihe, model.StavkaZalihe{
Naziv: z.Naziv,
Kolicina: z.Kolicina,
BojaTacke: boja,
})
}
}
// poslednjih 5 prodajnih naloga sa nazivom klijenta
prodajaRedovi, err := h.DB.QueryContext(ctx, `
SELECT
pn.broj_naloga, pn.ukupno, pn.datum,
COALESCE(kp.naziv, '') AS klijent_naziv
FROM prodajni_nalozi pn
LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id
ORDER BY pn.datum DESC LIMIT 5`)
if err != nil {
slog.Error("dashboard: poslednje prodaje", "error", err)
}
// poslednjih 5 prodajnih naloga
var poslednjeProdaje []model.StavkaProdajePregled
if prodajaRedovi != nil {
defer prodajaRedovi.Close()
for prodajaRedovi.Next() {
var p model.StavkaProdajePregled
var datum time.Time
if err := prodajaRedovi.Scan(&p.BrojNaloga, &p.Ukupno, &datum, &p.KlijentNaziv); err == nil {
p.Datum = datum.Format("02.01.")
poslednjeProdaje = append(poslednjeProdaje, p)
}
if redovi, err := h.IzvestajRepo.PoslednjeProdaje(ctx, 5); err != nil {
slog.Error("dashboard: poslednje prodaje", "error", err)
} else {
for _, p := range redovi {
poslednjeProdaje = append(poslednjeProdaje, model.StavkaProdajePregled{
BrojNaloga: p.BrojNaloga,
Ukupno: p.Ukupno,
Datum: p.Datum.Format("02.01."),
KlijentNaziv: p.KlijentNaziv,
})
}
}
+3
View File
@@ -29,6 +29,7 @@ type Handler struct {
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
@@ -86,6 +87,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
@@ -109,6 +111,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc)
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
+34 -90
View File
@@ -86,42 +86,21 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
// --- mesečni prihod: prodaja ---
prodajaPoMesecu := map[string]float64{}
prodajaRed, err := h.DB.QueryContext(ctx, `
SELECT substr(datum, 1, 7), SUM(ukupno)
FROM prodajni_nalozi
WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum, 1, 7)`)
if err != nil {
if redovi, err := h.IzvestajRepo.MesecniPrihodProdaja(ctx); err != nil {
slog.Error("izvestaji: prihod prodaja", "error", err)
} else {
defer prodajaRed.Close()
for prodajaRed.Next() {
var mesec string
var iznos float64
if err := prodajaRed.Scan(&mesec, &iznos); err == nil {
prodajaPoMesecu[mesec] = iznos
}
for _, m := range redovi {
prodajaPoMesecu[m.Mesec] = m.Iznos
}
}
// --- mesečni prihod: servis ---
servisPoMesecu := map[string]float64{}
servisRed, err := h.DB.QueryContext(ctx, `
SELECT substr(datum_zavrsetka, 1, 7), SUM(cena_konacna)
FROM servisni_nalozi
WHERE datum_zavrsetka IS NOT NULL
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum_zavrsetka, 1, 7)`)
if err != nil {
if redovi, err := h.IzvestajRepo.MesecniPrihodServis(ctx); err != nil {
slog.Error("izvestaji: prihod servis", "error", err)
} else {
defer servisRed.Close()
for servisRed.Next() {
var mesec string
var iznos float64
if err := servisRed.Scan(&mesec, &iznos); err == nil {
servisPoMesecu[mesec] = iznos
}
for _, m := range redovi {
servisPoMesecu[m.Mesec] = m.Iznos
}
}
@@ -160,86 +139,51 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
})
// --- stari otvoreni nalozi (>14 dana bez završetka) ---
stariRed, err := h.DB.QueryContext(ctx, `
SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema,
COALESCE(NULLIF(kp.naziv, ''), '—') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id
WHERE sn.datum_zavrsetka IS NULL
AND substr(sn.datum_prijema, 1, 10) <= date('now', '-14 days')
ORDER BY sn.datum_prijema ASC`)
var stariNalozi []StariNalog
if err != nil {
if redovi, err := h.IzvestajRepo.StariOtvoreniNalozi(ctx); err != nil {
slog.Error("izvestaji: stari nalozi", "error", err)
} else {
defer stariRed.Close()
for stariRed.Next() {
var sn StariNalog
var datumVreme time.Time
if err := stariRed.Scan(&sn.ID, &sn.BrojNaloga, &sn.Uredjaj, &sn.Status, &datumVreme, &sn.KlijentNaziv); err == nil {
sn.DatumPrijema = datumVreme.Format("02.01.2006.")
sn.DanaProslo = int(time.Since(datumVreme).Hours() / 24)
stariNalozi = append(stariNalozi, sn)
}
for _, sn := range redovi {
stariNalozi = append(stariNalozi, StariNalog{
ID: sn.ID,
BrojNaloga: sn.BrojNaloga,
Uredjaj: sn.Uredjaj,
KlijentNaziv: sn.KlijentNaziv,
Status: sn.Status,
DatumPrijema: sn.DatumPrijema.Format("02.01.2006."),
DanaProslo: int(time.Since(sn.DatumPrijema).Hours() / 24),
})
}
}
// --- top 10 najprodavanijih artikala ---
artRed, err := h.DB.QueryContext(ctx, `
SELECT a.naziv, COALESCE(k.naziv, '—'), SUM(sp.kolicina), SUM(sp.ukupno)
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
LEFT JOIN kategorije k ON k.id = a.kategorija_id
GROUP BY sp.artikal_id
ORDER BY SUM(sp.kolicina) DESC
LIMIT 10`)
var topArtikli []TopArtikal
if err != nil {
if redovi, err := h.IzvestajRepo.TopArtikli(ctx, 10); err != nil {
slog.Error("izvestaji: top artikli", "error", err)
} else {
defer artRed.Close()
for artRed.Next() {
var a TopArtikal
if err := artRed.Scan(&a.Naziv, &a.Kategorija, &a.UkupnoKolicina, &a.UkupnoPrihod); err == nil {
a.Rang = len(topArtikli) + 1
topArtikli = append(topArtikli, a)
}
for i, a := range redovi {
topArtikli = append(topArtikli, TopArtikal{
Rang: i + 1,
Naziv: a.Naziv,
Kategorija: a.Kategorija,
UkupnoKolicina: a.UkupnoKolicina,
UkupnoPrihod: a.UkupnoPrihod,
})
}
}
// --- top 10 klijenata po ukupnoj vrednosti ---
klijRed, err := h.DB.QueryContext(ctx, `
SELECT
kp.naziv AS naziv,
COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) AS ukupno_vrednost,
COALESCE(p.broj_prodaja, 0) + COALESCE(s.broj_servisa, 0) AS broj_naloga
FROM klijenti k
LEFT JOIN klijent_prikaz kp ON kp.id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
FROM prodajni_nalozi GROUP BY klijent_id
) p ON p.klijent_id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
) s ON s.klijent_id = k.id
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
ORDER BY ukupno_vrednost DESC
LIMIT 10`)
var topKlijenti []TopKlijent
if err != nil {
if redovi, err := h.IzvestajRepo.TopKlijenti(ctx, 10); err != nil {
slog.Error("izvestaji: top klijenti", "error", err)
} else {
defer klijRed.Close()
for klijRed.Next() {
var k TopKlijent
if err := klijRed.Scan(&k.Naziv, &k.UkupnoVrednost, &k.BrojNaloga); err == nil {
k.Rang = len(topKlijenti) + 1
topKlijenti = append(topKlijenti, k)
}
for i, k := range redovi {
topKlijenti = append(topKlijenti, TopKlijent{
Rang: i + 1,
Naziv: k.Naziv,
BrojNaloga: k.BrojNaloga,
UkupnoVrednost: k.UkupnoVrednost,
})
}
}