Servis: storno naloga (kao Prodaja/Nabavka) — KIR/KPO storno stavke, fiskalni refund

Do sada je jedini način da se poništi naplaćen/preuzet servisni nalog bio
fizičko brisanje (ObrisiNalog), koje ostavlja KIR/KPO/fiskalne zapise sa mrtvim
izvor_id — prihod ostaje prijavljen za nalog koji više ne postoji.

Migracija 100: servisni_nalozi dobija stornirano/razlog_storniranja (kao
prodajni_nalozi/nabavke). ServisRepo.Storno vraća ugrađene delove na stanje
ali NE briše nalog. Novi handler StornoNaloga (dozvola servis.storno):
- KIR/KPO original ostaje, dograđuje se storno stavka (model.KirStorno/KpoStorno)
- fiskalni refund (best-effort) preko refundujServis — Refund zahtev sa
  ReferentDocumentNumber ka originalnom PFR broju, isti obrazac kao stornirajProdaju
- ObrisiNalog sad odbija brisanje ako nalog već ima fiskalni/KIR/KPO zapis i
  upućuje na Storno

stornoBezRefunda (dashboard upozorenje) prošireno da prati i servisne stornose
bez uspešnog fiskalnog refunda, ne samo prodaju.
This commit is contained in:
2026-07-02 11:30:55 +02:00
parent 3c61063bbb
commit f74cb04024
9 changed files with 392 additions and 41 deletions
+1
View File
@@ -380,6 +380,7 @@ func main() {
r.Get("/servis/izmeni/{id}", h.IzmeniNalog) r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga) r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog) r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
r.With(doz("servis.storno")).Post("/servis/storno/{id}", h.StornoNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
+3
View File
@@ -217,6 +217,9 @@ type ServisRepository interface {
SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error
ObrisiOdlukuKlijenta(ctx context.Context, id int64) error ObrisiOdlukuKlijenta(ctx context.Context, id int64) error
Obrisi(ctx context.Context, id int64, korisnikID *int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error
// Storno stornira nalog (vraća ugrađene delove na stanje, markira stornirano=1)
// bez brisanja — koristi se kad nalog već ima finansijske zapise (KIR/KPO/fiskalni)
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
SledeciBroj(ctx context.Context) (string, error) SledeciBroj(ctx context.Context) (string, error)
SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error
} }
+88 -4
View File
@@ -58,7 +58,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj, sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna, sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum, sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno, sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno, sn.stornirano, sn.razlog_storniranja,
COALESCE(kp.naziv, '') AS klijent_naziv, COALESCE(kp.naziv, '') AS klijent_naziv,
(EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1) (EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1)
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1) OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)
@@ -108,7 +108,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
FROM servisni_nalozi WHERE id = ?`, id) FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog var n model.ServisniNalog
@@ -160,7 +160,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
FROM servisni_nalozi WHERE javni_token = ?`, token) FROM servisni_nalozi WHERE javni_token = ?`, token)
var n model.ServisniNalog var n model.ServisniNalog
@@ -461,6 +461,86 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
return nil return nil
} }
// Storno stornira servisni nalog: vraća ugrađene delove na stanje (isto kao
// Obrisi) ali NALOG SE NE BRIŠE — samo se markira stornirano=1, po istom principu
// kao Prodaja. Finansijski zapisi (KIR/KPO/fiskalni) se ne diraju ovde — to radi
// handler posle uspešnog storna, dograđivanjem storno stavki na osnovu izvor_id.
func (r *ServisRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: begin tx: %w", err)
}
defer tx.Rollback()
var vecStornirano bool
if err := tx.QueryRowContext(ctx, "SELECT stornirano FROM servisni_nalozi WHERE id = ?", id).Scan(&vecStornirano); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: provera: %w", err)
}
if vecStornirano {
return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran")
}
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati delove: %w", err)
}
type povrat struct {
artikalID int64
kolicina int
}
var delovi []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ServisRepo.Storno: scan dela: %w", err)
}
delovi = append(delovi, p)
}
redovi.Close()
for _, p := range delovi {
var stanjePre int
var tip string
err := tx.QueryRowContext(ctx,
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
).Scan(&stanjePre, &tip)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati stanje: %w", err)
}
if !(tip == model.TipProizvod || tip == "") {
continue
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno servisnog naloga")
if err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: magacin: %w", err)
}
}
if _, err := tx.ExecContext(ctx,
"UPDATE servisni_nalozi SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
nullString(razlog), id,
); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: update: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisRepo.Storno: commit: %w", err)
}
return nil
}
// SacuvajNaplatu beleži način plaćanja i naplaćeni iznos pri preuzimanju uređaja. // SacuvajNaplatu beleži način plaćanja i naplaćeni iznos pri preuzimanju uređaja.
func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error { func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
@@ -485,13 +565,15 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
var nacinPlacanja sql.NullString var nacinPlacanja sql.NullString
var naplaceno sql.NullFloat64 var naplaceno sql.NullFloat64
var stornirano sql.NullInt64
var razlogStorniranja sql.NullString
args := []any{ args := []any{
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj, &n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum, &avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke, &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
&nacinPlacanja, &naplaceno, &nacinPlacanja, &naplaceno, &stornirano, &razlogStorniranja,
} }
if klijentNaziv != nil { if klijentNaziv != nil {
@@ -565,6 +647,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
if naplaceno.Valid { if naplaceno.Valid {
n.Naplaceno = naplaceno.Float64 n.Naplaceno = naplaceno.Float64
} }
n.Stornirano = stornirano.Int64 != 0
n.RazlogStorniranja = razlogStorniranja.String
return nil return nil
} }
+246 -27
View File
@@ -85,6 +85,7 @@ type PodaciDetaljiNaloga struct {
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
RokPodizanja *time.Time // DatumZavrsetka + 30 dana; nil dok nije završeno RokPodizanja *time.Time // DatumZavrsetka + 30 dana; nil dok nije završeno
FiskalGreska bool // true: fiskalizacija aktivna, status Preuzeto, ali nema fiskalnog računa FiskalGreska bool // true: fiskalizacija aktivna, status Preuzeto, ali nema fiskalnog računa
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
} }
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa // Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -505,7 +506,9 @@ func (h *Handler) SacuvajIzmenaNaloga(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)
} }
// ObrisiNalog prima POST zahtev i briše servisni nalog po ID-u // ObrisiNalog prima POST zahtev i briše servisni nalog po ID-u. Odbija brisanje
// ako nalog već ima finansijske zapise (fiskalni račun, KIR ili KPO) — brisanje bi
// ostavilo te zapise sa mrtvim izvor_id; za takve naloge koristi se Storno.
func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) { func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.obrisi") k, ok := h.zahtevajDozvolu(w, r, "servis.obrisi")
if !ok { if !ok {
@@ -517,6 +520,17 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) {
return return
} }
if imaFinansijskeZapise, err := h.servisImaFinansijskeZapise(r.Context(), id); err != nil {
slog.Error("provera finansijskih zapisa nije uspela", "id", id, "error", err)
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
return
} else if imaFinansijskeZapise {
middleware.SetFlash(w, r, h.DB, "greska",
"Nalog ima fiskalni račun i/ili KIR/KPO upis — ne može se obrisati. Koristite Storno.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
if err := h.ServisRepo.Obrisi(r.Context(), id, &k.ID); err != nil { if err := h.ServisRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
slog.Error("greška pri brisanju naloga", "id", id, "error", err) slog.Error("greška pri brisanju naloga", "id", id, "error", err)
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError) http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
@@ -526,6 +540,114 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis?obrisan=1", http.StatusSeeOther) http.Redirect(w, r, "/servis?obrisan=1", http.StatusSeeOther)
} }
// servisImaFinansijskeZapise proverava da li servisni nalog već ima fiskalni
// račun ili KIR/KPO upis — takav nalog se ne sme fizički obrisati.
func (h *Handler) servisImaFinansijskeZapise(ctx context.Context, id int64) (bool, error) {
if fr, _ := h.FiskalRepo.DohvatiPoServisu(ctx, id); fr != nil {
return true, nil
}
if postoji, err := h.PdvKirRepo.PostojiZaIzvor(ctx, "servis", id); err != nil {
return false, err
} else if postoji {
return true, nil
}
if postoji, err := h.KpoRepo.PostojiZaIzvor(ctx, "servis", id); err != nil {
return false, err
} else if postoji {
return true, nil
}
return false, nil
}
// StornoNaloga stornira servisni nalog: vraća ugrađene delove na stanje, dograđuje
// storno stavke u KIR/KPO (original se ne briše — v. model.KirStorno/KpoStorno) i
// šalje fiskalni refund (best-effort). Koristi se umesto brisanja kad nalog već
// ima finansijske zapise.
func (h *Handler) StornoNaloga(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.storno")
if !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
razlog := strings.TrimSpace(r.FormValue("razlog"))
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
ctx := r.Context()
// refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG)
var originalniFiskal *model.FiskalniRacun
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
if fr, _ := h.FiskalRepo.DohvatiPoServisu(ctx, id); fr != nil && !fr.Storniran {
originalniFiskal = fr
imaIdentKupca := false
if nalog, e := h.ServisRepo.DohvatiID(ctx, id); e == nil && nalog.KlijentID != nil {
if kk, e2 := h.KlijentiRepo.DohvatiID(ctx, *nalog.KlijentID); e2 == nil {
imaIdentKupca = kk.PIB != "" || kk.JMBG != ""
}
}
if !imaIdentKupca && poreskiBrojKupca == "" {
middleware.SetFlash(w, r, h.DB, "greska", errPotrebnaIdentKupca.Error())
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
}
}
if err := h.ServisRepo.Storno(ctx, id, razlog, &k.ID); err != nil {
slog.Error("greška pri storniranju servisnog naloga", "id", id, "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri storniranju. Možda je nalog već storniran.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
if k := middleware.KorisnikIzKonteksta(ctx); k != nil {
kid := k.ID
_ = h.ServisniLogRepo.Kreiraj(ctx, id, "storno", &kid)
}
// KIR — original se ne briše, dograđuje se storno stavka
if zapisi, e := h.PdvKirRepo.DohvatiPoIzvoru(ctx, "servis", id); e != nil {
slog.Error("čitanje vezanog KIR zapisa nije uspelo", "servis_id", id, "error", e)
} else {
for _, z := range zapisi {
storno := model.KirStorno(z, razlog, time.Now())
if _, e := h.PdvKirRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KIR zapisa nije uspeo", "servis_id", id, "error", e)
}
}
}
// KPO — original se ne briše, dograđuje se storno stavka
if h.modulUkljucen(ctx, "kpo") {
if zapisi, e := h.KpoRepo.DohvatiPoIzvoru(ctx, "servis", id); e != nil {
slog.Error("čitanje vezanog KPO zapisa nije uspelo", "servis_id", id, "error", e)
} else {
for _, z := range zapisi {
storno := model.KpoStorno(z, razlog, time.Now())
if _, e := h.KpoRepo.Kreiraj(ctx, &storno); e != nil {
slog.Error("upis storno KPO zapisa nije uspeo", "servis_id", id, "error", e)
}
}
}
}
// fiskalni refund — best-effort
if originalniFiskal != nil {
if fk := h.fiskalKlijent(); fk != nil {
h.refundujServis(ctx, id, fk, originalniFiskal.ID, originalniFiskal.PfrBroj, poreskiBrojKupca)
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima // DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima
func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id")) id, err := parseID(chi.URLParam(r, "id"))
@@ -601,10 +723,12 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
bezGarancije := nalog.GarancijaDana == nil || *nalog.GarancijaDana <= 0 bezGarancije := nalog.GarancijaDana == nil || *nalog.GarancijaDana <= 0
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 {
klijentNaziv = klijent.PunoIme() klijentNaziv = klijent.PunoIme()
imaIdentKupca = klijent.PIB != "" || klijent.JMBG != ""
} }
} }
@@ -762,6 +886,9 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
podaci.FiskalniRacun == nil { podaci.FiskalniRacun == nil {
podaci.FiskalGreska = true podaci.FiskalGreska = true
} }
// storno zahteva PIB/JMBG kupca za refundaciju ako klijent nema upisan poreski broj
podaci.PotrebnaIdentKupca = h.modulUkljucen(r.Context(), config.ModulFiskalizacija) &&
!nalog.Stornirano && podaci.FiskalniRacun != nil && !podaci.FiskalniRacun.Storniran && !imaIdentKupca
h.renderujTemplate(w, "servis_detalji", podaci) h.renderujTemplate(w, "servis_detalji", podaci)
} }
@@ -2286,32 +2413,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
return return
} }
// napravi stavke za fiskalni račun items := stavkeFiskalnogServisa(radovi, delovi)
items := make([]fiskal.InvoiceItem, 0)
for _, r := range radovi {
if r.Predlozeno {
continue
}
items = append(items, fiskal.InvoiceItem{
Name: r.Naziv,
Labels: []string{fiskal.OznakaPDV(r.PdvStopa)},
TotalAmount: fiskal.BrutoCena(r.Ukupno(), r.PdvStopa),
UnitPrice: fiskal.BrutoCena(r.CenaKomada, r.PdvStopa),
Quantity: r.Kolicina,
})
}
for _, d := range delovi {
if d.Predlozeno {
continue
}
items = append(items, fiskal.InvoiceItem{
Name: d.ArtikalNaziv,
Labels: []string{fiskal.OznakaPDV(d.PdvStopa)},
TotalAmount: fiskal.BrutoCena(d.Ukupno(), d.PdvStopa),
UnitPrice: fiskal.BrutoCena(d.CenaKomada, d.PdvStopa),
Quantity: float64(d.Kolicina),
})
}
if len(items) == 0 { if len(items) == 0 {
slog.Warn("fiskalizujServis: nema stavki za fiskalni račun, zahtev odbačen", "id", servisID) slog.Warn("fiskalizujServis: nema stavki za fiskalni račun, zahtev odbačen", "id", servisID)
return return
@@ -2373,6 +2475,123 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
} }
} }
// stavkeFiskalnogServisa gradi stavke fiskalnog računa od radova i ugrađenih
// delova servisnog naloga — koristi se i za Sale (fiskalizujServis) i za
// Refund (refundujServis), predloženi (neprihvaćeni) redovi se ne naplaćuju.
func stavkeFiskalnogServisa(radovi []model.ServisniRad, delovi []model.ServisniDeoSaArtiklom) []fiskal.InvoiceItem {
items := make([]fiskal.InvoiceItem, 0)
for _, r := range radovi {
if r.Predlozeno {
continue
}
items = append(items, fiskal.InvoiceItem{
Name: r.Naziv,
Labels: []string{fiskal.OznakaPDV(r.PdvStopa)},
TotalAmount: fiskal.BrutoCena(r.Ukupno(), r.PdvStopa),
UnitPrice: fiskal.BrutoCena(r.CenaKomada, r.PdvStopa),
Quantity: r.Kolicina,
})
}
for _, d := range delovi {
if d.Predlozeno {
continue
}
items = append(items, fiskal.InvoiceItem{
Name: d.ArtikalNaziv,
Labels: []string{fiskal.OznakaPDV(d.PdvStopa)},
TotalAmount: fiskal.BrutoCena(d.Ukupno(), d.PdvStopa),
UnitPrice: fiskal.BrutoCena(d.CenaKomada, d.PdvStopa),
Quantity: float64(d.Kolicina),
})
}
return items
}
// refundujServis šalje fiskalni Refund zahtev za storniran servisni nalog —
// best-effort, greška se samo loguje (storno u bazi ostaje validan i bez uspešnog
// refunda; PraznineKnjigovodstva prati takve slučajeve preko stornoBezRefunda).
func (h *Handler) refundujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, originalniFiskalID int64, referentBroj, poreskiBrojKupca string) {
nalog, err := h.ServisRepo.DohvatiID(ctx, servisID)
if err != nil {
slog.Error("refundujServis: nije pronađen nalog", "id", servisID, "error", err)
return
}
radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, servisID)
if err != nil {
slog.Error("refundujServis: greška pri dohvatanju radova", "id", servisID, "error", err)
return
}
delovi, err := h.deloviSaPotrazivanima(ctx, servisID)
if err != nil {
slog.Error("refundujServis: greška pri dohvatanju delova", "id", servisID, "error", err)
return
}
items := stavkeFiskalnogServisa(radovi, delovi)
if len(items) == 0 {
return
}
pib, jmbg := "", ""
if nalog.KlijentID != nil {
if k, e := h.KlijentiRepo.DohvatiID(ctx, *nalog.KlijentID); e == nil {
pib, jmbg = k.PIB, k.JMBG
}
}
if pib == "" && jmbg == "" {
pib, jmbg = klasifikujPoreskiBroj(poreskiBrojKupca)
}
zahtev := fiskal.InvoiceRequest{
InvoiceRequest: fiskal.InvoiceRequestBody{
InvoiceType: "Normal",
TransactionType: "Refund",
ReferentDocumentNumber: referentBroj,
Payment: []fiskal.PaymentItem{
{Amount: nalog.Naplaceno, PaymentType: fiskal.TipPlacanja(nalog.NacinPlacanja)},
},
Items: items,
Cashier: h.imeKasira(ctx),
},
}
if pib != "" {
zahtev.InvoiceRequest.BuyerID = "10:" + pib
} else if jmbg != "" {
zahtev.InvoiceRequest.BuyerID = "01:" + jmbg
}
odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev)
if errFisk != nil {
slog.Error("fiskalni refund servisa nije uspeo", "servis_id", servisID, "error", errFisk)
return
}
poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
siroviJSON, _ := json.Marshal(odgovor)
fr := &model.FiskalniRacun{
ServisID: servisID,
TipRacuna: "Normal",
TipTransakcije: "Refund",
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 _, e := h.FiskalRepo.Kreiraj(ctx, fr); e != nil {
slog.Error("čuvanje fiskalnog refunda servisa nije uspelo", "servis_id", servisID, "error", e)
return
}
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, originalniFiskalID)
}
// RetryFiskalizacija pokušava ponovo da izda fiskalni račun za nalog čija je // RetryFiskalizacija pokušava ponovo da izda fiskalni račun za nalog čija je
// prethodna fiskalizacija pala. Bezbedno za ponovni poziv — proverava da račun // prethodna fiskalizacija pala. Bezbedno za ponovni poziv — proverava da račun
// još uvek ne postoji pre slanja zahteva ka ESIR/PFR. // još uvek ne postoji pre slanja zahteva ka ESIR/PFR.
+15 -9
View File
@@ -118,7 +118,7 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
} }
naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi)) naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi))
for _, sn := range servisNalozi { for _, sn := range servisNalozi {
if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 { if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 && !sn.Stornirano {
naplaceni = append(naplaceni, sn) naplaceni = append(naplaceni, sn)
} else { } else {
nenaplaceniBroj++ nenaplaceniBroj++
@@ -211,17 +211,23 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
return p, nil return p, nil
} }
// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni // stornoBezRefunda broji stornirane prodajne i servisne naloge čiji je originalni
// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali // fiskalni račun i dalje neoznačen kao storniran — znači da je storno u bazi
// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao). // prošao, ali fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv
// Servis nema storno funkciju — obuhvaćena je samo prodaja. // je pao).
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) { func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) {
var n int var n int
err := h.DB.QueryRowContext(ctx, ` err := h.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prodajni_nalozi pn SELECT
JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id (SELECT COUNT(*) FROM prodajni_nalozi pn
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0 JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id
WHERE pn.stornirano = 1 AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE pn.stornirano = 1)
+
(SELECT COUNT(*) FROM servisni_nalozi sn
JOIN fiskalni_racuni fr ON fr.servis_id = sn.id
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
WHERE sn.stornirano = 1)
`).Scan(&n) `).Scan(&n)
return n, err return n, err
} }
+2 -1
View File
@@ -24,6 +24,7 @@ var sveAkcije = []string{
"servis.dodaj", "servis.dodaj",
"servis.izmeni", "servis.izmeni",
"servis.obrisi", "servis.obrisi",
"servis.storno",
"prodaja.pregled", "prodaja.pregled",
"prodaja.dodaj", "prodaja.dodaj",
"prodaja.storno", "prodaja.storno",
@@ -77,7 +78,7 @@ func ImaDozvolu(uloga, akcija string) bool {
return true return true
// servis // servis
case "servis.pregled", "servis.dodaj", case "servis.pregled", "servis.dodaj",
"servis.izmeni", "servis.obrisi": "servis.izmeni", "servis.obrisi", "servis.storno":
return true return true
// prodaja // prodaja
case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno": case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno":
+2
View File
@@ -60,6 +60,8 @@ type ServisniNalog struct {
JavniToken string JavniToken string
NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...) NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...)
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
Stornirano bool
RazlogStorniranja string
} }
// ServisniLog je jedan zapis u istoriji događaja servisnog naloga. // ServisniLog je jedan zapis u istoriji događaja servisnog naloga.
+5
View File
@@ -0,0 +1,5 @@
-- Servisni nalog dobija storno (kao Prodaja i Nabavka) — do sada je jedini način
-- da se poništi naplaćen/preuzet nalog bio fizičko brisanje, koje ostavlja mrtve
-- KIR/KPO/fiskalne zapise iza sebe.
ALTER TABLE servisni_nalozi ADD COLUMN stornirano INTEGER NOT NULL DEFAULT 0;
ALTER TABLE servisni_nalozi ADD COLUMN razlog_storniranja TEXT;
@@ -45,6 +45,11 @@
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;"> <span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
{{.Nalog.BrojNaloga}} {{.Nalog.BrojNaloga}}
</span> </span>
{{if .Nalog.Stornirano}}
<span style="display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">
Stornirano
</span>
{{end}}
{{if .Nalog.PopravkaOdbijena}} {{if .Nalog.PopravkaOdbijena}}
<div style="padding:8px 14px;background:color-mix(in srgb, var(--crvena, #ef4444) 14%, transparent);border:1px solid color-mix(in srgb, var(--crvena, #ef4444) 35%, transparent);border-radius:8px;"> <div style="padding:8px 14px;background:color-mix(in srgb, var(--crvena, #ef4444) 14%, transparent);border:1px solid color-mix(in srgb, var(--crvena, #ef4444) 35%, transparent);border-radius:8px;">
<span style="font-size:15px;font-weight:700;color:var(--crvena, #ef4444);">✕ Klijent je odbio popravku</span> <span style="font-size:15px;font-weight:700;color:var(--crvena, #ef4444);">✕ Klijent je odbio popravku</span>
@@ -1017,6 +1022,31 @@
</div> </div>
{{end}} {{end}}
<!-- storno -->
{{if and (index .Dozvole "servis.storno") (eq .Nalog.Status "Preuzeto") (not .Nalog.Stornirano)}}
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Storniranje naloga</div>
<div class="pomocni-tekst">
Storno vraća ugrađene delove na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
</div>
</div>
<form method="POST" action="/servis/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
</button>
</form>
</div>
</div>
{{end}}
<!-- zona za brisanje --> <!-- zona za brisanje -->
{{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}} {{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}}
{{if eq .Nalog.Status "Primljeno"}} {{if eq .Nalog.Status "Primljeno"}}