diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index d81ed78..5bd17e3 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -278,6 +278,8 @@ func main() { r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) + r.With(doz("artikal.izmeni")).Post("/magacin/promeni-cenu/{id}", h.PromeniCenuArtikla) + r.With(doz("artikal.izmeni")).Get("/nivelacije", h.Nivelacije) r.Get("/magacin/kategorije", h.Kategorije) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) diff --git a/internal/db/repository.go b/internal/db/repository.go index aa82b94..2d6d279 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -52,6 +52,19 @@ type PdvKprRepository interface { ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error } +// NivelacijaRepository definiše operacije nad evidencijom promene prodajnih cena +type NivelacijaRepository interface { + // PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis; + // vraća kreirani zapis (sa starom i novom cenom). Izvor je "rucno". + PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) + // Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla) + Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error) + // Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice + Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error) + // ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo) + ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error) +} + // ArtikalFilter definiše parametre za filtriranje liste artikala type ArtikalFilter struct { Pretraga string diff --git a/internal/db/sqlite/nivelacija.go b/internal/db/sqlite/nivelacija.go new file mode 100644 index 0000000..da0dedd --- /dev/null +++ b/internal/db/sqlite/nivelacija.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "ntech/internal/model" +) + +// NivelacijaRepo je SQLite implementacija NivelacijaRepository interfejsa +type NivelacijaRepo struct { + db *sql.DB +} + +// NoviNivelacijaRepo kreira novi NivelacijaRepo +func NoviNivelacijaRepo(db *sql.DB) *NivelacijaRepo { + return &NivelacijaRepo{db: db} +} + +// ErrArtikalNePostoji se vraća kada se menja cena nepostojećeg artikla +var ErrArtikalNePostoji = errors.New("artikal ne postoji") + +// PromeniCenu transakciono menja prodajnu cenu artikla i upisuje nivelacioni zapis. +// Stara cena se čita iz baze unutar transakcije; izvor je "rucno". +func (r *NivelacijaRepo) PromeniCenu(ctx context.Context, artikalID int64, novaCena float64, razlog string, korisnikID *int64) (*model.Nivelacija, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: begin: %w", err) + } + defer tx.Rollback() + + var stara float64 + err = tx.QueryRowContext(ctx, "SELECT prodajna_cena FROM artikli WHERE id = ?", artikalID).Scan(&stara) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrArtikalNePostoji + } + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: čitanje cene: %w", err) + } + + if _, err = tx.ExecContext(ctx, "UPDATE artikli SET prodajna_cena = ? WHERE id = ?", novaCena, artikalID); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: update cene: %w", err) + } + + sada := time.Now() + rez, err := tx.ExecContext(ctx, ` + INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum) + VALUES (?, ?, ?, ?, 'rucno', ?, ?)`, + artikalID, stara, novaCena, razlog, izvorIDArg(korisnikID), sada) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: upis nivelacije: %w", err) + } + id, _ := rez.LastInsertId() + + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo.PromeniCenu: commit: %w", err) + } + + return &model.Nivelacija{ + ID: id, ArtikalID: artikalID, StaraCena: stara, NovaCena: novaCena, + Razlog: razlog, Izvor: "rucno", KorisnikID: korisnikID, Datum: sada, + }, nil +} + +// Kreiraj upisuje gotov nivelacioni zapis (npr. auto-trag pri izmeni artikla). +func (r *NivelacijaRepo) Kreiraj(ctx context.Context, n *model.Nivelacija) (int64, error) { + izvor := n.Izvor + if izvor == "" { + izvor = "rucno" + } + datum := n.Datum + if datum.IsZero() { + datum = time.Now() + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO nivelacije (artikal_id, stara_cena, nova_cena, razlog, izvor, korisnik_id, datum) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + n.ArtikalID, n.StaraCena, n.NovaCena, n.Razlog, izvor, izvorIDArg(n.KorisnikID), datum) + if err != nil { + return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: NivelacijaRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Lista vraća nivelacije u periodu (po datumu); nulti datum znači bez granice. +func (r *NivelacijaRepo) Lista(ctx context.Context, od, do time.Time) ([]model.Nivelacija, error) { + upit := nivelacijaSelect + " WHERE 1=1" + args := []any{} + if !od.IsZero() { + upit += " AND n.datum >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND n.datum <= ?" + args = append(args, do) + } + upit += " ORDER BY n.datum DESC, n.id DESC" + return r.upitVisestruko(ctx, upit, args...) +} + +// ListaZaArtikal vraća sve nivelacije jednog artikla (najnovije prvo). +func (r *NivelacijaRepo) ListaZaArtikal(ctx context.Context, artikalID int64) ([]model.Nivelacija, error) { + upit := nivelacijaSelect + " WHERE n.artikal_id = ? ORDER BY n.datum DESC, n.id DESC" + return r.upitVisestruko(ctx, upit, artikalID) +} + +// nivelacijaSelect je zajednički SELECT sa JOIN-ovima na artikle i korisnike (za prikaz). +const nivelacijaSelect = ` + SELECT n.id, n.artikal_id, COALESCE(a.naziv, ''), n.stara_cena, n.nova_cena, + COALESCE(n.razlog, ''), n.izvor, n.korisnik_id, COALESCE(k.korisnicko_ime, ''), + n.datum, n.datum_unosa + FROM nivelacije n + LEFT JOIN artikli a ON a.id = n.artikal_id + LEFT JOIN korisnici k ON k.id = n.korisnik_id` + +// upitVisestruko izvršava SELECT i skenira sve redove u listu nivelacija. +func (r *NivelacijaRepo) upitVisestruko(ctx context.Context, upit string, args ...any) ([]model.Nivelacija, error) { + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err) + } + defer redovi.Close() + + var rezultat []model.Nivelacija + for redovi.Next() { + var n model.Nivelacija + var korisnikID sql.NullInt64 + if err := redovi.Scan( + &n.ID, &n.ArtikalID, &n.ArtikalNaziv, &n.StaraCena, &n.NovaCena, + &n.Razlog, &n.Izvor, &korisnikID, &n.KorisnikIme, &n.Datum, &n.DatumUnosa, + ); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: scan: %w", err) + } + if korisnikID.Valid { + n.KorisnikID = &korisnikID.Int64 + } + rezultat = append(rezultat, n) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: NivelacijaRepo: %w", err) + } + return rezultat, nil +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d45149b..b506a4d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -39,6 +39,7 @@ type Handler struct { PdvStopeRepo db.PdvStopaRepository PdvKirRepo db.PdvKirRepository PdvKprRepo db.PdvKprRepository + NivelacijaRepo db.NivelacijaRepository Verzija string AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template @@ -101,6 +102,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza), PdvKirRepo: sqlite.NoviPdvKirRepo(baza), PdvKprRepo: sqlite.NoviPdvKprRepo(baza), + NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza), } } @@ -129,6 +131,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB) h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB) h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB) + h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB) } // modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu. diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 9489633..43b72a9 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -29,6 +29,7 @@ var saSidebar = []string{ "pdv_kir", "pdv_kir_forma", "pdv_kpr", "pdv_kpr_forma", "pdv_obracun", + "nivelacije", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 201f830..1910c9f 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -2,6 +2,7 @@ package handler import ( "fmt" + "log/slog" "net/http" "strconv" @@ -182,12 +183,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { return } + // stara prodajna cena — za nivelacioni trag ako se promeni kroz izmenu + var staraCena float64 + if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil { + staraCena = stari.ProdajnaCena + } + artikal.ID = id if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) return } + // ako se prodajna cena promenila, automatski upiši nivelacioni zapis (izvor "izmena") + if razlika := artikal.ProdajnaCena - staraCena; razlika > 0.005 || razlika < -0.005 { + korisnikID := &k.ID + if _, e := h.NivelacijaRepo.Kreiraj(r.Context(), &model.Nivelacija{ + ArtikalID: id, + StaraCena: staraCena, + NovaCena: artikal.ProdajnaCena, + Izvor: "izmena", + KorisnikID: korisnikID, + }); e != nil { + slog.Error("auto-nivelacija pri izmeni artikla nije upisana", "artikal_id", id, "error", e) + } + } + http.Redirect(w, r, "/magacin?sacuvano=1", http.StatusSeeOther) } diff --git a/internal/handler/nivelacija.go b/internal/handler/nivelacija.go new file mode 100644 index 0000000..00cc85f --- /dev/null +++ b/internal/handler/nivelacija.go @@ -0,0 +1,89 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" + + "github.com/go-chi/chi/v5" +) + +// PodaciNivelacije su podaci za pregled istorije promene cena +type PodaciNivelacije struct { + model.PodaciStranice + Zapisi []model.Nivelacija + Od string + Do string +} + +// Nivelacije renderuje istoriju promena prodajnih cena za izabrani period +func (h *Handler) Nivelacije(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok { + return + } + podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + + odStr := r.URL.Query().Get("od") + doStr := r.URL.Query().Get("do") + zapisi, err := h.NivelacijaRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr)) + if err != nil { + http.Error(w, "Greška pri učitavanju nivelacija", http.StatusInternalServerError) + return + } + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "nivelacije" + ps.NaslovStranice = "Nivelacije — promene cena" + h.renderujTemplate(w, "nivelacije", PodaciNivelacije{ + PodaciStranice: ps, + Zapisi: zapisi, + Od: odStr, + Do: doStr, + }) +} + +// PromeniCenuArtikla menja prodajnu cenu artikla i upisuje nivelacioni zapis (izvor "rucno"). +func (h *Handler) PromeniCenuArtikla(w http.ResponseWriter, r *http.Request) { + k, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni") + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "Neispravan ID artikla", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + novaCena := parsiraIznos(r.FormValue("nova_cena")) + razlog := strings.TrimSpace(r.FormValue("razlog")) + if novaCena <= 0 { + middleware.SetFlash(w, r, h.DB, "greska", "Nova cena mora biti veća od nule.") + http.Redirect(w, r, "/magacin", http.StatusSeeOther) + return + } + + korisnikID := &k.ID + _, err = h.NivelacijaRepo.PromeniCenu(r.Context(), id, novaCena, razlog, korisnikID) + switch { + case errors.Is(err, sqlite.ErrArtikalNePostoji): + middleware.SetFlash(w, r, h.DB, "greska", "Artikal nije pronađen.") + case err != nil: + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri promeni cene.") + default: + middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajna cena je izmenjena.") + } + http.Redirect(w, r, "/magacin", http.StatusSeeOther) +} diff --git a/internal/model/nivelacija.go b/internal/model/nivelacija.go new file mode 100644 index 0000000..223c25d --- /dev/null +++ b/internal/model/nivelacija.go @@ -0,0 +1,37 @@ +package model + +import "time" + +// Nivelacija je zapis o promeni prodajne cene artikla (revizioni trag). +// Razlika i procenat se izvode iz stare i nove cene (ne čuvaju se u bazi). +type Nivelacija struct { + ID int64 + ArtikalID int64 + ArtikalNaziv string // iz JOIN-a, radi prikaza; nije kolona u nivelacije + StaraCena float64 + NovaCena float64 + Razlog string + Izvor string // "rucno" | "izmena" | "kalkulacija" + KorisnikID *int64 + KorisnikIme string // iz JOIN-a, radi prikaza + Datum time.Time + DatumUnosa time.Time +} + +// Razlika vraća apsolutnu promenu cene (nova − stara); negativna znači pojeftinjenje. +func (n Nivelacija) Razlika() float64 { + return n.NovaCena - n.StaraCena +} + +// Procenat vraća procentualnu promenu u odnosu na staru cenu (0 ako je stara cena 0). +func (n Nivelacija) Procenat() float64 { + if n.StaraCena == 0 { + return 0 + } + return (n.NovaCena - n.StaraCena) / n.StaraCena * 100 +} + +// Poskupljenje vraća true kada je nova cena veća od stare. +func (n Nivelacija) Poskupljenje() bool { + return n.NovaCena > n.StaraCena +} diff --git a/internal/model/nivelacija_test.go b/internal/model/nivelacija_test.go new file mode 100644 index 0000000..1b915a9 --- /dev/null +++ b/internal/model/nivelacija_test.go @@ -0,0 +1,23 @@ +package model + +import "testing" + +func TestNivelacija(t *testing.T) { + // poskupljenje: 100 → 120 = +20, +20% + n := Nivelacija{StaraCena: 100, NovaCena: 120} + if !blizu(n.Razlika(), 20) || !blizu(n.Procenat(), 20) || !n.Poskupljenje() { + t.Errorf("razlika=%v procenat=%v poskupljenje=%v, očekivano 20/20/true", n.Razlika(), n.Procenat(), n.Poskupljenje()) + } + + // pojeftinjenje: 200 → 150 = -50, -25% + n2 := Nivelacija{StaraCena: 200, NovaCena: 150} + if !blizu(n2.Razlika(), -50) || !blizu(n2.Procenat(), -25) || n2.Poskupljenje() { + t.Errorf("razlika=%v procenat=%v poskupljenje=%v, očekivano -50/-25/false", n2.Razlika(), n2.Procenat(), n2.Poskupljenje()) + } + + // stara cena 0 → procenat 0 (bez deljenja nulom) + n3 := Nivelacija{StaraCena: 0, NovaCena: 80} + if !blizu(n3.Procenat(), 0) { + t.Errorf("procenat=%v, očekivano 0 kada je stara cena 0", n3.Procenat()) + } +} diff --git a/migrations/045_nivelacije.sql b/migrations/045_nivelacije.sql new file mode 100644 index 0000000..917f8af --- /dev/null +++ b/migrations/045_nivelacije.sql @@ -0,0 +1,17 @@ +-- Nivelacije (Faza A kalkulacije/nivelacije): trag svake promene prodajne cene artikla. +-- Razlika (nova − stara) se izvodi u programu, ne čuva se. Izvor razlikuje način promene: +-- 'rucno' (posebna akcija sa razlogom), 'izmena' (auto pri izmeni artikla), 'kalkulacija' (kasnije). +CREATE TABLE IF NOT EXISTS nivelacije ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artikal_id INTEGER NOT NULL REFERENCES artikli(id) ON DELETE CASCADE, + stara_cena REAL NOT NULL, + nova_cena REAL NOT NULL, + razlog TEXT, + izvor TEXT NOT NULL DEFAULT 'rucno', + korisnik_id INTEGER REFERENCES korisnici(id) ON DELETE SET NULL, + datum DATE NOT NULL, -- poslovni datum promene + datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_nivelacije_artikal ON nivelacije(artikal_id); +CREATE INDEX IF NOT EXISTS idx_nivelacije_datum ON nivelacije(datum); diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 9a846af..c2e0cf1 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -39,6 +39,14 @@ + {{if index .Dozvole "artikal.izmeni"}} + + + Nivelacije + + + {{end}} + {{if index .Dozvole "nabavka.pregled"}} diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index d1af42b..ca5983b 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -83,6 +83,7 @@ Izmeni + {{template "promeniCenuMeni" (dict "ID" .ID "Cena" .ProdajnaCena)}} {{end}} {{if index $.Dozvole "artikal.premesti"}}{{if $.Kategorije}} {{template "premestiMeni" (dict "ID" .ID "Kategorije" $.Kategorije)}} @@ -112,7 +113,7 @@