feat(pdv): KIR/KPR evidencija — migracija, model i repozitorijum (Faza 2a)

Tabele pdv_kir i pdv_kpr (iznosi po vrsti stope), modeli PdvKir/PdvKpr,
repozitorijumi sa filterom perioda i integracioni test (datum round-trip,
nullable datum plaćanja).
This commit is contained in:
2026-06-13 21:22:48 +02:00
parent d06a353a52
commit 26c829fef3
6 changed files with 485 additions and 0 deletions
+16
View File
@@ -32,6 +32,22 @@ type PdvStopaRepository interface {
PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error
}
// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR)
type PdvKirRepository interface {
Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error)
DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error)
Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error)
Obrisi(ctx context.Context, id int64) error
}
// PdvKprRepository definiše operacije nad knjigom primljenih računa (KPR)
type PdvKprRepository interface {
Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error)
DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error)
Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error)
Obrisi(ctx context.Context, id int64) error
}
// ArtikalFilter definiše parametre za filtriranje liste artikala
type ArtikalFilter struct {
Pretraga string
+243
View File
@@ -0,0 +1,243 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"ntech/internal/model"
)
// --- KIR: knjiga izdatih računa ---
// PdvKirRepo je SQLite implementacija PdvKirRepository interfejsa
type PdvKirRepo struct {
db *sql.DB
}
// NoviPdvKirRepo kreira novi PdvKirRepo
func NoviPdvKirRepo(db *sql.DB) *PdvKirRepo {
return &PdvKirRepo{db: db}
}
// Lista vraća zapise KIR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice
func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) {
upit := `
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), datum_unosa
FROM pdv_kir WHERE 1=1`
args := []any{}
if !od.IsZero() {
upit += " AND datum_prometa >= ?"
args = append(args, od)
}
if !do.IsZero() {
upit += " AND datum_prometa <= ?"
args = append(args, do)
}
upit += " ORDER BY datum_prometa ASC, id ASC"
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.PdvKir
for redovi.Next() {
var k model.PdvKir
if err := redovi.Scan(
&k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &k.BrojDokumenta,
&k.KupacNaziv, &k.KupacPib, &k.KupacMesto,
&k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna,
&k.OslobodenSaPravom, &k.OslobodenBezPrava, &k.Ukupno,
&k.Napomena, &k.DatumUnosa,
); err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err)
}
return rezultat, nil
}
// DohvatiID vraća jedan zapis KIR-a po identifikatoru
func (r *PdvKirRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) {
var k model.PdvKir
err := r.db.QueryRowContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
COALESCE(napomena, ''), datum_unosa
FROM pdv_kir WHERE id = ?`, id).Scan(
&k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &k.BrojDokumenta,
&k.KupacNaziv, &k.KupacPib, &k.KupacMesto,
&k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna,
&k.OslobodenSaPravom, &k.OslobodenBezPrava, &k.Ukupno,
&k.Napomena, &k.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiID: %w", err)
}
return &k, nil
}
// Kreiraj dodaje novi zapis u KIR i vraća njegov id
func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) {
rez, err := r.db.ExecContext(ctx, `
INSERT INTO pdv_kir (
datum_prometa, datum_knjizenja, broj_dokumenta,
kupac_naziv, kupac_pib, kupac_mesto,
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta,
k.KupacNaziv, k.KupacPib, k.KupacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena)
if err != nil {
return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Obrisi briše zapis KIR-a
func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: PdvKirRepo.Obrisi: %w", err)
}
return nil
}
// --- KPR: knjiga primljenih računa ---
// PdvKprRepo je SQLite implementacija PdvKprRepository interfejsa
type PdvKprRepo struct {
db *sql.DB
}
// NoviPdvKprRepo kreira novi PdvKprRepo
func NoviPdvKprRepo(db *sql.DB) *PdvKprRepo {
return &PdvKprRepo{db: db}
}
// Lista vraća zapise KPR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice
func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) {
upit := `
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), datum_unosa
FROM pdv_kpr WHERE 1=1`
args := []any{}
if !od.IsZero() {
upit += " AND datum_prometa >= ?"
args = append(args, od)
}
if !do.IsZero() {
upit += " AND datum_prometa <= ?"
args = append(args, do)
}
upit += " ORDER BY datum_prometa ASC, id ASC"
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.PdvKpr
for redovi.Next() {
k, err := skenirajKpr(redovi.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: scan: %w", err)
}
rezultat = append(rezultat, k)
}
if err := redovi.Err(); err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err)
}
return rezultat, nil
}
// DohvatiID vraća jedan zapis KPR-a po identifikatoru
func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) {
red := r.db.QueryRowContext(ctx, `
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno,
COALESCE(napomena, ''), datum_unosa
FROM pdv_kpr WHERE id = ?`, id)
k, err := skenirajKpr(red.Scan)
if err != nil {
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiID: %w", err)
}
return &k, nil
}
// skenirajKpr čita jedan red KPR-a; datum_placanja je nullable pa ide preko sql.NullTime
func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) {
var k model.PdvKpr
var datumPlacanja sql.NullTime
if err := scan(
&k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &datumPlacanja, &k.BrojDokumenta,
&k.DobavljacNaziv, &k.DobavljacPib, &k.DobavljacMesto,
&k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna,
&k.PdvBezOdbitka, &k.OslobodenNabavka, &k.Ukupno,
&k.Napomena, &k.DatumUnosa,
); err != nil {
return model.PdvKpr{}, err
}
if datumPlacanja.Valid {
k.DatumPlacanja = &datumPlacanja.Time
}
return k, nil
}
// Kreiraj dodaje novi zapis u KPR i vraća njegov id
func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) {
var datumPlacanja any
if k.DatumPlacanja != nil {
datumPlacanja = *k.DatumPlacanja
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO pdv_kpr (
datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
dobavljac_naziv, dobavljac_pib, dobavljac_mesto,
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta,
k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena)
if err != nil {
return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: id: %w", err)
}
return id, nil
}
// Obrisi briše zapis KPR-a
func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: PdvKprRepo.Obrisi: %w", err)
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package sqlite
import (
"context"
"testing"
"time"
"ntech/internal/model"
)
// istiDan poredi datume po godini/mesecu/danu (izbegava razlike u vremenskoj zoni/času)
func istiDan(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
func TestPdvKirRepo(t *testing.T) {
db := testDB(t)
repo := NoviPdvKirRepo(db)
ctx := context.Background()
dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
dk := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
id, err := repo.Kreiraj(ctx, &model.PdvKir{
DatumPrometa: dp, DatumKnjizenja: dk, BrojDokumenta: "R-1",
KupacNaziv: "Kupac doo", KupacPib: "123456789", KupacMesto: "Niš",
OsnovicaOpsta: 100, PdvOpsta: 20, Ukupno: 120,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
t.Run("datum i iznosi round-trip", func(t *testing.T) {
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if !istiDan(k.DatumPrometa, dp) {
t.Errorf("datum_prometa = %v, očekivano %v", k.DatumPrometa, dp)
}
if !istiDan(k.DatumKnjizenja, dk) {
t.Errorf("datum_knjizenja = %v, očekivano %v", k.DatumKnjizenja, dk)
}
if k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" || k.KupacMesto != "Niš" {
t.Errorf("kupac podaci ne odgovaraju: %+v", k)
}
if k.OsnovicaOpsta != 100 || k.PdvOpsta != 20 || k.Ukupno != 120 {
t.Errorf("iznosi ne odgovaraju: %+v", k)
}
})
t.Run("filter perioda", func(t *testing.T) {
uPeriodu, err := repo.Lista(ctx, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Lista u periodu: %v", err)
}
if len(uPeriodu) != 1 {
t.Errorf("u junu očekivano 1 zapis, dobijeno %d", len(uPeriodu))
}
vanPerioda, err := repo.Lista(ctx, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("Lista van perioda: %v", err)
}
if len(vanPerioda) != 0 {
t.Errorf("u julu očekivano 0 zapisa, dobijeno %d", len(vanPerioda))
}
})
t.Run("brisanje", func(t *testing.T) {
if err := repo.Obrisi(ctx, id); err != nil {
t.Fatalf("Obrisi: %v", err)
}
sve, _ := repo.Lista(ctx, time.Time{}, time.Time{})
if len(sve) != 0 {
t.Errorf("posle brisanja očekivano 0, dobijeno %d", len(sve))
}
})
}
func TestPdvKprRepo(t *testing.T) {
db := testDB(t)
repo := NoviPdvKprRepo(db)
ctx := context.Background()
dp := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
dpl := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
t.Run("sa datumom plaćanja", func(t *testing.T) {
id, err := repo.Kreiraj(ctx, &model.PdvKpr{
DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: &dpl, BrojDokumenta: "U-1",
DobavljacNaziv: "Dobavljač doo", OsnovicaOpsta: 200, PdvOpsta: 40, Ukupno: 240,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if k.DatumPlacanja == nil {
t.Fatalf("datum_placanja je nil, očekivan %v", dpl)
}
if !istiDan(*k.DatumPlacanja, dpl) {
t.Errorf("datum_placanja = %v, očekivano %v", *k.DatumPlacanja, dpl)
}
if k.OsnovicaOpsta != 200 || k.PdvOpsta != 40 {
t.Errorf("iznosi ne odgovaraju: %+v", k)
}
})
t.Run("bez datuma plaćanja (NULL)", func(t *testing.T) {
id, err := repo.Kreiraj(ctx, &model.PdvKpr{
DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: nil, BrojDokumenta: "U-2",
DobavljacNaziv: "Dobavljač 2", OsnovicaPosebna: 50, PdvPosebna: 5, Ukupno: 55,
})
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
k, err := repo.DohvatiID(ctx, id)
if err != nil {
t.Fatalf("DohvatiID: %v", err)
}
if k.DatumPlacanja != nil {
t.Errorf("datum_placanja = %v, očekivan nil", *k.DatumPlacanja)
}
})
}
+6
View File
@@ -36,6 +36,8 @@ type Handler struct {
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
Verzija string
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
@@ -96,6 +98,8 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
}
}
@@ -122,6 +126,8 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB)
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
}
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
+45
View File
@@ -0,0 +1,45 @@
package model
import "time"
// PdvKir je jedan zapis u knjizi izdatih računa (izlazni PDV).
// Iznosi se vode po vrsti stope (opšta/posebna) — vidi migraciju 041.
type PdvKir struct {
ID int64
DatumPrometa time.Time
DatumKnjizenja time.Time
BrojDokumenta string
KupacNaziv string
KupacPib string
KupacMesto string
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
OslobodenSaPravom float64
OslobodenBezPrava float64
Ukupno float64
Napomena string
DatumUnosa time.Time
}
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
type PdvKpr struct {
ID int64
DatumPrometa time.Time
DatumKnjizenja time.Time
DatumPlacanja *time.Time // može biti prazan
BrojDokumenta string
DobavljacNaziv string
DobavljacPib string
DobavljacMesto string
OsnovicaOpsta float64
PdvOpsta float64
OsnovicaPosebna float64
PdvPosebna float64
PdvBezOdbitka float64
OslobodenNabavka float64
Ukupno float64
Napomena string
DatumUnosa time.Time
}