diff --git a/internal/db/repository.go b/internal/db/repository.go
index 3c4cec4..e05b9bc 100644
--- a/internal/db/repository.go
+++ b/internal/db/repository.go
@@ -383,6 +383,8 @@ type IzvestajRepository interface {
// izveštaji
MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error)
MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error)
+ MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error)
+ MesecniZaradaServis(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)
diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go
index a300f3d..908224c 100644
--- a/internal/db/sqlite/izvestaj.go
+++ b/internal/db/sqlite/izvestaj.go
@@ -165,6 +165,42 @@ func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.M
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,
diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go
index 179d1e4..c405dc2 100644
--- a/internal/db/sqlite/prodaja.go
+++ b/internal/db/sqlite/prodaja.go
@@ -204,9 +204,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
s := stavke[i]
var naziv, tip string
var stanjePre int
+ var nabavnaCena float64
err := tx.QueryRowContext(ctx,
- "SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
- ).Scan(&naziv, &stanjePre, &tip)
+ "SELECT naziv, kolicina, tip, nabavna_cena FROM artikli WHERE id = ?", s.ArtikalID,
+ ).Scan(&naziv, &stanjePre, &tip, &nabavnaCena)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
@@ -248,10 +249,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
stavke[i].Ukupno = ukupnoStavke
_, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje
- (nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ (nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv, nabavna_cena)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke,
- s.PdvStopa, pdvIznos, cenaBezPdv,
+ s.PdvStopa, pdvIznos, cenaBezPdv, nabavnaCena,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go
index bdbdc07..08487ad 100644
--- a/internal/db/sqlite/servisni_delovi.go
+++ b/internal/db/sqlite/servisni_delovi.go
@@ -95,9 +95,10 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
// Ugrađeni delovi: standardna logika — skidamo sa lagera
var stanjePre int
+ var nabavnaCena float64
if err = tx.QueryRowContext(ctx,
- "SELECT kolicina FROM artikli WHERE id = ?", artikalID,
- ).Scan(&stanjePre); err != nil {
+ "SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID,
+ ).Scan(&stanjePre, &nabavnaCena); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err)
}
@@ -119,15 +120,15 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
- "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
- postojeciKol+ugradjeno, cenaKomada, postojeciID,
+ "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
+ postojeciKol+ugradjeno, cenaKomada, nabavnaCena, postojeciID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx,
- "INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
- nalogID, artikalID, ugradjeno, cenaKomada,
+ "INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno, nabavna_cena) VALUES (?, ?, ?, ?, 0, ?)",
+ nalogID, artikalID, ugradjeno, cenaKomada, nabavnaCena,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err)
}
diff --git a/internal/db/sqlite/servisni_potrazivani_delovi.go b/internal/db/sqlite/servisni_potrazivani_delovi.go
index 078f9bf..8b9957e 100644
--- a/internal/db/sqlite/servisni_potrazivani_delovi.go
+++ b/internal/db/sqlite/servisni_potrazivani_delovi.go
@@ -196,6 +196,13 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
// uvećava postojeći red za isti artikal na nalogu ili kreira novi. Radi unutar
// prosleđene transakcije. Ne dira magacin — to radi skiniSaMagacina zasebno.
func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64) error {
+ var nabavnaCena float64
+ if err := tx.QueryRowContext(ctx,
+ "SELECT nabavna_cena FROM artikli WHERE id = ?", artikalID,
+ ).Scan(&nabavnaCena); err != nil {
+ return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: nabavna cena: %w", err)
+ }
+
var postojeciID int64
var postojeciKol int
err := tx.QueryRowContext(ctx,
@@ -207,8 +214,8 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
// ne pregazi postojeću cenu nulom (npr. legacy potraživani red bez cene)
if cenaKomada > 0 {
_, err = tx.ExecContext(ctx,
- "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
- postojeciKol+kolicina, cenaKomada, postojeciID,
+ "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
+ postojeciKol+kolicina, cenaKomada, nabavnaCena, postojeciID,
)
} else {
_, err = tx.ExecContext(ctx,
@@ -223,9 +230,9 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
}
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
- INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
- VALUES (?, ?, ?, ?)`,
- nalogID, artikalID, kolicina, cenaKomada,
+ INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, nabavna_cena)
+ VALUES (?, ?, ?, ?, ?)`,
+ nalogID, artikalID, kolicina, cenaKomada, nabavnaCena,
)
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: insert: %w", err)
diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go
index 5fe49fd..86f1f9c 100644
--- a/internal/handler/izvestaji.go
+++ b/internal/handler/izvestaji.go
@@ -53,10 +53,13 @@ type PodaciIzvestaja struct {
// MesecniPrihod drži prihod od prodaje i servisa za jedan mesec
type MesecniPrihod struct {
- MesecPrikaz string
- Prodaja float64
- Servis float64
- Ukupno float64
+ MesecPrikaz string
+ Prodaja float64
+ Servis float64
+ Ukupno float64
+ Zarada float64
+ ZaradaProdaja float64
+ ZaradaServis float64
}
// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana
@@ -120,35 +123,68 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
}
}
+ // --- mesečna zarada: prodaja ---
+ zaradaProdajaPoMesecu := map[string]float64{}
+ if redovi, err := h.IzvestajRepo.MesecniZaradaProdaja(ctx); err != nil {
+ slog.Error("izvestaji: zarada prodaja", "error", err)
+ } else {
+ for _, m := range redovi {
+ zaradaProdajaPoMesecu[m.Mesec] = m.Iznos
+ }
+ }
+
+ // --- mesečna zarada: servis ---
+ zaradaServisPoMesecu := map[string]float64{}
+ if redovi, err := h.IzvestajRepo.MesecniZaradaServis(ctx); err != nil {
+ slog.Error("izvestaji: zarada servis", "error", err)
+ } else {
+ for _, m := range redovi {
+ zaradaServisPoMesecu[m.Mesec] = m.Iznos
+ }
+ }
+
// gradimo niz za poslednjih 12 meseci (hronološki)
var mesecniPrihodi []MesecniPrihod
var grafikonLabele []string
var grafikonProdaja []float64
var grafikonServis []float64
+ var grafikonZaradaProdaja []float64
+ var grafikonZaradaServis []float64
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
prod := prodajaPoMesecu[kljuc]
serv := servisPoMesecu[kljuc]
+ zaradaProd := zaradaProdajaPoMesecu[kljuc]
+ zaradaServ := zaradaServisPoMesecu[kljuc]
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
- MesecPrikaz: formatujMesec(kljuc),
- Prodaja: prod,
- Servis: serv,
- Ukupno: prod + serv,
+ MesecPrikaz: formatujMesec(kljuc),
+ Prodaja: prod,
+ Servis: serv,
+ Ukupno: prod + serv,
+ Zarada: zaradaProd + zaradaServ,
+ ZaradaProdaja: zaradaProd,
+ ZaradaServis: zaradaServ,
})
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
grafikonProdaja = append(grafikonProdaja, prod)
grafikonServis = append(grafikonServis, serv)
+ grafikonZaradaProdaja = append(grafikonZaradaProdaja, zaradaProd)
+ grafikonZaradaServis = append(grafikonZaradaServis, zaradaServ)
}
type grafikonPodaci struct {
- Labele []string `json:"labele"`
- Prodaja []float64 `json:"prodaja"`
- Servis []float64 `json:"servis"`
+ Labele []string `json:"labele"`
+ Prodaja []float64 `json:"prodaja"`
+ Servis []float64 `json:"servis"`
+ ZaradaProdaja []float64 `json:"zaradaProdaja"`
+ ZaradaServis []float64 `json:"zaradaServis"`
}
jsonBytes, _ := json.Marshal(grafikonPodaci{
- Labele: grafikonLabele,
- Prodaja: grafikonProdaja,
- Servis: grafikonServis,
+ Labele: grafikonLabele,
+ Prodaja: grafikonProdaja,
+ Servis: grafikonServis,
+ ZaradaProdaja: grafikonZaradaProdaja,
+ ZaradaServis: grafikonZaradaServis,
})
// --- stari otvoreni nalozi (>14 dana bez završetka) ---
diff --git a/migrations/101_nabavna_cena_stavke.sql b/migrations/101_nabavna_cena_stavke.sql
new file mode 100644
index 0000000..835d4b4
--- /dev/null
+++ b/migrations/101_nabavna_cena_stavke.sql
@@ -0,0 +1,11 @@
+-- Zamrzava nabavnu cenu na stavci prodaje i ugrađenom servisnom delu u trenutku
+-- unosa — potrebno za obračun prave zarade (cena − nabavna cena) u Izveštajima
+-- bez retroaktivnog preračunavanja ako se artikli.nabavna_cena kasnije promeni.
+ALTER TABLE stavke_prodaje ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
+ALTER TABLE servisni_delovi ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
+
+UPDATE stavke_prodaje
+SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = stavke_prodaje.artikal_id), 0);
+
+UPDATE servisni_delovi
+SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = servisni_delovi.artikal_id), 0);
diff --git a/web/templates/stranice/izvestaji.html b/web/templates/stranice/izvestaji.html
index 94c097c..6525c71 100644
--- a/web/templates/stranice/izvestaji.html
+++ b/web/templates/stranice/izvestaji.html
@@ -51,23 +51,41 @@
Mesec
- Prodaja
- Servis
- Ukupno
+ Prodaja
+ Servis
+ Ukupno