Prodaja: obavezna identifikacija kupca kod refundacije, ispravljeno mapiranje načina plaćanja
Refundacija po propisu zahteva PIB/JMBG kupca — ako nalog nema klijenta sa upisanim poreskim brojem, storno sad traži ručni unos pre nego što se pošalje fiskalni refund zahtev. TipPlacanja je upoređivao velika slova (Gotovina/Kartica/Virman) dok Prodaja čuva nazive malim slovima (gotovina/kartica/prenos) — svaka prodaja je zato padala na paymentType "Other" umesto stvarnog načina plaćanja. Poređenje je sad case-insensitive.
This commit is contained in:
@@ -2,6 +2,7 @@ package fiskal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
@@ -28,18 +29,20 @@ func BrutoCena(neto, stopa float64) float64 {
|
|||||||
return math.Round(neto*(1+stopa/100)*100) / 100
|
return math.Round(neto*(1+stopa/100)*100) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
// TipPlacanja mapira NTech način plaćanja na Teron enumeraciju.
|
// TipPlacanja mapira NTech način plaćanja na Teron enumeraciju. Poredi bez obzira
|
||||||
|
// na velika/mala slova jer Prodaja čuva nazive malim slovima ("gotovina", "kartica",
|
||||||
|
// "prenos"), a Servis velikim ("Gotovina", "Kartica", "Virman").
|
||||||
func TipPlacanja(nacin string) string {
|
func TipPlacanja(nacin string) string {
|
||||||
switch nacin {
|
switch strings.ToLower(nacin) {
|
||||||
case "Gotovina":
|
case "gotovina":
|
||||||
return "Cash"
|
return "Cash"
|
||||||
case "Kartica":
|
case "kartica":
|
||||||
return "Card"
|
return "Card"
|
||||||
case "Virman", "Račun":
|
case "virman", "račun", "racun", "prenos":
|
||||||
return "WireTransfer"
|
return "WireTransfer"
|
||||||
case "Ček":
|
case "ček", "cek":
|
||||||
return "Check"
|
return "Check"
|
||||||
case "Vaučer":
|
case "vaučer", "vaucer":
|
||||||
return "Voucher"
|
return "Voucher"
|
||||||
default:
|
default:
|
||||||
return "Other"
|
return "Other"
|
||||||
|
|||||||
+77
-20
@@ -50,12 +50,13 @@ type PodaciFormeProdaje struct {
|
|||||||
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
|
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
|
||||||
type PodaciDetaljiProdaje struct {
|
type PodaciDetaljiProdaje struct {
|
||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
Nalog model.ProdajniNalog
|
Nalog model.ProdajniNalog
|
||||||
Stavke []model.StavkaProdajeSaArtiklom
|
Stavke []model.StavkaProdajeSaArtiklom
|
||||||
KlijentNaziv string
|
KlijentNaziv string
|
||||||
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
|
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
|
||||||
FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa
|
FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa
|
||||||
Sacuvano bool
|
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
|
||||||
|
Sacuvano bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
|
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
|
||||||
@@ -354,6 +355,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
klijentNaziv := ""
|
klijentNaziv := ""
|
||||||
|
imaIdentKupca := false
|
||||||
if nalog.KlijentID != nil {
|
if nalog.KlijentID != nil {
|
||||||
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -362,6 +364,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
|
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
|
||||||
}
|
}
|
||||||
|
imaIdentKupca = klijent.PIB != "" || klijent.JMBG != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,13 +375,14 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
ps.Stranica = "prodaja"
|
ps.Stranica = "prodaja"
|
||||||
ps.NaslovStranice = "Detalji prodaje"
|
ps.NaslovStranice = "Detalji prodaje"
|
||||||
podaci := PodaciDetaljiProdaje{
|
podaci := PodaciDetaljiProdaje{
|
||||||
PodaciStranice: ps,
|
PodaciStranice: ps,
|
||||||
Nalog: *nalog,
|
Nalog: *nalog,
|
||||||
Stavke: stavke,
|
Stavke: stavke,
|
||||||
KlijentNaziv: klijentNaziv,
|
KlijentNaziv: klijentNaziv,
|
||||||
FiskalniRacun: fr,
|
FiskalniRacun: fr,
|
||||||
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
|
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
|
||||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca,
|
||||||
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderujTemplate(w, "prodaja_detalji", podaci)
|
h.renderujTemplate(w, "prodaja_detalji", podaci)
|
||||||
@@ -457,9 +461,19 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stornirajProdaju(r.Context(), id, "administrativno uklanjanje", &k.ID); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
slog.Error("greška pri uklanjanju naloga", "error", err)
|
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju. Možda je nalog već storniran.")
|
return
|
||||||
|
}
|
||||||
|
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
|
||||||
|
if err := h.stornirajProdaju(r.Context(), id, "administrativno uklanjanje", &k.ID, poreskiBrojKupca); err != nil {
|
||||||
|
poruka := "Greška pri uklanjanju. Možda je nalog već storniran."
|
||||||
|
if errors.Is(err, errPotrebnaIdentKupca) {
|
||||||
|
poruka = errPotrebnaIdentKupca.Error()
|
||||||
|
} else {
|
||||||
|
slog.Error("greška pri uklanjanju naloga", "error", err)
|
||||||
|
}
|
||||||
|
middleware.SetFlash(w, r, h.DB, "greska", poruka)
|
||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -565,9 +579,15 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
razlog := strings.TrimSpace(r.FormValue("razlog"))
|
razlog := strings.TrimSpace(r.FormValue("razlog"))
|
||||||
if err := h.stornirajProdaju(r.Context(), id, razlog, &k.ID); err != nil {
|
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
|
||||||
slog.Error("greška pri storniranju naloga", "error", err)
|
if err := h.stornirajProdaju(r.Context(), id, razlog, &k.ID, poreskiBrojKupca); err != nil {
|
||||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri storniranju. Možda je nalog već storniran.")
|
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)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -575,12 +595,46 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
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
|
// stornirajProdaju sprovodi kompletan storno prodajnog naloga: menja status naloga i
|
||||||
// vraća artikle na stanje, uklanja vezani auto-KIR zapis, šalje fiskalni refund
|
// 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.
|
// (best-effort — storno ostaje validan i bez uspešnog refunda) i briše KPO zapis.
|
||||||
// Zajednička je za StornoProdaje i ObrisiProdaju kako obe akcije imaju isti,
|
// Zajednička je za StornoProdaje i ObrisiProdaju kako obe akcije imaju isti,
|
||||||
// potpuni efekat prema PDV/fiskalnoj i KPO evidenciji.
|
// potpuni efekat prema PDV/fiskalnoj i KPO evidenciji.
|
||||||
func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
|
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 {
|
if err := h.ProdajaRepo.Storno(ctx, id, razlog, korisnikID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -606,6 +660,9 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
|||||||
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
|
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if pibFisk == "" && jmbgFisk == "" {
|
||||||
|
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
|
||||||
|
}
|
||||||
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
|
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
|
||||||
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
|
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
|
||||||
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
|
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
|
||||||
|
|||||||
@@ -255,6 +255,9 @@
|
|||||||
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
|
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
|
||||||
|
{{if .PotrebnaIdentKupca}}
|
||||||
|
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
|
||||||
|
{{end}}
|
||||||
<button type="submit" class="btn-upozorenje"
|
<button type="submit" class="btn-upozorenje"
|
||||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
|
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
|
||||||
Storniraj nalog
|
Storniraj nalog
|
||||||
@@ -311,8 +314,11 @@
|
|||||||
Nalog će biti storniran (vraća artikle na stanje) i označen kao uklonjen. Ne briše se fizički — ostaje u evidenciji.
|
Nalog će biti storniran (vraća artikle na stanje) i označen kao uklonjen. Ne briše se fizički — ostaje u evidenciji.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}">
|
<form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
|
{{if .PotrebnaIdentKupca}}
|
||||||
|
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
|
||||||
|
{{end}}
|
||||||
<button type="submit" class="btn-upozorenje"
|
<button type="submit" class="btn-upozorenje"
|
||||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}? Količine artikala biće vraćene na stanje.">
|
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}? Količine artikala biće vraćene na stanje.">
|
||||||
Ukloni nalog
|
Ukloni nalog
|
||||||
|
|||||||
Reference in New Issue
Block a user