diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index b5ac95e..db23fb9 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -197,7 +197,8 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk } // provera stanja, smanjenje i insert stavki - for _, s := range stavke { + for i := range stavke { + s := stavke[i] var naziv, tip string var stanjePre int err := tx.QueryRowContext(ctx, @@ -230,14 +231,18 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk } ukupnoStavke := float64(s.Kolicina) * cenaPoslePopusta - // CenaPoKomadu je neto (bez PDV); PDV se dodaje naviše. - // cena_bez_pdv/pdv_iznos moraju biti diskontovani da ostanu konzistentni sa ukupno. + // CenaPoKomadu je cena za naplatu: kod punog PDV obveznika bruto (sa PDV-om, + // PDV se izdvaja naniže), kod firme na evidenciji nema PDV-a (PdvStopa=0, pa je bez razlike). cenaBezPdv := s.CenaBezPdv pdvIznos := s.PdvIznos if cenaBezPdv == 0 { - cenaBezPdv = cenaPoslePopusta - pdvIznos = cenaBezPdv * s.PdvStopa / 100 + cenaBezPdv = cenaPoslePopusta / (1 + s.PdvStopa/100) + pdvIznos = cenaPoslePopusta - cenaBezPdv } + // vraćamo izračunate vrednosti u slajs pozivaoca (npr. za auto-upis u KIR posle Kreiraj) + stavke[i].CenaBezPdv = cenaBezPdv + stavke[i].PdvIznos = pdvIznos + 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) @@ -443,9 +448,10 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) } - // stavke grupisane po PDV stopi + // stavke grupisane po PDV stopi — osnovica i PDV se čitaju iz već izračunatih + // cena_bez_pdv/pdv_iznos kolona (ukupno je bruto iznos za naplatu, ne osnovica) redovi, err := r.db.QueryContext(ctx, ` - SELECT s.pdv_stopa, SUM(s.ukupno), SUM(s.ukupno * s.pdv_stopa / 100.0) + SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina) FROM stavke_prodaje s JOIN prodajni_nalozi p ON p.id = s.nalog_id WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND DATE(p.datum) = ? diff --git a/internal/db/sqlite/prodaja_kreiraj_test.go b/internal/db/sqlite/prodaja_kreiraj_test.go index cc36e04..aa9f510 100644 --- a/internal/db/sqlite/prodaja_kreiraj_test.go +++ b/internal/db/sqlite/prodaja_kreiraj_test.go @@ -81,7 +81,8 @@ func TestProdajaKreiraj_NedovoljnoStanja(t *testing.T) { } // TestProdajaKreiraj_PdvAutoKalkulacija: kada CenaBezPdv=0 a PdvStopa>0, -// cena_bez_pdv i pdv_iznos se automatski računaju iz neto cene (CenaPoKomadu je neto). +// cena_bez_pdv i pdv_iznos se automatski izdvajaju naniže iz bruto cene +// (CenaPoKomadu je kod punog PDV obveznika cena za naplatu, sa PDV-om). func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) { ctx := context.Background() baza := testDB(t) @@ -92,13 +93,14 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) { Naziv: "Tastatura", Tip: model.TipProizvod, Kolicina: 10, }) - // neto cena 1000, PDV 20% → pdv_iznos = 200 - _, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{ - BrojNaloga: "PR-PDV-001", Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(), - }, []model.StavkaProdaje{ - {ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1000, PdvStopa: 20}, + // bruto cena 1200, PDV 20% → cena_bez_pdv = 1000, pdv_iznos = 200 + stavke := []model.StavkaProdaje{ + {ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20}, // CenaBezPdv=0 namerno — treba da se auto-izračuna - }, nil) + } + _, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{ + BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(), + }, stavke, nil) if err != nil { t.Fatalf("Kreiraj: %v", err) } diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 42f74d5..2e34246 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -105,8 +105,9 @@ func (k *PdvKir) DodajNeto(osnovica, stopa float64) { } // KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi -// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena BEZ PDV -// (neto), PDV se dodaje naviše — isti obrazac kao kod servisnih radova/delova. +// (20→opšta, 10→posebna, ostalo→oslobođeno). Koristi već izračunatu CenaBezPdv +// (neto, popust je već uračunat pri upisu stavke — v. ProdajaRepo.Kreiraj), jer je +// CenaPoKomadu kod punog PDV obveznika bruto (cena za naplatu), ne neto. // DatumPrometa je datum same prodaje (nalog.Datum); DatumKnjizenja je datum kada se // ovaj KIR zapis stvarno upisuje u knjigu — za prodaju upisanu odmah to je isti trenutak, // ali za naknadni backfill starih naloga upis kasni za periodom pa se prosleđuje posebno. @@ -123,11 +124,7 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac IzvorID: &id, } for _, s := range stavke { - cenaPoslePopusta := s.CenaPoKomadu - if s.PopustProcenat > 0 { - cenaPoslePopusta = cenaPoslePopusta * (1 - s.PopustProcenat/100) - } - k.DodajNeto(float64(s.Kolicina)*cenaPoslePopusta, s.PdvStopa) + k.DodajNeto(float64(s.Kolicina)*s.CenaBezPdv, s.PdvStopa) } return k } diff --git a/internal/model/pdv_evidencija_test.go b/internal/model/pdv_evidencija_test.go index d706367..c82f0d8 100644 --- a/internal/model/pdv_evidencija_test.go +++ b/internal/model/pdv_evidencija_test.go @@ -11,12 +11,12 @@ func blizu(a, b float64) bool { return math.Abs(a-b) < 0.01 } func TestKirIzProdaje(t *testing.T) { nalog := ProdajniNalog{ID: 5, BrojNaloga: "P-1", Datum: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)} stavke := []StavkaProdaje{ - // 20%: CenaPoKomadu je NETO → osnovica 2×120=240, PDV 240×20%=48 - {Kolicina: 2, CenaPoKomadu: 120, PdvStopa: 20}, + // 20%: CenaBezPdv je već izračunata neto vrednost (v. ProdajaRepo.Kreiraj) → osnovica 2×120=240, PDV 240×20%=48 + {Kolicina: 2, CenaBezPdv: 120, PdvStopa: 20}, // 10%: osnovica 1×110=110, PDV 110×10%=11 - {Kolicina: 1, CenaPoKomadu: 110, PdvStopa: 10}, + {Kolicina: 1, CenaBezPdv: 110, PdvStopa: 10}, // 0%: 1 × 50 = 50 (oslobođeno, bez PDV) - {Kolicina: 1, CenaPoKomadu: 50, PdvStopa: 0}, + {Kolicina: 1, CenaBezPdv: 50, PdvStopa: 0}, } k := KirIzProdaje(nalog, stavke, "Kupac doo", "123456789", "Niš", nalog.Datum) diff --git a/web/static/js/ntech.js b/web/static/js/ntech.js index ca68fe5..35479fc 100644 --- a/web/static/js/ntech.js +++ b/web/static/js/ntech.js @@ -136,7 +136,7 @@ document.addEventListener('alpine:init', () => { // forma za prodaju Alpine.data('prodajaForma', () => ({ - stavke: [{artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: ''}], + stavke: [{artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: 20, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}], artikliOpcije: [], pretragaArtikal: '', pdvObveznik: true, @@ -225,31 +225,89 @@ document.addEventListener('alpine:init', () => { this.isMobile = e.matches }) }, - get filtriraniArtikli() { - const q = this.pretragaArtikal.trim().toLowerCase() - if (!q) return this.artikliOpcije.slice(0, 50) + // _filtriraniArtikliSvi vraća SVE opcije koje odgovaraju pretrazi jedne stavke (bez ograničenja), + // koristi ga i filtriraniArtikliZaStavku (za prikaz) i imaJosArtikala (da zna da li dogruzati još) + _filtriraniArtikliSvi(stavka) { + const q = (stavka._pretraga || '').trim().toLowerCase() + if (!q) return this.artikliOpcije return this.artikliOpcije.filter(a => (a.naziv || '').toLowerCase().includes(q) || (a.sifra || '').toLowerCase().includes(q) || (a.barkod || '').toLowerCase().includes(q) - ).slice(0, 30) + ) + }, + // filtriraniArtikliZaStavku vraća opcije za prilagođeni dropdown izbora artikla jedne stavke + // (svaka stavka ima svoju nezavisnu pretragu i limit, poput pretrage klijenta) + filtriraniArtikliZaStavku(stavka) { + return this._filtriraniArtikliSvi(stavka).slice(0, stavka._limit || 30) + }, + imaJosArtikala(stavka) { + return (stavka._limit || 30) < this._filtriraniArtikliSvi(stavka).length + }, + ucitajViseArtikala(stavka, e) { + const el = e.target + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40 && this.imaJosArtikala(stavka)) { + stavka._limit = (stavka._limit || 30) + 30 + } }, dodajStavku() { - this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: ''}) + // ne dozvoli gomilanje praznih redova — ako poslednja stavka nema izabran artikal, samo je otvori + const poslednja = this.stavke[this.stavke.length - 1] + if (poslednja && !poslednja.artikal_id) { + poslednja._prikaziListu = true + return + } + this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}) }, ukloniStavku(i) { if (this.stavke.length > 1) this.stavke.splice(i, 1) }, + izaberiArtikal(stavka, a) { + stavka.artikal_id = a.id + stavka._naziv = a.naziv + stavka._pretraga = '' + stavka._prikaziListu = false + stavka._limit = 30 + this.popuniCenu(stavka) + }, + promeniArtikal(stavka) { + stavka.artikal_id = '' + stavka._naziv = '' + stavka._pretraga = '' + stavka._limit = 30 + stavka.kategorija_naziv = '' + stavka.cena = 0 + stavka.cena_sa_pdv = 0 + stavka.pdv_stopa = this.pdvObveznik ? 20 : 0 + }, + dodajPoBarkodu() { + const kod = this.pretragaArtikal.trim() + if (!kod) return + const a = this.artikliOpcije.find(x => x.barkod && x.barkod === kod) + if (!a) return + this.pretragaArtikal = '' + const postojeca = this.stavke.find(s => s.artikal_id == a.id) + if (postojeca) { + postojeca.kolicina = (parseInt(postojeca.kolicina) || 0) + 1 + return + } + const prazna = this.stavke.find(s => !s.artikal_id) + const stavka = prazna || this.stavke[this.stavke.push({artikal_id: '', kolicina: 1, cena: 0, cena_sa_pdv: 0, pdv_stopa: this.pdvObveznik ? 20 : 0, popust: 0, kategorija_naziv: '', _naziv: '', _pretraga: '', _prikaziListu: false, _limit: 30}) - 1] + this.izaberiArtikal(stavka, a) + }, popuniCenu(stavka) { const a = this.artikliOpcije.find(x => x.id == stavka.artikal_id) if (a) { stavka.cena_sa_pdv = a.cena_sa_pdv || 0 stavka.kategorija_naziv = a.kategorija_naziv || '' + stavka._naziv = a.naziv if (this.pdvObveznik) { - stavka.cena = a.cena || 0 + // pun PDV obveznik naplaćuje cenu sa PDV-om (bruto) — PDV se izdvaja naniže + stavka.cena = a.cena_sa_pdv || a.cena || 0 stavka.pdv_stopa = a.pdv_stopa !== undefined ? a.pdv_stopa : 20 } else { - stavka.cena = a.cena_sa_pdv || a.cena || 0 + // firma na evidenciji ne obračunava PDV — naplaćuje se čista prodajna cena + stavka.cena = a.cena || 0 stavka.pdv_stopa = 0 } } @@ -271,6 +329,9 @@ document.addEventListener('alpine:init', () => { imaPrekoracenja() { return this.stavke.some((_, i) => this.prekoracenje(i)) }, + imaPraznihStavki() { + return this.stavke.some(s => !s.artikal_id) + }, ukupnoStavke(s) { const cena = parseFloat(s.cena) || 0 const popust = parseFloat(s.popust) || 0 @@ -285,6 +346,26 @@ document.addEventListener('alpine:init', () => { return z + (parseFloat(s.kolicina) * cenaPosle || 0) }, 0).toFixed(2) }, + // pdvIznosStavke vraća PDV sadržan u ceni po komadu (cena je bruto — PDV se izdvaja naniže) + pdvIznosStavke(s) { + const cena = parseFloat(s.cena) || 0 + const stopa = parseFloat(s.pdv_stopa) || 0 + if (!stopa) return 0 + return cena - cena / (1 + stopa / 100) + }, + osnovicaSvega() { + 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 + const stopa = parseFloat(s.pdv_stopa) || 0 + const neto = stopa > 0 ? cenaPosle / (1 + stopa / 100) : cenaPosle + return z + (parseFloat(s.kolicina) * neto || 0) + }, 0).toFixed(2) + }, + pdvSvega() { + return (parseFloat(this.ukupnoSvega()) - parseFloat(this.osnovicaSvega())).toFixed(2) + }, // fmtDin formatira broj sa separatorom hiljada (tačka) i 2 decimale (zarez): 1234567.5 → "1.234.567,50" fmtDin(v) { const n = parseFloat(v) || 0 diff --git a/web/templates/stranice/prodaja_forma.html b/web/templates/stranice/prodaja_forma.html index 5be3ab4..5d1f44b 100644 --- a/web/templates/stranice/prodaja_forma.html +++ b/web/templates/stranice/prodaja_forma.html @@ -112,19 +112,19 @@ - +
| Artikal | Kol. | -Cena/kom | +Cena/kom{{if index .Moduli "pdv"}} (sa PDV){{end}} | Popust% | Ukupno | @@ -133,15 +133,37 @@ |
|---|---|---|---|---|---|---|
| + |
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skrolujte za još rezultata...
+ |
Prekoračena količina |
-
+
-
+
|
@@ -171,8 +193,22 @@ | ||
| Ukupno: | +||||||
| Osnovica: | ++ + | ++ | ||||
| PDV: | ++ + | ++ | ||||
| @@ -185,7 +221,7 @@ @@ -240,7 +302,7 @@ | ||||||