feat(nabavka): kalkulacija — formiranje prodajne cene pri nabavci (Faza B)

Po stavci nabavke: marža% (predpopunjena globalnom iz podešavanja,
kalkulacija_marza) i prodajna cena = nabavna × (1+marža/100) × (1+PDV/100),
živo računato u Alpine, izmenjivo. Na čuvanje se ažurira nabavna+prodajna
cena artikla (ArtikalRepo.AzurirajCene) i upiše nivelacija 'kalkulacija';
prazna/nulta prodajna se preskače. ArtikliJSON nosi pdv_stopa. Postavka
podrazumevane marže u Podešavanja → Sistem.
This commit is contained in:
2026-06-14 10:13:56 +02:00
parent a466f591b6
commit 97683534ac
7 changed files with 139 additions and 11 deletions
+46 -4
View File
@@ -30,6 +30,7 @@ type PodaciFormeNabavke struct {
ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u <script>
Dobavljaci []model.Dobavljac
Kategorije []model.Kategorija // za dropdown u modalu novog artikla
Marza string // podrazumevana marža (%) za kalkulaciju
Greska string
}
@@ -44,12 +45,13 @@ type PodaciDetaljiNabavke struct {
// artikalUJSON pretvara listu artikala u template.JS vrednost bezbednu za umetanje u <script> tag
func artikalUJSON(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct {
ID int64 `json:"id"`
Naziv string `json:"naziv"`
ID int64 `json:"id"`
Naziv string `json:"naziv"`
PdvStopa float64 `json:"pdv_stopa"`
}
lista := make([]stavka, 0, len(artikli))
for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv})
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, PdvStopa: a.PdvStopa})
}
b, _ := json.Marshal(lista)
return template.JS(b)
@@ -117,12 +119,14 @@ func (h *Handler) NovaNabavka(w http.ResponseWriter, r *http.Request) {
ArtikliJSON: artikalUJSON(artikli),
Dobavljaci: dobavljaci,
Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
})
}
// SacuvajNabavku prima POST formu, parsira stavke i upisuje nabavku u bazu
func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "nabavka.dodaj"); !ok {
k, ok := h.zahtevajDozvolu(w, r, "nabavka.dodaj")
if !ok {
return
}
if err := r.ParseForm(); err != nil {
@@ -145,6 +149,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
ArtikliJSON: artikalUJSON(artikli),
Dobavljaci: dobavljaci,
Kategorije: kategorije,
Marza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
Greska: greska,
})
return
@@ -182,6 +187,43 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
}
}
// kalkulacija: ažuriraj nabavnu i prodajnu cenu artikla iz forme + nivelacioni trag.
// prodajna[] je paralelni niz uz stavke (isti redosled kao artikal_id[]).
prodajne := r.Form["prodajna[]"]
var korisnikID *int64
if k != nil {
korisnikID = &k.ID
}
for i, s := range stavke {
if i >= len(prodajne) {
break
}
prodajna, e := strconv.ParseFloat(strings.TrimSpace(prodajne[i]), 64)
if e != nil || prodajna <= 0 {
continue // prazno/nula ne sme da pregazi postojeću cenu
}
// stara prodajna cena — za nivelacioni zapis
var staraProdajna float64
if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil {
staraProdajna = a.ProdajnaCena
}
if e := h.Artikli.AzurirajCene(r.Context(), s.ArtikalID, s.CenaPoKomadu, prodajna); e != nil {
slog.Error("kalkulacija: ažuriranje cena nije uspelo", "artikal_id", s.ArtikalID, "error", e)
continue
}
if razlika := prodajna - staraProdajna; razlika > 0.005 || razlika < -0.005 {
if _, e := h.NivelacijaRepo.Kreiraj(r.Context(), &model.Nivelacija{
ArtikalID: s.ArtikalID,
StaraCena: staraProdajna,
NovaCena: prodajna,
Izvor: "kalkulacija",
KorisnikID: korisnikID,
}); e != nil {
slog.Error("kalkulacija: nivelacija nije upisana", "artikal_id", s.ArtikalID, "error", e)
}
}
}
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
+16
View File
@@ -42,6 +42,7 @@ type PodaciPodesavanja struct {
Backupi []BackupInfo
BackupIntervalSati string
BackupBrojKopija string
KalkulacijaMarza string
LoginPozadina string
LoginPozadinaOpacity string
LoginPozadinaBlurPozadine string
@@ -288,6 +289,20 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
}
}
// podrazumevana marža za kalkulaciju (procenat, 01000)
if v := strings.TrimSpace(r.FormValue("kalkulacija_marza")); v != "" {
marza, err := strconv.ParseFloat(strings.Replace(v, ",", ".", 1), 64)
if err != nil || marza < 0 || marza > 1000 {
middleware.SetFlash(w, r, h.DB, "greska", "Marža mora biti broj između 0 i 1000.")
http.Redirect(w, r, sledeci, http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "kalkulacija_marza", strconv.FormatFloat(marza, 'f', -1, 64)); err != nil {
http.Error(w, "Greška pri čuvanju podešavanja", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, sledeci+"?sacuvano=1", http.StatusSeeOther)
}
@@ -627,6 +642,7 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
LoginPozadinaZatamnjenjeKartice: vrednostIliDefault(podesavanja, "login_pozadina_zatamnjenje_kartice", "0"),
BackupIntervalSati: vrednostIliDefault(podesavanja, "backup_interval_sati", "24"),
BackupBrojKopija: vrednostIliDefault(podesavanja, "backup_broj_kopija", "7"),
KalkulacijaMarza: vrednostIliDefault(podesavanja, "kalkulacija_marza", "20"),
}, nil
}