From ac7c9df2eb078941b1992ab62abde0773763f349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Tue, 30 Jun 2026 19:14:40 +0200 Subject: [PATCH] =?UTF-8?q?prodaja:=20popust,=20kategorija,=20cene=20po=20?= =?UTF-8?q?re=C5=BEimu,=20pretraga,=20zabrana=20brisanja?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- internal/db/sqlite/prodaja.go | 28 +- internal/handler/prodaja.go | 59 +++- internal/model/prodaja.go | 24 +- migrations/092_prodaja_popust_na_stavci.sql | 4 + web/static/js/ntech.js | 35 +- web/templates/stranice/prodaja_detalji.html | 31 +- web/templates/stranice/prodaja_forma.html | 371 ++++---------------- 7 files changed, 207 insertions(+), 345 deletions(-) create mode 100644 migrations/092_prodaja_popust_na_stavci.sql diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 56ff91a..7745a3a 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -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 { diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 9897d07..7022b69 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -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, }) } diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go index 8b26cd9..a7a6d62 100644 --- a/internal/model/prodaja.go +++ b/internal/model/prodaja.go @@ -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 } diff --git a/migrations/092_prodaja_popust_na_stavci.sql b/migrations/092_prodaja_popust_na_stavci.sql new file mode 100644 index 0000000..66092eb --- /dev/null +++ b/migrations/092_prodaja_popust_na_stavci.sql @@ -0,0 +1,4 @@ +-- Prodaja: popust na stavci (rabat u procentima) +-- Neophodno za B2B fakture; PDV se obračunava na iznos posle popusta + +ALTER TABLE stavke_prodaje ADD COLUMN popust_procenat REAL NOT NULL DEFAULT 0; diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index 21d7f18..bcc4410 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -136,18 +136,30 @@ document.addEventListener('alpine:init', () => { // forma za prodaju Alpine.data('prodajaForma', () => ({ - stavke: [{artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}], + stavke: [{artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: ''}], artikliOpcije: [], + pretragaArtikal: '', + pdvObveznik: true, isMobile: false, init() { this.artikliOpcije = window._ntechArtikli || [] + this.pdvObveznik = window._ntechPdvObveznik === true this.isMobile = window.matchMedia('(max-width: 768px)').matches window.matchMedia('(max-width: 768px)').addEventListener('change', e => { this.isMobile = e.matches }) }, + get filtriraniArtikli() { + const q = this.pretragaArtikal.trim().toLowerCase() + if (!q) return this.artikliOpcije.slice(0, 50) + return this.artikliOpcije.filter(a => + (a.naziv || '').toLowerCase().includes(q) || + (a.sifra || '').toLowerCase().includes(q) || + (a.barkod || '').toLowerCase().includes(q) + ).slice(0, 30) + }, dodajStavku() { - this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, pdv_stopa: 20}) + this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: ''}) }, ukloniStavku(i) { if (this.stavke.length > 1) this.stavke.splice(i, 1) @@ -155,10 +167,15 @@ document.addEventListener('alpine:init', () => { popuniCenu(stavka) { const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id) if (a) { - stavka.cena = a.cena + stavka.cena = a.cena || 0 + stavka.cena_sa_pdv = a.cena_sa_pdv || 0 stavka.pdv_stopa = a.pdv_stopa !== undefined ? a.pdv_stopa : 20 + stavka.kategorija_naziv = a.kategorija_naziv || '' } }, + prikaziCenu(stavka) { + return this.pdvObveznik ? (stavka.cena || 0) : (stavka.cena_sa_pdv || stavka.cena || 0) + }, dostupnaKolicina(i) { const stavka = this.stavke[i] if (!stavka.artikal_id) return null @@ -177,10 +194,18 @@ document.addEventListener('alpine:init', () => { return this.stavke.some((_, i) => this.prekoracenje(i)) }, ukupnoStavke(s) { - return (parseFloat(s.kolicina) * parseFloat(s.cena) || 0).toFixed(2) + const cena = parseFloat(s.cena) || 0 + const popust = parseFloat(s.popust) || 0 + const cenaPosle = popust > 0 ? cena * (1 - popust/100) : cena + return (parseFloat(s.kolicina) * cenaPosle || 0).toFixed(2) }, ukupnoSvega() { - return this.stavke.reduce((z, s) => z + (parseFloat(s.kolicina) * parseFloat(s.cena) || 0), 0).toFixed(2) + return this.stavke.reduce((z, s) => { + const cena = parseFloat(s.cena) || 0 + const popust = parseFloat(s.popust) || 0 + const cenaPosle = popust > 0 ? cena * (1 - popust/100) : cena + return z + (parseFloat(s.kolicina) * cenaPosle || 0) + }, 0).toFixed(2) }, // fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50" fmtDin(v) { diff --git a/web/templates/stranice/prodaja_detalji.html b/web/templates/stranice/prodaja_detalji.html index a0ffc3e..18c88bd 100644 --- a/web/templates/stranice/prodaja_detalji.html +++ b/web/templates/stranice/prodaja_detalji.html @@ -188,10 +188,21 @@ font-size: 12px; font-weight: 500; color: var(--tekst-sporedni); - width: 140px; + width: 120px; "> Cena/kom + + Popust + {{if index .Moduli "pdv"}} PDV % {{end}} @@ -203,9 +214,15 @@ {{range .Stavke}} - {{.ArtikalNaziv}} + + {{.ArtikalNaziv}} + {{if .KategorijaNaziv}}
{{.KategorijaNaziv}}
{{end}} + {{.Kolicina}} {{dinari .CenaPoKomadu}} din + + {{if gt .PopustProcenat 0.0}}{{printf "%.1f" .PopustProcenat}}%{{else}}—{{end}} + {{if index $.Moduli "pdv"}} {{if .PdvStopa}}{{printf "%.0f" .PdvStopa}}%{{else}}—{{end}} {{end}} @@ -272,16 +289,16 @@ {{if index $.Dozvole "prodaja.obrisi"}}
-
Brisanje naloga
+
Uklanjanje naloga
- Brisanje je trajno.{{if not .Nalog.Stornirano}} Količine artikala biće vraćene na stanje u magacinu.{{end}} + Nalog će biti storniran (vraća artikle na stanje) i označen kao uklonjen. Ne briše se fizički — ostaje u evidenciji.
-
diff --git a/web/templates/stranice/prodaja_forma.html b/web/templates/stranice/prodaja_forma.html index dc0706f..c9f67c7 100644 --- a/web/templates/stranice/prodaja_forma.html +++ b/web/templates/stranice/prodaja_forma.html @@ -4,28 +4,18 @@ .forma-kartica:nth-child(1) { animation-delay: 0.04s; } .forma-kartica:nth-child(2) { animation-delay: 0.12s; } @media (max-width: 768px) { .stavke-tabela-wrapper { display: none; } .stavke-kartice { display: flex !important; } } + .polje-labela { font-size: 12px; color: var(--tekst-sporedni); display: block; margin-bottom: 4px; } {{end}} {{define "sadrzaj"}} -
- -
-
- Nova prodaja +
+ Nova prodaja
-
+
+
-
- Stavke -
+ +
+ +
+
- - - - - + + + + + +
- Artikal - - Količina - - Cena/kom (din) - - Ukupno - ArtikalKol.Cena/komPopust%Ukupno