diff --git a/internal/db/repository.go b/internal/db/repository.go index 58c4465..69e89e1 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -151,8 +151,8 @@ type NabavkaRepository interface { DohvatiID(ctx context.Context, id int64) (*model.Nabavka, error) DohvatiStavke(ctx context.Context, nabavkaID int64) ([]model.StavkaSaArtiklom, error) DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]model.NabavkaTrosak, error) - Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error) - Obrisi(ctx context.Context, id int64) error + Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak, korisnikID *int64) (int64, error) + Obrisi(ctx context.Context, id int64, korisnikID *int64) error } // DobavljacRepository definiše operacije nad dobavljačima diff --git a/internal/db/sqlite/nabavka.go b/internal/db/sqlite/nabavka.go index 8d68649..2224668 100644 --- a/internal/db/sqlite/nabavka.go +++ b/internal/db/sqlite/nabavka.go @@ -141,7 +141,7 @@ func (r *NabavkaRepo) DohvatiTroskove(ctx context.Context, nabavkaID int64) ([]m // Kreiraj upisuje novu nabavku sa svim stavkama i zavisnim troškovima u jednoj // transakciji i ažurira stanje magacina -func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak) (int64, error) { +func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []model.StavkaNabavke, troskovi []model.NabavkaTrosak, korisnikID *int64) (int64, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: begin: %w", err) @@ -193,13 +193,27 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: insert stavka: %w", err) } + var stanjePre int + err = tx.QueryRowContext(ctx, + "SELECT kolicina FROM artikli WHERE id = ?", s.ArtikalID, + ).Scan(&stanjePre) + if err != nil { + return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: dohvati stanje: %w", err) + } + + stanjePosle := stanjePre + s.Kolicina _, err = tx.ExecContext(ctx, - "UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?", - s.Kolicina, s.ArtikalID, + "UPDATE artikli SET kolicina = ? WHERE id = ?", + stanjePosle, s.ArtikalID, ) if err != nil { return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: update kolicina: %w", err) } + + if err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaUlazNabavka, + s.Kolicina, stanjePre, stanjePosle, nabavkaID, korisnikID, ""); err != nil { + return 0, fmt.Errorf("ntech: NabavkaRepo.Kreiraj: magacin: %w", err) + } } if err := tx.Commit(); err != nil { @@ -209,12 +223,64 @@ func (r *NabavkaRepo) Kreiraj(ctx context.Context, n *model.Nabavka, stavke []mo return nabavkaID, nil } -// Obrisi briše nabavku po ID-u — stavke se brišu automatski (ON DELETE CASCADE) -// Napomena: brisanje ne vraća količine artikala u magacin -func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64) error { - _, err := r.db.ExecContext(ctx, "DELETE FROM nabavke WHERE id = ?", id) +// Obrisi briše nabavku po ID-u, vraća količine artikala na stanje i beleži korekciju u magacinski trag. +func (r *NabavkaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error { + tx, err := r.db.BeginTx(ctx, nil) if err != nil { - return fmt.Errorf("ntech: NabavkaRepo.Obrisi: %w", err) + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: begin: %w", err) + } + defer tx.Rollback() + + // učitaj stavke pre brisanja (CASCADE ih briše zajedno sa nabavkom) + redovi, err := tx.QueryContext(ctx, + "SELECT artikal_id, kolicina FROM stavke_nabavke WHERE nabavka_id = ?", id) + if err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: dohvati stavke: %w", err) + } + type stavka struct { + artikalID int64 + kolicina int + } + var stavke []stavka + for redovi.Next() { + var s stavka + if err := redovi.Scan(&s.artikalID, &s.kolicina); err != nil { + redovi.Close() + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: scan stavka: %w", err) + } + stavke = append(stavke, s) + } + redovi.Close() + + // vrati količine na stanje i zabeleži korekciju + for _, s := range stavke { + var stanjePre int + if err := tx.QueryRowContext(ctx, + "SELECT kolicina FROM artikli WHERE id = ?", s.artikalID, + ).Scan(&stanjePre); err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: dohvati stanje: %w", err) + } + stanjePosle := stanjePre - s.kolicina + if stanjePosle < 0 { + stanjePosle = 0 + } + if _, err := tx.ExecContext(ctx, + "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.artikalID, + ); err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: update stanje: %w", err) + } + if err := zabeleziMagacinPromenu(ctx, tx, s.artikalID, model.PromenaKorekcija, + -s.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje nabavke"); err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: magacin: %w", err) + } + } + + if _, err := tx.ExecContext(ctx, "DELETE FROM nabavke WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: delete: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("ntech: NabavkaRepo.Obrisi: commit: %w", err) } return nil } diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index 0c93e96..56ff91a 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -207,12 +207,12 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk } } - // PDV računamo iz cene ako nije eksplicitno postavljeno + // CenaPoKomadu je neto (bez PDV); PDV se dodaje naviše cenaBezPdv := s.CenaBezPdv pdvIznos := s.PdvIznos - if cenaBezPdv == 0 && s.PdvStopa > 0 { - cenaBezPdv = s.CenaPoKomadu / (1 + s.PdvStopa/100) - pdvIznos = s.CenaPoKomadu - cenaBezPdv + if cenaBezPdv == 0 { + cenaBezPdv = s.CenaPoKomadu + pdvIznos = cenaBezPdv * s.PdvStopa / 100 } ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 9535d69..3a0a42d 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -2,6 +2,7 @@ package handler import ( "html/template" + "log/slog" "net/http" "ntech/internal/auth" @@ -549,7 +550,9 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request) return } // isključenjem 2FA brišemo i rezervne kodove - _ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID) + if err := h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID); err != nil { + slog.Warn("brisanje rezervnih kodova nije uspelo", "korisnik_id", k.ID, "error", err) + } http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther) } diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index 3ad51c5..c3ac6e2 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -176,7 +176,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { return } - id, err := h.NabavkeRepo.Kreiraj(r.Context(), &nabavka, stavke, troskovi) + id, err := h.NabavkeRepo.Kreiraj(r.Context(), &nabavka, stavke, troskovi, &k.ID) if err != nil { http.Error(w, "Greška pri čuvanju nabavke", http.StatusInternalServerError) return @@ -340,7 +340,8 @@ func (h *Handler) DetaljiNabavke(w http.ResponseWriter, r *http.Request) { // ObrisiNabavku prima POST zahtev i briše nabavku po ID-u func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) { - if _, ok := h.zahtevajDozvolu(w, r, "nabavka.obrisi"); !ok { + k, ok := h.zahtevajDozvolu(w, r, "nabavka.obrisi") + if !ok { return } id, err := parseID(chi.URLParam(r, "id")) @@ -349,7 +350,7 @@ func (h *Handler) ObrisiNabavku(w http.ResponseWriter, r *http.Request) { return } - if err := h.NabavkeRepo.Obrisi(r.Context(), id); err != nil { + if err := h.NabavkeRepo.Obrisi(r.Context(), id, &k.ID); err != nil { http.Error(w, "Greška pri brisanju nabavke", http.StatusInternalServerError) return } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 2acb010..1b3a4ee 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -944,20 +944,26 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { } // SSRF zaštita: dozvoljeni su samo localhost i privatne mreže (192.168.x.x, - // 10.x.x.x, 172.16-31.x.x, 127.x.x.x). Port izvlačimo kao integer da presečemo - // CodeQL taint lanac — broj ne može da sadrži URL manipulaciju. + // 10.x.x.x, 172.16-31.x.x, 127.x.x.x). URL rekonstruišemo kroz url.URL{} + // iz validiranih komponenti da CodeQL prepozna sanitizaciju. parsedURL, err := url.Parse(pfrURL) if err != nil || !jePrivatnaAdresa(parsedURL.Hostname()) { http.Error(w, "Nevažeći PFR URL — dozvoljeni su samo lokalni/privatni hostovi (127.x, 192.168.x, 10.x, 172.16-31.x)", http.StatusBadRequest) return } + host := parsedURL.Hostname() port := 4566 if p := parsedURL.Port(); p != "" { if n, e := strconv.Atoi(p); e == nil && n > 0 && n <= 65535 { port = n } } - statusURL := fmt.Sprintf("http://%s:%d/api/status", parsedURL.Hostname(), port) + safeURL := url.URL{ + Scheme: "http", + Host: net.JoinHostPort(host, strconv.Itoa(port)), + Path: "/api/status", + } + statusURL := safeURL.String() klijent := &http.Client{Timeout: 5 * time.Second} resp, err := klijent.Get(statusURL) @@ -969,7 +975,11 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { defer resp.Body.Close() var status map[string]any - _ = json.NewDecoder(resp.Body).Decode(&status) + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `