From 4a94db76846a0559524cee435bfee6bdcf38d1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 3 Jul 2026 03:49:18 +0200 Subject: [PATCH] =?UTF-8?q?Izve=C5=A1taji:=20prava=20zarada=20(cena=20-=20?= =?UTF-8?q?nabavna=20cena)=20za=20prodaju=20i=20servis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nabavna cena se zamrzava na stavci prodaje i ugrađenom servisnom delu u trenutku unosa (migracija 101), umesto naknadnog join-a na trenutnu artikli.nabavna_cena. Tabela mesečnog prihoda sad prikazuje zaradu uz prodaju/servis/ukupno, a grafikon dobija preklopljene tamne barove (zarada) preko svetlih (ukupan prihod), odvojene za prodaju i servis. --- internal/db/repository.go | 2 + internal/db/sqlite/izvestaj.go | 36 ++++++++++ internal/db/sqlite/prodaja.go | 11 +-- internal/db/sqlite/servisni_delovi.go | 13 ++-- .../db/sqlite/servisni_potrazivani_delovi.go | 17 +++-- internal/handler/izvestaji.go | 64 +++++++++++++---- migrations/101_nabavna_cena_stavke.sql | 11 +++ web/templates/stranice/izvestaji.html | 69 +++++++++++++++---- 8 files changed, 179 insertions(+), 44 deletions(-) create mode 100644 migrations/101_nabavna_cena_stavke.sql 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 {{range .MesecniPrihodi}} {{.MesecPrikaz}} - - {{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}} + + {{if gt .Prodaja 0.0}} +
+ {{dinariCeli .Prodaja}} din + - + {{dinariCeli .ZaradaProdaja}} din +
+ {{else}}
{{end}} - - {{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}} + + {{if gt .Servis 0.0}} +
+ {{dinariCeli .Servis}} din + - + {{dinariCeli .ZaradaServis}} din +
+ {{else}}
{{end}} - - {{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}} + + {{if gt .Ukupno 0.0}} +
+ {{dinariCeli .Ukupno}} din + - + {{dinariCeli .Zarada}} din +
+ {{else}}
{{end}} {{end}} @@ -207,18 +225,40 @@ labels: podaci.labele, datasets: [ { - label: 'Prodaja', + label: 'Prodaja — ukupno', data: podaci.prodaja, - backgroundColor: 'rgba(79, 126, 248, 0.75)', + backgroundColor: 'rgba(79, 126, 248, 0.35)', borderRadius: 4, borderSkipped: false, + stack: 'prodaja', + order: 2, }, { - label: 'Servis', + label: 'Servis — ukupno', data: podaci.servis, - backgroundColor: 'rgba(22, 163, 74, 0.75)', + backgroundColor: 'rgba(22, 163, 74, 0.35)', borderRadius: 4, borderSkipped: false, + stack: 'servis', + order: 2, + }, + { + label: 'Zarada — prodaja', + data: podaci.zaradaProdaja, + backgroundColor: 'rgba(29, 66, 168, 0.9)', + borderRadius: 4, + borderSkipped: false, + stack: 'prodaja', + order: 1, + }, + { + label: 'Zarada — servis', + data: podaci.zaradaServis, + backgroundColor: 'rgba(21, 94, 51, 0.9)', + borderRadius: 4, + borderSkipped: false, + stack: 'servis', + order: 1, } ] }, @@ -239,11 +279,12 @@ }, scales: { x: { - stacked: false, + stacked: true, ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' } }, grid: { color: ivicaBoja } }, y: { + stacked: false, ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' },