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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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"
|
||||
if redovi, err := h.IzvestajRepo.PoslednjiServisi(ctx, 5); err != nil {
|
||||
slog.Error("dashboard: poslednji servisi", "error", err)
|
||||
} else {
|
||||
z.BojaTacke = "#f97316"
|
||||
}
|
||||
kriticneZalihe = append(kriticneZalihe, z)
|
||||
}
|
||||
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."),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// artikli sa kritičnom zalihom
|
||||
var kriticneZalihe []model.StavkaZalihe
|
||||
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
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user