diff --git a/internal/db/repository.go b/internal/db/repository.go index ab4e689..b7ed66a 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -23,6 +23,15 @@ type KategorijaRepository interface { 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 type ArtikalFilter struct { Pretraga string @@ -117,8 +126,8 @@ type SesijeRepository interface { // PodsetnikFilter definiše parametre za filtriranje liste podsetnika type PodsetnikFilter struct { - SamoAktivni bool // true = samo nezavršeni; false = svi - KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika + SamoAktivni bool // true = samo nezavršeni; false = svi + KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika } // PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave diff --git a/internal/db/sqlite/pdv_stopa.go b/internal/db/sqlite/pdv_stopa.go new file mode 100644 index 0000000..d2cfe38 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa.go @@ -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 +} diff --git a/internal/db/sqlite/pdv_stopa_test.go b/internal/db/sqlite/pdv_stopa_test.go new file mode 100644 index 0000000..22ea8c4 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa_test.go @@ -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) + } + }) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 565ce5f..c0b96cc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -35,6 +35,7 @@ type Handler struct { PokusajiRepo db.PokusajiPrijaveRepository LoginIstorijsaRepo db.LoginIstorijsaRepository DozvoleRepo db.DozvoleRepository + PdvStopeRepo db.PdvStopaRepository Verzija string AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template @@ -94,6 +95,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), 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.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) 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. diff --git a/internal/model/pdv_stopa.go b/internal/model/pdv_stopa.go new file mode 100644 index 0000000..4da463b --- /dev/null +++ b/internal/model/pdv_stopa.go @@ -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 +} diff --git a/migrations/040_pdv_stope.sql b/migrations/040_pdv_stope.sql new file mode 100644 index 0000000..b82489f --- /dev/null +++ b/migrations/040_pdv_stope.sql @@ -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);