Merge feature/prodaja-refundacija-i-toast → develop

This commit is contained in:
2026-07-01 15:42:04 +02:00
5 changed files with 133 additions and 35 deletions
+10 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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
+12 -6
View File
@@ -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);