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:
@@ -344,6 +344,8 @@ func main() {
|
|||||||
r.Get("/izvestaji", h.Izvestaji)
|
r.Get("/izvestaji", h.Izvestaji)
|
||||||
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
|
r.Get("/izvestaji/prometni-list", h.PrometniListMagacina)
|
||||||
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
|
r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj)
|
||||||
|
r.Get("/izvestaji/popis", h.Popis)
|
||||||
|
r.With(doz("artikal.izmeni")).Post("/izvestaji/popis", h.SacuvajPopis)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
||||||
r.Get("/prodaja/nova", h.NovaProdaja)
|
r.Get("/prodaja/nova", h.NovaProdaja)
|
||||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type ArtikalRepository interface {
|
|||||||
Obrisi(ctx context.Context, id int64) error
|
Obrisi(ctx context.Context, id int64) error
|
||||||
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042)
|
// SledecaSifra vraća predlog sledeće auto-šifre (npr. ART-00042)
|
||||||
SledecaSifra(ctx context.Context) (string, error)
|
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
|
// KategorijaRepository definiše operacije nad kategorijama
|
||||||
|
|||||||
@@ -229,3 +229,32 @@ func (r *ArtikalRepo) Obrisi(ctx context.Context, id int64) error {
|
|||||||
|
|
||||||
return nil
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
appdbPkg "ntech/internal/db"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,3 +298,94 @@ func (h *Handler) StanjeZalihaIzvestaj(w http.ResponseWriter, r *http.Request) {
|
|||||||
BrojArtikala: len(zalihe),
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var saSidebar = []string{
|
|||||||
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
|
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"dobavljaci", "dobavljac_forma",
|
"dobavljaci", "dobavljac_forma",
|
||||||
"izvestaji", "prometni_list", "stanje_zaliha",
|
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
||||||
"kategorije",
|
"kategorije",
|
||||||
"klijenti", "klijent_forma",
|
"klijenti", "klijent_forma",
|
||||||
"magacin", "magacin_forma", "magacin_kartica",
|
"magacin", "magacin_forma", "magacin_kartica",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
<a href="/izvestaji/prometni-list" class="btn-sekundarno">Prometni list magacina</a>
|
<a href="/izvestaji/prometni-list" class="btn-sekundarno">Prometni list magacina</a>
|
||||||
<a href="/izvestaji/stanje-zaliha" class="btn-sekundarno">Stanje zaliha</a>
|
<a href="/izvestaji/stanje-zaliha" class="btn-sekundarno">Stanje zaliha</a>
|
||||||
|
<a href="/izvestaji/popis" class="btn-sekundarno">Popis (inventura)</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1. mesečni prihod -->
|
<!-- 1. mesečni prihod -->
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "naslov"}}Popis — NTech{{end}}
|
||||||
|
|
||||||
|
{{define "dodatni-css"}}
|
||||||
|
<style>
|
||||||
|
.popis-input {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
.popis-input.izmenjeno {
|
||||||
|
border-color: #f97316;
|
||||||
|
background: rgba(249,115,22,0.06);
|
||||||
|
}
|
||||||
|
.popis-razlika {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.red-kriticno td { background: rgba(220,38,38,0.04); }
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "sadrzaj"}}
|
||||||
|
<div class="kolona" style="gap:16px;">
|
||||||
|
|
||||||
|
{{if .Sacuvano}}
|
||||||
|
<div class="poruka-uspeh poruka-animacija">Popis je uspešno sačuvan. Korekcije su upisane u magacinsku evidenciju.</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Greska}}
|
||||||
|
<div class="poruka-greska greska-animacija">{{.Greska}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- uputstvo -->
|
||||||
|
<div class="kartica animiraj" style="padding:14px 18px;">
|
||||||
|
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:260px;">
|
||||||
|
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);margin-bottom:4px;">Godišnji popis magacina</div>
|
||||||
|
<div style="font-size:13px;color:var(--tekst-slabi);line-height:1.6;">
|
||||||
|
Unesite stvarno prebrojano stanje za svaki artikal. Polja sa razlikom biće istaknuta narandžasto.
|
||||||
|
Samo artikli sa izmenjenom količinom biće korigovani — ostali ostaju nepromenjeni.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/izvestaji" class="btn-sekundarno" style="white-space:nowrap;">← Izveštaji</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- forma -->
|
||||||
|
<form method="POST" action="/izvestaji/popis">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
|
||||||
|
|
||||||
|
<div class="kartica animiraj" style="padding:0;overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px;border-bottom:0.5px solid var(--ivica);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
|
||||||
|
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Artikli</span>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<label class="polje-labela" style="display:inline;margin-right:6px;">Napomena:</label>
|
||||||
|
<input type="text" name="napomena" value="Godišnji popis" style="width:200px;font-size:13px;padding:5px 8px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .Artikli}}
|
||||||
|
<div style="padding:40px;text-align:center;color:var(--tekst-slabi);font-size:14px;">
|
||||||
|
Nema artikala u magacinu.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="tabela">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artikal</th>
|
||||||
|
<th>Šifra</th>
|
||||||
|
<th>Kategorija</th>
|
||||||
|
<th style="text-align:right;">Knjižno stanje</th>
|
||||||
|
<th style="text-align:right;">Stvarno stanje</th>
|
||||||
|
<th style="text-align:right;">Razlika</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Artikli}}
|
||||||
|
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}">
|
||||||
|
<td style="font-weight:500;">{{.Naziv}}</td>
|
||||||
|
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
|
||||||
|
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
|
||||||
|
<td style="text-align:right;font-family:monospace;font-weight:600;" id="knjizno-{{.ID}}">{{.Kolicina}}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<input type="number"
|
||||||
|
class="popis-input"
|
||||||
|
name="kolicina_{{.ID}}"
|
||||||
|
id="input-{{.ID}}"
|
||||||
|
value="{{.Kolicina}}"
|
||||||
|
min="0"
|
||||||
|
data-knjizno="{{.Kolicina}}"
|
||||||
|
data-id="{{.ID}}"
|
||||||
|
oninput="azurirajRazliku(this)">
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<span class="popis-razlika" id="razlika-{{.ID}}">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- dugme na dnu -->
|
||||||
|
<div style="padding:14px 20px;border-top:0.5px solid var(--ivica);display:flex;justify-content:flex-end;">
|
||||||
|
<button type="submit" class="btn-primarno">Sačuvaj popis</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function azurirajRazliku(input) {
|
||||||
|
var id = input.dataset.id;
|
||||||
|
var knjizno = parseInt(input.dataset.knjizno, 10);
|
||||||
|
var stvarno = parseInt(input.value, 10);
|
||||||
|
var span = document.getElementById('razlika-' + id);
|
||||||
|
|
||||||
|
if (isNaN(stvarno)) {
|
||||||
|
span.textContent = '—';
|
||||||
|
span.style.color = '';
|
||||||
|
input.classList.remove('izmenjeno');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var razlika = stvarno - knjizno;
|
||||||
|
if (razlika === 0) {
|
||||||
|
span.textContent = '—';
|
||||||
|
span.style.color = '';
|
||||||
|
input.classList.remove('izmenjeno');
|
||||||
|
} else if (razlika > 0) {
|
||||||
|
span.textContent = '+' + razlika;
|
||||||
|
span.style.color = '#22c55e';
|
||||||
|
input.classList.add('izmenjeno');
|
||||||
|
} else {
|
||||||
|
span.textContent = razlika;
|
||||||
|
span.style.color = '#dc2626';
|
||||||
|
input.classList.add('izmenjeno');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user