KPO: knjiga o ostvarenom prometu — lista, ručni unos, backfill, auto-upis iz prodaje i servisa

This commit is contained in:
2026-06-30 21:13:16 +02:00
parent 3166a93e1d
commit 432a559902
13 changed files with 664 additions and 2 deletions
+9
View File
@@ -385,3 +385,12 @@ type FiskalRepository interface {
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
OznačiKaoStorniran(ctx context.Context, id int64) error
}
// KpoRepository definiše operacije nad knjigom o ostvarenom prometu.
type KpoRepository interface {
Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis, error)
Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error)
Obrisi(ctx context.Context, id int64) error
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
}
+124
View File
@@ -0,0 +1,124 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"ntech/internal/model"
)
// KpoRepo je SQLite implementacija KpoRepository interfejsa.
type KpoRepo struct {
db *sql.DB
}
// NoviKpoRepo kreira novi KpoRepo.
func NoviKpoRepo(db *sql.DB) *KpoRepo {
return &KpoRepo{db: db}
}
// Lista vraća KPO unose u zadatom periodu (po datumu prometa); nulti datum = bez granice.
func (r *KpoRepo) Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis, error) {
q := `SELECT id, datum_prometa, redni_broj, broj_dokumenta, opis, prihod,
nacin_placanja, napomena, izvor, izvor_id, datum_unosa
FROM kpo_unosi WHERE 1=1`
var args []any
if !od.IsZero() {
q += " AND datum_prometa >= ?"
args = append(args, od.Format("2006-01-02"))
}
if !do.IsZero() {
q += " AND datum_prometa <= ?"
args = append(args, do.Format("2006-01-02"))
}
q += " ORDER BY datum_prometa, id"
redovi, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.KpoZapis
for redovi.Next() {
var z model.KpoZapis
var opis, nacinPlacanja, napomena sql.NullString
var redniBroj sql.NullInt64
var izvorID sql.NullInt64
err := redovi.Scan(
&z.ID, &z.DatumPrometa, &redniBroj, &z.BrojDokumenta, &opis, &z.Prihod,
&nacinPlacanja, &napomena, &z.Izvor, &izvorID, &z.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: KpoRepo.Lista: scan: %w", err)
}
if redniBroj.Valid {
n := int(redniBroj.Int64)
z.RedniBroj = &n
}
z.Opis = opis.String
z.NacinPlacanja = nacinPlacanja.String
z.Napomena = napomena.String
if izvorID.Valid {
z.IzvorID = &izvorID.Int64
}
rezultat = append(rezultat, z)
}
return rezultat, redovi.Err()
}
// Kreiraj upisuje novi KPO zapis i vraća njegov ID.
func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) {
rez, err := r.db.ExecContext(ctx, `
INSERT INTO kpo_unosi
(datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
z.DatumPrometa.Format("2006-01-02"),
z.BrojDokumenta,
nullString(z.Opis),
z.Prihod,
nullString(z.NacinPlacanja),
nullString(z.Napomena),
izvorIliRucno(z.Izvor),
izvorIDArg(z.IzvorID),
)
if err != nil {
return 0, fmt.Errorf("ntech: KpoRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: KpoRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Obrisi briše KPO zapis po ID-ju.
func (r *KpoRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: KpoRepo.Obrisi: %w", err)
}
return nil
}
// PostojiZaIzvor vraća true ako postoji bar jedan KPO zapis za dati izvor i izvorID.
func (r *KpoRepo) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error) {
var n int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID,
).Scan(&n)
if err != nil {
return false, fmt.Errorf("ntech: KpoRepo.PostojiZaIzvor: %w", err)
}
return n > 0, nil
}
// ObrisiPoIzvoru briše KPO zapise vezane za dati izvor (npr. pri stornu prodaje).
func (r *KpoRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
return fmt.Errorf("ntech: KpoRepo.ObrisiPoIzvoru: %w", err)
}
return nil
}
+3
View File
@@ -46,6 +46,7 @@ type Handler struct {
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
FiskalRepo db.FiskalRepository
KpoRepo db.KpoRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
@@ -115,6 +116,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
FiskalRepo: sqlite.NoviFiskalRepo(baza),
KpoRepo: sqlite.NoviKpoRepo(baza),
}
}
@@ -149,6 +151,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
h.FiskalRepo = sqlite.NoviFiskalRepo(novaDB)
h.KpoRepo = sqlite.NoviKpoRepo(novaDB)
}
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
+1
View File
@@ -32,6 +32,7 @@ var saSidebar = []string{
"pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar",
"pdv_kpr", "pdv_kpr_forma",
"pdv_obracun",
"kpo", "kpo_forma",
"nivelacije",
"podsetnici", "podsetnik_forma",
"profil_tema",
+235
View File
@@ -0,0 +1,235 @@
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 int
// prodajni nalozi
nalozi, err := h.ProdajaRepo.Lista(ctx, "")
if err != nil {
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
return
}
for _, nd := range nalozi {
if nd.Stornirano {
preskoceno++
continue
}
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
if postoji {
preskoceno++
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
servisNalozi, err := h.ServisRepo.Lista(ctx, "", "")
if err != nil {
slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err)
} else {
for _, sn := range servisNalozi {
if sn.Status != model.StatusPreuzeto || sn.Naplaceno == 0 {
preskoceno++
continue
}
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
if postoji {
preskoceno++
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++
}
}
middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno))
http.Redirect(w, r, "/kpo", http.StatusSeeOther)
}
+24
View File
@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
@@ -293,6 +294,22 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
}
}
// auto-KPO: svaki potvrđeni nalog ide u KPO ako je modul aktivan
if h.modulUkljucen(r.Context(), "kpo") {
kpoZ := model.KpoZapis{
DatumPrometa: nalog.Datum,
BrojDokumenta: nalog.BrojNaloga,
Opis: fmt.Sprintf("Prodaja %s", nalog.BrojNaloga),
Prihod: nalog.Ukupno,
NacinPlacanja: nalog.NacinPlacanja,
Izvor: "prodaja",
IzvorID: &id,
}
if _, e := h.KpoRepo.Kreiraj(r.Context(), &kpoZ); e != nil {
slog.Error("auto-upis u KPO nije uspeo", "prodaja_id", id, "error", e)
}
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
@@ -585,6 +602,13 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
}
}
// brisanje KPO zapisa na storno
if h.modulUkljucen(r.Context(), "kpo") {
if e := h.KpoRepo.ObrisiPoIzvoru(r.Context(), "prodaja", id); e != nil {
slog.Error("brisanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
}
}
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
+18
View File
@@ -2134,6 +2134,24 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
}
}
}
// auto-KPO za servisni nalog
if h.modulUkljucen(r.Context(), "kpo") && iznos > 0 {
nalogKpo, _ := h.ServisRepo.DohvatiID(r.Context(), id)
if nalogKpo != nil {
kpoZ := model.KpoZapis{
DatumPrometa: time.Now(),
BrojDokumenta: nalogKpo.BrojNaloga,
Opis: fmt.Sprintf("Servis %s", nalogKpo.BrojNaloga),
Prihod: iznos,
NacinPlacanja: nacin,
Izvor: "servis",
IzvorID: &id,
}
if _, e := h.KpoRepo.Kreiraj(r.Context(), &kpoZ); e != nil {
slog.Error("auto-upis u KPO za servis nije uspeo", "servis_id", id, "error", e)
}
}
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
+24
View File
@@ -0,0 +1,24 @@
package model
import "time"
// KpoZapis je jedan red u knjizi o ostvarenom prometu.
type KpoZapis struct {
ID int64
DatumPrometa time.Time
RedniBroj *int
BrojDokumenta string
Opis string
Prihod float64
NacinPlacanja string
Napomena string
Izvor string // "rucno" | "prodaja" | "servis"
IzvorID *int64
DatumUnosa time.Time
}
// KpoSume su zbrojeni prihodi za period.
type KpoSume struct {
Prihod float64
Broj int
}