Files
GoNtech/internal/db/sqlite/izvestaj.go
T
Dasko d9f337617b Nizak prioritet iz pregleda koda: indeksi, greške, sitne popravke
- migracija 102: indeksi na FK kolonama (stavke_prodaje, servisni_delovi,
  servisni_potrazivani_delovi, magacinske_promene, stavke_nabavke, pdv_kir,
  pdv_kpr) — potvrđeno kreiranje i idempotentnost
- BrojKriticnihZaliha/KriticneZalihe filtriraju samo proizvode i aktivne
  artikle, usklađeno sa ArtikalRepo.Lista(SamoKriticni)
- url.QueryEscape na query string u magacin.go/klijent.go (paginacija)
- TestFiskalizacije/BeStatus vraćaju generičku poruku, detalj u slog
- SetFlash loguje grešku umesto tihog ignorisanja
- os.WriteFile/os.MkdirAll u main.go proveravaju grešku
- dummyHash generisanje panikuje na startu ako bcrypt ikad zakaže
  (umesto tihog sloma anti-enumeracije)
- PrihvatiPredlozene proverava redovi.Err()
- /healthz endpoint (DB ping)
- hx-boost na "Osveži" linkovima (blokirane_ip, fiskal_pazar)
- MaxBytesReader (4KB) na javne /status/{token}/* POST rute
- komentar u auth.go zašto postoje dva flash mehanizma (namerno, ne spojeno)

Provereno: jePrivatnaAdresa ne radi DNS lookup (nema net.LookupIP u repou),
pa DNS-rebinding nalaz iz pregleda nije primenljiv na trenutni kod.
2026-07-03 23:31:00 +02:00

344 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"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 NOT IN ('Završeno', 'Preuzeto')`).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(
(SELECT SUM(ukupno) FROM prodajni_nalozi
WHERE stornirano = 0
AND substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
0)
+ COALESCE(
(SELECT SUM(naplaceno + COALESCE(avans, 0)) FROM servisni_nalozi
WHERE status = 'Preuzeto'
AND (naplaceno > 0 OR avans > 0)
AND substr(datum_zavrsetka, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
0)`).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 (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0`).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 id, 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.ID, &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, kolicina_min FROM artikli
WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0
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, &z.KolicinaMin); 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
WHERE pn.stornirano = 0
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 stornirano = 0
AND 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(naplaceno + COALESCE(avans, 0))
FROM servisni_nalozi
WHERE datum_zavrsetka IS NOT NULL
AND status = 'Preuzeto'
AND (naplaceno > 0 OR avans > 0)
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
}
// MesecniZaradaProdaja je zarada prodaje (ukupno nabavna cena × količina),
// nabavna_cena je zamrznuta na stavci u trenutku prodaje (vidi migraciju 101).
func (r *sqliteIzvestajRepo) MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(ctx, `
SELECT substr(pn.datum, 1, 7), SUM(sp.ukupno - sp.nabavna_cena * sp.kolicina)
FROM prodajni_nalozi pn
JOIN stavke_prodaje sp ON sp.nalog_id = pn.id
WHERE pn.stornirano = 0
AND substr(pn.datum, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(pn.datum, 1, 7)`, "MesecniZaradaProdaja")
}
// MesecniZaradaServis je zarada servisa: ugrađeni delovi (cena nabavna cena) +
// rad u punom iznosu (servis_radovi nema polje za trošak). Isti filter statusa/
// naplate kao MesecniPrihodServis.
func (r *sqliteIzvestajRepo) MesecniZaradaServis(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(ctx, `
SELECT mesec, SUM(zarada) FROM (
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
(sd.cena_komada - sd.nabavna_cena) * sd.kolicina AS zarada
FROM servisni_delovi sd
JOIN servisni_nalozi sn ON sn.id = sd.nalog_id
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
AND (sn.naplaceno > 0 OR sn.avans > 0) AND sd.predlozeno = 0
UNION ALL
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
sr.cena_komada * sr.kolicina AS zarada
FROM servis_radovi sr
JOIN servisni_nalozi sn ON sn.id = sr.nalog_id
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
AND (sn.naplaceno > 0 OR sn.avans > 0)
)
WHERE mesec >= strftime('%Y-%m', date('now', '-11 months', 'start of month'))
GROUP BY mesec`, "MesecniZaradaServis")
}
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 WHERE stornirano = 0 GROUP BY klijent_id
) p ON p.klijent_id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(naplaceno + COALESCE(avans, 0)) AS ukupno_servis, COUNT(*) AS broj_servisa
FROM servisni_nalozi WHERE status = 'Preuzeto' AND (naplaceno > 0 OR avans > 0) 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()
}
// PrometniList vraća sve magacinske promene u zadatom periodu
func (r *sqliteIzvestajRepo) PrometniList(ctx context.Context, od, do time.Time) ([]model.PrometniRed, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT mp.datum, a.naziv, COALESCE(a.sifra, ''), mp.tip_promene,
mp.promena_kolicine, mp.stanje_pre, mp.stanje_posle,
COALESCE(mp.napomena, '')
FROM magacinske_promene mp
JOIN artikli a ON a.id = mp.artikal_id
WHERE DATE(mp.datum) >= DATE(?) AND DATE(mp.datum) <= DATE(?)
ORDER BY mp.datum ASC`,
od.Format("2006-01-02"), do.Format("2006-01-02"),
)
if err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.PrometniList: %w", err)
}
defer rows.Close()
var lista []model.PrometniRed
for rows.Next() {
var p model.PrometniRed
if err := rows.Scan(&p.Datum, &p.ArtikalNaziv, &p.ArtikalSifra, &p.TipPromene,
&p.PromenaKolicine, &p.StanjePre, &p.StanjePosle, &p.Napomena); err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.PrometniList: scan: %w", err)
}
lista = append(lista, p)
}
return lista, rows.Err()
}
// StanjeZaliha vraća trenutno stanje svih artikala sa vrednostima
func (r *sqliteIzvestajRepo) StanjeZaliha(ctx context.Context) ([]model.StanjeZalihaRed, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT a.naziv, COALESCE(a.sifra, ''), COALESCE(k.naziv, ''),
a.kolicina, a.kolicina_min, a.nabavna_cena, a.prodajna_cena,
a.cena_sa_pdv,
a.kolicina * a.nabavna_cena AS vrednost,
a.kolicina * a.cena_sa_pdv AS vrednost_sa_pdv
FROM artikli a
LEFT JOIN kategorije k ON k.id = a.kategorija_id
ORDER BY k.naziv ASC, a.naziv ASC`,
)
if err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: %w", err)
}
defer rows.Close()
var lista []model.StanjeZalihaRed
for rows.Next() {
var s model.StanjeZalihaRed
if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija,
&s.Kolicina, &s.KolicinaMin, &s.NabavnaCena, &s.ProdajnaCena,
&s.CenaSaPdv, &s.VrednostZalihe, &s.VrednostSaPdv); err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err)
}
lista = append(lista, s)
}
return lista, rows.Err()
}