Troškovi: zaseban modul (šifrarnik vrsta troškova)

Opcija 1 — katalog dodatnih troškova (Prevoz, Carina, Ambalaža) koji se vezuju
za radni nalog i ulaze u cenu koštanja. Zaseban entitet: tabela troskovi (šifra
TRO-001…999, naziv, podrazumevana cena, opis), repo, handleri, forma i lista.
Stari tip-filter handler uklonjen iz magacina. (Knjiga rashoda firme je zaseban
budući modul — Opcija 2.)
This commit is contained in:
2026-06-21 23:18:44 +02:00
parent ef0b917bf6
commit 8fe727f927
12 changed files with 553 additions and 5 deletions
+19
View File
@@ -126,6 +126,25 @@ type UslugaRepository interface {
SledecaSifra(ctx context.Context) (string, error)
}
// TrosakFilter su parametri za listanje troškova
type TrosakFilter struct {
Pretraga string
Arhivirani bool
Limit int
Offset int
}
// TrosakRepository definiše operacije nad šifrarnikom vrsta troškova
type TrosakRepository interface {
Lista(ctx context.Context, filter TrosakFilter) ([]model.Trosak, error)
DohvatiID(ctx context.Context, id int64) (*model.Trosak, error)
Kreiraj(ctx context.Context, t *model.Trosak) (int64, error)
Izmeni(ctx context.Context, t *model.Trosak) error
Obrisi(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre troška (TRO-001 … TRO-999)
SledecaSifra(ctx context.Context) (string, error)
}
// NabavkaRepository definiše operacije nad nabavkama
type NabavkaRepository interface {
Lista(ctx context.Context) ([]model.NabavkaSaDetaljem, error)
+142
View File
@@ -0,0 +1,142 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
// TrosakRepo je SQLite implementacija db.TrosakRepository
type TrosakRepo struct {
db *sql.DB
}
// NoviTrosakRepo kreira repozitorijum troškova nad datom bazom
func NoviTrosakRepo(baza *sql.DB) *TrosakRepo {
return &TrosakRepo{db: baza}
}
// skenirajTrosak čita jedan red u model.Trosak
func skenirajTrosak(s interface {
Scan(dest ...any) error
}) (model.Trosak, error) {
var t model.Trosak
var sifra sql.NullString
var arhiviran int
if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &t.DatumUnosa); err != nil {
return t, err
}
t.Sifra = sifra.String
t.Arhiviran = arhiviran == 1
return t, nil
}
const trosakKolone = "id, sifra, naziv, cena, opis, arhiviran, datum_unosa"
// Lista vraća troškove prema filteru (pretraga po nazivu/šifri, aktivne/arhivirane)
func (r *TrosakRepo) Lista(ctx context.Context, filter db.TrosakFilter) ([]model.Trosak, error) {
upit := "SELECT " + trosakKolone + " FROM troskovi WHERE 1=1"
var args []any
if filter.Pretraga != "" {
upit += " AND (naziv LIKE ? OR sifra LIKE ?)"
t := "%" + filter.Pretraga + "%"
args = append(args, t, t)
}
if filter.Arhivirani {
upit += " AND arhiviran = 1"
} else {
upit += " AND arhiviran = 0"
}
upit += " ORDER BY naziv"
if filter.Limit > 0 {
upit += " LIMIT ? OFFSET ?"
args = append(args, filter.Limit, filter.Offset)
}
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: TrosakRepo.Lista: %w", err)
}
defer redovi.Close()
var troskovi []model.Trosak
for redovi.Next() {
t, err := skenirajTrosak(redovi)
if err != nil {
return nil, fmt.Errorf("ntech: TrosakRepo.Lista: %w", err)
}
troskovi = append(troskovi, t)
}
return troskovi, redovi.Err()
}
// DohvatiID vraća jedan trošak po ID-u
func (r *TrosakRepo) DohvatiID(ctx context.Context, id int64) (*model.Trosak, error) {
red := r.db.QueryRowContext(ctx, "SELECT "+trosakKolone+" FROM troskovi WHERE id = ?", id)
t, err := skenirajTrosak(red)
if err != nil {
return nil, fmt.Errorf("ntech: TrosakRepo.DohvatiID: %w", err)
}
return &t, nil
}
// Kreiraj upisuje nov trošak i vraća njegov ID
func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error) {
var sifra any
if t.Sifra != "" {
sifra = t.Sifra
}
rez, err := r.db.ExecContext(ctx, `
INSERT INTO troskovi (sifra, naziv, cena, opis)
VALUES (?, ?, ?, ?)`,
sifra, t.Naziv, t.Cena, t.Opis,
)
if err != nil {
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: last insert id: %w", err)
}
return id, nil
}
// Izmeni ažurira postojeći trošak
func (r *TrosakRepo) Izmeni(ctx context.Context, t *model.Trosak) error {
var sifra any
if t.Sifra != "" {
sifra = t.Sifra
}
_, err := r.db.ExecContext(ctx, `
UPDATE troskovi SET sifra = ?, naziv = ?, cena = ?, opis = ?
WHERE id = ?`,
sifra, t.Naziv, t.Cena, t.Opis, t.ID,
)
if err != nil {
return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err)
}
return nil
}
// Obrisi trajno briše trošak po ID-u
func (r *TrosakRepo) Obrisi(ctx context.Context, id int64) error {
if _, err := r.db.ExecContext(ctx, "DELETE FROM troskovi WHERE id = ?", id); err != nil {
return fmt.Errorf("ntech: TrosakRepo.Obrisi: %w", err)
}
return nil
}
// SledecaSifra vraća predlog sledeće auto-šifre troška u formatu TRO-001 … TRO-999
func (r *TrosakRepo) SledecaSifra(ctx context.Context) (string, error) {
var maks sql.NullInt64
err := r.db.QueryRowContext(ctx,
"SELECT MAX(CAST(SUBSTR(sifra, 5) AS INTEGER)) FROM troskovi WHERE sifra LIKE 'TRO-%'").Scan(&maks)
if err != nil {
return "TRO-001", fmt.Errorf("ntech: TrosakRepo.SledecaSifra: %w", err)
}
return fmt.Sprintf("TRO-%03d", maks.Int64+1), nil
}
+3
View File
@@ -21,6 +21,7 @@ type Handler struct {
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
@@ -86,6 +87,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
@@ -116,6 +118,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.DB = novaDB
h.Artikli = sqlite.NoviArtikalRepo(novaDB)
h.UslugeRepo = sqlite.NoviUslugaRepo(novaDB)
h.TroskoviRepo = sqlite.NoviTrosakRepo(novaDB)
h.KategorijeRepo = sqlite.NovaKategorijaRepo(novaDB)
h.DobavljaciRepo = sqlite.NoviDobavljacRepo(novaDB)
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
+1
View File
@@ -38,6 +38,7 @@ var saSidebar = []string{
"prodaja", "prodaja_detalji", "prodaja_forma",
"servis", "servis_arhiva", "servis_forma", "servis_detalji",
"usluge", "usluge_forma",
"troskovi", "troskovi_forma",
}
// standalone su šabloni bez base layouta
-5
View File
@@ -42,11 +42,6 @@ func (h *Handler) Magacin(w http.ResponseWriter, r *http.Request) {
h.magacinPrikaz(w, r, model.TipProizvod, "magacin", "Magacin", "/magacin")
}
// Troskovi renderuje listu troškova (tip 'trosak')
func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
h.magacinPrikaz(w, r, model.TipTrosak, "troskovi", "Troškovi", "/troskovi")
}
// magacinPrikaz je zajednička logika liste artikala, parametrizovana tipom; isti
// šablon služi za proizvode, usluge i troškove (razlikuju se samo tip i bazna ruta)
func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, stranica, naslov, osnovniUrl string) {
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"net/http"
"strconv"
"strings"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/model"
"github.com/go-chi/chi/v5"
)
// PodaciTroskovi su podaci za listu troškova
type PodaciTroskovi struct {
model.PodaciStranice
Troskovi []model.Trosak
Pretraga string
Sacuvano bool
Obrisan bool
}
// PodaciFormeTroska su podaci za formu novog/izmenjenog troška
type PodaciFormeTroska struct {
model.PodaciStranice
Trosak model.Trosak
Greska string
Izmena bool
}
// Troskovi renderuje listu šifrarnika vrsta troškova
func (h *Handler) Troskovi(w http.ResponseWriter, r *http.Request) {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
pretraga := r.URL.Query().Get("pretraga")
troskovi, err := h.TroskoviRepo.Lista(r.Context(), db.TrosakFilter{Pretraga: pretraga})
if err != nil {
http.Error(w, "Greška pri učitavanju troškova", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "troskovi"
ps.NaslovStranice = "Troškovi"
h.renderujTemplate(w, "troskovi", PodaciTroskovi{
PodaciStranice: ps,
Troskovi: troskovi,
Pretraga: pretraga,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
Obrisan: r.URL.Query().Get("obrisan") == "1",
})
}
// NoviTrosak prikazuje praznu formu sa predloženom auto-šifrom (TRO-NNN)
func (h *Handler) NoviTrosak(w http.ResponseWriter, r *http.Request) {
sifra, err := h.TroskoviRepo.SledecaSifra(r.Context())
if err != nil {
sifra = "TRO-001"
}
h.renderujFormuTroska(w, r, model.Trosak{Sifra: sifra}, false, "")
}
// IzmeniTrosak prikazuje formu sa postojećim troškom
func (h *Handler) IzmeniTrosak(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID troška", http.StatusBadRequest)
return
}
trosak, err := h.TroskoviRepo.DohvatiID(r.Context(), id)
if err != nil {
http.Error(w, "Trošak nije pronađen", http.StatusNotFound)
return
}
h.renderujFormuTroska(w, r, *trosak, true, "")
}
// SacuvajTrosak prima POST i kreira nov trošak
func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok {
return
}
trosak, greska := parseFormuTroska(r)
if greska != "" {
h.renderujFormuTroska(w, r, trosak, false, greska)
return
}
if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil {
h.renderujFormuTroska(w, r, trosak, false, "Greška pri čuvanju troška. Pokušajte ponovo.")
return
}
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
}
// SacuvajIzmenuTroska prima POST i ažurira postojeći trošak
func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID troška", http.StatusBadRequest)
return
}
trosak, greska := parseFormuTroska(r)
trosak.ID = id
if greska != "" {
h.renderujFormuTroska(w, r, trosak, true, greska)
return
}
if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil {
h.renderujFormuTroska(w, r, trosak, true, "Greška pri čuvanju troška. Pokušajte ponovo.")
return
}
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
}
// ObrisiTrosak briše trošak po ID-u
func (h *Handler) ObrisiTrosak(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.obrisi"); !ok {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.Error(w, "Neispravan ID troška", http.StatusBadRequest)
return
}
if err := h.TroskoviRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri brisanju troška", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/troskovi?obrisan=1", http.StatusSeeOther)
}
// parseFormuTroska čita i validira polja forme troška
func parseFormuTroska(r *http.Request) (model.Trosak, string) {
if err := r.ParseForm(); err != nil {
return model.Trosak{}, "Greška pri čitanju forme."
}
naziv := strings.TrimSpace(r.FormValue("naziv"))
if naziv == "" {
return model.Trosak{}, "Naziv troška je obavezan."
}
cena, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("cena")), 64)
if err != nil || cena < 0 {
cena = 0
}
return model.Trosak{
Sifra: strings.TrimSpace(r.FormValue("sifra")),
Naziv: naziv,
Cena: cena,
Opis: strings.TrimSpace(r.FormValue("opis")),
}, ""
}
// renderujFormuTroska prikazuje formu troška (zajedničko za nov i izmenu)
func (h *Handler) renderujFormuTroska(w http.ResponseWriter, r *http.Request, trosak model.Trosak, izmena bool, greska string) {
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 = "troskovi"
if izmena {
ps.NaslovStranice = "Izmena troška"
} else {
ps.NaslovStranice = "Nov trošak"
}
h.renderujTemplate(w, "troskovi_forma", PodaciFormeTroska{
PodaciStranice: ps,
Trosak: trosak,
Greska: greska,
Izmena: izmena,
})
}
+14
View File
@@ -0,0 +1,14 @@
package model
// Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža).
// To je katalog dodatnih troškova koji se vezuju za radni nalog i ulaze u cenu
// koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul.
type Trosak struct {
ID int64
Sifra string
Naziv string
Cena float64
Opis string
Arhiviran bool
DatumUnosa string
}