feat(pdv): šifarnik PDV stopa — migracija, model i repozitorijum (Faza 1)
Tabela pdv_stope (seed 20/10/0%), model PdvStopa, PdvStopaRepository + SQLite implementacija (arhiviranje umesto brisanja) i integracioni test (migracija + CRUD round-trip).
This commit is contained in:
@@ -23,6 +23,15 @@ type KategorijaRepository interface {
|
|||||||
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error)
|
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PdvStopaRepository definiše operacije nad šifarnikom PDV stopa
|
||||||
|
type PdvStopaRepository interface {
|
||||||
|
Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error)
|
||||||
|
DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error)
|
||||||
|
Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error)
|
||||||
|
Izmeni(ctx context.Context, s *model.PdvStopa) error
|
||||||
|
PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error
|
||||||
|
}
|
||||||
|
|
||||||
// ArtikalFilter definiše parametre za filtriranje liste artikala
|
// ArtikalFilter definiše parametre za filtriranje liste artikala
|
||||||
type ArtikalFilter struct {
|
type ArtikalFilter struct {
|
||||||
Pretraga string
|
Pretraga string
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PdvStopaRepo je SQLite implementacija PdvStopaRepository interfejsa
|
||||||
|
type PdvStopaRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoviPdvStopaRepo kreira novi PdvStopaRepo
|
||||||
|
func NoviPdvStopaRepo(db *sql.DB) *PdvStopaRepo {
|
||||||
|
return &PdvStopaRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista vraća stope iz šifarnika; ako je samoAktivne true, izostavlja arhivirane
|
||||||
|
func (r *PdvStopaRepo) Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) {
|
||||||
|
upit := `
|
||||||
|
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
|
||||||
|
FROM pdv_stope
|
||||||
|
WHERE 1=1`
|
||||||
|
if samoAktivne {
|
||||||
|
upit += " AND aktivna = 1"
|
||||||
|
}
|
||||||
|
upit += " ORDER BY redosled ASC, stopa DESC"
|
||||||
|
|
||||||
|
redovi, err := r.db.QueryContext(ctx, upit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err)
|
||||||
|
}
|
||||||
|
defer redovi.Close()
|
||||||
|
|
||||||
|
var rezultat []model.PdvStopa
|
||||||
|
for redovi.Next() {
|
||||||
|
var s model.PdvStopa
|
||||||
|
if err := redovi.Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: scan: %w", err)
|
||||||
|
}
|
||||||
|
rezultat = append(rezultat, s)
|
||||||
|
}
|
||||||
|
if err := redovi.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err)
|
||||||
|
}
|
||||||
|
return rezultat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DohvatiID vraća jednu stopu po identifikatoru
|
||||||
|
func (r *PdvStopaRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) {
|
||||||
|
var s model.PdvStopa
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
|
||||||
|
FROM pdv_stope WHERE id = ?`, id).
|
||||||
|
Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: PdvStopaRepo.DohvatiID: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kreiraj dodaje novu stopu i vraća njen id
|
||||||
|
func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) {
|
||||||
|
rez, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO pdv_stope (naziv, stopa, oznaka, aktivna, redosled)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: %w", err)
|
||||||
|
}
|
||||||
|
id, err := rez.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: id: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Izmeni menja podatke postojeće stope (osim datuma unosa)
|
||||||
|
func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE pdv_stope
|
||||||
|
SET naziv = ?, stopa = ?, oznaka = ?, aktivna = ?, redosled = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled, s.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: PdvStopaRepo.Izmeni: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostaviAktivnu arhivira (false) ili vraća u upotrebu (true) stopu, bez brisanja
|
||||||
|
func (r *PdvStopaRepo) PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, "UPDATE pdv_stope SET aktivna = ? WHERE id = ?", aktivna, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntech: PdvStopaRepo.PostaviAktivnu: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPdvStopaRepo(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
repo := NoviPdvStopaRepo(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// migracija 040 seeduje 3 stope; redosled ASC → opšta(20), posebna(10), oslobođeno(0)
|
||||||
|
t.Run("seed iz migracije", func(t *testing.T) {
|
||||||
|
stope, err := repo.Lista(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lista: %v", err)
|
||||||
|
}
|
||||||
|
if len(stope) != 3 {
|
||||||
|
t.Fatalf("očekivano 3 seed stope, dobijeno %d", len(stope))
|
||||||
|
}
|
||||||
|
if stope[0].Stopa != 20 || stope[0].Oznaka != "opsta" {
|
||||||
|
t.Errorf("prva stopa = %v %q, očekivano 20 opsta", stope[0].Stopa, stope[0].Oznaka)
|
||||||
|
}
|
||||||
|
if stope[2].Stopa != 0 || stope[2].Oznaka != "oslobodjeno" {
|
||||||
|
t.Errorf("treća stopa = %v %q, očekivano 0 oslobodjeno", stope[2].Stopa, stope[2].Oznaka)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var novaID int64
|
||||||
|
|
||||||
|
t.Run("kreiraj i dohvati", func(t *testing.T) {
|
||||||
|
id, err := repo.Kreiraj(ctx, &model.PdvStopa{
|
||||||
|
Naziv: "Test stopa", Stopa: 11.5, Oznaka: "posebna", Aktivna: true, Redosled: 9,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Kreiraj: %v", err)
|
||||||
|
}
|
||||||
|
novaID = id
|
||||||
|
s, err := repo.DohvatiID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DohvatiID: %v", err)
|
||||||
|
}
|
||||||
|
if s.Naziv != "Test stopa" || s.Stopa != 11.5 || s.Oznaka != "posebna" || !s.Aktivna || s.Redosled != 9 {
|
||||||
|
t.Errorf("dohvaćeno %+v, ne poklapa se sa unetim", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("izmeni", func(t *testing.T) {
|
||||||
|
err := repo.Izmeni(ctx, &model.PdvStopa{
|
||||||
|
ID: novaID, Naziv: "Izmenjena", Stopa: 12, Oznaka: "opsta", Aktivna: true, Redosled: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Izmeni: %v", err)
|
||||||
|
}
|
||||||
|
s, err := repo.DohvatiID(ctx, novaID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DohvatiID posle izmene: %v", err)
|
||||||
|
}
|
||||||
|
if s.Naziv != "Izmenjena" || s.Stopa != 12 || s.Oznaka != "opsta" || s.Redosled != 5 {
|
||||||
|
t.Errorf("posle izmene %+v, ne poklapa se sa izmenom", s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("arhiviranje izostavlja iz aktivnih ali zadržava u punoj listi", func(t *testing.T) {
|
||||||
|
if err := repo.PostaviAktivnu(ctx, novaID, false); err != nil {
|
||||||
|
t.Fatalf("PostaviAktivnu: %v", err)
|
||||||
|
}
|
||||||
|
aktivne, err := repo.Lista(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lista aktivne: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range aktivne {
|
||||||
|
if s.ID == novaID {
|
||||||
|
t.Errorf("arhivirana stopa %d ne sme biti među aktivnima", novaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sve, err := repo.Lista(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lista sve: %v", err)
|
||||||
|
}
|
||||||
|
nadjena := false
|
||||||
|
for _, s := range sve {
|
||||||
|
if s.ID == novaID {
|
||||||
|
nadjena = true
|
||||||
|
if s.Aktivna {
|
||||||
|
t.Errorf("stopa %d treba da bude aktivna=false posle arhiviranja", novaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nadjena {
|
||||||
|
t.Errorf("arhivirana stopa %d mora ostati u punoj listi (ne briše se)", novaID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ type Handler struct {
|
|||||||
PokusajiRepo db.PokusajiPrijaveRepository
|
PokusajiRepo db.PokusajiPrijaveRepository
|
||||||
LoginIstorijsaRepo db.LoginIstorijsaRepository
|
LoginIstorijsaRepo db.LoginIstorijsaRepository
|
||||||
DozvoleRepo db.DozvoleRepository
|
DozvoleRepo db.DozvoleRepository
|
||||||
|
PdvStopeRepo db.PdvStopaRepository
|
||||||
Verzija string
|
Verzija string
|
||||||
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
||||||
Templates map[string]*template.Template
|
Templates map[string]*template.Template
|
||||||
@@ -94,6 +95,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
|||||||
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
|
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
|
||||||
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
|
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
|
||||||
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
|
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
|
||||||
|
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +121,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
|
|||||||
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
|
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
|
||||||
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
|
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
|
||||||
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
|
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
|
||||||
|
h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB)
|
||||||
}
|
}
|
||||||
|
|
||||||
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
|
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PdvStopa je jedna stavka u šifarniku PDV stopa (Faza 1 knjigovodstvenog modula).
|
||||||
|
// Stope su podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda.
|
||||||
|
type PdvStopa struct {
|
||||||
|
ID int64
|
||||||
|
Naziv string // npr. "Opšta stopa"
|
||||||
|
Stopa float64 // procenat, npr. 20.0
|
||||||
|
Oznaka string // "opsta" | "posebna" | "oslobodjeno"
|
||||||
|
Aktivna bool // false = arhivirana (ne nudi se u listama, ali stari zapisi ostaju ispravni)
|
||||||
|
Redosled int // redosled prikaza
|
||||||
|
DatumUnosa time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Šifarnik PDV stopa (Faza 1 knjigovodstvenog modula).
|
||||||
|
-- Stope se čuvaju kao podatak, ne hardkod — nova ili izmenjena stopa bez diranja koda.
|
||||||
|
-- Početne vrednosti po važećem zakonu (jun 2026): opšta 20%, posebna 10%, oslobođeno 0%.
|
||||||
|
CREATE TABLE IF NOT EXISTS pdv_stope (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
naziv TEXT NOT NULL, -- npr. "Opšta stopa"
|
||||||
|
stopa REAL NOT NULL, -- procenat, npr. 20.0
|
||||||
|
oznaka TEXT NOT NULL, -- "opsta" | "posebna" | "oslobodjeno"
|
||||||
|
aktivna INTEGER NOT NULL DEFAULT 1, -- 1 = u upotrebi, 0 = arhivirana
|
||||||
|
redosled INTEGER NOT NULL DEFAULT 0, -- redosled prikaza u listama
|
||||||
|
datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed početnih stopa samo ako je tabela prazna (idempotentno, ne gazi ručne izmene).
|
||||||
|
INSERT INTO pdv_stope (naziv, stopa, oznaka, redosled)
|
||||||
|
SELECT naziv, stopa, oznaka, redosled FROM (
|
||||||
|
SELECT 'Opšta stopa' AS naziv, 20.0 AS stopa, 'opsta' AS oznaka, 1 AS redosled
|
||||||
|
UNION ALL SELECT 'Posebna stopa', 10.0, 'posebna', 2
|
||||||
|
UNION ALL SELECT 'Oslobođeno (0%)', 0.0, 'oslobodjeno', 3
|
||||||
|
)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pdv_stope);
|
||||||
Reference in New Issue
Block a user