diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 44b3fb0..237d65d 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -380,6 +380,7 @@ func main() { r.Get("/servis/izmeni/{id}", h.IzmeniNalog) 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.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}/radni-nalog", h.StampaRadnogNaloga) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice) diff --git a/internal/db/repository.go b/internal/db/repository.go index 1e63310..b78947b 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -217,6 +217,9 @@ type ServisRepository interface { SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error ObrisiOdlukuKlijenta(ctx context.Context, id 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) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error } diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index b2b980f..47255f4 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -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.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.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, (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) @@ -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, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, 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) 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, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, 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) var n model.ServisniNalog @@ -461,6 +461,86 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er 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. func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error { _, 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 naplaceno sql.NullFloat64 + var stornirano sql.NullInt64 + var razlogStorniranja sql.NullString args := []any{ &n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj, &n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum, &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke, - &nacinPlacanja, &naplaceno, + &nacinPlacanja, &naplaceno, &stornirano, &razlogStorniranja, } if klijentNaziv != nil { @@ -565,6 +647,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st if naplaceno.Valid { n.Naplaceno = naplaceno.Float64 } + n.Stornirano = stornirano.Int64 != 0 + n.RazlogStorniranja = razlogStorniranja.String return nil } diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 5a1eefa..a9f04ed 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -85,6 +85,7 @@ type PodaciDetaljiNaloga struct { FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano RokPodizanja *time.Time // DatumZavrsetka + 30 dana; nil dok nije završeno 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 @@ -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) } -// 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) { k, ok := h.zahtevajDozvolu(w, r, "servis.obrisi") if !ok { @@ -517,6 +520,17 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) { 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 { slog.Error("greška pri brisanju naloga", "id", id, "error", err) 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) } +// 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 func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { 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 klijentNaziv := "" + imaIdentKupca := false if nalog.KlijentID != nil { klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID) if err == nil { 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.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) } @@ -2286,32 +2413,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent return } - // napravi stavke za fiskalni račun - 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), - }) - } + items := stavkeFiskalnogServisa(radovi, delovi) if len(items) == 0 { slog.Warn("fiskalizujServis: nema stavki za fiskalni račun, zahtev odbačen", "id", servisID) 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 // prethodna fiskalizacija pala. Bezbedno za ponovni poziv — proverava da račun // još uvek ne postoji pre slanja zahteva ka ESIR/PFR. diff --git a/internal/handler/uskladjivanje.go b/internal/handler/uskladjivanje.go index d222934..c418f9d 100644 --- a/internal/handler/uskladjivanje.go +++ b/internal/handler/uskladjivanje.go @@ -118,7 +118,7 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se } naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(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) } else { nenaplaceniBroj++ @@ -211,17 +211,23 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji return p, nil } -// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni -// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali -// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao). -// Servis nema storno funkciju — obuhvaćena je samo prodaja. +// stornoBezRefunda broji stornirane prodajne i servisne naloge čiji je originalni +// fiskalni račun i dalje neoznačen kao storniran — znači da je storno u bazi +// prošao, ali fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv +// je pao). func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) { var n int err := h.DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM prodajni_nalozi pn - JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id - AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0 - WHERE pn.stornirano = 1 + SELECT + (SELECT COUNT(*) FROM prodajni_nalozi pn + JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id + 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) return n, err } diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index e425ce5..1a0c711 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -24,6 +24,7 @@ var sveAkcije = []string{ "servis.dodaj", "servis.izmeni", "servis.obrisi", + "servis.storno", "prodaja.pregled", "prodaja.dodaj", "prodaja.storno", @@ -77,7 +78,7 @@ func ImaDozvolu(uloga, akcija string) bool { return true // servis case "servis.pregled", "servis.dodaj", - "servis.izmeni", "servis.obrisi": + "servis.izmeni", "servis.obrisi", "servis.storno": return true // prodaja case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno": diff --git a/internal/model/servis.go b/internal/model/servis.go index e9e78f8..c043eaa 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -60,6 +60,8 @@ type ServisniNalog struct { JavniToken string NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...) Naplaceno float64 // iznos koji je naplaćen pri preuzimanju + Stornirano bool + RazlogStorniranja string } // ServisniLog je jedan zapis u istoriji događaja servisnog naloga. diff --git a/migrations/100_servis_storno.sql b/migrations/100_servis_storno.sql new file mode 100644 index 0000000..79bf7a0 --- /dev/null +++ b/migrations/100_servis_storno.sql @@ -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; diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index 994b542..bd412ea 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -45,6 +45,11 @@ {{.Nalog.BrojNaloga}} + {{if .Nalog.Stornirano}} + + Stornirano + + {{end}} {{if .Nalog.PopravkaOdbijena}}
✕ Klijent je odbio popravku @@ -1017,6 +1022,31 @@
{{end}} + + {{if and (index .Dozvole "servis.storno") (eq .Nalog.Status "Preuzeto") (not .Nalog.Stornirano)}} +
+
+
+
Storniranje naloga
+
+ Storno vraća ugrađene delove na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran. +
+
+
+ + + {{if .PotrebnaIdentKupca}} + + {{end}} + +
+
+
+ {{end}} + {{/* 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"}}