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..520d5e1 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -13,6 +13,8 @@ type ArtikalRepository interface { DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Izmeni(ctx context.Context, a *model.Artikal) error + // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) + AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error Obrisi(ctx context.Context, id int64) error } @@ -52,6 +54,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/artikal.go b/internal/db/sqlite/artikal.go index a324a40..d1e0289 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -147,6 +147,16 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { return nil } +// AzurirajCene menja samo nabavnu i prodajnu cenu artikla (kalkulacija pri prijemu robe). +func (r *ArtikalRepo) AzurirajCene(ctx context.Context, id int64, nabavna, prodajna float64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE artikli SET nabavna_cena = ?, prodajna_cena = ? WHERE id = ?", nabavna, prodajna, id) + if err != nil { + return fmt.Errorf("ntech: ArtikalRepo.AzurirajCene: %w", err) + } + return nil +} + // PremestiKategoriju menja samo kategoriju artikla (premeštanje u drugu kategoriju). // kategorijaID može biti nil — tada artikal ostaje bez kategorije. func (r *ArtikalRepo) PremestiKategoriju(ctx context.Context, id int64, kategorijaID *int64) error { 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/nabavka.go b/internal/handler/nabavka.go index ecc0101..a6cdd84 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -30,6 +30,7 @@ type PodaciFormeNabavke struct { ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u +