Servis: avans se fiskalizuje kao Advance/Sale, konačan račun ga zatvara
Avans se do sada nikad nije fiskalizovao — bio je samo interno polje. Zbog toga je konačni fiskalni račun (pri preuzimanju) uvek prikazivao povraćaj protiv PUNOG iznosa stavki, bez znanja da je avans već delimično plaćen. Sad: - avans se fiskalizuje kao Advance/Sale pri kreiranju/izmeni naloga (fiskalizujAvansServisa — fiskalizuje samo deltu ako se avans dopunjava) - konačan Normal/Sale račun pri preuzimanju šalje advancePaid/advanceTax/ advanceLastInvoiceNumber/advanceLastInvoiceDateTime — PFR sad zna za avans i tačno računa preostali dug i kusur - ako avans premaši punu cenu, konačan račun se zatvara do pune cene, a razlika se automatski vraća posebnim Advance/Refund zahtevom - servis_detalji modal prikazuje 'Avans premašuje cenu — vratiti klijentu X' umesto tihog svođenja duga na 0 Uzgred ispravljena dva mesta koja su koristila DohvatiPoServisu (samo najnoviji zapis, bilo kog tipa) umesto DohvatiPoServisuITip(Normal,Sale): - PromeniStatus je avansni račun pogrešno tretirao kao 'već fiskalizovano' i preskakao konačan račun kad god je nalog imao avans - RetryFiskalizacija i FiskalGreska detekcija na servis_detalji stranici su imale isti problem — nalog sa avansom je izgledao fiskalizovan i dugme 'Pokušaj ponovo' se nije ni prikazivalo PorezIzBrutoAvansa pretpostavlja opštu PDV stopu (20%) jer stavke u trenutku uplate avansa još nisu poznate — nema opšteg rešenja bez pretpostavke.
This commit is contained in:
@@ -401,6 +401,11 @@ type FiskalRepository interface {
|
|||||||
Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
|
Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
|
||||||
DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
|
DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
|
||||||
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
|
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
|
||||||
|
// DohvatiPoServisuITip vraća poslednji nestorniran račun tačno određenog tipa
|
||||||
|
// (npr. avansni Sale) — za razliku od DohvatiPoServisu (samo najnoviji, bilo kog tipa)
|
||||||
|
DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error)
|
||||||
|
// SumaAvansaPoServisu vraća neto fiskalizovan avans (Sale - Refund) za nalog
|
||||||
|
SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error)
|
||||||
OznačiKaoStorniran(ctx context.Context, id int64) error
|
OznačiKaoStorniran(ctx context.Context, id int64) error
|
||||||
ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
||||||
ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
||||||
|
|||||||
@@ -117,6 +117,61 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod
|
|||||||
return fr, nil
|
return fr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DohvatiPoServisuITip vraća poslednji nestorniran fiskalni račun za dati servisni
|
||||||
|
// nalog sa tačno određenim tipom (npr. tip_racuna="Advance", tip_transakcije="Sale"
|
||||||
|
// za avansni račun) — za razliku od DohvatiPoServisu koji vraća samo najnoviji
|
||||||
|
// zapis bilo kog tipa. Vraća nil bez greške ako takav račun ne postoji.
|
||||||
|
func (r *FiskalRepo) DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error) {
|
||||||
|
fr := &model.FiskalniRacun{}
|
||||||
|
var storniran int
|
||||||
|
var sID, pID sql.NullInt64
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, prodaja_id, servis_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
|
||||||
|
brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
|
||||||
|
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||||
|
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||||
|
FROM fiskalni_racuni
|
||||||
|
WHERE servis_id = ? AND tip_racuna = ? AND tip_transakcije = ? AND storniran = 0
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
`, servisID, tipRacuna, tipTransakcije).Scan(
|
||||||
|
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||||
|
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
||||||
|
&fr.PoreskeStavke, &fr.UkupnoZaNaplatu, &fr.UkupanPorez,
|
||||||
|
&fr.SiroviOdgovor, &fr.Potpisao, &fr.Zatrazio, &fr.Poruka, &storniran, &fr.VremeKreiranja,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: FiskalRepo.DohvatiPoServisuITip: %w", err)
|
||||||
|
}
|
||||||
|
if pID.Valid {
|
||||||
|
fr.ProdajaID = pID.Int64
|
||||||
|
}
|
||||||
|
if sID.Valid {
|
||||||
|
fr.ServisID = sID.Int64
|
||||||
|
}
|
||||||
|
fr.Storniran = storniran != 0
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SumaAvansaPoServisu vraća neto fiskalizovan avans za dati servisni nalog —
|
||||||
|
// zbir Advance/Sale iznosa umanjen za Advance/Refund iznose (nestornirani).
|
||||||
|
// Koristi se da se pri izmeni avansa fiskalizuje samo razlika (delta), ne ceo
|
||||||
|
// novi iznos ponovo.
|
||||||
|
func (r *FiskalRepo) SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error) {
|
||||||
|
var suma float64
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN tip_transakcije = 'Refund' THEN -ukupno_za_naplatu ELSE ukupno_za_naplatu END), 0)
|
||||||
|
FROM fiskalni_racuni
|
||||||
|
WHERE servis_id = ? AND tip_racuna = 'Advance' AND storniran = 0
|
||||||
|
`, servisID).Scan(&suma)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: FiskalRepo.SumaAvansaPoServisu: %w", err)
|
||||||
|
}
|
||||||
|
return suma, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ServisiBezFiskalnog vraća skup ID-eva servisnih naloga koji su u statusu "Preuzeto"
|
// ServisiBezFiskalnog vraća skup ID-eva servisnih naloga koji su u statusu "Preuzeto"
|
||||||
// a nemaju fiskalni račun (za oznaku u listi naloga).
|
// a nemaju fiskalni račun (za oznaku u listi naloga).
|
||||||
func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) {
|
func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) {
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ func NoviKlijent(baseURL, apiKey string) *Klijent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat.
|
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat. Polja Advance*
|
||||||
|
// su na vrhu (van invoiceRequest) — Teron ih tako očekuje za konačni račun koji
|
||||||
|
// zatvara prethodno izdat avansni račun (v. NapraviKonacniZahtevSaAvansom).
|
||||||
type InvoiceRequest struct {
|
type InvoiceRequest struct {
|
||||||
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
|
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
|
||||||
|
AdvancePaid *float64 `json:"advancePaid,omitempty"`
|
||||||
|
AdvanceTax *float64 `json:"advanceTax,omitempty"`
|
||||||
|
AdvanceLastInvoiceNumber string `json:"advanceLastInvoiceNumber,omitempty"`
|
||||||
|
AdvanceLastInvoiceDateTime string `json:"advanceLastInvoiceDateTime,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
|
// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
|
||||||
|
|||||||
@@ -142,3 +142,48 @@ func NapraviRefundZahtev(
|
|||||||
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
|
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
|
||||||
return zahtev
|
return zahtev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NazivAvansneStavke je naziv stavke na avansnim fiskalnim računima (isti naziv
|
||||||
|
// mora biti dosledan, jer ga PFR ne povezuje po ID-u već po sadržaju stavke).
|
||||||
|
const NazivAvansneStavke = "Аванс"
|
||||||
|
|
||||||
|
// StopaAvansa je pretpostavljena PDV stopa za avans — u trenutku uplate avansa
|
||||||
|
// stavke (radovi/delovi) obično još nisu poznate, pa se ne može tačno odrediti
|
||||||
|
// stvarna stopa. Koristi se opšta stopa (20%) kao razumna pretpostavka za
|
||||||
|
// srpsko tržište; ako firma posluje isključivo po posebnoj/nultoj stopi, ovo
|
||||||
|
// treba prilagoditi ručno (nema opšteg rešenja bez poznavanja stavki unapred).
|
||||||
|
const StopaAvansa = 20.0
|
||||||
|
|
||||||
|
// NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka
|
||||||
|
// "Аванс" u punom (bruto) iznosu.
|
||||||
|
func NapraviAvansZahtev(iznos float64, nacinPlacanja, kasir string) InvoiceRequest {
|
||||||
|
return InvoiceRequest{
|
||||||
|
InvoiceRequest: InvoiceRequestBody{
|
||||||
|
InvoiceType: "Advance",
|
||||||
|
TransactionType: "Sale",
|
||||||
|
Payment: []PaymentItem{
|
||||||
|
{Amount: iznos, PaymentType: TipPlacanja(nacinPlacanja)},
|
||||||
|
},
|
||||||
|
Items: []InvoiceItem{
|
||||||
|
{Name: NazivAvansneStavke, Labels: []string{OznakaPDV(StopaAvansa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1},
|
||||||
|
},
|
||||||
|
Cashier: kasir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NapraviAvansRefundZahtev gradi Advance/Refund zahtev za povraćaj dela ili celog
|
||||||
|
// avansa (npr. kad avans premaši konačnu cenu popravke). referentBroj je PfrBroj
|
||||||
|
// originalnog avansnog računa.
|
||||||
|
func NapraviAvansRefundZahtev(iznos float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest {
|
||||||
|
zahtev := NapraviAvansZahtev(iznos, nacinPlacanja, kasir)
|
||||||
|
zahtev.InvoiceRequest.TransactionType = "Refund"
|
||||||
|
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
|
||||||
|
return zahtev
|
||||||
|
}
|
||||||
|
|
||||||
|
// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po StopaAvansa
|
||||||
|
// (npr. 500 din avansa po 20% → porez ≈ 83.33 din).
|
||||||
|
func PorezIzBrutoAvansa(bruto float64) float64 {
|
||||||
|
return math.Round(bruto*StopaAvansa/(100+StopaAvansa)*100) / 100
|
||||||
|
}
|
||||||
|
|||||||
+161
-16
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image/png"
|
"image/png"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -78,6 +79,8 @@ type PodaciDetaljiNaloga struct {
|
|||||||
Avans float64
|
Avans float64
|
||||||
PreostaloSve float64
|
PreostaloSve float64
|
||||||
PreostaloSveSaPdv float64
|
PreostaloSveSaPdv float64
|
||||||
|
VisakAvansa float64 // >0 kad avans premaši ukupnu cenu — iznos za povraćaj klijentu
|
||||||
|
VisakAvansaSaPdv float64
|
||||||
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
|
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
|
||||||
CenaDijagnostikePredlog string // podrazumevana cena dijagnostike iz podešavanja (za prefill input-a)
|
CenaDijagnostikePredlog string // podrazumevana cena dijagnostike iz podešavanja (za prefill input-a)
|
||||||
KorisceneUsluge map[int64]bool // ID-evi usluga već dodatih na nalog — izostavljaju se iz dropdown-a
|
KorisceneUsluge map[int64]bool // ID-evi usluga već dodatih na nalog — izostavljaju se iz dropdown-a
|
||||||
@@ -305,6 +308,10 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if nalog.Avans != nil && *nalog.Avans > 0 {
|
||||||
|
h.fiskalizujAvansServisa(r.Context(), id, *nalog.Avans, "Gotovina")
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +506,10 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if nalog.Avans != nil && *nalog.Avans > 0 {
|
||||||
|
h.fiskalizujAvansServisa(r.Context(), id, *nalog.Avans, "Gotovina")
|
||||||
|
}
|
||||||
|
|
||||||
if autosave {
|
if autosave {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -822,11 +833,15 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
|||||||
avans = *nalog.Avans
|
avans = *nalog.Avans
|
||||||
}
|
}
|
||||||
preostaloSve := ukupnoSve - avans
|
preostaloSve := ukupnoSve - avans
|
||||||
|
var visakAvansa float64
|
||||||
if preostaloSve < 0 {
|
if preostaloSve < 0 {
|
||||||
|
visakAvansa = -preostaloSve
|
||||||
preostaloSve = 0
|
preostaloSve = 0
|
||||||
}
|
}
|
||||||
preostaloSveSaPdv := ukupnoSveSaPdv - avans
|
preostaloSveSaPdv := ukupnoSveSaPdv - avans
|
||||||
|
var visakAvansaSaPdv float64
|
||||||
if preostaloSveSaPdv < 0 {
|
if preostaloSveSaPdv < 0 {
|
||||||
|
visakAvansaSaPdv = -preostaloSveSaPdv
|
||||||
preostaloSveSaPdv = 0
|
preostaloSveSaPdv = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,6 +882,8 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
|||||||
Avans: avans,
|
Avans: avans,
|
||||||
PreostaloSve: preostaloSve,
|
PreostaloSve: preostaloSve,
|
||||||
PreostaloSveSaPdv: preostaloSveSaPdv,
|
PreostaloSveSaPdv: preostaloSveSaPdv,
|
||||||
|
VisakAvansa: visakAvansa,
|
||||||
|
VisakAvansaSaPdv: visakAvansaSaPdv,
|
||||||
ZakljucanStatus: zakljucanStatus,
|
ZakljucanStatus: zakljucanStatus,
|
||||||
CenaDijagnostikePredlog: podesavanja["servis_cena_dijagnostike"],
|
CenaDijagnostikePredlog: podesavanja["servis_cena_dijagnostike"],
|
||||||
KorisceneUsluge: korisceneUsluge,
|
KorisceneUsluge: korisceneUsluge,
|
||||||
@@ -880,11 +897,13 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
slog.Error("greška pri učitavanju fiskalnog računa za servis", "servis_id", id, "error", err)
|
slog.Error("greška pri učitavanju fiskalnog računa za servis", "servis_id", id, "error", err)
|
||||||
}
|
}
|
||||||
// fiskalna greška: modul aktivan, nalog preuzet, ali nema fiskalnog računa
|
// fiskalna greška: modul aktivan, nalog preuzet, ali nema KONAČNOG (Normal/Sale)
|
||||||
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) &&
|
// fiskalnog računa — avansni (Advance/Sale) se ne računa kao konačan, inače bi
|
||||||
nalog.Status == model.StatusPreuzeto &&
|
// nalog sa avansom lažno izgledao fiskalizovan i dugme Fiskalizuj bi bilo skriveno
|
||||||
podaci.FiskalniRacun == nil {
|
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Status == model.StatusPreuzeto {
|
||||||
podaci.FiskalGreska = true
|
if konacan, err := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); err == nil && konacan == nil {
|
||||||
|
podaci.FiskalGreska = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// storno zahteva PIB/JMBG kupca za refundaciju ako klijent nema upisan poreski broj
|
// storno zahteva PIB/JMBG kupca za refundaciju ako klijent nema upisan poreski broj
|
||||||
podaci.PotrebnaIdentKupca = h.modulUkljucen(r.Context(), config.ModulFiskalizacija) &&
|
podaci.PotrebnaIdentKupca = h.modulUkljucen(r.Context(), config.ModulFiskalizacija) &&
|
||||||
@@ -2343,12 +2362,15 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
middleware.SetFlash(w, r, h.DB, "greska", "Naplata nije sačuvana. Pokušajte ponovo.")
|
middleware.SetFlash(w, r, h.DB, "greska", "Naplata nije sačuvana. Pokušajte ponovo.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// fiskalizacija servisa — ako je modul uključen; preskoči ako fiskalni račun
|
// fiskalizacija servisa — ako je modul uključen; preskoči ako KONAČAN (Normal/Sale)
|
||||||
// za ovaj nalog već postoji (ponovni ulazak u Preuzeto ne sme duplirati PFR zahtev)
|
// fiskalni račun za ovaj nalog već postoji (ponovni ulazak u Preuzeto ne sme
|
||||||
|
// duplirati PFR zahtev) — avansni (Advance/Sale) račun se ne računa kao konačan.
|
||||||
|
// iznos==0 je dozvoljen (npr. avans premašuje cenu) jer i tada treba izdati
|
||||||
|
// konačan račun da zatvori avans i pokrene eventualni povraćaj viška.
|
||||||
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||||
if fr, _ := h.FiskalRepo.DohvatiPoServisu(r.Context(), id); fr == nil {
|
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr == nil {
|
||||||
klijent := h.fiskalKlijent()
|
klijent := h.fiskalKlijent()
|
||||||
if klijent != nil && iznos > 0 {
|
if klijent != nil {
|
||||||
h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos, primljeno)
|
h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos, primljeno)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2405,6 +2427,68 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fiskalizujAvansServisa izdaje avansni fiskalni račun (Advance/Sale) za primljeni
|
||||||
|
// avans. Best-effort: greška se loguje, ne zaustavlja tok pozivaoca. noviAvans je
|
||||||
|
// UKUPAN trenutni iznos avansa na nalogu (ne delta) — funkcija sama izračunava i
|
||||||
|
// fiskalizuje samo razliku u odnosu na već fiskalizovan avans, da izmena/dopuna
|
||||||
|
// avansa ne fiskalizuje ceo iznos ponovo. Bez efekta ako razlika nije pozitivna
|
||||||
|
// (smanjenje avansa se ne fiskalizuje automatski — v. razgovor o povraćaju).
|
||||||
|
func (h *Handler) fiskalizujAvansServisa(ctx context.Context, servisID int64, noviAvans float64, nacinPlacanja string) {
|
||||||
|
if noviAvans <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.modulUkljucen(ctx, config.ModulFiskalizacija) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vecFiskalizovano, err := h.FiskalRepo.SumaAvansaPoServisu(ctx, servisID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("fiskalizujAvansServisa: provera postojećeg avansa nije uspela", "servis_id", servisID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delta := math.Round((noviAvans-vecFiskalizovano)*100) / 100
|
||||||
|
if delta <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
klijent := h.fiskalKlijent()
|
||||||
|
if klijent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if nacinPlacanja == "" {
|
||||||
|
nacinPlacanja = "Gotovina"
|
||||||
|
}
|
||||||
|
|
||||||
|
zahtev := fiskal.NapraviAvansZahtev(delta, nacinPlacanja, h.imeKasira(ctx))
|
||||||
|
odgovor, err := klijent.IzdajRacun(ctx, zahtev)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("fiskalizacija avansa servisa nije uspela", "servis_id", servisID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
|
||||||
|
siroviJSON, _ := json.Marshal(odgovor)
|
||||||
|
fr := &model.FiskalniRacun{
|
||||||
|
ServisID: servisID,
|
||||||
|
TipRacuna: "Advance",
|
||||||
|
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 avansnog fiskalnog računa servisa", "servis_id", servisID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fiskalizujServis šalje fiskalni zahtev za servisni nalog pri preuzimanju.
|
// fiskalizujServis šalje fiskalni zahtev za servisni nalog pri preuzimanju.
|
||||||
// Best-effort: greške se loguju, ne zaustavljaju promenu statusa. primljeno je
|
// Best-effort: greške se loguju, ne zaustavljaju promenu statusa. primljeno je
|
||||||
// iznos koji je klijent stvarno predao — ako je veći od iznos (dug posle avansa),
|
// iznos koji je klijent stvarno predao — ako je veći od iznos (dug posle avansa),
|
||||||
@@ -2463,6 +2547,35 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
|
|||||||
zahtev.InvoiceRequest.BuyerID = "10:" + pib
|
zahtev.InvoiceRequest.BuyerID = "10:" + pib
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ako je avans fiskalizovan (v. fiskalizujAvansServisa), konačni račun ga
|
||||||
|
// zatvara — PFR sam raspoređuje avans na ovaj račun preko Advance* polja.
|
||||||
|
// Ako avans premašuje punu cenu (visak), zatvara se samo do pune cene, a
|
||||||
|
// razlika se posle uspešnog Sale-a vraća posebnim Advance/Refund zahtevom.
|
||||||
|
var visakAvansa float64
|
||||||
|
var avansRacun *model.FiskalniRacun
|
||||||
|
if avansNeto, e := h.FiskalRepo.SumaAvansaPoServisu(ctx, servisID); e == nil && avansNeto > 0 {
|
||||||
|
if ar, e2 := h.FiskalRepo.DohvatiPoServisuITip(ctx, servisID, "Advance", "Sale"); e2 == nil && ar != nil {
|
||||||
|
avansRacun = ar
|
||||||
|
punaCena := 0.0
|
||||||
|
for _, it := range items {
|
||||||
|
punaCena += it.TotalAmount
|
||||||
|
}
|
||||||
|
punaCena = math.Round(punaCena*100) / 100
|
||||||
|
iskoriscenAvans := avansNeto
|
||||||
|
if iskoriscenAvans > punaCena {
|
||||||
|
visakAvansa = math.Round((iskoriscenAvans-punaCena)*100) / 100
|
||||||
|
iskoriscenAvans = punaCena
|
||||||
|
}
|
||||||
|
porez := fiskal.PorezIzBrutoAvansa(iskoriscenAvans)
|
||||||
|
zahtev.AdvancePaid = &iskoriscenAvans
|
||||||
|
zahtev.AdvanceTax = &porez
|
||||||
|
zahtev.AdvanceLastInvoiceNumber = avansRacun.PfrBroj
|
||||||
|
zahtev.AdvanceLastInvoiceDateTime = avansRacun.PfrVreme
|
||||||
|
}
|
||||||
|
} else if e != nil {
|
||||||
|
slog.Error("fiskalizujServis: provera avansa nije uspela", "servis_id", servisID, "error", e)
|
||||||
|
}
|
||||||
|
|
||||||
odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev)
|
odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev)
|
||||||
if errFisk != nil {
|
if errFisk != nil {
|
||||||
slog.Error("fiskalizacija servisa nije uspela", "servis_id", servisID, "error", errFisk)
|
slog.Error("fiskalizacija servisa nije uspela", "servis_id", servisID, "error", errFisk)
|
||||||
@@ -2492,6 +2605,40 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
|
|||||||
}
|
}
|
||||||
if _, err := h.FiskalRepo.Kreiraj(ctx, fr); err != nil {
|
if _, err := h.FiskalRepo.Kreiraj(ctx, fr); err != nil {
|
||||||
slog.Error("greška pri čuvanju fiskalnog računa za servis", "servis_id", servisID, "error", err)
|
slog.Error("greška pri čuvanju fiskalnog računa za servis", "servis_id", servisID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// avans je premašio punu cenu — vrati razliku posebnim Advance/Refund zahtevom
|
||||||
|
if visakAvansa > 0 && avansRacun != nil {
|
||||||
|
refundZahtev := fiskal.NapraviAvansRefundZahtev(visakAvansa, nacinPlacanja, kasir, avansRacun.PfrBroj)
|
||||||
|
refundOdgovor, errRefund := klijent.IzdajRacun(ctx, refundZahtev)
|
||||||
|
if errRefund != nil {
|
||||||
|
slog.Error("povraćaj viška avansa nije uspeo", "servis_id", servisID, "error", errRefund)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
poreskeRefJSON, _ := json.Marshal(refundOdgovor.TaxItems)
|
||||||
|
siroviRefJSON, _ := json.Marshal(refundOdgovor)
|
||||||
|
refFr := &model.FiskalniRacun{
|
||||||
|
ServisID: servisID,
|
||||||
|
TipRacuna: "Advance",
|
||||||
|
TipTransakcije: "Refund",
|
||||||
|
PfrBroj: refundOdgovor.InvoiceNumber,
|
||||||
|
PfrVreme: refundOdgovor.SdcDateTime,
|
||||||
|
Brojac: refundOdgovor.InvoiceCounter,
|
||||||
|
EkstenzijaBrojaca: refundOdgovor.InvoiceCounterExtension,
|
||||||
|
UrlVerifikacija: refundOdgovor.VerificationURL,
|
||||||
|
QRKod: refundOdgovor.VerificationQRCode,
|
||||||
|
PoreskeStavke: string(poreskeRefJSON),
|
||||||
|
UkupnoZaNaplatu: refundOdgovor.TotalAmount,
|
||||||
|
UkupanPorez: refundOdgovor.TotalTax,
|
||||||
|
SiroviOdgovor: string(siroviRefJSON),
|
||||||
|
Potpisao: refundOdgovor.SignedBy,
|
||||||
|
Zatrazio: refundOdgovor.RequestedBy,
|
||||||
|
Poruka: refundOdgovor.Messages,
|
||||||
|
}
|
||||||
|
if _, err := h.FiskalRepo.Kreiraj(ctx, refFr); err != nil {
|
||||||
|
slog.Error("greška pri čuvanju povraćaja viška avansa", "servis_id", servisID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2625,8 +2772,9 @@ func (h *Handler) RetryFiskalizacija(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// provjeri da fiskal još nije izdat (zaštita od duplikata)
|
// provjeri da KONAČAN (Normal/Sale) fiskal još nije izdat (zaštita od duplikata) —
|
||||||
if fr, _ := h.FiskalRepo.DohvatiPoServisu(r.Context(), id); fr != nil {
|
// avansni (Advance/Sale) račun se ne računa kao konačan, ne sme blokirati retry
|
||||||
|
if fr, _ := h.FiskalRepo.DohvatiPoServisuITip(r.Context(), id, "Normal", "Sale"); fr != nil {
|
||||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun već postoji.")
|
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni račun već postoji.")
|
||||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -2637,11 +2785,8 @@ func (h *Handler) RetryFiskalizacija(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Nalog nije u statusu Preuzeto", http.StatusBadRequest)
|
http.Error(w, "Nalog nije u statusu Preuzeto", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if nalog.Naplaceno <= 0 {
|
// Naplaceno==0 je dozvoljeno kad avans u potpunosti pokriva cenu — konačan
|
||||||
middleware.SetFlash(w, r, h.DB, "greska", "Iznos naplate je 0 — fiskalizacija nije moguća.")
|
// račun se i tada mora izdati da zatvori avans (i eventualno vrati višak)
|
||||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
klijent := h.fiskalKlijent()
|
klijent := h.fiskalKlijent()
|
||||||
if klijent == nil {
|
if klijent == nil {
|
||||||
|
|||||||
@@ -130,6 +130,14 @@
|
|||||||
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Za naplatu</span>
|
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Za naplatu</span>
|
||||||
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);" id="za-naplatu-iznos">{{if index .Moduli "pdv"}}{{dinari .PreostaloSveSaPdv}}{{else}}{{dinari .PreostaloSve}}{{end}} din</span>
|
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);" id="za-naplatu-iznos">{{if index .Moduli "pdv"}}{{dinari .PreostaloSveSaPdv}}{{else}}{{dinari .PreostaloSve}}{{end}} din</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{$visak := .VisakAvansa}}{{if index .Moduli "pdv"}}{{$visak = .VisakAvansaSaPdv}}{{end}}
|
||||||
|
{{if gt $visak 0.0}}
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,#f59e0b 12%,transparent);border:0.5px solid #f59e0b;border-radius:8px;">
|
||||||
|
<span style="color:#92400e;font-weight:500;">Avans premašuje cenu — vratiti klijentu</span>
|
||||||
|
<span style="font-size:16px;font-weight:600;color:#92400e;">{{dinari $visak}} din</span>
|
||||||
|
</div>
|
||||||
|
<div class="pomocni-tekst">Ovaj iznos se automatski vraća kroz avansni povraćaj na fiskalnom računu.</div>
|
||||||
|
{{end}}
|
||||||
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;">
|
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;">
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||||
<input type="hidden" name="status" value="Preuzeto">
|
<input type="hidden" name="status" value="Preuzeto">
|
||||||
|
|||||||
Reference in New Issue
Block a user