Files
GoNtech/internal/handler/kpo.go
T
Dasko 3a0f98622b Knjigovodstvo i fiskalizacija: dashboard upozorenje za praznine, KPO dozvole, pojednostavljen storno, ispravke verifikacije i kasira
- Dashboard kartica prikazuje naloge bez KIR/KPO/fiskalnog upisa i sumnjive duplikate (double-submit), sa zaštitom od automatskog dupliranja prihoda pri bulk dopuni
- Registrovane nedostajuće KPO dozvole (kpo.pregled/dodaj/obrisi) — modul je bio potpuno nedostupan
- Uklonjen suvišan/nefunkcionalan 'Ukloni nalog' (duplirao Storno bez stvarne razlike); Storno u listi prodaje sad vodi direktno na formu
- Kasir na fiskalnom računu koristi ime prijavljenog korisnika umesto fiksnog podešavanja
- Fiskalni računi za nalog uvek prikazuju najnoviji zapis (original vs. refund posle storna)
- Verifikaciona stranica (/v/) jasno označena kao lokalna test simulacija, ne prava provera kod PURS-a; dodat prikaz kusura
- Mock brojač fiskalnih računa (kartica emulator) sad preživljava restart servera
2026-07-01 23:01:26 +02:00

245 lines
6.7 KiB
Go

package handler
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciKpo su podaci za pregled KPO knjige
type PodaciKpo struct {
model.PodaciStranice
Zapisi []model.KpoZapis
Sume model.KpoSume
Od string
Do string
}
// PodaciKpoForma su podaci za formu ručnog unosa u KPO
type PodaciKpoForma struct {
model.PodaciStranice
Danas string
}
// Kpo renderuje pregled knjige o ostvarenom prometu sa sumama za period.
func (h *Handler) Kpo(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kpo.pregled"); !ok {
return
}
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
odStr := r.URL.Query().Get("od")
doStr := r.URL.Query().Get("do")
if odStr == "" || doStr == "" {
sada := time.Now()
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
poslednji := prvi.AddDate(0, 1, -1)
if odStr == "" {
odStr = prvi.Format("2006-01-02")
}
if doStr == "" {
doStr = poslednji.Format("2006-01-02")
}
}
zapisi, err := h.KpoRepo.Lista(r.Context(), parsiraDatumOpcionalno(odStr), parsiraDatumOpcionalno(doStr))
if err != nil {
http.Error(w, "Greška pri učitavanju KPO knjige", http.StatusInternalServerError)
return
}
var sume model.KpoSume
for _, z := range zapisi {
sume.Prihod += z.Prihod
sume.Broj++
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "kpo"
ps.NaslovStranice = "KPO — knjiga o ostvarenom prometu"
h.renderujTemplate(w, "kpo", PodaciKpo{
PodaciStranice: ps,
Zapisi: zapisi,
Sume: sume,
Od: odStr,
Do: doStr,
})
}
// NoviKpoUnos prikazuje formu za ručni unos u KPO.
func (h *Handler) NoviKpoUnos(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kpo.dodaj"); !ok {
return
}
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "kpo"
ps.NaslovStranice = "Novi KPO unos"
h.renderujTemplate(w, "kpo_forma", PodaciKpoForma{
PodaciStranice: ps,
Danas: time.Now().Format("2006-01-02"),
})
}
// SacuvajKpoUnos prima POST i upisuje ručni KPO zapis.
func (h *Handler) SacuvajKpoUnos(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kpo.dodaj"); !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
datum, err := time.Parse("2006-01-02", strings.TrimSpace(r.FormValue("datum_prometa")))
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Datum prometa je obavezan.")
http.Redirect(w, r, "/kpo/nova", http.StatusSeeOther)
return
}
brojDok := strings.TrimSpace(r.FormValue("broj_dokumenta"))
if brojDok == "" {
middleware.SetFlash(w, r, h.DB, "greska", "Broj dokumenta je obavezan.")
http.Redirect(w, r, "/kpo/nova", http.StatusSeeOther)
return
}
z := model.KpoZapis{
DatumPrometa: datum,
BrojDokumenta: brojDok,
Opis: strings.TrimSpace(r.FormValue("opis")),
Prihod: parsiraIznos(r.FormValue("prihod")),
NacinPlacanja: strings.TrimSpace(r.FormValue("nacin_placanja")),
Napomena: strings.TrimSpace(r.FormValue("napomena")),
Izvor: "rucno",
}
if _, err := h.KpoRepo.Kreiraj(r.Context(), &z); err != nil {
http.Error(w, "Greška pri čuvanju unosa", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/kpo?sacuvano=1", http.StatusSeeOther)
}
// ObrisiKpoUnos briše KPO zapis.
func (h *Handler) ObrisiKpoUnos(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kpo.obrisi"); !ok {
return
}
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID", http.StatusBadRequest)
return
}
if err := h.KpoRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju unosa", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/kpo?obrisan=1", http.StatusSeeOther)
}
// KpoBackfill kreira KPO unose za sve prodaje i naplaćene servisne naloge koji ih nemaju.
func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "kpo.dodaj"); !ok {
return
}
ctx := r.Context()
var kreirano, preskoceno, sumnjivo int
// prodajni nalozi
nalozi, prodajaPostoji, prodajaKandidati, err := h.kpoKandidatiProdaje(ctx)
if err != nil {
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
return
}
prodajaDuplikati := sumnjiviDuplikati(prodajaKandidati)
for _, nd := range nalozi {
if nd.Stornirano {
preskoceno++
continue
}
if prodajaPostoji[nd.ID] {
preskoceno++
continue
}
if prodajaDuplikati[nd.ID] {
sumnjivo++
continue
}
z := model.KpoZapis{
DatumPrometa: nd.Datum,
BrojDokumenta: nd.BrojNaloga,
Opis: fmt.Sprintf("Prodaja %s", nd.BrojNaloga),
Prihod: nd.Ukupno,
NacinPlacanja: nd.NacinPlacanja,
Izvor: "prodaja",
IzvorID: &nd.ID,
}
if _, e := h.KpoRepo.Kreiraj(ctx, &z); e != nil {
slog.Error("kpo backfill: prodaja", "id", nd.ID, "error", e)
continue
}
kreirano++
}
// naplaćeni servisni nalozi
naplaceni, servisPostoji, servisKandidati, nenaplaceniBroj, err := h.kpoKandidatiServisa(ctx)
if err != nil {
slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err)
} else {
preskoceno += nenaplaceniBroj
servisDuplikati := sumnjiviDuplikati(servisKandidati)
for _, sn := range naplaceni {
if servisPostoji[sn.ID] {
preskoceno++
continue
}
if servisDuplikati[sn.ID] {
sumnjivo++
continue
}
datum := sn.DatumPrijema
if sn.DatumZavrsetka != nil {
datum = *sn.DatumZavrsetka
}
z := model.KpoZapis{
DatumPrometa: datum,
BrojDokumenta: sn.BrojNaloga,
Opis: fmt.Sprintf("Servis %s", sn.BrojNaloga),
Prihod: sn.Naplaceno,
NacinPlacanja: sn.NacinPlacanja,
Izvor: "servis",
IzvorID: &sn.ID,
}
if _, e := h.KpoRepo.Kreiraj(ctx, &z); e != nil {
slog.Error("kpo backfill: servis", "id", sn.ID, "error", e)
continue
}
kreirano++
}
}
poruka := fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno)
if sumnjivo > 0 {
poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo)
}
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
http.Redirect(w, r, "/kpo", http.StatusSeeOther)
}