Izveštaji: popis magacina (inventura)

- Nova stranica /izvestaji/popis — forma za unos stvarnog stanja
- Razlika se prikazuje u realnom vremenu (JS) dok se kuca
- Pri snimanju: samo izmenjene količine upisuju se kao korekcija
  u magacinske_promene sa napomenom (podrazumevano "Godišnji popis")
- Nova metoda KorigujKolicinu u ArtikalRepository — transakciona,
  ažurira kolicina i upisuje promenu tipa korekcija
- Link Popis (inventura) dodat na stranicu izveštaja
This commit is contained in:
2026-06-19 19:56:02 +02:00
parent a3c68632be
commit 86cbace213
7 changed files with 282 additions and 1 deletions
+2
View File
@@ -19,6 +19,8 @@ type ArtikalRepository interface {
Obrisi(ctx context.Context, id int64) error
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042)
SledecaSifra(ctx context.Context) (string, error)
// KorigujKolicinu postavlja novu količinu artikla i upisuje korekciju u magacinske_promene
KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error
}
// KategorijaRepository definiše operacije nad kategorijama
+29
View File
@@ -229,3 +229,32 @@ func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
return nil
}
// KorigujKolicinu postavlja novu količinu i upisuje korekciju u magacinske_promene
func (r *ArtikalRepo) KorigujKolicinu(ctx context.Context, artikalID int64, novaKolicina int, korisnikID *int64, napomena string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: begin: %w", err)
}
defer tx.Rollback()
var staraCena float64
var staraKolicina int
err = tx.QueryRowContext(ctx, "SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID).
Scan(&staraKolicina, &staraCena)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: dohvati: %w", err)
}
if _, err = tx.ExecContext(ctx, "UPDATE artikli SET kolicina = ? WHERE id = ?", novaKolicina, artikalID); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: update: %w", err)
}
promena := novaKolicina - staraKolicina
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaKorekcija, promena,
staraKolicina, novaKolicina, 0, korisnikID, napomena); err != nil {
return fmt.Errorf("ntech: ArtikalRepo.KorigujKolicinu: %w", err)
}
return tx.Commit()
}
+94
View File
@@ -6,9 +6,12 @@ import (
"html/template"
"log/slog"
"net/http"
"strconv"
"time"
appdbPkg "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
)
@@ -295,3 +298,94 @@ func (h *Handler) StanjeZalihaIzvestaj(w http.ResponseWriter, r *http.Request) {
BrojArtikala: len(zalihe),
})
}
// PodaciPopisa su podaci za stranicu popisa
type PodaciPopisa struct {
model.PodaciStranice
Artikli []model.ArtikalSaKategorijom
Sacuvano bool
Greska string
}
// Popis prikazuje formu za unos stvarnog stanja (inventuru)
func (h *Handler) Popis(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
return
}
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "izvestaji"
ps.NaslovStranice = "Popis"
h.renderujTemplate(w, "popis", PodaciPopisa{
PodaciStranice: ps,
Artikli: artikli,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
})
}
// SacuvajPopis prima POST formu sa prebrojenim količinama i upisuje korekcije
func (h *Handler) SacuvajPopis(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "artikal.izmeni") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
artikli, err := h.Artikli.Lista(r.Context(), appdbPkg.ArtikalFilter{})
if err != nil {
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
return
}
napomena := r.FormValue("napomena")
if napomena == "" {
napomena = "Godišnji popis"
}
var greskaBroj int
for _, a := range artikli {
kljuc := fmt.Sprintf("kolicina_%d", a.ID)
vr := r.FormValue(kljuc)
if vr == "" {
continue
}
nova, err := strconv.Atoi(vr)
if err != nil || nova < 0 {
continue
}
if nova == a.Kolicina {
continue
}
if err := h.Artikli.KorigujKolicinu(r.Context(), a.ID, nova, &k.ID, napomena); err != nil {
slog.Error("popis: korekcija artikla", "id", a.ID, "error", err)
greskaBroj++
}
}
if greskaBroj > 0 {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "izvestaji"
ps.NaslovStranice = "Popis"
h.renderujTemplate(w, "popis", PodaciPopisa{
PodaciStranice: ps,
Artikli: artikli,
Greska: fmt.Sprintf("Došlo je do greške pri upisivanju %d artikala.", greskaBroj),
})
return
}
http.Redirect(w, r, "/izvestaji/popis?sacuvano=1", http.StatusSeeOther)
}
+1 -1
View File
@@ -19,7 +19,7 @@ var saSidebar = []string{
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
"dashboard",
"dobavljaci", "dobavljac_forma",
"izvestaji", "prometni_list", "stanje_zaliha",
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
"kategorije",
"klijenti", "klijent_forma",
"magacin", "magacin_forma", "magacin_kartica",