From 4a6a623549ccba88e067522fa9ba7fe76846d894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 26 Jun 2026 00:54:13 +0200 Subject: [PATCH] =?UTF-8?q?Servis:=20fiskalizacija=20pri=20preuzimanju=20?= =?UTF-8?q?=E2=80=94=20na=C4=8Din=20pla=C4=87anja=20+=20iznos=20+=20PFR=20?= =?UTF-8?q?ra=C4=8Dun=20Migracija=20082:=20nacin=5Fplacanja=20i=20naplacen?= =?UTF-8?q?o=20na=20servisni=5Fnalog=20Model,=20repo,=20handler=20?= =?UTF-8?q?=E2=80=94=20pun=20lanac=20za=20=C4=8Duvanje=20naplate=20fiskali?= =?UTF-8?q?zujServis=20helper=20=E2=80=94=20=C5=A1alje=20radove+delove=20k?= =?UTF-8?q?a=20PFR=20serveru=20Modal=20za=20Preuzeto=20dobio=20izbor=20na?= =?UTF-8?q?=C4=8Dina=20pla=C4=87anja=20(Gotovina/Kartica/Virman)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/repository.go | 1 + internal/db/sqlite/servis.go | 26 +++- internal/handler/servis.go | 149 ++++++++++++++++++++- internal/model/servis.go | 2 + migrations/082_servis_naplata.sql | 4 + web/templates/stranice/servis_detalji.html | 9 ++ 6 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 migrations/082_servis_naplata.sql diff --git a/internal/db/repository.go b/internal/db/repository.go index c55d5fb..515f56e 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -206,6 +206,7 @@ type ServisRepository interface { ObrisiOdlukuKlijenta(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error SledeciBroj(ctx context.Context) (string, error) + SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error } // ProdajaRepository definiše operacije nad prodajnim nalozima diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 34fcccc..0756850 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.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, 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 + ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno 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 + ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno FROM servisni_nalozi WHERE javni_token = ?`, token) var n model.ServisniNalog @@ -451,6 +451,18 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er 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, + "UPDATE servisni_nalozi SET nacin_placanja = ?, naplaceno = ? WHERE id = ?", + nacinPlacanja, naplaceno, id, + ) + if err != nil { + return fmt.Errorf("ntech: ServisRepo.SacuvajNaplatu: %w", err) + } + return nil +} + // scanNalog čita redove iz upita u ServisniNalog struct — // klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string, imaPredlog *bool) error { @@ -461,11 +473,15 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st var garancijaDana sql.NullInt64 var popravkaOdbijena sql.NullInt64 + var nacinPlacanja sql.NullString + var naplaceno sql.NullFloat64 + 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, } if klijentNaziv != nil { @@ -535,6 +551,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st v := datumOdluke.Time n.DatumOdluke = &v } + n.NacinPlacanja = nacinPlacanja.String + if naplaceno.Valid { + n.Naplaceno = naplaceno.Float64 + } return nil } diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 8994cc7..d75bc0d 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "image/png" "log/slog" @@ -12,8 +13,10 @@ import ( "strings" "time" + "ntech/internal/config" appdbPkg "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/fiskal" "ntech/internal/middleware" "ntech/internal/model" @@ -1935,10 +1938,154 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { http.Error(w, "Greška pri promeni statusa", http.StatusInternalServerError) return } + + // pri prelasku u Preuzeto — sačuvaj naplatu i fiskalizuj + if noviStatus == model.StatusPreuzeto { + nacin := strings.TrimSpace(r.FormValue("nacin_placanja")) + if nacin == "" { + nacin = "Gotovina" + } + iznosStr := strings.TrimSpace(r.FormValue("naplaceno")) + iznos := 0.0 + if iznosStr != "" { + if v, e := strconv.ParseFloat(iznosStr, 64); e == nil && v > 0 { + iznos = v + } + } + if iznos == 0 { + // izračunaj iz za naplatu ako nije prosleđen + nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id) + if nalog != nil { + iznos = nalog.CenaDijagnostike + if !nalog.PopravkaOdbijena && nalog.CenaKonacna != nil { + delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id) + ukupnoDelovi := 0.0 + for _, d := range delovi { + ukupnoDelovi += d.Ukupno() + } + iznos = *nalog.CenaKonacna + ukupnoDelovi + } + if nalog.Avans != nil { + iznos -= *nalog.Avans + } + if iznos < 0 { + iznos = 0 + } + } + } + _ = h.ServisRepo.SacuvajNaplatu(r.Context(), id, nacin, iznos) + + // fiskalizacija servisa — ako je modul uključen + if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { + klijent := h.fiskalKlijent() + if klijent != nil && iznos > 0 { + h.fiskalizujServis(r.Context(), id, klijent, nacin, iznos) + } + } + } + http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther) } -// AzurirajGaranciju ažurira datum garancije na servisnom nalogu +// fiskalizujServis šalje fiskalni zahtev za servisni nalog pri preuzimanju. +// Best-effort: greške se loguju, ne zaustavljaju promenu statusa. +func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent *fiskal.Klijent, nacinPlacanja string, iznos float64) { + nalog, err := h.ServisRepo.DohvatiID(ctx, servisID) + if err != nil { + slog.Error("fiskalizujServis: nije pronađen nalog", "id", servisID, "error", err) + return + } + + radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, servisID) + delovi, _ := h.deloviSaPotrazivanima(ctx, servisID) + + // napravi stavke za fiskalni račun + items := make([]fiskal.InvoiceItem, 0) + for _, r := range radovi { + if r.Predlozeno { + continue + } + stopa := 20.0 // podrazumevano — servisne usluge su 20% PDV + items = append(items, fiskal.InvoiceItem{ + Name: r.Naziv, + Labels: []string{fiskal.OznakaPDV(stopa)}, + TotalAmount: fiskal.BrutoCena(r.Ukupno(), stopa), + UnitPrice: fiskal.BrutoCena(r.CenaKomada, stopa), + Quantity: r.Kolicina, + }) + } + for _, d := range delovi { + if d.Predlozeno { + continue + } + stopa := 20.0 + items = append(items, fiskal.InvoiceItem{ + Name: d.ArtikalNaziv, + Labels: []string{fiskal.OznakaPDV(stopa)}, + TotalAmount: fiskal.BrutoCena(d.Ukupno(), stopa), + UnitPrice: fiskal.BrutoCena(d.CenaKomada, stopa), + Quantity: float64(d.Kolicina), + }) + } + + pib := "" + if nalog.KlijentID != nil { + if k, e := h.KlijentiRepo.DohvatiID(ctx, *nalog.KlijentID); e == nil { + pib = k.PIB + } + } + + kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir") + if kasir == "" { + kasir = "NTech" + } + + zahtev := fiskal.InvoiceRequest{ + InvoiceRequest: fiskal.InvoiceRequestBody{ + InvoiceType: "Normal", + TransactionType: "Sale", + Payment: []fiskal.PaymentItem{ + {Amount: iznos, PaymentType: fiskal.TipPlacanja(nacinPlacanja)}, + }, + Items: items, + Cashier: kasir, + }, + } + if pib != "" { + zahtev.InvoiceRequest.BuyerID = "10:" + pib + } + + odgovor, errFisk := klijent.IzdajRacun(ctx, zahtev) + if errFisk != nil { + slog.Error("fiskalizacija servisa nije uspela", "servis_id", servisID, "error", errFisk) + return + } + + // sačuvaj fiskalni račun (kao i za prodaju, ali bez veze prodaja_id — koristimo negativan ID) + poreskeJSON, _ := json.Marshal(odgovor.TaxItems) + siroviJSON, _ := json.Marshal(odgovor) + fr := &model.FiskalniRacun{ + ProdajaID: -servisID, // negativan ID za servis (razlikuje se od prodaje) + TipRacuna: "Normal", + 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 fiskalnog računa za servis", "servis_id", servisID, "error", err) + } +} func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok { return diff --git a/internal/model/servis.go b/internal/model/servis.go index 76c61dc..0405e23 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -58,6 +58,8 @@ type ServisniNalog struct { OdlukaKlijenta string // '' / 'prihvaceno' / 'odbijeno' DatumOdluke *time.Time // kada je klijent doneo odluku JavniToken string + NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...) + Naplaceno float64 // iznos koji je naplaćen pri preuzimanju } // ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog diff --git a/migrations/082_servis_naplata.sql b/migrations/082_servis_naplata.sql new file mode 100644 index 0000000..9dc5cbc --- /dev/null +++ b/migrations/082_servis_naplata.sql @@ -0,0 +1,4 @@ +-- Servis: način plaćanja i naplaćeni iznos pri preuzimanju uređaja. +-- Koristi se za evidenciju i fiskalizaciju pri prelasku u status "Preuzeto". +ALTER TABLE servisni_nalozi ADD COLUMN nacin_placanja TEXT DEFAULT ''; +ALTER TABLE servisni_nalozi ADD COLUMN naplaceno REAL DEFAULT 0; diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index 9899495..3671f61 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -150,6 +150,15 @@
+ +
+ + +