package handler import ( "context" "encoding/json" "errors" "fmt" "html/template" "log/slog" "net/http" "strconv" "strings" "time" "ntech/internal/config" appdb "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/fiskal" "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" ) // PodaciProdaje su podaci za stranicu sa listom prodajnih naloga type PodaciProdaje struct { model.PodaciStranice Nalozi []model.ProdajniNalogSaDetaljem Sacuvano bool Pretraga string Od string Do string OvajMesecOd string OvajMesecDo string SamoStornirano bool NemaFiskalnog map[int64]bool // ID-evi naloga bez izdatog fiskalnog računa } // PodaciFormeProdaje su podaci za formu unosa nove prodaje type PodaciFormeProdaje struct { model.PodaciStranice Artikli []model.ArtikalSaKategorijom ArtikliJSON template.JS Klijenti []model.Klijent KlijentiJSON template.JS Greska string } // PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama type PodaciDetaljiProdaje struct { model.PodaciStranice Nalog model.ProdajniNalog Stavke []model.StavkaProdajeSaArtiklom KlijentNaziv string FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa StornoBezRefunda bool // true: nalog je storniran, ali fiskalni refund ka PFR-u nije uspeo PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta) PotrebnaIdentKupcaRetry bool // isto, ali za retry dugme na već storniranom nalogu (StornoBezRefunda) Sacuvano bool } // PodaciStampeProdaje su podaci za stranicu za štampanje priznanice type PodaciStampeProdaje struct { Nalog model.ProdajniNalog Stavke []model.StavkaProdajeSaArtiklom KlijentNaziv string Moduli map[string]bool UkupnoBezPdv float64 NazivFirme string Podnazlov string Adresa string Telefon string PIB string } // 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"` 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, 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) } // klijentiUJSON pretvara listu klijenata u template.JS vrednost za pretragu na strani klijenta // (naziv, tip za filter/prikaz u listi) i pun prikaz podataka kad je klijent izabran // (PIB/JMBG, mesto, telefon, email — u zavisnosti od pravno/fizičko lice). func klijentiUJSON(klijenti []model.Klijent) template.JS { type stavka struct { ID int64 `json:"id"` Naziv string `json:"naziv"` Tip string `json:"tip"` Mesto string `json:"mesto"` Adresa string `json:"adresa"` Telefon string `json:"telefon"` PIB string `json:"pib"` JMBG string `json:"jmbg"` TipIdentifikacije string `json:"tipIdentifikacije"` Email string `json:"email"` } lista := make([]stavka, 0, len(klijenti)) for _, k := range klijenti { lista = append(lista, stavka{ ID: k.ID, Naziv: k.PunoIme(), Tip: k.Tip, Mesto: k.Mesto, Adresa: k.Adresa, Telefon: k.Telefon, PIB: k.PIB, JMBG: k.JMBG, TipIdentifikacije: k.TipIdentifikacije, Email: k.Email, }) } b, _ := json.Marshal(lista) return template.JS(b) } // Prodaja renderuje listu svih prodajnih naloga func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) { podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } pretraga := strings.TrimSpace(r.URL.Query().Get("pretraga")) od := r.URL.Query().Get("od") do := r.URL.Query().Get("do") samoStornirano := r.URL.Query().Get("stornirano") == "1" sada := time.Now() // bez ijednog parametra u upitu (prvi ulazak na stranicu) — podrazumevano // prikaži samo današnje naloge; jednom kad korisnik pošalje formu (čak i // praznu, da vidi sve), poštuje se njegov eksplicitan izbor if r.URL.RawQuery == "" { danas := sada.Format("2006-01-02") od = danas do = danas } nalozi, err := h.ProdajaRepo.Lista(r.Context(), appdb.ProdajaFilter{ Pretraga: pretraga, Od: od, Do: do, SamoStornirano: samoStornirano, }) if err != nil { http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError) return } var nemaFiskalnog map[int64]bool if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { nemaFiskalnog, _ = h.FiskalRepo.ProdajeBezFiskalnog(r.Context()) } prviDanMeseca := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location()) poslednjiDanMeseca := prviDanMeseca.AddDate(0, 1, -1) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Prodaja" podaci := PodaciProdaje{ PodaciStranice: ps, Nalozi: nalozi, Sacuvano: r.URL.Query().Get("sacuvano") == "1", Pretraga: pretraga, Od: od, Do: do, OvajMesecOd: prviDanMeseca.Format("2006-01-02"), OvajMesecDo: poslednjiDanMeseca.Format("2006-01-02"), SamoStornirano: samoStornirano, NemaFiskalnog: nemaFiskalnog, } h.renderujTemplate(w, "prodaja", podaci) } // NovaProdaja prikazuje formu za unos novog prodajnog naloga func (h *Handler) NovaProdaja(w http.ResponseWriter, r *http.Request) { podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) if err != nil { http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) return } artikli, err := h.Artikli.Lista(r.Context(), appdb.ArtikalFilter{}) if err != nil { http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError) return } klijenti, err := h.KlijentiRepo.Lista(r.Context(), "") if err != nil { http.Error(w, "Greška pri učitavanju klijenata", http.StatusInternalServerError) return } ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Nova prodaja" h.renderujFormuProdaje(w, PodaciFormeProdaje{ PodaciStranice: ps, Artikli: artikli, ArtikliJSON: artikalUJSONSaCenom(artikli), Klijenti: klijenti, KlijentiJSON: klijentiUJSON(klijenti), }) } // SacuvajProdaju prima POST formu, parsira stavke i upisuje prodajni nalog u bazu func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) { k, ok := h.zahtevajDozvolu(w, r, "prodaja.dodaj") if !ok { return } if err := r.ParseForm(); err != nil { http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) return } nalog, stavke, greska := parseFormuProdaje(r, h.validnePdvStope(r.Context())) renderujGresku := func(poruka string) { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) artikli, _ := h.Artikli.Lista(r.Context(), appdb.ArtikalFilter{}) klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "") ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Nova prodaja" h.renderujFormuProdaje(w, PodaciFormeProdaje{ PodaciStranice: ps, Artikli: artikli, ArtikliJSON: artikalUJSONSaCenom(artikli), Klijenti: klijenti, KlijentiJSON: klijentiUJSON(klijenti), Greska: poruka, }) } if greska != "" { renderujGresku(greska) return } // za firme koje nisu PDV obveznici prisilno nulliraj PDV stopu (odbrana od klijentske greške) if !h.modulUkljucen(r.Context(), "pdv") { for i := range stavke { stavke[i].PdvStopa = 0 } } // broj naloga generiše ProdajaRepo.Kreiraj unutar transakcije upisa nalog.Datum = time.Now() var ukupno float64 for _, s := range stavke { cenaPoslePopusta := s.CenaPoKomadu if s.PopustProcenat > 0 { cenaPoslePopusta = cenaPoslePopusta * (1 - s.PopustProcenat/100) } ukupno += float64(s.Kolicina) * cenaPoslePopusta } nalog.Ukupno = ukupno id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke, &k.ID) if err != nil { var errStanje *appdb.ErrNedovoljnoKolicine if errors.As(err, &errStanje) { renderujGresku(errStanje.Error()) } else { slog.Error("greška pri čuvanju prodaje", "error", err) renderujGresku("Greška pri čuvanju prodajnog naloga.") } return } // automatski zavedi u KIR ako je firma PDV obveznik i prodaja je na klijenta (B2B faktura). // Maloprodaja građanima (bez klijenta) ide zbirno preko fiskalizacije (Faza 3) — preskače se. if nalog.KlijentID != nil && h.modulUkljucen(r.Context(), "pdv") { if klijent, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil { nalog.ID = id pib := klijent.PIB if klijent.Tip != "pravno" { pib = klijent.JMBG } kir := model.KirIzProdaje(nalog, stavke, klijent.PunoIme(), pib, klijent.Mesto, nalog.Datum) if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil { slog.Error("auto-upis u KIR nije uspeo", "prodaja_id", id, "error", e) } } } // Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije) racunKreiran := false if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { if klijent := h.fiskalKlijent(r.Context()); klijent != nil { primljeno, _ := strconv.ParseFloat(strings.TrimSpace(r.FormValue("primljeno")), 64) h.fiskalizujProdaju(r.Context(), id, klijent, primljeno) if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil { racunKreiran = true } } } // auto-KPO: svaki potvrđeni nalog ide u KPO ako je modul aktivan if h.modulUkljucen(r.Context(), "kpo") { kpoZ := model.KpoZapis{ DatumPrometa: nalog.Datum, BrojDokumenta: nalog.BrojNaloga, Opis: fmt.Sprintf("Prodaja %s", nalog.BrojNaloga), Prihod: nalog.Ukupno, NacinPlacanja: nalog.NacinPlacanja, Izvor: "prodaja", IzvorID: &id, } if _, e := h.KpoRepo.Kreiraj(r.Context(), &kpoZ); e != nil { slog.Error("auto-upis u KPO nije uspeo", "prodaja_id", id, "error", e) } } redirectURL := "/prodaja/" + strconv.FormatInt(id, 10) + "?sacuvano=1" if r.FormValue("prikazi_racun") == "1" && racunKreiran { redirectURL += "&racun=1" } http.Redirect(w, r, redirectURL, http.StatusSeeOther) } // DetaljiProdaje prikazuje pregled jednog prodajnog naloga sa svim stavkama func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) { id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) return } nalog, err := h.ProdajaRepo.DohvatiID(r.Context(), id) if err != nil { http.Error(w, "Nalog nije pronađen", http.StatusNotFound) return } stavke, err := h.ProdajaRepo.DohvatiStavke(r.Context(), id) if err != nil { http.Error(w, "Greška pri učitavanju stavki", http.StatusInternalServerError) 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 } klijentNaziv := "" imaIdentKupca := false if nalog.KlijentID != nil { klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) if err == nil { if klijent.NazivFirme != "" { klijentNaziv = klijent.NazivFirme } else { klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime) } imaIdentKupca = klijent.PIB != "" || klijent.JMBG != "" } } // učitaj fiskalni račun ako postoji fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "prodaja" ps.NaslovStranice = "Detalji prodaje" podaci := PodaciDetaljiProdaje{ PodaciStranice: ps, Nalog: *nalog, Stavke: stavke, KlijentNaziv: klijentNaziv, FiskalniRacun: fr, FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil, StornoBezRefunda: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran, PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca, PotrebnaIdentKupcaRetry: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran && !imaIdentKupca, Sacuvano: r.URL.Query().Get("sacuvano") == "1", } h.renderujTemplate(w, "prodaja_detalji", podaci) } // StampaProdaje renderuje print-friendly stranicu za dati prodajni nalog func (h *Handler) StampaProdaje(w http.ResponseWriter, r *http.Request) { id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) return } nalog, err := h.ProdajaRepo.DohvatiID(r.Context(), id) if err != nil { http.Error(w, "Nalog nije pronađen", http.StatusNotFound) return } stavke, err := h.ProdajaRepo.DohvatiStavke(r.Context(), id) if err != nil { http.Error(w, "Greška pri učitavanju stavki", http.StatusInternalServerError) 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 } klijentNaziv := "" if nalog.KlijentID != nil { klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) if err == nil { if klijent.NazivFirme != "" { klijentNaziv = klijent.NazivFirme } else { klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime) } } } moduli := config.SviModuli(podesavanja) var ukupnoBezPdv float64 for _, s := range stavke { ukupnoBezPdv += s.CenaBezPdv * float64(s.Kolicina) } podaci := PodaciStampeProdaje{ Nalog: *nalog, Stavke: stavke, KlijentNaziv: klijentNaziv, Moduli: moduli, UkupnoBezPdv: ukupnoBezPdv, NazivFirme: podesavanja["naziv_firme"], Podnazlov: podesavanja["podnazlov"], Adresa: podesavanja["adresa"], Telefon: podesavanja["telefon"], PIB: podesavanja["pib"], } h.renderujStandalone(w, "prodaja_stampa", podaci) } // StampaFiskalnogProdaje renderuje journal izdatog fiskalnog računa za prodaju (za štampu/uvid) func (h *Handler) StampaFiskalnogProdaje(w http.ResponseWriter, r *http.Request) { id, err := parseID(chi.URLParam(r, "id")) if err != nil { http.Error(w, "Neispravan ID", http.StatusBadRequest) return } fr, err := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id) if err != nil || fr == nil { http.Error(w, "Fiskalni račun nije pronađen", http.StatusNotFound) return } nalog, _ := h.ProdajaRepo.DohvatiID(r.Context(), id) journal := "" if fr.SiroviOdgovor != "" { var raw map[string]any if json.Unmarshal([]byte(fr.SiroviOdgovor), &raw) == nil { if j, ok := raw["journal"].(string); ok { journal = j } } } if nalog != nil { journal += "\n\n--- NTech prodaja: " + nalog.BrojNaloga + " ---\n" } // Podeli journal na deo pre i posle QR placeholder-a const qrPlaceholder = "{{{{QR-KOD}}}}" pre, post, hasQR := strings.Cut(journal, qrPlaceholder) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `
`)
fmt.Fprint(w, pre)
if hasQR && fr.QRKod != "" {
fmt.Fprintf(w, `
`, fr.QRKod)
}
fmt.Fprint(w, post)
fmt.Fprint(w, ``)
fmt.Fprint(w, ``)
}
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
// validneStope su dozvoljene PDV stope na stavci (v. Handler.validnePdvStope) —
// izvedene iz šifarnika, ne hardkodovane.
func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.ProdajniNalog, []model.StavkaProdaje, string) {
var nalog model.ProdajniNalog
if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" {
id, err := strconv.ParseInt(klijentIDStr, 10, 64)
if err == nil {
nalog.KlijentID = &id
}
}
nalog.Napomena = strings.TrimSpace(r.FormValue("napomena"))
nalog.NacinPlacanja = r.FormValue("nacin_placanja")
if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" {
nalog.NacinPlacanja = "gotovina"
}
artikalIDovi := r.Form["artikal_id[]"]
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."
}
if len(artikalIDovi) != len(kolicine) || len(artikalIDovi) != len(cene) {
return nalog, nil, "Greška u podacima forme — broj stavki nije ispravan."
}
var stavke []model.StavkaProdaje
for i := range artikalIDovi {
artikalID, err := strconv.ParseInt(strings.TrimSpace(artikalIDovi[i]), 10, 64)
if err != nil || artikalID <= 0 {
return nalog, nil, "Neispravan artikal u stavci."
}
kolicina, err := strconv.Atoi(strings.TrimSpace(kolicine[i]))
if err != nil || kolicina <= 0 {
return nalog, nil, "Količina mora biti pozitivan broj."
}
cena, err := strconv.ParseFloat(strings.TrimSpace(cene[i]), 64)
if err != nil || cena < 0 {
return nalog, nil, "Cena mora biti pozitivan broj."
}
var pdvStopa float64
if i < len(pdvStope) {
pdvStopa, err = strconv.ParseFloat(strings.TrimSpace(pdvStope[i]), 64)
if err != nil {
return nalog, nil, "Neispravna PDV stopa u stavci."
}
}
if !validneStope[pdvStopa] {
return nalog, nil, "PDV stopa u stavci nije u šifarniku PDV stopa."
}
var popust float64
if i < len(popusti) {
popust, err = strconv.ParseFloat(strings.TrimSpace(popusti[i]), 64)
if err != nil {
return nalog, nil, "Neispravan popust u stavci."
}
}
if popust < 0 || popust > 100 {
return nalog, nil, "Popust mora biti u opsegu 0–100%."
}
stavke = append(stavke, model.StavkaProdaje{
ArtikalID: artikalID,
Kolicina: kolicina,
CenaPoKomadu: cena,
PdvStopa: pdvStopa,
PopustProcenat: popust,
})
}
return nalog, stavke, ""
}
// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i označava nalog kao storniran
func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "prodaja.storno")
if !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
razlog := strings.TrimSpace(r.FormValue("razlog"))
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
if err := h.stornirajProdaju(r.Context(), id, razlog, &k.ID, poreskiBrojKupca); err != nil {
poruka := "Greška pri storniranju. Možda je nalog već storniran."
if errors.Is(err, errPotrebnaIdentKupca) {
poruka = errPotrebnaIdentKupca.Error()
} else {
slog.Error("greška pri storniranju naloga", "error", err)
}
middleware.SetFlash(w, r, h.DB, "greska", poruka)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// errPotrebnaIdentKupca signalizuje da refundacija zahteva PIB/JMBG kupca koji nije unet.
var errPotrebnaIdentKupca = errors.New("za fiskalnu refundaciju je obavezan PIB ili JMBG kupca")
// klasifikujPoreskiBroj razvrstava ručno unet poreski broj kupca po broju cifara:
// 9 cifara je PIB (pravno lice/preduzetnik), 13 cifara je JMBG (fizičko lice).
func klasifikujPoreskiBroj(broj string) (pib, jmbg string) {
cifre := 0
for _, r := range broj {
if r >= '0' && r <= '9' {
cifre++
}
}
if cifre == 13 {
return "", broj
}
return broj, ""
}
// stornirajProdaju sprovodi kompletan storno prodajnog naloga: menja status naloga i
// vraća artikle na stanje, uklanja vezani auto-KIR zapis, šalje fiskalni refund
// (best-effort — storno ostaje validan i bez uspešnog refunda) i briše KPO zapis.
func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, korisnikID *int64, poreskiBrojKupca string) error {
// Refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG) — ako je nalog
// bez klijenta (ili klijent nema upisan PIB/JMBG), mora se ručno uneti pre storna.
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(ctx, id); fr != nil && !fr.Storniran {
imaIdentKupca := false
if nalogProvera, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil && nalogProvera.KlijentID != nil {
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogProvera.KlijentID); e3 == nil {
imaIdentKupca = kk.PIB != "" || kk.JMBG != ""
}
}
if !imaIdentKupca && strings.TrimSpace(poreskiBrojKupca) == "" {
return errPotrebnaIdentKupca
}
}
}
if err := h.ProdajaRepo.Storno(ctx, id, razlog, korisnikID); err != nil {
return err
}
// stornirana prodaja se ne briše iz KIR (zakon o računovodstvu ne dozvoljava
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
if zapisi, err := h.PdvKirRepo.DohvatiPoIzvoru(ctx, "prodaja", id); err != nil {
slog.Error("čitanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
} else {
for _, z := range zapisi {
storno := model.KirStorno(z, razlog, time.Now())
if _, e := h.PdvKirRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KIR zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
}
// fiskalni refund — best-effort
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if err := h.posaljiFiskalniRefundProdaje(ctx, id, poreskiBrojKupca); err != nil {
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", err)
}
}
// stornirana prodaja se ne briše iz KPO (redni broj mora ostati bez prekida) —
// dograđuje se storno stavka koja poništava original
if h.modulUkljucen(ctx, "kpo") {
if zapisi, e := h.KpoRepo.DohvatiPoIzvoru(ctx, "prodaja", id); e != nil {
slog.Error("čitanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
} else {
for _, z := range zapisi {
storno := model.KpoStorno(z, razlog, time.Now())
if _, e := h.KpoRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KPO zapisa nije uspeo", "prodaja_id", id, "error", e)
}
}
}
}
return nil
}
// posaljiFiskalniRefundProdaje šalje fiskalni refund za već storniranu prodaju čiji
// originalni fiskalni (Sale) račun još nije označen kao storniran — koristi ga i
// stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi
// pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici.
func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
if err != nil {
return err
}
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
return nil
}
fk := h.fiskalKlijent(ctx)
if fk == nil {
return errors.New("fiskalizacija nije podešena")
}
nalogFisk, err := h.ProdajaRepo.DohvatiID(ctx, id)
if err != nil {
return err
}
stavkeFisk, err := h.ProdajaRepo.DohvatiStavke(ctx, id)
if err != nil {
return err
}
kasirFisk := h.imeKasira(ctx)
var pibFisk, jmbgFisk string
if nalogFisk.KlijentID != nil {
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil {
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
}
}
if pibFisk == "" && jmbgFisk == "" {
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
if pibFisk == "" && jmbgFisk == "" {
return errPotrebnaIdentKupca
}
}
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
odgFisk, err := fk.IzdajRacun(ctx, zahtevFisk)
if err != nil {
return err
}
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 _, err := h.FiskalRepo.Kreiraj(ctx, refundFr); err != nil {
return err
}
return h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
}
// PokusajRefundProdaje je ručni retry za "storno bez fiskalnog refunda" — kad je
// prodaja već stornirana u NTech-u, ali fiskalni refund ka PFR-u nije uspeo pri
// samom stornu (best-effort poziv je pao). Prikazuje se kao dugme na stranici detalja.
func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.storno"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
if err := h.posaljiFiskalniRefundProdaje(r.Context(), id, poreskiBrojKupca); err != nil {
poruka := "Fiskalni refund nije uspeo — fiskalni server nije dostupan."
if errors.Is(err, errPotrebnaIdentKupca) {
poruka = errPotrebnaIdentKupca.Error()
}
middleware.SetFlash(w, r, h.DB, "greska", poruka)
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni refund je uspešno poslat.")
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// fiskalizujProdaju šalje fiskalni zahtev za prodajni nalog. Best-effort: greške
// se loguju, ne zaustavljaju tok pozivaoca (kreiranje prodaje ili ručni retry).
// primljeno je iznos koji je kupac predao (za tačan povraćaj na računu) — 0 ako
// nije poznat (npr. ručni retry), tada se šalje tačan dug bez povraćaja.
func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijent *fiskal.Klijent, primljeno float64) {
ctx, cancel := odvojenKontekstFiskalizacije(ctx)
defer cancel()
nalog, err := h.ProdajaRepo.DohvatiID(ctx, prodajaID)
if err != nil {
slog.Error("fiskalizujProdaju: nije pronađen nalog", "prodaja_id", prodajaID, "error", err)
return
}
stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, prodajaID)
if err != nil {
slog.Error("fiskalizujProdaju: greška pri dohvatanju stavki", "prodaja_id", prodajaID, "error", err)
return
}
kasir := h.imeKasira(ctx)
var pib, jmbg string
if nalog.KlijentID != nil {
if kk, e := h.KlijentiRepo.DohvatiID(ctx, *nalog.KlijentID); e == nil {
pib, jmbg = kk.PIB, kk.JMBG
}
}
zahtev := fiskal.NapraviZahtev(*nalog, stavke, pib, jmbg, kasir, primljeno)
odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev)
if errFisk != nil {
slog.Error("fiskalizacija prodaje nije uspela", "prodaja_id", prodajaID, "error", errFisk)
return
}
poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
siroviJSON, _ := json.Marshal(odgovor)
fr := &model.FiskalniRacun{
ProdajaID: prodajaID,
TipRacuna: "Normal",
TipTransakcije: "Sale",
PfrBroj: odgovor.InvoiceNumber,
PfrVreme: odgovor.SdcDateTime,
Brojac: odgovor.InvoiceCounter,
EkstenzijaBrojaca: odgovor.InvoiceCounterExtension,
UrlVerifikacija: odgovor.VerificationURL,
QRKod: odgovor.VerificationQRCode,
PoreskeStavke: string(poreskeJSON),
UkupnoZaNaplatu: odgovor.TotalAmount,
UkupanPorez: odgovor.TotalTax,
SiroviOdgovor: string(siroviJSON),
Potpisao: odgovor.SignedBy,
Zatrazio: odgovor.RequestedBy,
Poruka: odgovor.Messages,
}
if _, err := h.FiskalRepo.Kreiraj(ctx, fr); err != nil {
slog.Error("greška pri čuvanju fiskalnog računa", "prodaja_id", prodajaID, "error", err)
}
}
// RetryFiskalizacijaProdaje pokušava ponovo da izda fiskalni račun za prodajni
// nalog čija je prethodna fiskalizacija pala (ESIR/PFR nedostupan pri kreiranju).
func (h *Handler) RetryFiskalizacijaProdaje(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.dodaj"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
// zaštita od duplikata — ako fiskalni račun već postoji, ne šalji ponovo
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil {
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun već postoji.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
nalog, err := h.ProdajaRepo.DohvatiID(r.Context(), id)
if err != nil || nalog.Stornirano {
http.Error(w, "Nalog ne postoji ili je stornirano", http.StatusBadRequest)
return
}
klijent := h.fiskalKlijent(r.Context())
if klijent == nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalni servis nije dostupan. Proverite vezu sa ESIR/PFR.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
h.fiskalizujProdaju(r.Context(), id, klijent, 0)
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id); fr != nil {
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun izdat.")
} else {
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uspela. Proverite vezu sa ESIR/PFR i pokušajte ponovo.")
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
func (h *Handler) renderujFormuProdaje(w http.ResponseWriter, podaci PodaciFormeProdaje) {
h.renderujTemplate(w, "prodaja_forma", podaci)
}