feat(kalkulacija): Faza C — marža po kategoriji/artiklu + zavisni troškovi (backend)

Celina 1 (kompletna) — marža po kategoriji/artiklu:
- migracija 046: nullable marza na artikli i kategorije
- model Marza *float64 (Artikal, Kategorija) + KategorijaMarza u ArtikalSaKategorijom
- repo: čitanje/pisanje marže; nove DohvatiID/Izmeni za kategoriju
- dozvola kategorija.izmeni; handler IzmeniKategoriju + ruta
- UI: polje marže u formi artikla i kategorije; modal izmene kategorije
- nabavka: fallback predlog marže artikal → kategorija → globalna (izaberiArtikal)

Celina 2 (backend) — zavisni troškovi nabavke:
- migracija 047: tabela nabavka_troskovi + kolona metod_raspodele na nabavke
- model NabavkaTrosak, MetodRaspodele; čista funkcija RasporediTroskove + test
- repo: Kreiraj upisuje troškove i metod; DohvatiTroskove
- handler: parsiranje troškova/metoda; kalkulativna nabavna cena na serveru

UI forme troškova i prikaz u detaljima nabavke slede.
This commit is contained in:
2026-06-14 16:12:03 +02:00
parent cb1a3b21c3
commit 30db396ee6
18 changed files with 448 additions and 51 deletions
+3
View File
@@ -14,6 +14,7 @@ type Artikal struct {
NabavnaCena float64
ProdajnaCena float64
PdvStopa float64
Marza *float64 // podrazumevana marža (%) za kalkulaciju; NULL = nije postavljeno
Napomena string
DatumUnosa time.Time
}
@@ -33,11 +34,13 @@ type Kategorija struct {
ID int64
Naziv string
Opis string
Marza *float64 // podrazumevana marža (%) za artikle ove kategorije; NULL = nije postavljeno
}
// ArtikalSaKategorijom je artikal sa nazivom kategorije — za prikaz u tabeli
type ArtikalSaKategorijom struct {
Artikal
KategorijaNaziv string
KategorijaMarza *float64 // marža kategorije; za fallback predloga marže pri nabavci
KriticnaZaliha bool
}
+56 -6
View File
@@ -1,14 +1,26 @@
package model
import "time"
import (
"math"
"time"
)
// Nabavka predstavlja zaglavlje jedne nabavke
type Nabavka struct {
ID int64
DobavljacID *int64
Napomena string
Ukupno float64
Datum time.Time
ID int64
DobavljacID *int64
Napomena string
Ukupno float64
MetodRaspodele string // "vrednost" ili "kolicina"; prazno = nema zavisnih troškova
Datum time.Time
}
// NabavkaTrosak je jedna stavka zavisnih troškova nabavke (npr. prevoz, carina)
type NabavkaTrosak struct {
ID int64
NabavkaID int64
Naziv string
Iznos float64
}
// StavkaNabavke predstavlja jednu liniju (artikal) unutar nabavke
@@ -21,6 +33,44 @@ type StavkaNabavke struct {
Ukupno float64
}
// RasporediTroskove raspodeljuje ukupan zavisni trošak na stavke nabavke i vraća
// kalkulativnu nabavnu cenu po komadu za svaku stavku (isti redosled kao ulazne stavke).
// metod "kolicina" deli trošak po broju komada; svaka druga vrednost (uklj. "vrednost")
// deli po nabavnoj vrednosti stavke (količina × cena).
// Ako nema troška ili je osnovica nula, vraća nepromenjenu cenu po komadu.
func RasporediTroskove(stavke []StavkaNabavke, ukupanTrosak float64, metod string) []float64 {
kalk := make([]float64, len(stavke))
// osnovica raspodele po izabranom metodu
var osnovica float64
for _, s := range stavke {
if metod == "kolicina" {
osnovica += float64(s.Kolicina)
} else {
osnovica += float64(s.Kolicina) * s.CenaPoKomadu
}
}
for i, s := range stavke {
kalk[i] = s.CenaPoKomadu
// bez troška, bez osnovice ili bez količine — nema šta da se raspodeli
if ukupanTrosak <= 0 || osnovica <= 0 || s.Kolicina == 0 {
continue
}
var udeo float64
if metod == "kolicina" {
udeo = float64(s.Kolicina) / osnovica
} else {
udeo = (float64(s.Kolicina) * s.CenaPoKomadu) / osnovica
}
trosakPoKomadu := (ukupanTrosak * udeo) / float64(s.Kolicina)
// zaokruženo na 2 decimale — kalkulativna nabavna je cena koja se čuva
kalk[i] = math.Round((s.CenaPoKomadu+trosakPoKomadu)*100) / 100
}
return kalk
}
// NabavkaSaDetaljem je nabavka sa nazivom dobavljača — za prikaz u listi
type NabavkaSaDetaljem struct {
Nabavka
+74
View File
@@ -0,0 +1,74 @@
package model
import "testing"
// proverava raspodelu zavisnih troškova na stavke nabavke po oba metoda
func TestRasporediTroskove(t *testing.T) {
// dve stavke: A = 10×100 (vrednost 1000), B = 10×200 (vrednost 2000)
stavke := []StavkaNabavke{
{Kolicina: 10, CenaPoKomadu: 100},
{Kolicina: 10, CenaPoKomadu: 200},
}
slucajevi := []struct {
naziv string
stavke []StavkaNabavke
trosak float64
metod string
ocekuje []float64
}{
{
naziv: "bez troška — cena nepromenjena",
stavke: stavke,
trosak: 0,
metod: "vrednost",
ocekuje: []float64{100, 200},
},
{
// trošak 300 po vrednosti: A nosi 1/3 (100 → 10/kom), B nosi 2/3 (200 → 20/kom)
naziv: "po vrednosti",
stavke: stavke,
trosak: 300,
metod: "vrednost",
ocekuje: []float64{110, 220},
},
{
// trošak 300 po količini: svaka stavka 1/2 (150 → 15/kom)
naziv: "po količini",
stavke: stavke,
trosak: 300,
metod: "kolicina",
ocekuje: []float64{115, 215},
},
{
// osnovica po vrednosti je 0 (sve cene 0) — nema raspodele
naziv: "nulta osnovica vrednosti",
stavke: []StavkaNabavke{{Kolicina: 5, CenaPoKomadu: 0}},
trosak: 100,
metod: "vrednost",
ocekuje: []float64{0},
},
{
// stavka bez količine se preskače (bez deljenja nulom)
naziv: "količina nula",
stavke: []StavkaNabavke{{Kolicina: 0, CenaPoKomadu: 50}},
trosak: 100,
metod: "kolicina",
ocekuje: []float64{50},
},
}
for _, s := range slucajevi {
t.Run(s.naziv, func(t *testing.T) {
dobijeno := RasporediTroskove(s.stavke, s.trosak, s.metod)
if len(dobijeno) != len(s.ocekuje) {
t.Fatalf("dužina = %d, očekivano %d", len(dobijeno), len(s.ocekuje))
}
for i := range s.ocekuje {
if dobijeno[i] != s.ocekuje[i] {
t.Errorf("stavka %d: dobijeno %.2f, očekivano %.2f", i, dobijeno[i], s.ocekuje[i])
}
}
})
}
}