diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 610d6e9..2252034 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -58,7 +58,9 @@ func main() { // kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" { if _, err := os.Stat(envFajl); os.IsNotExist(err) { - os.WriteFile(envFajl, []byte(""), 0600) + if err := os.WriteFile(envFajl, []byte(""), 0600); err != nil { + slog.Error("kreiranje praznog ntech.env nije uspelo", "putanja", envFajl, "error", err) + } } } godotenv.Load(envFajl) @@ -141,7 +143,9 @@ func main() { napraviBackup(db, putanjaBaze, max) } - os.MkdirAll("web/static/uploads", 0755) + if err := os.MkdirAll("web/static/uploads", 0755); err != nil { + slog.Error("kreiranje foldera za uploade nije uspelo", "error", err) + } h := handler.Novi(db, totpKljuc) h.Verzija = Verzija @@ -238,18 +242,44 @@ func main() { http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) }))) - // javne rute (bez autentifikacije) - r.Get("/prijava", h.PrikazPrijave) - r.Post("/prijava", h.Prijava) - r.Get("/prijava/totp", h.PrikazTotp) - r.Post("/prijava/totp", h.VerifikujTotp) - r.Get("/setup", h.PrikazSetupa) - r.Post("/setup", h.SacuvajSetup) + // health check — bez autentifikacije, za monitoring/orkestraciju (Docker healthcheck i sl.) + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + if err := db.PingContext(r.Context()); err != nil { + http.Error(w, "baza nedostupna", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + // javne rute (bez autentifikacije), ali i dalje sa CSRF zaštitom — sprečava login-CSRF + r.Group(func(r chi.Router) { + r.Use(ntechmw.CsrfMiddleware) + r.Get("/prijava", h.PrikazPrijave) + r.Post("/prijava", h.Prijava) + r.Get("/prijava/totp", h.PrikazTotp) + r.Post("/prijava/totp", h.VerifikujTotp) + r.Get("/setup", h.PrikazSetupa) + r.Post("/setup", h.SacuvajSetup) + }) r.Get("/odjava", h.Odjava) + // /status/{token}/* NAMERNO je van CSRF grupe — autentikacija je jednokratni + // tajni token u URL-u (capability model), ne kolačić/sesija, pa CSRF ovde ne + // primenjuje isti pretpostavljeni napadački model (napadač bez tokena ne može + // ni da pogodi rutu). r.Get("/status/{token}", h.ServisJavniStatus) - r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) - r.Post("/status/{token}/odbij", h.ServisJavniOdbij) - r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) + r.Group(func(r chi.Router) { + // telo javnih POST-ova (komentar klijenta) ograničeno na 4KB — nema razloga da bude veće + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) + next.ServeHTTP(w, r) + }) + }) + r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) + r.Post("/status/{token}/odbij", h.ServisJavniOdbij) + r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) + }) r.Get("/v/", h.FiskalVerifikacija) // zaštićene rute — zahtevaju prijavljenog korisnika @@ -283,18 +313,19 @@ func main() { }) r.Get("/dashboard", h.Dashboard) r.Get("/podesavanja", h.Podesavanja) - r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) - r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) - r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) - r.Get("/admin/podesavanja/servis", h.PodesavanjaServis) - r.Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija) - r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) - r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) - r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) - r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) - r.With(doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) - r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) - r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) + pregledPodesavanja := ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "podesavanja.pregled") + r.With(pregledPodesavanja).Get("/admin/podesavanja/opste", h.PodesavanjaOpste) + r.With(pregledPodesavanja).Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) + r.With(pregledPodesavanja).Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) + r.With(pregledPodesavanja).Get("/admin/podesavanja/servis", h.PodesavanjaServis) + r.With(pregledPodesavanja).Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija) + r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) + r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) + r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) + r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) + r.With(modul("fiskalizacija"), doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) + r.With(pregledPodesavanja).Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) @@ -349,8 +380,8 @@ func main() { r.Get("/magacin/sledeca-sifra", h.PredlogSifre) r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) - r.With(doz("artikal.obrisi")).Get("/magacin/obrisi/{id}", h.ObrisiArtikal) - r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal) + r.With(doz("artikal.obrisi")).Post("/magacin/obrisi/{id}", h.ObrisiArtikal) + r.With(doz("artikal.obrisi")).Post("/magacin/vrati/{id}", h.VratiArtikal) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/dodaj", h.DodajDobavljacaArtiklu) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) @@ -359,7 +390,7 @@ func main() { r.Get("/magacin/kategorije", h.Kategorije) r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) r.With(doz("kategorija.izmeni")).Post("/magacin/kategorije/izmeni/{id}", h.IzmeniKategoriju) - r.With(doz("kategorija.obrisi")).Get("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) + r.With(doz("kategorija.obrisi")).Post("/magacin/kategorije/obrisi/{id}", h.ObrisiKategoriju) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke", h.Nabavke) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e1868c3..3d887df 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool { // dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju. // Koristi ga IzjednaciVremeProvere kada korisnik ne postoji. -var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost) +var dummyHash []byte + +func init() { + h, err := bcrypt.GenerateFromPassword([]byte("ntech-dummy-lozinka"), bcryptCost) + if err != nil { + // cost je fiksna konstanta i lozinka nije prazna — ovo se praktično ne + // može desiti; ako se ipak desi, tiho propadanje bi obesmislilo + // anti-enumeraciju u IzjednaciVremeProvere, pa je bolje pući na startu. + panic(fmt.Sprintf("ntech: auth: generisanje dummyHash nije uspelo: %v", err)) + } + dummyHash = h +} // IzjednaciVremeProvere izvršava bcrypt poređenje protiv fiksnog heša da bi vreme // odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom — diff --git a/internal/be/kartica.go b/internal/be/kartica.go index dd5dac4..f5beab1 100644 --- a/internal/be/kartica.go +++ b/internal/be/kartica.go @@ -5,6 +5,7 @@ package be import ( "context" + "crypto/subtle" "database/sql" "encoding/base64" "encoding/json" @@ -254,7 +255,7 @@ func (k *Kartica) cmdCertificate() map[string]any { func (k *Kartica) cmdVerifyPin(pin string) map[string]any { k.mu.Lock() defer k.mu.Unlock() - if pin != k.PIN { + if subtle.ConstantTimeCompare([]byte(pin), []byte(k.PIN)) != 1 { return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"} } k.pinUnesen = true diff --git a/internal/db/repository.go b/internal/db/repository.go index e05b9bc..18738fe 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -12,11 +12,17 @@ import ( // (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira. var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi") +// ErrKategorijaUUpotrebi se vraća kad se kategorija ne može obrisati jer je +// referencirana od strane artikla. +var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi") + // ArtikalRepository definiše operacije nad artiklima type ArtikalRepository interface { Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) + // DohvatiVise vraća artikle za dati skup ID-jeva kao mapu id → artikal (jedan upit) + DohvatiVise(ctx context.Context, ids []int64) (map[int64]*model.Artikal, error) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) Izmeni(ctx context.Context, a *model.Artikal) error // AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) @@ -49,6 +55,8 @@ type KategorijaRepository interface { DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) Izmeni(ctx context.Context, k *model.Kategorija) error + // Obrisi briše kategoriju; vraća ErrKategorijaUUpotrebi ako je referencirana od artikla + Obrisi(ctx context.Context, id int64) error } // PdvStopaRepository definiše operacije nad šifarnikom PDV stopa diff --git a/internal/db/sqlite/artikal.go b/internal/db/sqlite/artikal.go index 9766297..77dfef5 100644 --- a/internal/db/sqlite/artikal.go +++ b/internal/db/sqlite/artikal.go @@ -92,7 +92,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod err := redovi.Scan( &a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, - &a.Kolicina, &a.KolicinMin, &a.Lokacija, + &a.Kolicina, &a.KolicinaMin, &a.Lokacija, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran, &a.KategorijaNaziv, &katMarza, ) @@ -118,7 +118,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod } // kritična zaliha važi samo za proizvode (usluge/troškovi nemaju lager) - a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinMin + a.KriticnaZaliha = a.PratiLager() && a.Kolicina <= a.KolicinaMin rezultat = append(rezultat, a) } @@ -126,24 +126,23 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod return rezultat, nil } -// DohvatiID vraća jedan artikal po ID-u -func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { +const artikalKolone = `id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, + lokacija, nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena, datum_unosa, arhiviran` + +// skenirajArtikal čita jedan red artikla iz Scan-kompatibilnog izvora (red ili redovi) +func skenirajArtikal(scan func(...any) error) (*model.Artikal, error) { var a model.Artikal var kategorijaID sql.NullInt64 var sifra, barkod sql.NullString var marza sql.NullFloat64 var arhiviran int - err := r.db.QueryRowContext(ctx, ` - SELECT id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, - lokacija, nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena, datum_unosa, arhiviran - FROM artikli WHERE id = ?`, id).Scan( + if err := scan( &a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, - &a.Kolicina, &a.KolicinMin, &a.Lokacija, + &a.Kolicina, &a.KolicinaMin, &a.Lokacija, &a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran, - ) - if err != nil { - return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) + ); err != nil { + return nil, err } a.Arhiviran = arhiviran == 1 @@ -163,6 +162,52 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, return &a, nil } +// DohvatiID vraća jedan artikal po ID-u +func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { + row := r.db.QueryRowContext(ctx, "SELECT "+artikalKolone+" FROM artikli WHERE id = ?", id) + a, err := skenirajArtikal(row.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err) + } + return a, nil +} + +// DohvatiVise vraća artikle za dati skup ID-jeva kao mapu id → artikal — jedan upit +// umesto poziva DohvatiID u petlji (koristi se npr. pri obradi stavki nabavke). +func (r *ArtikalRepo) DohvatiVise(ctx context.Context, ids []int64) (map[int64]*model.Artikal, error) { + rezultat := make(map[int64]*model.Artikal, len(ids)) + if len(ids) == 0 { + return rezultat, nil + } + + drzaci := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + drzaci[i] = "?" + args[i] = id + } + + redovi, err := r.db.QueryContext(ctx, + "SELECT "+artikalKolone+" FROM artikli WHERE id IN ("+strings.Join(drzaci, ",")+")", args...) + if err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: %w", err) + } + defer redovi.Close() + + for redovi.Next() { + a, err := skenirajArtikal(redovi.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: scan: %w", err) + } + rezultat[a.ID] = a + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiVise: %w", err) + } + + return rezultat, nil +} + // Kreiraj dodaje novi artikal u bazu func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) { var sifra, barkod any @@ -178,7 +223,7 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err (kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija, nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinMin, + a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, a.Kolicina, a.KolicinaMin, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, ) if err != nil { @@ -210,7 +255,7 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error { nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, cena_sa_pdv = ?, marza = ?, napomena = ? WHERE id = ?`, a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, - a.Kolicina, a.KolicinMin, a.Lokacija, + a.Kolicina, a.KolicinaMin, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID, ) if err != nil { diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go index 908224c..6e49473 100644 --- a/internal/db/sqlite/izvestaj.go +++ b/internal/db/sqlite/izvestaj.go @@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) { var n int err := r.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM artikli WHERE kolicina <= kolicina_min`).Scan(&n) + `SELECT COUNT(*) FROM artikli WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0`).Scan(&n) if err != nil { return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err) } @@ -86,7 +86,7 @@ func (r *sqliteIzvestajRepo) PoslednjiServisi(ctx context.Context, limit int) ([ func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) { rows, err := r.db.QueryContext(ctx, ` SELECT naziv, kolicina, kolicina_min FROM artikli - WHERE kolicina <= kolicina_min + WHERE (tip = 'proizvod' OR tip = '') AND kolicina <= kolicina_min AND arhiviran = 0 ORDER BY kolicina ASC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err) @@ -333,7 +333,7 @@ func (r *sqliteIzvestajRepo) StanjeZaliha(ctx context.Context) ([]model.StanjeZa for rows.Next() { var s model.StanjeZalihaRed if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija, - &s.Kolicina, &s.KolicinMin, &s.NabavnaCena, &s.ProdajnaCena, + &s.Kolicina, &s.KolicinaMin, &s.NabavnaCena, &s.ProdajnaCena, &s.CenaSaPdv, &s.VrednostZalihe, &s.VrednostSaPdv); err != nil { return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err) } diff --git a/internal/db/sqlite/izvestaj_test.go b/internal/db/sqlite/izvestaj_test.go index 5d009d0..e9bdf29 100644 --- a/internal/db/sqlite/izvestaj_test.go +++ b/internal/db/sqlite/izvestaj_test.go @@ -20,9 +20,9 @@ func TestIzvestajArtikliBrojaci(t *testing.T) { t.Fatalf("Kreiraj: %v", err) } } - dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5}) - dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) - dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) + dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinaMin: 5}) + dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinaMin: 5}) + dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinaMin: 5}) if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 { t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err) diff --git a/internal/db/sqlite/kategorija.go b/internal/db/sqlite/kategorija.go index 699306a..8a88ec4 100644 --- a/internal/db/sqlite/kategorija.go +++ b/internal/db/sqlite/kategorija.go @@ -3,9 +3,13 @@ package sqlite import ( "context" "database/sql" + "errors" "fmt" + "ntech/internal/db" "ntech/internal/model" + + mosqlite "modernc.org/sqlite" ) // KategorijaRepo je SQLite implementacija KategorijaRepository interfejsa @@ -109,3 +113,17 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error } return nil } + +// Obrisi briše kategoriju. Ako je referencirana od artikla (FK ograničenje), +// vraća db.ErrKategorijaUUpotrebi da pozivalac može da prikaže razumljivu poruku. +func (r *KategorijaRepo) Obrisi(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM kategorije WHERE id = ?", id) + if err != nil { + var sqliteErr *mosqlite.Error + if errors.As(err, &sqliteErr) && sqliteErr.Code() == 787 { + return db.ErrKategorijaUUpotrebi + } + return fmt.Errorf("ntech: KategorijaRepo.Obrisi: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 76bb318..f92ae30 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -122,6 +122,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 2. Naziv firme + mesto @@ -131,6 +133,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 3. Samo naziv firme @@ -152,6 +156,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 2. Ime + prezime + mesto @@ -161,6 +167,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 3. Ime + prezime + telefon @@ -170,6 +178,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 4. Ime + prezime + email @@ -179,6 +189,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email) if err := skeniraj(row); err == nil { goto popuni + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) } } // 5. Samo ime + prezime (poslednji fallback) diff --git a/internal/db/sqlite/magacin.go b/internal/db/sqlite/magacin.go index b847691..7b5a45a 100644 --- a/internal/db/sqlite/magacin.go +++ b/internal/db/sqlite/magacin.go @@ -100,3 +100,63 @@ func zabeleziMagacinPromenu( } return nil } + +// vratiStavkeNaStanje čita (artikal_id, kolicina) iz izvorne tabele stavki +// (stavke_prodaje ili servisni_delovi — uvek fiksan literal iz poziva, nikad +// korisnički unos) za dati nalog, vraća proizvode na stanje i beleži magacinsku +// promenu za svaki. Usluge i troškovi (koji ne prate stanje) se preskaču. +// Zajednički je za Storno/Obrisi u ProdajaRepo i ServisRepo — sve unutar +// postojeće transakcije pozivaoca. +func vratiStavkeNaStanje(ctx context.Context, tx *sql.Tx, tabelaStavki string, nalogID int64, korisnikID *int64, napomena string) error { + redovi, err := tx.QueryContext(ctx, + "SELECT artikal_id, kolicina FROM "+tabelaStavki+" WHERE nalog_id = ?", nalogID) + if err != nil { + return fmt.Errorf("dohvati stavke: %w", err) + } + + type stavkaPovrat struct { + artikalID int64 + kolicina int + } + var stavke []stavkaPovrat + for redovi.Next() { + var p stavkaPovrat + if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { + redovi.Close() + return fmt.Errorf("scan stavke: %w", err) + } + stavke = append(stavke, p) + } + redovi.Close() + if err := redovi.Err(); err != nil { + return fmt.Errorf("iteracija stavki: %w", err) + } + + for _, p := range stavke { + var stanjePre int + var tip string + if err := tx.QueryRowContext(ctx, + "SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID, + ).Scan(&stanjePre, &tip); err != nil { + return fmt.Errorf("dohvati stanje: %w", err) + } + + // usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj + if !(tip == model.TipProizvod || tip == "") { + continue + } + + stanjePosle := stanjePre + p.kolicina + if _, err := tx.ExecContext(ctx, + "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID, + ); err != nil { + return fmt.Errorf("vrati stanje: %w", err) + } + + if err := zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj, + p.kolicina, stanjePre, stanjePosle, nalogID, korisnikID, napomena); err != nil { + return fmt.Errorf("magacin: %w", err) + } + } + return nil +} diff --git a/internal/db/sqlite/migracije.go b/internal/db/sqlite/migracije.go index 789bcfb..0bc41e8 100644 --- a/internal/db/sqlite/migracije.go +++ b/internal/db/sqlite/migracije.go @@ -12,23 +12,26 @@ import ( _ "modernc.org/sqlite" ) -// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja +// OtvoriDB otvara konekciju ka SQLite bazi i primenjuje performance PRAGMA podešavanja. +// +// Pragme se prosleđuju kroz DSN (_pragma=...) jer database/sql drži pul konekcija — +// PRAGMA postavljena preko db.Exec važi samo za konekciju koja je taj Exec uslužila, +// dok DSN _pragma parametri primenjuje driver na SVAKU novu konekciju pri otvaranju. func OtvoriDB(putanja string) (*sql.DB, error) { - db, err := sql.Open("sqlite", putanja) + dsn := "file:" + putanja + + "?_pragma=journal_mode(WAL)" + + "&_pragma=synchronous(NORMAL)" + + "&_pragma=cache_size(10000)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + "&_txlock=immediate" + + db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) } - - pragme := []string{ - "PRAGMA journal_mode=WAL", - "PRAGMA synchronous=NORMAL", - "PRAGMA cache_size=10000", - "PRAGMA foreign_keys=ON", - } - for _, p := range pragme { - if _, err := db.Exec(p); err != nil { - return nil, fmt.Errorf("ntech: OtvoriDB: %s: %w", p, err) - } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) } return db, nil diff --git a/internal/db/sqlite/pdv_evidencija.go b/internal/db/sqlite/pdv_evidencija.go index 412bff3..534e3e4 100644 --- a/internal/db/sqlite/pdv_evidencija.go +++ b/internal/db/sqlite/pdv_evidencija.go @@ -33,11 +33,11 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi args := []any{} if !od.IsZero() { upit += " AND datum_prometa >= ?" - args = append(args, od) + args = append(args, od.Format("2006-01-02")) } if !do.IsZero() { upit += " AND datum_prometa <= ?" - args = append(args, do) + args = append(args, do.Format("2006-01-02")) } upit += " ORDER BY datum_prometa ASC, id ASC" @@ -105,7 +105,7 @@ func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta, + k.DatumPrometa.Format("2006-01-02"), k.DatumKnjizenja.Format("2006-01-02"), k.BrojDokumenta, k.KupacNaziv, k.KupacPib, k.KupacMesto, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena, @@ -229,11 +229,11 @@ func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKp args := []any{} if !od.IsZero() { upit += " AND datum_prometa >= ?" - args = append(args, od) + args = append(args, od.Format("2006-01-02")) } if !do.IsZero() { upit += " AND datum_prometa <= ?" - args = append(args, do) + args = append(args, do.Format("2006-01-02")) } upit += " ORDER BY datum_prometa ASC, id ASC" @@ -302,7 +302,7 @@ func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) { func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) { var datumPlacanja any if k.DatumPlacanja != nil { - datumPlacanja = *k.DatumPlacanja + datumPlacanja = k.DatumPlacanja.Format("2006-01-02") } uvoz := 0 if k.Uvoz { @@ -315,7 +315,7 @@ func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta, + k.DatumPrometa.Format("2006-01-02"), k.DatumKnjizenja.Format("2006-01-02"), datumPlacanja, k.BrojDokumenta, k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, diff --git a/internal/db/sqlite/prodaja.go b/internal/db/sqlite/prodaja.go index c405dc2..e292228 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -294,53 +294,8 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris } // vraćanje stanja u magacin - redovi, err := tx.QueryContext(ctx, - "SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) - if err != nil { - return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stavke: %w", err) - } - - type stavkaPovrat struct { - artikalID int64 - kolicina int - } - var stavke []stavkaPovrat - for redovi.Next() { - var p stavkaPovrat - if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { - redovi.Close() - return fmt.Errorf("ntech: ProdajaRepo.Storno: scan stavke: %w", err) - } - stavke = append(stavke, p) - } - redovi.Close() - - for _, p := range stavke { - 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.Storno: dohvati stanje: %w", err) - } - - // usluge i troškovi nemaju stanje na lageru — preskačemo povraćaj - 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.Storno: vrati stanje: %w", err) - } - - err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj, - p.kolicina, stanjePre, stanjePosle, id, korisnikID, razlog) - if err != nil { - return fmt.Errorf("ntech: ProdajaRepo.Storno: magacin: %w", err) - } + if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, razlog); err != nil { + return fmt.Errorf("ntech: ProdajaRepo.Storno: %w", err) } _, err = tx.ExecContext(ctx, @@ -374,54 +329,8 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e } if !stornirano { - redovi, err := tx.QueryContext(ctx, - "SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) - if err != nil { - return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err) - } - - type povrat struct { - artikalID int64 - kolicina int - } - var stavke []povrat - for redovi.Next() { - var p povrat - if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { - redovi.Close() - return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err) - } - stavke = append(stavke, p) - } - redovi.Close() - - for _, p := range stavke { - // usluge i troškovi nemaju stanje — vraćamo samo proizvodima - 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) - } + if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, "brisanje prodajnog naloga"); err != nil { + return fmt.Errorf("ntech: ProdajaRepo.Obrisi: %w", err) } } @@ -446,7 +355,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) // broj naloga tog dana err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM prodajni_nalozi - WHERE klijent_id IS NULL AND stornirano = 0 AND DATE(datum) = ?`, datum, + WHERE klijent_id IS NULL AND stornirano = 0 AND substr(datum, 1, 10) = ?`, datum, ).Scan(&p.BrojNaloga) if err != nil { return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) @@ -458,7 +367,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string) SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina) FROM stavke_prodaje s JOIN prodajni_nalozi p ON p.id = s.nalog_id - WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND DATE(p.datum) = ? + WHERE p.klijent_id IS NULL AND p.stornirano = 0 AND substr(p.datum, 1, 10) = ? GROUP BY s.pdv_stopa`, datum, ) if err != nil { diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index 47255f4..1682eb6 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -394,53 +394,8 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er defer tx.Rollback() // pokupi ugrađene delove pre brisanja (CASCADE bi ih obrisao bez povraćaja) - redovi, err := tx.QueryContext(ctx, - "SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) - if err != nil { - return fmt.Errorf("ntech: ServisRepo.Obrisi: dohvati delove: %w", err) - } - type povrat struct { - artikalID int64 - kolicina int - } - var delovi []povrat - for redovi.Next() { - var p povrat - if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { - redovi.Close() - return fmt.Errorf("ntech: ServisRepo.Obrisi: scan dela: %w", err) - } - delovi = append(delovi, p) - } - redovi.Close() - - for _, p := range delovi { - // usluge i troškovi nemaju stanje na lageru — vraćamo samo proizvodima - 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: ServisRepo.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: ServisRepo.Obrisi: vrati stanje: %w", err) - } - - err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj, - p.kolicina, stanjePre, stanjePosle, id, korisnikID, "brisanje servisnog naloga") - if err != nil { - return fmt.Errorf("ntech: ServisRepo.Obrisi: magacin: %w", err) - } + if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "brisanje servisnog naloga"); err != nil { + return fmt.Errorf("ntech: ServisRepo.Obrisi: %w", err) } // potraživani delovi nemaju ON DELETE CASCADE — ručno ih čistimo da brisanje ne padne na FK @@ -480,52 +435,8 @@ func (r *ServisRepo) Storno(ctx context.Context, id int64, razlog string, korisn return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran") } - redovi, err := tx.QueryContext(ctx, - "SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) - if err != nil { - return fmt.Errorf("ntech: ServisRepo.Storno: dohvati delove: %w", err) - } - type povrat struct { - artikalID int64 - kolicina int - } - var delovi []povrat - for redovi.Next() { - var p povrat - if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil { - redovi.Close() - return fmt.Errorf("ntech: ServisRepo.Storno: scan dela: %w", err) - } - delovi = append(delovi, p) - } - redovi.Close() - - for _, p := range delovi { - 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: ServisRepo.Storno: 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: ServisRepo.Storno: vrati stanje: %w", err) - } - - err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj, - p.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno servisnog naloga") - if err != nil { - return fmt.Errorf("ntech: ServisRepo.Storno: magacin: %w", err) - } + if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "storno servisnog naloga"); err != nil { + return fmt.Errorf("ntech: ServisRepo.Storno: %w", err) } if _, err := tx.ExecContext(ctx, diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 08487ad..e965981 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "log/slog" "ntech/internal/model" ) @@ -80,16 +79,13 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) { // Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge) if predlozeno { - slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina) _, err = tx.ExecContext(ctx, "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)", nalogID, artikalID, kolicina, cenaKomada, ) if err != nil { - slog.Error("PREDLOG_INSERT_ERR", "err", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err) } - slog.Info("PREDLOG_INSERT_OK") return 0, kolicina, nil } @@ -263,6 +259,9 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int } stavke = append(stavke, s) } + if err := redovi.Err(); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: rows: %w", err) + } // Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može) for _, s := range stavke { diff --git a/internal/handler/kategorija.go b/internal/handler/kategorija.go index f86809d..daf5fc7 100644 --- a/internal/handler/kategorija.go +++ b/internal/handler/kategorija.go @@ -1,11 +1,14 @@ package handler import ( + "errors" "net/http" "strconv" "strings" + "ntech/internal/db" "ntech/internal/db/sqlite" + "ntech/internal/middleware" "ntech/internal/model" "github.com/go-chi/chi/v5" @@ -159,7 +162,12 @@ func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) { return } - if _, err := h.DB.ExecContext(r.Context(), "DELETE FROM kategorije WHERE id = ?", id); err != nil { + if err := h.KategorijeRepo.Obrisi(r.Context(), id); err != nil { + if errors.Is(err, db.ErrKategorijaUUpotrebi) { + middleware.SetFlash(w, r, h.DB, "greska", "Kategorija je u upotrebi kod artikala i ne može se obrisati.") + http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther) + return + } http.Error(w, "Greška pri brisanju kategorije", http.StatusInternalServerError) return } diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 04907c4..c6d1fe9 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -129,6 +129,15 @@ var sablonskeFunkcije = template.FuncMap{ }, // zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima "zbirF64": func(a, b float64) float64 { return a + b }, + // inicijali vraća prva najviše n RUNA stringa (ne bajta) — sigurno za ćirilicu + // i č/ć/š/ž/đ, za razliku od ugrađenog {{slice}} koji seče po bajtovima. + "inicijali": func(s string, n int) string { + r := []rune(s) + if len(r) > n { + r = r[:n] + } + return string(r) + }, } // KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 523e112..9283254 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "net/url" "strconv" "strings" @@ -82,10 +83,10 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) { queryDelići := "" if pretraga != "" { - queryDelići += "&pretraga=" + pretraga + queryDelići += "&pretraga=" + url.QueryEscape(pretraga) } if tipFilter != "" { - queryDelići += "&tip=" + tipFilter + queryDelići += "&tip=" + url.QueryEscape(tipFilter) } stranicaPrev := stranicaBr - 1 diff --git a/internal/handler/magacin.go b/internal/handler/magacin.go index d11f62a..902a109 100644 --- a/internal/handler/magacin.go +++ b/internal/handler/magacin.go @@ -4,6 +4,7 @@ import ( "errors" "log/slog" "net/http" + "net/url" "strconv" "ntech/internal/db" @@ -104,10 +105,10 @@ func (h *Handler) magacinPrikaz(w http.ResponseWriter, r *http.Request, tip, str // izgradi query string za paginaciju (čuva filtere) queryDelići := "" if v := filter.Pretraga; v != "" { - queryDelići += "&pretraga=" + v + queryDelići += "&pretraga=" + url.QueryEscape(v) } if katIDStr != "" { - queryDelići += "&kategorija=" + katIDStr + queryDelići += "&kategorija=" + url.QueryEscape(katIDStr) } if filter.SamoKriticni { queryDelići += "&kriticni=1" diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 2c6f559..eb39f02 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -374,13 +374,13 @@ func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artik if err != nil || v < 0 { return artikal, "Minimalna količina mora biti pozitivan broj." } - artikal.KolicinMin = v + artikal.KolicinaMin = v } // usluge i troškovi nemaju stanje na lageru if !artikal.PratiLager() { artikal.Kolicina = 0 - artikal.KolicinMin = 0 + artikal.KolicinaMin = 0 } if c := r.FormValue("nabavna_cena"); c != "" { diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index 02451bd..a11be1e 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -196,12 +196,27 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { return } + // jedan batch-dohvat svih artikala iz stavki — umesto DohvatiID po stavki u svakoj petlji ispod + artikalIDji := make([]int64, 0, len(stavke)) + vidjeni := make(map[int64]bool, len(stavke)) + for _, s := range stavke { + if !vidjeni[s.ArtikalID] { + vidjeni[s.ArtikalID] = true + artikalIDji = append(artikalIDji, s.ArtikalID) + } + } + artikliMapa, err := h.Artikli.DohvatiVise(r.Context(), artikalIDji) + if err != nil { + slog.Error("dohvat artikala za nabavku nije uspeo", "error", err) + artikliMapa = map[int64]*model.Artikal{} + } + // automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla if h.modulUkljucen(r.Context(), "pdv") { var stavkePdv []model.NabavkaStavkaPdv for _, s := range stavke { var stopa float64 - if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil { + if a, ok := artikliMapa[s.ArtikalID]; ok { stopa = a.PdvStopa } stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{ @@ -241,7 +256,7 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) { } // stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo var staraProdajna, nabavna float64 - if a, e := h.Artikli.DohvatiID(r.Context(), s.ArtikalID); e == nil { + if a, ok := artikliMapa[s.ArtikalID]; ok { staraProdajna = a.ProdajnaCena nabavna = a.NabavnaCena } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 77a4988..05a58c7 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) { klijent := &http.Client{Timeout: 5 * time.Second} resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese) if err != nil { + slog.Error("test fiskalizacije: konekcija nije uspela", "url", statusURL, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `