Merge feature/prodaja-refundacija-i-toast → develop
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)
|
||||||
|
|||||||
+27
-1
@@ -862,6 +862,32 @@ select {
|
|||||||
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
|
.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
|
||||||
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
|
.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
|
||||||
.toast.nestaje { animation: toastOut 0.3s ease forwards; }
|
.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 {
|
@keyframes toastIn {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -1525,7 +1551,7 @@ select {
|
|||||||
|
|
||||||
.flash-zatvori:hover { opacity: 1; }
|
.flash-zatvori:hover { opacity: 1; }
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 768px) {
|
||||||
.flash-toast {
|
.flash-toast {
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: 20px;
|
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;">
|
<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
|
||||||
|
|||||||
@@ -281,10 +281,19 @@
|
|||||||
window._ntechPotvrdi = prikaziModal;
|
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) {
|
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');
|
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
|
// ikonica
|
||||||
var svg = tip === 'greska'
|
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>'
|
? '<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');
|
var tekstSpan = document.createElement('span');
|
||||||
tekstSpan.textContent = tekst;
|
tekstSpan.textContent = tekst;
|
||||||
t.appendChild(tekstSpan);
|
t.appendChild(tekstSpan);
|
||||||
// prilagođavamo poziciju za mobilne uređaje
|
kontejner.appendChild(t);
|
||||||
t.style.bottom = '24px';
|
|
||||||
t.style.right = '16px';
|
|
||||||
document.body.appendChild(t);
|
|
||||||
var ukloni = function() {
|
var ukloni = function() {
|
||||||
t.classList.add('nestaje');
|
t.classList.add('nestaje');
|
||||||
setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
|
setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
|
||||||
|
|||||||
Reference in New Issue
Block a user