diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 1ee418e..8fa663e 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -312,6 +312,8 @@ func main() { r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir) r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir) r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-prodaje", h.KirBackfillProdaje) + r.With(modul("pdv"), doz("pdv.dodaj")).Get("/pdv/kir/dnevni-pazar", h.DnevniPazarKir) + r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/dnevni-pazar", h.SacuvajDnevniPazarKir) r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr) r.With(modul("pdv")).Get("/pdv/kpr/nova", h.NoviPdvKpr) r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kpr/nova", h.SacuvajPdvKpr) diff --git a/internal/db/repository.go b/internal/db/repository.go index d2e5e29..d26e44f 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -223,6 +223,8 @@ type ProdajaRepository interface { Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error SledeciBroj(ctx context.Context) (string, error) + // DnevniPrometMaloprodaje vraća zbir maloprodajnih stavki (bez klijenta) za zadati dan (YYYY-MM-DD). + DnevniPrometMaloprodaje(ctx context.Context, datum string) (model.DnevniPrometKir, error) } // ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu @@ -381,4 +383,5 @@ type FiskalRepository interface { Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error) DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error) + OznačiKaoStorniran(ctx context.Context, id int64) error } diff --git a/internal/db/sqlite/fiskal.go b/internal/db/sqlite/fiskal.go index b9d7a18..acf7da9 100644 --- a/internal/db/sqlite/fiskal.go +++ b/internal/db/sqlite/fiskal.go @@ -114,3 +114,12 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod fr.Storniran = storniran != 0 return fr, nil } + +// OznačiKaoStorniran postavlja storniran=1 za fiskalni račun sa datim ID-jem. +func (r *FiskalRepo) OznačiKaoStorniran(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, "UPDATE fiskalni_racuni SET storniran = 1 WHERE id = ?", id) + if err != nil { + return fmt.Errorf("ntech: FiskalRepo.OznačiKaoStorniran: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 7745a3a..e9f09bf 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -414,3 +414,47 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e return nil } + +// DnevniPrometMaloprodaje vraća zbrojene iznose maloprodajnih stavki za zadati dan. +// datum je string oblika "YYYY-MM-DD". Isključuje stornirane naloge i B2B (sa klijentom). +func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) (model.DnevniPrometKir, error) { + var p model.DnevniPrometKir + + // broj naloga tog dana + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM prodajni_nalozi + WHERE klijent_id IS NULL AND stornirano = 0 AND DATE(datum) = ?`, datum, + ).Scan(&p.BrojNaloga) + if err != nil { + return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) + } + + // stavke grupisane po PDV stopi + redovi, err := r.db.QueryContext(ctx, ` + SELECT s.pdv_stopa, SUM(s.ukupno), SUM(s.ukupno * s.pdv_stopa / 100.0) + 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) = ? + GROUP BY s.pdv_stopa`, datum, + ) + if err != nil { + return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: query: %w", err) + } + defer redovi.Close() + + for redovi.Next() { + var stopa, osnovica, pdv float64 + if err := redovi.Scan(&stopa, &osnovica, &pdv); err != nil { + return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: scan: %w", err) + } + switch { + case stopa >= 19.9 && stopa <= 20.1: + p.OsnovicaOpsta += osnovica + p.PdvOpsta += pdv + case stopa >= 9.9 && stopa <= 10.1: + p.OsnovicaPosebna += osnovica + p.PdvPosebna += pdv + } + } + return p, redovi.Err() +} diff --git a/internal/fiskal/mapiranje.go b/internal/fiskal/mapiranje.go index 7f8cf0c..5c7564a 100644 --- a/internal/fiskal/mapiranje.go +++ b/internal/fiskal/mapiranje.go @@ -62,10 +62,13 @@ func NapraviZahtev( if s.PdvStopa > 0 { stopa = s.PdvStopa } - // CenaPoKomadu je neto cena — konvertujemo u bruto + // CenaPoKomadu je neto cena — konvertujemo u bruto; popust umanjuje neto cenu pre PDV-a netoCena := s.CenaBezPdv if netoCena == 0 { - netoCena = s.CenaPoKomadu // fallback: ako CenaBezPdv nije popunjena + netoCena = s.CenaPoKomadu + } + if s.PopustProcenat > 0 { + netoCena = math.Round(netoCena*(1-s.PopustProcenat/100)*100) / 100 } brutoCena := BrutoCena(netoCena, stopa) brutoTotal := math.Round(brutoCena*float64(s.Kolicina)*100) / 100 @@ -80,11 +83,8 @@ func NapraviZahtev( ukupanIznos += brutoTotal } - // Ukupno za plaćanje — iz naloga (bruto) - ukupnoPlacanje := nalog.Ukupno - if ukupnoPlacanje == 0 { - ukupnoPlacanje = ukupanIznos - } + // Ukupno za plaćanje je uvek suma bruto stavki (neto iz naloga bi bilo pogrešno) + ukupnoPlacanje := ukupanIznos nacinPlacanja := nalog.NacinPlacanja if nacinPlacanja == "" { @@ -115,3 +115,16 @@ func NapraviZahtev( return zahtev } + +// NapraviRefundZahtev gradi Refund zahtev za storno fiskalnog računa. +// referentBroj je PfrBroj originalnog fiskalnog računa (invoiceNumber iz PFR odgovora). +func NapraviRefundZahtev( + nalog model.ProdajniNalog, + stavke []model.StavkaProdajeSaArtiklom, + pib, jmbg, kasir, referentBroj string, +) InvoiceRequest { + zahtev := NapraviZahtev(nalog, stavke, pib, jmbg, kasir) + zahtev.InvoiceRequest.TransactionType = "Refund" + zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj + return zahtev +} diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 996eb05..6685252 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -29,7 +29,7 @@ var saSidebar = []string{ "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija", "pdv_stope", - "pdv_kir", "pdv_kir_forma", + "pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar", "pdv_kpr", "pdv_kpr_forma", "pdv_obracun", "nivelacije", @@ -125,6 +125,8 @@ var sablonskeFunkcije = template.FuncMap{ } return m, nil }, + // zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima + "zbirF64": func(a, b float64) float64 { return a + b }, } // KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi diff --git a/internal/handler/pdv_kir.go b/internal/handler/pdv_kir.go index 790f358..7a5b718 100644 --- a/internal/handler/pdv_kir.go +++ b/internal/handler/pdv_kir.go @@ -12,6 +12,8 @@ import ( "ntech/internal/middleware" "ntech/internal/model" + "math" + "github.com/go-chi/chi/v5" ) @@ -245,3 +247,87 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) { middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno)) http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther) } + +// PodaciDnevniPazar su podaci za formu zbirnog KIR unosa (maloprodaja) +type PodaciDnevniPazar struct { + model.PodaciStranice + Datum string + Promet model.DnevniPrometKir + Ukupno float64 +} + +// DnevniPazarKir prikazuje formu za potvrdu zbirnog KIR unosa za izabrani datum. +// Iznosi se računaju iz maloprodajnih naloga (bez klijenta) tog dana. +func (h *Handler) DnevniPazarKir(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !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 + } + + datum := strings.TrimSpace(r.URL.Query().Get("datum")) + if datum == "" { + datum = time.Now().Format("2006-01-02") + } + + promet, err := h.ProdajaRepo.DnevniPrometMaloprodaje(r.Context(), datum) + if err != nil { + http.Error(w, "Greška pri računanju dnevnog prometa", http.StatusInternalServerError) + return + } + + ukupno := math.Round((promet.OsnovicaOpsta+promet.PdvOpsta+promet.OsnovicaPosebna+promet.PdvPosebna)*100) / 100 + + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "pdv-kir" + ps.NaslovStranice = "Dnevni pazar — KIR unos" + h.renderujTemplate(w, "pdv_kir_dnevni_pazar", PodaciDnevniPazar{ + PodaciStranice: ps, + Datum: datum, + Promet: promet, + Ukupno: ukupno, + }) +} + +// SacuvajDnevniPazarKir prima POST i upisuje zbirni KIR zapis za dnevni pazar. +func (h *Handler) SacuvajDnevniPazarKir(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + datum, e1 := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum"))) + if e1 != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Datum je obavezan.") + http.Redirect(w, r, "/pdv/kir/dnevni-pazar", http.StatusSeeOther) + return + } + + z := model.PdvKir{ + DatumPrometa: datum, + DatumKnjizenja: datum, + BrojDokumenta: fmt.Sprintf("FISK-%s", datum.Format("20060102")), + KupacNaziv: "Promet fizičkim licima", + OsnovicaOpsta: parsiraIznos(r.FormValue("osnovica_opsta")), + PdvOpsta: parsiraIznos(r.FormValue("pdv_opsta")), + OsnovicaPosebna: parsiraIznos(r.FormValue("osnovica_posebna")), + PdvPosebna: parsiraIznos(r.FormValue("pdv_posebna")), + Napomena: "Zbirni promet fizičkim licima — fiskalna kasa", + Izvor: "rucno", + } + z.Ukupno = math.Round((z.OsnovicaOpsta+z.PdvOpsta+z.OsnovicaPosebna+z.PdvPosebna)*100) / 100 + + if _, err := h.PdvKirRepo.Kreiraj(r.Context(), &z); err != nil { + slog.Error("greška pri čuvanju dnevnog pazara u KIR", "datum", datum, "error", err) + http.Error(w, "Greška pri čuvanju zapisa", http.StatusInternalServerError) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Dnevni pazar za %s upisan u KIR.", datum.Format("02.01.2006."))) + http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther) +} diff --git a/internal/handler/prodaja.go b/internal/handler/prodaja.go index 7022b69..7b5fbe8 100644 --- a/internal/handler/prodaja.go +++ b/internal/handler/prodaja.go @@ -532,6 +532,59 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) { if err := h.PdvKirRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); err != nil { slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err) } + + // fiskalni refund — best-effort (storno ostaje validan i bez uspešnog refunda) + if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { + if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil && !fr.Storniran { + if fk := h.fiskalKlijent(); fk != nil { + if nalogFisk, e := h.ProdajaRepo.DohvatiID(r.Context(), id); e == nil { + if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(r.Context(), id); e2 == nil { + kasirFisk, _ := sqlite.DohvatiPodesavanje(r.Context(), h.DB, "pfr_kasir") + if kasirFisk == "" { + kasirFisk = "NTech" + } + var pibFisk, jmbgFisk string + if nalogFisk.KlijentID != nil { + if kk, e3 := h.KlijentiRepo.DohvatiID(r.Context(), *nalogFisk.KlijentID); e3 == nil { + pibFisk, jmbgFisk = kk.PIB, kk.JMBG + } + } + zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj) + if odgFisk, errFisk := fk.IzdajRacun(r.Context(), zahtevFisk); errFisk != nil { + slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk) + } else { + poreskeJSON, _ := json.Marshal(odgFisk.TaxItems) + siroviJSON, _ := json.Marshal(odgFisk) + refundFr := &model.FiskalniRacun{ + ProdajaID: id, + TipRacuna: "Normal", + TipTransakcije: "Refund", + PfrBroj: odgFisk.InvoiceNumber, + PfrVreme: odgFisk.SdcDateTime, + Brojac: odgFisk.InvoiceCounter, + EkstenzijaBrojaca: odgFisk.InvoiceCounterExtension, + UrlVerifikacija: odgFisk.VerificationURL, + QRKod: odgFisk.VerificationQRCode, + PoreskeStavke: string(poreskeJSON), + UkupnoZaNaplatu: odgFisk.TotalAmount, + UkupanPorez: odgFisk.TotalTax, + SiroviOdgovor: string(siroviJSON), + Potpisao: odgFisk.SignedBy, + Zatrazio: odgFisk.RequestedBy, + Poruka: odgFisk.Messages, + } + if _, errFisk = h.FiskalRepo.Kreiraj(r.Context(), refundFr); errFisk != nil { + slog.Error("čuvanje fiskalnog refunda nije uspelo", "prodaja_id", id, "error", errFisk) + } else { + _ = h.FiskalRepo.OznačiKaoStorniran(r.Context(), fr.ID) + } + } + } + } + } + } + } + http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) } diff --git a/internal/model/prodaja.go b/internal/model/prodaja.go index a7a6d62..621a1a4 100644 --- a/internal/model/prodaja.go +++ b/internal/model/prodaja.go @@ -47,3 +47,13 @@ type StavkaProdajeSaArtiklom struct { JedinicaMere string KategorijaNaziv string } + +// DnevniPrometKir su zbrojeni iznosi maloprodaje za jedan dan, razvrstani po PDV stopi. +// Koristi se za kreiranje zbirnog KIR zapisa (promet fizičkim licima). +type DnevniPrometKir struct { + OsnovicaOpsta float64 // neto suma po stopi 20% + PdvOpsta float64 + OsnovicaPosebna float64 // neto suma po stopi 10% + PdvPosebna float64 + BrojNaloga int // ukupan broj prodajnih naloga taj dan +} diff --git a/web/templates/stranice/pdv_kir.html b/web/templates/stranice/pdv_kir.html index 7b4f84b..e9b17b9 100644 --- a/web/templates/stranice/pdv_kir.html +++ b/web/templates/stranice/pdv_kir.html @@ -22,6 +22,10 @@
{{if index .Dozvole "pdv.dodaj"}} + + Dnevni pazar +
+
+
+ + + +
+
Iznosi za {{.Datum}} ({{.Promet.BrojNaloga}} naloga)
+
+ + + + + + + + + + + {{if gt .Promet.OsnovicaOpsta 0.0}} + + + + + + + {{end}} + {{if gt .Promet.OsnovicaPosebna 0.0}} + + + + + + + {{end}} + {{if eq .Promet.BrojNaloga 0}} + + + + {{end}} + + {{if gt .Promet.BrojNaloga 0}} + + + + + + + + + {{end}} +
StopaOsnovica (neto)PDVBruto
Opšta 20%{{dinari .Promet.OsnovicaOpsta}}{{dinari .Promet.PdvOpsta}}{{dinari (zbirF64 .Promet.OsnovicaOpsta .Promet.PdvOpsta)}}
Posebna 10%{{dinari .Promet.OsnovicaPosebna}}{{dinari .Promet.PdvPosebna}}{{dinari (zbirF64 .Promet.OsnovicaPosebna .Promet.PdvPosebna)}}
Nema maloprodajnih naloga za ovaj datum.
Ukupno{{dinari (zbirF64 .Promet.OsnovicaOpsta .Promet.OsnovicaPosebna)}}{{dinari (zbirF64 .Promet.PdvOpsta .Promet.PdvPosebna)}}{{dinari .Ukupno}}
+
+
+ + {{if gt .Promet.BrojNaloga 0}} + +
+ + + + + + +
+
+ Odustani + +
+
+
+ {{end}} + + +{{end}}