Ispravke: QR proxy šema, race šifra, JM validacija, zaštita zaliha, magacin history flash
- servis.go: qrNalogURL helper čita X-Forwarded-Proto za ispravan HTTPS QR kod iza proxy-ja - magacin_forma.go: šifra se generiše pre INSERT (uklanja race condition); normalizujJM validacija 4 kar.; blokada promene tipa ako postoji stanje na lageru - prodaja.go + repository.go: Obrisi beleži magacinsku promenu (PromenaPovracaj) uz korisnikID; ispravljeni zamenjeni potpisi interfejsa ServisRepository/ProdajaRepository - kategorije.html: UI hint kada kategorija nema kôd (prefiks šifre) - 061_backfill_kategorija_kod.sql: popunjava kod postojećim kategorijama iz naziva - magacin.html: htmx:beforeHistorySave sklanja bez-anim pre snimanja snapshota (fix flash animacije)
This commit is contained in:
@@ -149,7 +149,7 @@ type ProdajaRepository interface {
|
||||
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
|
||||
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error)
|
||||
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
|
||||
}
|
||||
|
||||
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
|
||||
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err)
|
||||
@@ -366,13 +366,31 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
|
||||
for _, p := range stavke {
|
||||
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')",
|
||||
p.kolicina, p.artikalID,
|
||||
var stanjePre int
|
||||
var tip string
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
|
||||
).Scan(&stanjePre, &tip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stanje: %w", err)
|
||||
}
|
||||
if !(tip == model.TipProizvod || tip == "") {
|
||||
continue
|
||||
}
|
||||
|
||||
stanjePosle := stanjePre + p.kolicina
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
|
||||
}
|
||||
|
||||
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
|
||||
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje prodajnog naloga")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: magacin: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
@@ -88,24 +90,23 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ako korisnik nije uneo šifru, auto-generišemo pre Kreiraj
|
||||
// (tako je dodela šifre atomična: ako INSERT padne na UNIQUE constraint,
|
||||
// Kreiraj vraća grešku umesto da šifra ostane NULL bez ikakve poruke)
|
||||
if artikal.Sifra == "" {
|
||||
autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
|
||||
if e != nil {
|
||||
autoSifra = fmt.Sprintf("ART-%04d", time.Now().UnixMilli()%10000)
|
||||
}
|
||||
artikal.Sifra = autoSifra
|
||||
}
|
||||
|
||||
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// ako korisnik nije uneo šifru, auto-generišemo po prefiksu kategorije
|
||||
if artikal.Sifra == "" {
|
||||
autoSifra, e := h.Artikli.SledecaSifra(r.Context(), artikal.KategorijaID)
|
||||
if e != nil {
|
||||
autoSifra = fmt.Sprintf("ART-%04d", id)
|
||||
}
|
||||
artikal.ID = id
|
||||
artikal.Sifra = autoSifra
|
||||
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
||||
slog.Error("greška pri upisu auto-šifre", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
artikal.ID = id
|
||||
|
||||
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
|
||||
if r.Header.Get("X-Requested-With") == "fetch" {
|
||||
@@ -207,6 +208,13 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
var staraCena float64
|
||||
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
|
||||
staraCena = stari.ProdajnaCena
|
||||
// spreči promenu tipa sa proizvoda na uslugu/trošak ako artikal ima zalihu
|
||||
if stari.PratiLager() && stari.Kolicina > 0 && !artikal.PratiLager() {
|
||||
middleware.SetFlash(w, r, h.DB, "greska",
|
||||
fmt.Sprintf("Artikal ima %d %s na stanju. Prvo koriguj količinu na 0 pre promene tipa.", stari.Kolicina, stari.JedinicaMere))
|
||||
http.Redirect(w, r, "/magacin/izmeni/"+idStr, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
artikal.ID = id
|
||||
@@ -257,11 +265,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
artikal.Tip = model.TipProizvod
|
||||
}
|
||||
|
||||
// jedinica mere — podrazumevano "kom"
|
||||
artikal.JedinicaMere = r.FormValue("jedinica_mere")
|
||||
if artikal.JedinicaMere == "" {
|
||||
artikal.JedinicaMere = "kom"
|
||||
}
|
||||
// jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera
|
||||
artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere"))
|
||||
|
||||
if k := r.FormValue("kolicina"); k != "" {
|
||||
v, err := strconv.Atoi(k)
|
||||
@@ -341,3 +346,22 @@ func (h *Handler) PredlogSifre(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
||||
h.renderujTemplate(w, "magacin_forma", podaci)
|
||||
}
|
||||
|
||||
// normalizujJM čisti jedinicu mere: mala slova, samo slova i brojevi, max 4 karaktera.
|
||||
// Ako je rezultat prazan, vraća "kom" kao podrazumevanu vrednost.
|
||||
func normalizujJM(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
if b.Len() >= 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "kom"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -325,13 +325,14 @@ func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
|
||||
return
|
||||
}
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil {
|
||||
if err := h.ProdajaRepo.Obrisi(r.Context(), id, &k.ID); err != nil {
|
||||
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
+17
-15
@@ -644,11 +644,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKod string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||
@@ -754,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
nalogURL := "http"
|
||||
if r.TLS != nil {
|
||||
nalogURL += "s"
|
||||
}
|
||||
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKodOtpr string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
|
||||
@@ -894,11 +886,7 @@ func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) {
|
||||
vaziDo := datumIzdavanja.AddDate(0, 0, rok)
|
||||
|
||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||
nalogURL := "http"
|
||||
if r.TLS != nil {
|
||||
nalogURL += "s"
|
||||
}
|
||||
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
|
||||
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||
var qrKod string
|
||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||
@@ -991,3 +979,17 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
SviStatusi: model.SviStatusi,
|
||||
})
|
||||
}
|
||||
|
||||
// qrNalogURL konstruiše URL za QR kod vodeći računa o reverse proxy-ju.
|
||||
// Ako aplikacija radi iza nginx/Caddy/Traefik koji prekida TLS, r.TLS je nil,
|
||||
// ali X-Forwarded-Proto header sadrži stvarnu šemu.
|
||||
func qrNalogURL(r *http.Request, token string) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/status/" + token
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
UPDATE kategorije
|
||||
SET kod = UPPER(SUBSTR(REPLACE(naziv, ' ', ''), 1, 4))
|
||||
WHERE (kod IS NULL OR kod = '') AND naziv IS NOT NULL AND naziv != '';
|
||||
@@ -112,6 +112,7 @@
|
||||
<input type="text" name="kod" value="{{.Kod}}" maxlength="10"
|
||||
placeholder="npr. MEM"
|
||||
style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:14px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;text-transform:uppercase;">
|
||||
{{if not .Kod}}<div class="pomocni-tekst" style="margin-top:4px;color:#f59e0b;">Kôd nije postavljen — artikli će koristiti prefiks ART-.</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
<label class="polje-labela">Opis</label>
|
||||
|
||||
@@ -242,6 +242,12 @@ document.body.addEventListener('htmx:beforeRequest', function (e) {
|
||||
if (rez) rez.classList.add('bez-anim');
|
||||
}
|
||||
});
|
||||
// Pre nego što HTMX sačuva snapshot stranice u sessionStorage, skloni bez-anim
|
||||
// da snapshot ne prikazuje "zamrznutu" tabelu bez animacije pri sledećoj navigaciji.
|
||||
document.body.addEventListener('htmx:beforeHistorySave', function () {
|
||||
var rez = document.getElementById('magacin-rezultati');
|
||||
if (rez) rez.classList.remove('bez-anim');
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user