From 9aaafa2358c6918cadb4f41bfdc9abef06955fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 12 Jun 2026 22:53:15 +0200 Subject: [PATCH] =?UTF-8?q?refactor(izvestaji):=20direktan=20SQL=20dashboa?= =?UTF-8?q?rd/izve=C5=A1taja=20u=20IzvestajRepository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/db/repository.go | 20 +++ internal/db/sqlite/izvestaj.go | 234 ++++++++++++++++++++++++++++ internal/db/sqlite/izvestaj_test.go | 66 ++++++++ internal/handler/dashboard.go | 121 ++++++-------- internal/handler/handler.go | 3 + internal/handler/izvestaji.go | 124 ++++----------- internal/model/izvestaj.go | 59 +++++++ 7 files changed, 463 insertions(+), 164 deletions(-) create mode 100644 internal/db/sqlite/izvestaj.go create mode 100644 internal/db/sqlite/izvestaj_test.go create mode 100644 internal/model/izvestaj.go diff --git a/internal/db/repository.go b/internal/db/repository.go index 021a0c6..a766c94 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -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) diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go new file mode 100644 index 0000000..79d08c6 --- /dev/null +++ b/internal/db/sqlite/izvestaj.go @@ -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() +} diff --git a/internal/db/sqlite/izvestaj_test.go b/internal/db/sqlite/izvestaj_test.go new file mode 100644 index 0000000..ec08a4a --- /dev/null +++ b/internal/db/sqlite/izvestaj_test.go @@ -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) + } +} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 02342c3..eb5622e 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -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, + }) } } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 53a2705..5604193 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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()) diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go index 1cbcd14..8f5e1d0 100644 --- a/internal/handler/izvestaji.go +++ b/internal/handler/izvestaji.go @@ -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, + }) } } diff --git a/internal/model/izvestaj.go b/internal/model/izvestaj.go new file mode 100644 index 0000000..27f68aa --- /dev/null +++ b/internal/model/izvestaj.go @@ -0,0 +1,59 @@ +package model + +import "time" + +// Tipovi u ovom fajlu su SIROVI redovi koje vraća IzvestajRepository. +// Prezentaciju (formatiranje datuma, boje, rang, sklapanje niza meseci) radi +// handler — ovde su samo podaci iz baze. + +// ServisRedDashboard je jedan red za listu poslednjih servisa na dashboardu +type ServisRedDashboard struct { + Uredjaj string + Status string + DatumPrijema time.Time +} + +// ZalihaRed je artikal sa kritičnom zalihom (naziv + količina) +type ZalihaRed struct { + Naziv string + Kolicina int +} + +// ProdajaRedDashboard je jedan red za listu poslednjih prodaja na dashboardu +type ProdajaRedDashboard struct { + BrojNaloga string + Ukupno float64 + Datum time.Time + KlijentNaziv string +} + +// MesecniIznos je zbir za jedan mesec (ključ je "YYYY-MM") +type MesecniIznos struct { + Mesec string + Iznos float64 +} + +// StariNalogRed je otvoreni servisni nalog stariji od praga (sirov datum) +type StariNalogRed struct { + ID int64 + BrojNaloga string + Uredjaj string + Status string + KlijentNaziv string + DatumPrijema time.Time +} + +// TopArtikalRed je artikal rangiran po prodatoj količini (bez ranga — dodaje handler) +type TopArtikalRed struct { + Naziv string + Kategorija string + UkupnoKolicina int + UkupnoPrihod float64 +} + +// TopKlijentRed je klijent rangiran po ukupnoj vrednosti naloga (bez ranga) +type TopKlijentRed struct { + Naziv string + UkupnoVrednost float64 + BrojNaloga int +}