Merge feature/prodaja-refundacija-i-toast → develop
This commit is contained in:
@@ -2,6 +2,7 @@ package fiskal
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
@@ -28,18 +29,20 @@ func BrutoCena(neto, stopa float64) float64 {
|
||||
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 {
|
||||
switch nacin {
|
||||
case "Gotovina":
|
||||
switch strings.ToLower(nacin) {
|
||||
case "gotovina":
|
||||
return "Cash"
|
||||
case "Kartica":
|
||||
case "kartica":
|
||||
return "Card"
|
||||
case "Virman", "Račun":
|
||||
case "virman", "račun", "racun", "prenos":
|
||||
return "WireTransfer"
|
||||
case "Ček":
|
||||
case "ček", "cek":
|
||||
return "Check"
|
||||
case "Vaučer":
|
||||
case "vaučer", "vaucer":
|
||||
return "Voucher"
|
||||
default:
|
||||
return "Other"
|
||||
|
||||
+77
-20
@@ -50,12 +50,13 @@ type PodaciFormeProdaje struct {
|
||||
// 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
|
||||
Sacuvano bool
|
||||
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
|
||||
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
|
||||
@@ -354,6 +355,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
klijentNaziv := ""
|
||||
imaIdentKupca := false
|
||||
if nalog.KlijentID != nil {
|
||||
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
|
||||
if err == nil {
|
||||
@@ -362,6 +364,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
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.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,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
PodaciStranice: ps,
|
||||
Nalog: *nalog,
|
||||
Stavke: stavke,
|
||||
KlijentNaziv: klijentNaziv,
|
||||
FiskalniRacun: fr,
|
||||
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
|
||||
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)
|
||||
@@ -457,9 +461,19 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stornirajProdaju(r.Context(), id, "administrativno uklanjanje", &k.ID); err != nil {
|
||||
slog.Error("greška pri uklanjanju naloga", "error", err)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju. Možda je nalog već storniran.")
|
||||
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.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)
|
||||
return
|
||||
}
|
||||
@@ -565,9 +579,15 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
razlog := strings.TrimSpace(r.FormValue("razlog"))
|
||||
if err := h.stornirajProdaju(r.Context(), id, razlog, &k.ID); err != nil {
|
||||
slog.Error("greška pri storniranju naloga", "error", err)
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri storniranju. Možda je nalog već storniran.")
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Zajednička je za StornoProdaje i ObrisiProdaju kako obe akcije imaju isti,
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
@@ -606,6 +660,9 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
||||
pibFisk, jmbgFisk = kk.PIB, kk.JMBG
|
||||
}
|
||||
}
|
||||
if pibFisk == "" && jmbgFisk == "" {
|
||||
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
|
||||
}
|
||||
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
|
||||
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
|
||||
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
|
||||
|
||||
+27
-1
@@ -862,6 +862,32 @@ select {
|
||||
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
|
||||
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
|
||||
.toast.nestaje { animation: toastOut 0.3s ease forwards; }
|
||||
|
||||
/* kontejner za više toastova (window.ntechToast) — slaže ih jedan iznad drugog
|
||||
preko flex-a umesto da se svi iscrtaju na istoj fiksnoj poziciji */
|
||||
#ntech-toast-kontejner {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast.toast-u-nizu {
|
||||
position: static;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#ntech-toast-kontejner {
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.toast.toast-u-nizu {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -1525,7 +1551,7 @@ select {
|
||||
|
||||
.flash-zatvori:hover { opacity: 1; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@media (max-width: 768px) {
|
||||
.flash-toast {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
|
||||
@@ -255,6 +255,9 @@
|
||||
<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="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"
|
||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
|
||||
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.
|
||||
</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}}">
|
||||
{{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"
|
||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}? Količine artikala biće vraćene na stanje.">
|
||||
Ukloni nalog
|
||||
|
||||
@@ -281,10 +281,19 @@
|
||||
window._ntechPotvrdi = prikaziModal;
|
||||
})();
|
||||
|
||||
// toast obaveštenja — prikazuje poruku u uglu ekrana, nestaje posle 4s
|
||||
// toast obaveštenja — prikazuje poruku u uglu ekrana, nestaje posle 4s.
|
||||
// Više poziva u kratkom vremenu (npr. auto-snimanje više polja zaredom)
|
||||
// slaže toastove jedan iznad drugog preko zajedničkog kontejnera, umesto
|
||||
// da se svi iscrtaju na istoj fiksnoj poziciji jedan preko drugog.
|
||||
window.ntechToast = function(tekst, tip) {
|
||||
var kontejner = document.getElementById('ntech-toast-kontejner');
|
||||
if (!kontejner) {
|
||||
kontejner = document.createElement('div');
|
||||
kontejner.id = 'ntech-toast-kontejner';
|
||||
document.body.appendChild(kontejner);
|
||||
}
|
||||
var t = document.createElement('div');
|
||||
t.className = 'toast ' + (tip === 'greska' ? 'toast-greska' : 'toast-uspeh');
|
||||
t.className = 'toast toast-u-nizu ' + (tip === 'greska' ? 'toast-greska' : 'toast-uspeh');
|
||||
// ikonica
|
||||
var svg = tip === 'greska'
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
|
||||
@@ -295,10 +304,7 @@
|
||||
var tekstSpan = document.createElement('span');
|
||||
tekstSpan.textContent = tekst;
|
||||
t.appendChild(tekstSpan);
|
||||
// prilagođavamo poziciju za mobilne uređaje
|
||||
t.style.bottom = '24px';
|
||||
t.style.right = '16px';
|
||||
document.body.appendChild(t);
|
||||
kontejner.appendChild(t);
|
||||
var ukloni = function() {
|
||||
t.classList.add('nestaje');
|
||||
setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
|
||||
|
||||
Reference in New Issue
Block a user