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
+20
View File
@@ -143,6 +143,26 @@ type DozvoleRepository interface {
Reset(ctx context.Context) error
}
// IzvestajRepository definiše read-only upite za dashboard i stranicu izveštaja.
// Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler.
type IzvestajRepository interface {
// dashboard — brojači
BrojArtikala(ctx context.Context) (int, error)
BrojAktivnihServisa(ctx context.Context) (int, error)
PrihodTekuciMesec(ctx context.Context) (float64, error)
BrojKriticnihZaliha(ctx context.Context) (int, error)
// dashboard — liste
PoslednjiServisi(ctx context.Context, limit int) ([]model.ServisRedDashboard, error)
KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error)
PoslednjeProdaje(ctx context.Context, limit int) ([]model.ProdajaRedDashboard, error)
// izveštaji
MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error)
MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error)
StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error)
TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error)
TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error)
}
// PodsetnikRepository definiše operacije nad podsetnicima
type PodsetnikRepository interface {
Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error)
+234
View File
@@ -0,0 +1,234 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/model"
)
type sqliteIzvestajRepo struct{ db *sql.DB }
// NoviIzvestajRepo kreira SQLite implementaciju IzvestajRepository
func NoviIzvestajRepo(db *sql.DB) *sqliteIzvestajRepo {
return &sqliteIzvestajRepo{db: db}
}
func (r *sqliteIzvestajRepo) BrojArtikala(ctx context.Context) (int, error) {
var n int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM artikli`).Scan(&n); err != nil {
return 0, fmt.Errorf("ntech: izvestaj.BrojArtikala: %w", err)
}
return n, nil
}
func (r *sqliteIzvestajRepo) BrojAktivnihServisa(ctx context.Context) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM servisni_nalozi WHERE status != 'Završeno'`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("ntech: izvestaj.BrojAktivnihServisa: %w", err)
}
return n, nil
}
func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, error) {
var iznos float64
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(ukupno), 0) FROM prodajni_nalozi
WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')`).Scan(&iznos)
if err != nil {
return 0, fmt.Errorf("ntech: izvestaj.PrihodTekuciMesec: %w", err)
}
return iznos, nil
}
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err)
}
return n, nil
}
func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([]model.ServisRedDashboard, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT uredjaj, status, datum_prijema FROM servisni_nalozi
ORDER BY datum_prijema DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.PoslednjiServisi: %w", err)
}
defer rows.Close()
var lista []model.ServisRedDashboard
for rows.Next() {
var s model.ServisRedDashboard
if err := rows.Scan(&s.Uredjaj, &s.Status, &s.DatumPrijema); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.PoslednjiServisi: %w", err)
}
lista = append(lista, s)
}
return lista, rows.Err()
}
func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT naziv, kolicina FROM artikli
WHERE kolicina <= kolicina_min
ORDER BY kolicina ASC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err)
}
defer rows.Close()
var lista []model.ZalihaRed
for rows.Next() {
var z model.ZalihaRed
if err := rows.Scan(&z.Naziv, &z.Kolicina); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err)
}
lista = append(lista, z)
}
return lista, rows.Err()
}
func (r *sqliteIzvestajRepo) PoslednjeProdaje(ctx context.Context, limit int) ([]model.ProdajaRedDashboard, error) {
rows, err := r.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 ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.PoslednjeProdaje: %w", err)
}
defer rows.Close()
var lista []model.ProdajaRedDashboard
for rows.Next() {
var p model.ProdajaRedDashboard
if err := rows.Scan(&p.BrojNaloga, &p.Ukupno, &p.Datum, &p.KlijentNaziv); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.PoslednjeProdaje: %w", err)
}
lista = append(lista, p)
}
return lista, rows.Err()
}
// mesecniPrihod je deljeni čitalac grupisanih (mesec, iznos) redova
func (r *sqliteIzvestajRepo) mesecniPrihod(ctx context.Context, upit, ime string) ([]model.MesecniIznos, error) {
rows, err := r.db.QueryContext(ctx, upit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.%s: %w", ime, err)
}
defer rows.Close()
var lista []model.MesecniIznos
for rows.Next() {
var m model.MesecniIznos
if err := rows.Scan(&m.Mesec, &m.Iznos); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.%s: %w", ime, err)
}
lista = append(lista, m)
}
return lista, rows.Err()
}
func (r *sqliteIzvestajRepo) MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(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)`, "MesecniPrihodProdaja")
}
func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(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)`, "MesecniPrihodServis")
}
func (r *sqliteIzvestajRepo) StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error) {
rows, err := r.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`)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.StariOtvoreniNalozi: %w", err)
}
defer rows.Close()
var lista []model.StariNalogRed
for rows.Next() {
var sn model.StariNalogRed
if err := rows.Scan(&sn.ID, &sn.BrojNaloga, &sn.Uredjaj, &sn.Status, &sn.DatumPrijema, &sn.KlijentNaziv); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.StariOtvoreniNalozi: %w", err)
}
lista = append(lista, sn)
}
return lista, rows.Err()
}
func (r *sqliteIzvestajRepo) TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error) {
rows, err := r.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 ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.TopArtikli: %w", err)
}
defer rows.Close()
var lista []model.TopArtikalRed
for rows.Next() {
var a model.TopArtikalRed
if err := rows.Scan(&a.Naziv, &a.Kategorija, &a.UkupnoKolicina, &a.UkupnoPrihod); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.TopArtikli: %w", err)
}
lista = append(lista, a)
}
return lista, rows.Err()
}
func (r *sqliteIzvestajRepo) TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error) {
rows, err := r.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 ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.TopKlijenti: %w", err)
}
defer rows.Close()
var lista []model.TopKlijentRed
for rows.Next() {
var k model.TopKlijentRed
if err := rows.Scan(&k.Naziv, &k.UkupnoVrednost, &k.BrojNaloga); err != nil {
return nil, fmt.Errorf("ntech: izvestaj.TopKlijenti: %w", err)
}
lista = append(lista, k)
}
return lista, rows.Err()
}
+66
View File
@@ -0,0 +1,66 @@
package sqlite
import (
"context"
"testing"
"ntech/internal/model"
)
func TestIzvestajArtikliBrojaci(t *testing.T) {
ctx := context.Background()
db := testDB(t)
art := NoviArtikalRepo(db)
izv := NoviIzvestajRepo(db)
dodaj := func(a *model.Artikal) {
if _, err := art.Kreiraj(ctx, a); err != nil {
t.Fatalf("Kreiraj: %v", err)
}
}
dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5})
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) // kritičan
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) // kritičan, nula
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
}
if n, err := izv.BrojKriticnihZaliha(ctx); err != nil || n != 2 {
t.Fatalf("BrojKriticnihZaliha = %d, err=%v; očekivano 2", n, err)
}
zalihe, err := izv.KriticneZalihe(ctx, 5)
if err != nil {
t.Fatalf("KriticneZalihe: %v", err)
}
if len(zalihe) != 2 {
t.Fatalf("KriticneZalihe vratio %d, očekivano 2", len(zalihe))
}
// sortirano po količini rastuće → prvi je onaj sa 0
if zalihe[0].Kolicina != 0 {
t.Fatalf("prvi kritičan treba da ima količinu 0, ima %d", zalihe[0].Kolicina)
}
}
// prazna baza — brojači 0, liste prazne, bez greške
func TestIzvestajPraznaBaza(t *testing.T) {
ctx := context.Background()
db := testDB(t)
izv := NoviIzvestajRepo(db)
if n, err := izv.BrojArtikala(ctx); err != nil || n != 0 {
t.Errorf("BrojArtikala = %d, err=%v", n, err)
}
if v, err := izv.PrihodTekuciMesec(ctx); err != nil || v != 0 {
t.Errorf("PrihodTekuciMesec = %v, err=%v", v, err)
}
if l, err := izv.PoslednjiServisi(ctx, 5); err != nil || len(l) != 0 {
t.Errorf("PoslednjiServisi len=%d, err=%v", len(l), err)
}
if l, err := izv.TopKlijenti(ctx, 10); err != nil || len(l) != 0 {
t.Errorf("TopKlijenti len=%d, err=%v", len(l), err)
}
if l, err := izv.MesecniPrihodProdaja(ctx); err != nil || len(l) != 0 {
t.Errorf("MesecniPrihodProdaja len=%d, err=%v", len(l), err)
}
}