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, `
✗ Neispravan odgovor servera — %s
`, html.EscapeString(err.Error())) + return + } sdcDateTime, _ := status["sdcDateTime"].(string) lastInvoice, _ := status["lastInvoiceNumber"].(string) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 977ad99..af5d1f3 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -2076,7 +2076,10 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) { } } } - _ = h.ServisRepo.SacuvajNaplatu(r.Context(), id, nacin, iznos) + if err := h.ServisRepo.SacuvajNaplatu(r.Context(), id, nacin, iznos); err != nil { + slog.Error("greška pri čuvanju naplate servisa", "id", id, "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Naplata nije sačuvana. Pokušajte ponovo.") + } // fiskalizacija servisa — ako je modul uključen if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) { @@ -2145,8 +2148,16 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent return } - radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, servisID) - delovi, _ := h.deloviSaPotrazivanima(ctx, servisID) + radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, servisID) + if err != nil { + slog.Error("fiskalizujServis: greška pri dohvatanju radova", "id", servisID, "error", err) + return + } + delovi, err := h.deloviSaPotrazivanima(ctx, servisID) + if err != nil { + slog.Error("fiskalizujServis: greška pri dohvatanju delova", "id", servisID, "error", err) + return + } // napravi stavke za fiskalni račun items := make([]fiskal.InvoiceItem, 0) @@ -2154,12 +2165,11 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent if r.Predlozeno { continue } - stopa := 20.0 // podrazumevano — servisne usluge su 20% PDV items = append(items, fiskal.InvoiceItem{ Name: r.Naziv, - Labels: []string{fiskal.OznakaPDV(stopa)}, - TotalAmount: fiskal.BrutoCena(r.Ukupno(), stopa), - UnitPrice: fiskal.BrutoCena(r.CenaKomada, stopa), + Labels: []string{fiskal.OznakaPDV(r.PdvStopa)}, + TotalAmount: fiskal.BrutoCena(r.Ukupno(), r.PdvStopa), + UnitPrice: fiskal.BrutoCena(r.CenaKomada, r.PdvStopa), Quantity: r.Kolicina, }) } @@ -2167,15 +2177,18 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent if d.Predlozeno { continue } - stopa := 20.0 items = append(items, fiskal.InvoiceItem{ Name: d.ArtikalNaziv, - Labels: []string{fiskal.OznakaPDV(stopa)}, - TotalAmount: fiskal.BrutoCena(d.Ukupno(), stopa), - UnitPrice: fiskal.BrutoCena(d.CenaKomada, stopa), + Labels: []string{fiskal.OznakaPDV(d.PdvStopa)}, + TotalAmount: fiskal.BrutoCena(d.Ukupno(), d.PdvStopa), + UnitPrice: fiskal.BrutoCena(d.CenaKomada, d.PdvStopa), Quantity: float64(d.Kolicina), }) } + if len(items) == 0 { + slog.Warn("fiskalizujServis: nema stavki za fiskalni račun, zahtev odbačen", "id", servisID) + return + } pib := "" if nalog.KlijentID != nil { diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 074c520..5898881 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -105,8 +105,8 @@ func (k *PdvKir) DodajNeto(osnovica, stopa float64) { } // KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi -// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV, -// pa se osnovica izvodi deljenjem sa (1 + stopa/100). +// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena BEZ PDV +// (neto), PDV se dodaje naviše — isti obrazac kao kod servisnih radova/delova. func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupacPib, kupacMesto string) PdvKir { id := nalog.ID k := PdvKir{ @@ -120,12 +120,7 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac IzvorID: &id, } for _, s := range stavke { - ukupnoLinija := float64(s.Kolicina) * s.CenaPoKomadu - osnovica := ukupnoLinija - if s.PdvStopa > 0 { - osnovica = ukupnoLinija / (1 + s.PdvStopa/100) - } - k.dodajStavku(osnovica, ukupnoLinija-osnovica, s.PdvStopa) + k.DodajNeto(float64(s.Kolicina)*s.CenaPoKomadu, s.PdvStopa) } return k } diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 6b07f20..c817300 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -79,9 +79,13 @@ Naziv Kategorija Količina + {{if index .Moduli "pdv"}} Cena ▾ bez PDV + {{else}} + Cena + {{end}} Lokacija Akcije @@ -104,7 +108,7 @@ {{end}} - {{dinari .ProdajnaCena}} din + {{dinari .ProdajnaCena}}{{if index $.Moduli "pdv"}}{{end}} din {{if .Lokacija}}{{.Lokacija}}{{else}}—{{end}} @@ -259,6 +263,7 @@ document.body.addEventListener('htmx:afterSwap', function (e) { rez.classList.remove('bez-anim'); }); +{{if index .Moduli "pdv"}} // toggle cena: bez PDV ↔ sa PDV var cenaPdvRezim = false; // false = bez PDV function toggleCenaPdv() { @@ -270,6 +275,7 @@ function toggleCenaPdv() { for (var j = 0; j < sa.length; j++) { sa[j].style.display = cenaPdvRezim ? '' : 'none'; } if (tgl) tgl.textContent = cenaPdvRezim ? '▾ sa PDV' : '▾ bez PDV'; } +{{end}} {{end}}