Fiskalizacija: refund pri stornu, dnevni pazar KIR, popust na stavci

This commit is contained in:
2026-06-30 20:52:50 +02:00
parent 76a0df1c09
commit 3166a93e1d
11 changed files with 337 additions and 8 deletions
+3
View File
@@ -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
}
+9
View File
@@ -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
}
+44
View File
@@ -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()
}
+20 -7
View File
@@ -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
}
+3 -1
View File
@@ -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
+86
View File
@@ -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)
}
+53
View File
@@ -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)
}
+10
View File
@@ -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
}