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)
|
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
|
||||||
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, 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
|
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)
|
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)
|
// 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)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: begin tx: %w", err)
|
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 {
|
for _, p := range stavke {
|
||||||
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
|
// usluge i troškovi nemaju stanje — vraćamo samo proizvodima
|
||||||
_, err := tx.ExecContext(ctx,
|
var stanjePre int
|
||||||
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ? AND (tip = 'proizvod' OR tip = '')",
|
var tip string
|
||||||
p.kolicina, p.artikalID,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
|
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"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
@@ -88,24 +90,23 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
||||||
return
|
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.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
|
// fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog artikla
|
||||||
if r.Header.Get("X-Requested-With") == "fetch" {
|
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
|
var staraCena float64
|
||||||
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
|
if stari, e := h.Artikli.DohvatiID(r.Context(), id); e == nil {
|
||||||
staraCena = stari.ProdajnaCena
|
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
|
artikal.ID = id
|
||||||
@@ -257,11 +265,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
|||||||
artikal.Tip = model.TipProizvod
|
artikal.Tip = model.TipProizvod
|
||||||
}
|
}
|
||||||
|
|
||||||
// jedinica mere — podrazumevano "kom"
|
// jedinica mere — normalizacija: mala slova, samo slova/brojevi, max 4 karaktera
|
||||||
artikal.JedinicaMere = r.FormValue("jedinica_mere")
|
artikal.JedinicaMere = normalizujJM(r.FormValue("jedinica_mere"))
|
||||||
if artikal.JedinicaMere == "" {
|
|
||||||
artikal.JedinicaMere = "kom"
|
|
||||||
}
|
|
||||||
|
|
||||||
if k := r.FormValue("kolicina"); k != "" {
|
if k := r.FormValue("kolicina"); k != "" {
|
||||||
v, err := strconv.Atoi(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) {
|
func (h *Handler) renderujFormuArtikla(w http.ResponseWriter, podaci PodaciFormeArtikla) {
|
||||||
h.renderujTemplate(w, "magacin_forma", podaci)
|
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 {
|
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi"); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||||
id, err := parseID(chi.URLParam(r, "id"))
|
id, err := parseID(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||||
return
|
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)
|
http.Error(w, "Greška pri brisanju naloga", http.StatusInternalServerError)
|
||||||
return
|
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
|
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||||
scheme := "http"
|
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||||
if r.TLS != nil {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
nalogURL := scheme + "://" + r.Host + "/status/" + nalog.JavniToken
|
|
||||||
var qrKod string
|
var qrKod string
|
||||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||||
@@ -754,11 +750,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nalogURL := "http"
|
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||||
if r.TLS != nil {
|
|
||||||
nalogURL += "s"
|
|
||||||
}
|
|
||||||
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
|
|
||||||
var qrKodOtpr string
|
var qrKodOtpr string
|
||||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||||
qrKodOtpr = base64.StdEncoding.EncodeToString(png)
|
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)
|
vaziDo := datumIzdavanja.AddDate(0, 0, rok)
|
||||||
|
|
||||||
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
// QR kod vodi na javnu status stranicu — dostupnu bez prijave
|
||||||
nalogURL := "http"
|
nalogURL := qrNalogURL(r, nalog.JavniToken)
|
||||||
if r.TLS != nil {
|
|
||||||
nalogURL += "s"
|
|
||||||
}
|
|
||||||
nalogURL += "://" + r.Host + "/status/" + nalog.JavniToken
|
|
||||||
var qrKod string
|
var qrKod string
|
||||||
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
if png, err := qrcode.Encode(nalogURL, qrcode.Medium, 160); err == nil {
|
||||||
qrKod = base64.StdEncoding.EncodeToString(png)
|
qrKod = base64.StdEncoding.EncodeToString(png)
|
||||||
@@ -991,3 +979,17 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
SviStatusi: model.SviStatusi,
|
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"
|
<input type="text" name="kod" value="{{.Kod}}" maxlength="10"
|
||||||
placeholder="npr. MEM"
|
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;">
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="polje-labela">Opis</label>
|
<label class="polje-labela">Opis</label>
|
||||||
|
|||||||
@@ -242,6 +242,12 @@ document.body.addEventListener('htmx:beforeRequest', function (e) {
|
|||||||
if (rez) rez.classList.add('bez-anim');
|
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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user