prodaja: popust, kategorija, cene po režimu, pretraga, zabrana brisanja

Migracija 092: popust_procenat na stavke_prodaje.
Model: PopustProcenat u StavkaProdaje, KategorijaNaziv u StavkaProdajeSaArtiklom.
Repo: pretraga prodaje proširena na klijenta i napomenu. DohvatiStavke dodaje popust i kategoriju. Kreiraj primenjuje popust na ukupno.
Handler: artikalUJSONSaCenom proširen sa sifra, barkod, cena_sa_pdv, kategorija_naziv. parseFormuProdaje parsira popust. ObrisiProdaju sada koristi storno (zabrana fizičkog brisanja).
JS: prodajaForma ažurirana — pretraga artikala po nazivu/šifri/barkodu, popust polje, kategorija, kalkulacija sa popustom.
Forma: pretraga artikala, popust kolona, kategorija, PDV režim (cena/cena_sa_pdv). Responsive desktop+mobilne kartice.
Detalji: kategorija ispod naziva, popust kolona, 'Ukloni nalog' (storno) umesto 'Obriši'.
This commit is contained in:
2026-06-30 19:14:40 +02:00
parent 3c2dd06f28
commit ac7c9df2eb
7 changed files with 207 additions and 345 deletions
+18 -10
View File
@@ -51,8 +51,8 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda
args := []any{}
if pretraga != "" {
upit += " AND pn.broj_naloga LIKE ?"
args = append(args, "%"+pretraga+"%")
upit += " AND (pn.broj_naloga LIKE ? OR kp.naziv LIKE ? OR pn.napomena LIKE ?)"
args = append(args, "%"+pretraga+"%", "%"+pretraga+"%", "%"+pretraga+"%")
}
upit += " ORDER BY pn.datum DESC"
@@ -122,10 +122,13 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
// DohvatiStavke vraća stavke prodaje sa nazivima artikala i PDV podacima za dati nalog
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv, a.jedinica_mere
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu,
sp.popust_procenat, sp.ukupno,
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv,
a.naziv, a.jedinica_mere, COALESCE(k.naziv, '') AS kategorija_naziv
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
LEFT JOIN kategorije k ON k.id = a.kategorija_id
WHERE sp.nalog_id = ?
ORDER BY sp.id`, nalogID)
if err != nil {
@@ -138,9 +141,9 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
var s model.StavkaProdajeSaArtiklom
err := redovi.Scan(
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno,
&s.CenaPoKomadu, &s.PopustProcenat, &s.Ukupno,
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
&s.ArtikalNaziv, &s.JedinicaMere,
&s.ArtikalNaziv, &s.JedinicaMere, &s.KategorijaNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
@@ -215,12 +218,17 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
pdvIznos = cenaBezPdv * s.PdvStopa / 100
}
ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu
// popust umanjuje cenu po komadu pre računanja ukupnog i PDV-a
cenaPoslePopusta := s.CenaPoKomadu
if s.PopustProcenat > 0 {
cenaPoslePopusta = cenaPoslePopusta * (1 - s.PopustProcenat/100)
}
ukupnoStavke := float64(s.Kolicina) * cenaPoslePopusta
_, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje
(nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, ukupnoStavke,
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke,
s.PdvStopa, pdvIznos, cenaBezPdv,
)
if err != nil {
+42 -17
View File
@@ -62,18 +62,32 @@ type PodaciStampeProdaje struct {
PIB string
}
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom, PDV stopom i stanjem
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom, PDV stopom, kategorijom i stanjem
func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct {
ID int64 `json:"id"`
Naziv string `json:"naziv"`
Cena float64 `json:"cena"`
PdvStopa float64 `json:"pdv_stopa"`
Kolicina int `json:"kolicina"`
ID int64 `json:"id"`
Naziv string `json:"naziv"`
Sifra string `json:"sifra"`
Barkod string `json:"barkod"`
Cena float64 `json:"cena"`
CenaSaPdv float64 `json:"cena_sa_pdv"`
PdvStopa float64 `json:"pdv_stopa"`
Kolicina int `json:"kolicina"`
KategorijaNaziv string `json:"kategorija_naziv"`
}
lista := make([]stavka, 0, len(artikli))
for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, PdvStopa: a.PdvStopa, Kolicina: a.Kolicina})
lista = append(lista, stavka{
ID: a.ID,
Naziv: a.Naziv,
Sifra: a.Sifra,
Barkod: a.Barkod,
Cena: a.ProdajnaCena,
CenaSaPdv: a.CenaSaPdv,
PdvStopa: a.PdvStopa,
Kolicina: a.Kolicina,
KategorijaNaziv: a.KategorijaNaziv,
})
}
b, _ := json.Marshal(lista)
return template.JS(b)
@@ -186,7 +200,11 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
var ukupno float64
for _, s := range stavke {
ukupno += float64(s.Kolicina) * s.CenaPoKomadu
cenaPoslePopusta := s.CenaPoKomadu
if s.PopustProcenat > 0 {
cenaPoslePopusta = cenaPoslePopusta * (1 - s.PopustProcenat/100)
}
ukupno += float64(s.Kolicina) * cenaPoslePopusta
}
nalog.Ukupno = ukupno
@@ -394,9 +412,10 @@ func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) {
h.renderujStandalone(w, "prodaja_stampa", podaci)
}
// ObrisiProdaju prima POST zahtev, vraća stanje na magacin i briše nalog
// ObrisiProdaju prima POST zahtev i stornira nalog umesto fizičkog brisanja.
// Jednom izdat račun ne sme da nestane — umesto brisanja koristi se storno.
func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.storno"); !ok {
return
}
k := middleware.KorisnikIzKonteksta(r.Context())
@@ -406,9 +425,8 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.ProdajaRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return
if err := h.ProdajaRepo.Storno(r.Context(), id, "administrativno uklanjanje", &k.ID); err != nil {
slog.Error("greška pri uklanjanju naloga", "error", err)
}
// ukloni vezani auto-KIR zapis (ako ga je ova prodaja kreirala)
if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil {
@@ -438,6 +456,7 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
kolicine := r.Form["kolicina[]"]
cene := r.Form["cena_po_komadu[]"]
pdvStope := r.Form["pdv_stopa[]"]
popusti := r.Form["popust[]"]
if len(artikalIDovi) == 0 {
return nalog, nil, "Prodaja mora imati najmanje jednu stavku."
@@ -469,11 +488,17 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
pdvStopa, _ = strconv.ParseFloat(strings.TrimSpace(pdvStope[i]), 64)
}
var popust float64
if i < len(popusti) {
popust, _ = strconv.ParseFloat(strings.TrimSpace(popusti[i]), 64)
}
stavke = append(stavke, model.StavkaProdaje{
ArtikalID: artikalID,
Kolicina: kolicina,
CenaPoKomadu: cena,
PdvStopa: pdvStopa,
ArtikalID: artikalID,
Kolicina: kolicina,
CenaPoKomadu: cena,
PdvStopa: pdvStopa,
PopustProcenat: popust,
})
}
+13 -11
View File
@@ -17,15 +17,16 @@ type ProdajniNalog struct {
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
type StavkaProdaje struct {
ID int64
NalogID int64
ArtikalID int64
Kolicina int
CenaPoKomadu float64
Ukupno float64
PdvStopa float64
PdvIznos float64
CenaBezPdv float64
ID int64
NalogID int64
ArtikalID int64
Kolicina int
CenaPoKomadu float64
PopustProcenat float64 // rabat u procentima (npr. 10 = 10% popusta)
Ukupno float64
PdvStopa float64
PdvIznos float64
CenaBezPdv float64
}
// UkupnoBezPdv vraća ukupno bez PDV-a za stavku (CenaBezPdv × količina)
@@ -42,6 +43,7 @@ type ProdajniNalogSaDetaljem struct {
// StavkaProdajeSaArtiklom je stavka prodaje sa nazivom artikla — za prikaz u detaljima
type StavkaProdajeSaArtiklom struct {
StavkaProdaje
ArtikalNaziv string
JedinicaMere string
ArtikalNaziv string
JedinicaMere string
KategorijaNaziv string
}