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, `
✗ Nije dostupan — %s
`, html.EscapeString(err.Error())) + fmt.Fprint(w, `
✗ Nije dostupan — proverite adresu i mrežu
`) return } defer resp.Body.Close() var status map[string]any if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + slog.Error("test fiskalizacije: neispravan odgovor servera", "url", statusURL, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
✗ Neispravan odgovor servera — %s
`, html.EscapeString(err.Error())) + fmt.Fprint(w, `
✗ Neispravan odgovor servera
`) return } @@ -1041,9 +1043,10 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { conn, err := net.DialTimeout("tcp", addr, 2*time.Second) if err != nil { + slog.Error("BeStatus: emulator nedostupan", "addr", addr, "error", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
✗ Emulator nije dostupan na %s — %s
`, - html.EscapeString(addr), html.EscapeString(err.Error())) + fmt.Fprintf(w, `
✗ Emulator nije dostupan na %s
`, + html.EscapeString(addr)) return } defer conn.Close() @@ -1090,7 +1093,7 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) { // BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment. func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) { - if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok { + if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok { return } bePort := os.Getenv("BE_PORT") diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index 822dcd0..719c4a1 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -186,6 +186,23 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) { return } + // isti bruteforce brojač kao za lozinku — deli IP-bazirano zaključavanje + ip := izvuciIP(r) + od := time.Now().Add(-prozorPrijave) + n, _ := h.PokusajiRepo.BrojNeuspeha(r.Context(), ip, od) + if n >= maxNeuspehaPrijave { + if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj { + auth.LogZaklucano(ip, korisnik.KorisnickoIme) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "ip_zaklucano_totp", false) + h.renderujStandalone(w, "totp_provera", map[string]any{ + "Greska": "zakljucano", + "Preostalo": preostalo, + "CsrfToken": middleware.CsrfToken(r.Context()), + }) + return + } + } + kod := r.FormValue("kod") validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna) if !validan { @@ -196,10 +213,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) { } } if !validan { + _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, false) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešan_totp", false) + auth.LogNeuspehPrijave(ip, korisnik.KorisnickoIme, "wrong_totp") http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther) return } + _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, true) + novoIstice := time.Now().Add(trajanjeSeije) if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil { http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) @@ -295,24 +317,41 @@ func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Ti return fmt.Sprintf("%d sek", sek), true } -// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja), -// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr +// izvuciIP čita pravi IP klijenta iz X-Real-IP/X-Forwarded-For, ALI samo ako +// zahtev fizički stiže sa poverljive adrese (Caddy na istom hostu ili Docker +// mreža — loopback/privatni opseg). Ova zaglavlja su inače potpuno pod +// kontrolom klijenta; slepo verovanje u njih zaobilazi bruteforce zaključavanje +// prijave/TOTP-a. Ako RemoteAddr nije poverljiv, header se ignoriše. func izvuciIP(r *http.Request) string { - if ip := r.Header.Get("X-Real-IP"); ip != "" { - return ip - } - if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - // uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent - parts := strings.Split(fwd, ",") - return strings.TrimSpace(parts[len(parts)-1]) - } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { - return r.RemoteAddr + host = r.RemoteAddr } + + if remoteAdresaJePoverljiva(host) { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + // uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[len(parts)-1]) + } + } + return host } +// remoteAdresaJePoverljiva vraća true ako je adresa loopback ili iz privatnog +// (RFC 1918) opsega — tipično za reverse proxy na istom hostu ili u istoj Docker mreži. +func remoteAdresaJePoverljiva(host string) bool { + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() +} + func napraviKolacic(token string, istice time.Time) *http.Cookie { return &http.Cookie{ Name: imeKolacica, diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 3c69835..93b97cd 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -1049,12 +1049,15 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { } // predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje) - nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) - predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno - slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina) + nalog, err := h.ServisRepo.DohvatiID(r.Context(), nalogID) + if err != nil || nalog == nil { + middleware.SetFlash(w, r, h.DB, "greska", "Nalog nije pronađen.") + http.Redirect(w, r, "/servis", http.StatusSeeOther) + return + } + predlozeno := nalog.Status != model.StatusPrimljeno // atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID, predlozeno) - slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err) if err != nil { slog.Error("greška pri dodavanju dela", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") diff --git a/internal/handler/utils_test.go b/internal/handler/utils_test.go index 550aa22..a2bb927 100644 --- a/internal/handler/utils_test.go +++ b/internal/handler/utils_test.go @@ -13,8 +13,10 @@ func TestIzvuciIP(t *testing.T) { remoteAddr string ocek string }{ - {"X-Real-IP ima prioritet", "1.2.3.4", "9.9.9.9", "5.5.5.5:1234", "1.2.3.4"}, - {"poslednji X-Forwarded-For", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "5.5.5.5:1234", "3.3.3.3"}, + {"X-Real-IP ima prioritet kad je proxy poverljiv (loopback)", "1.2.3.4", "9.9.9.9", "127.0.0.1:1234", "1.2.3.4"}, + {"X-Real-IP ima prioritet kad je proxy poverljiv (privatna Docker mreža)", "1.2.3.4", "9.9.9.9", "172.18.0.1:1234", "1.2.3.4"}, + {"poslednji X-Forwarded-For kad je proxy poverljiv", "", "1.1.1.1, 2.2.2.2, 3.3.3.3", "127.0.0.1:1234", "3.3.3.3"}, + {"zaglavlje se IGNORIŠE kad RemoteAddr nije poverljiv (javna adresa — spoofing)", "1.2.3.4", "", "5.5.5.5:1234", "5.5.5.5"}, {"RemoteAddr bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"}, {"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"}, } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index c8b5447..f83f0f0 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -149,7 +149,12 @@ func RequireDozvolaMut(proveri func(ctx context.Context, uloga, akcija string) b } } -// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić +// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić. Namerno +// odvojen mehanizam od SetFlash/GetFlash (flash.go): ove funkcije se pozivaju +// iz middleware-a koji nema pristup *sql.DB, a redirect je uvek na /dashboard +// (jedino mesto koje čita ovaj kolačić) — pa kolačić bez DB upisa dovoljan. +// SetFlash/GetFlash (kolona sesije.flash) koristi se u handlerima koji imaju +// DB i redirect na proizvoljnu stranicu. func postaviFlashGresku(w http.ResponseWriter, poruka string) { http.SetCookie(w, &http.Cookie{ Name: "ntech_flash_greska", diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index d73de18..093508c 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -3,6 +3,7 @@ package middleware import ( "context" "crypto/rand" + "crypto/subtle" "encoding/base64" "net/http" "os" @@ -69,7 +70,7 @@ func CsrfMiddleware(next http.Handler) http.Handler { if submitted == "" { submitted = r.Header.Get("X-CSRF-Token") } - if token == "" || submitted != token { + if token == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 { http.Error(w, "Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.", http.StatusForbidden, diff --git a/internal/middleware/flash.go b/internal/middleware/flash.go index 5421c43..88552ba 100644 --- a/internal/middleware/flash.go +++ b/internal/middleware/flash.go @@ -3,6 +3,7 @@ package middleware import ( "database/sql" "encoding/json" + "log/slog" "net/http" "ntech/internal/model" @@ -18,9 +19,11 @@ func SetFlash(w http.ResponseWriter, r *http.Request, db *sql.DB, tip, poruka st if err != nil { return } - db.ExecContext(r.Context(), + if _, err := db.ExecContext(r.Context(), `UPDATE sesije SET flash = ? WHERE token = ?`, - string(data), kolacic.Value) + string(data), kolacic.Value); err != nil { + slog.Error("SetFlash: upis nije uspeo", "error", err) + } } // GetFlash čita i atomično briše flash poruku iz aktivne sesije diff --git a/internal/model/artikal.go b/internal/model/artikal.go index 3b86cc8..6da960d 100644 --- a/internal/model/artikal.go +++ b/internal/model/artikal.go @@ -20,7 +20,7 @@ type Artikal struct { Tip string // proizvod | usluga | trosak JedinicaMere string // kom, sat, set, m, l, kg ... Kolicina int - KolicinMin int + KolicinaMin int Lokacija string NabavnaCena float64 ProdajnaCena float64 diff --git a/internal/model/izvestaj.go b/internal/model/izvestaj.go index c210cfd..76a09e4 100644 --- a/internal/model/izvestaj.go +++ b/internal/model/izvestaj.go @@ -78,7 +78,7 @@ type StanjeZalihaRed struct { Sifra string Kategorija string Kolicina int - KolicinMin int + KolicinaMin int NabavnaCena float64 ProdajnaCena float64 CenaSaPdv float64 diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 1bc4c8b..72364da 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -37,8 +37,14 @@ func (k PdvKir) OslobodenUkupno() float64 { // OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice), // inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara). func (k PdvKir) OznakaPoreskogBroja() string { + return oznakaPoreskogBroja(k.KupacPib) +} + +// oznakaPoreskogBroja broji cifre u poreskom broju i vraća „JMBG" (13 cifara) +// ili „PIB" (ostalo) — zajednička logika za KIR (kupac) i KPR (dobavljač). +func oznakaPoreskogBroja(poreskiBroj string) string { cifre := 0 - for _, r := range k.KupacPib { + for _, r := range poreskiBroj { if r >= '0' && r <= '9' { cifre++ } @@ -244,16 +250,7 @@ func KprStorno(original PdvKpr, razlog string, datumStorna time.Time) PdvKpr { // OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme). func (k PdvKpr) OznakaPoreskogBroja() string { - cifre := 0 - for _, r := range k.DobavljacPib { - if r >= '0' && r <= '9' { - cifre++ - } - } - if cifre == 13 { - return "JMBG" - } - return "PIB" + return oznakaPoreskogBroja(k.DobavljacPib) } // PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige). diff --git a/internal/model/trosak.go b/internal/model/trosak.go index 9dd6d25..b23b0e4 100644 --- a/internal/model/trosak.go +++ b/internal/model/trosak.go @@ -1,5 +1,7 @@ package model +import "time" + // Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža). // To je katalog dodatnih troškova koji se vezuju za radni nalog i ulaze u cenu // koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul. @@ -10,5 +12,5 @@ type Trosak struct { Cena float64 Opis string Arhiviran bool - DatumUnosa string + DatumUnosa time.Time } diff --git a/internal/model/usluga.go b/internal/model/usluga.go index 481383f..9e130f7 100644 --- a/internal/model/usluga.go +++ b/internal/model/usluga.go @@ -1,5 +1,7 @@ package model +import "time" + // Usluga je stavka cenovnika usluga (npr. čišćenje laptopa, zamena ventilatora). // Za razliku od artikla, usluga ne prati lager i nema nabavnu cenu ni dobavljače — // ima samo cenu usluge i PDV stopu. Kategorija je tekstualna oznaka. @@ -13,7 +15,7 @@ type Usluga struct { PdvStopa float64 Opis string Arhiviran bool - DatumUnosa string + DatumUnosa time.Time } func (u Usluga) CenaSaPdv() float64 { diff --git a/migrations/102_indeksi_fk_kolona.sql b/migrations/102_indeksi_fk_kolona.sql new file mode 100644 index 0000000..ab6c512 --- /dev/null +++ b/migrations/102_indeksi_fk_kolona.sql @@ -0,0 +1,19 @@ +-- Indeksi na FK kolonama koje se često JOIN-uju/filtriraju (detalji naloga, izveštaji). +-- Bez indeksa ove tabele rade full table scan koji raste sa prometom. +CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_nalog_id ON stavke_prodaje(nalog_id); +CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_artikal_id ON stavke_prodaje(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_servisni_delovi_nalog_id ON servisni_delovi(nalog_id); +CREATE INDEX IF NOT EXISTS idx_servisni_delovi_artikal_id ON servisni_delovi(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_nalog_id ON servisni_potrazivani_delovi(nalog_id); +CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_artikal_id ON servisni_potrazivani_delovi(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_magacinske_promene_artikal_id ON magacinske_promene(artikal_id); + +CREATE INDEX IF NOT EXISTS idx_stavke_nabavke_nabavka_id ON stavke_nabavke(nabavka_id); + +CREATE INDEX IF NOT EXISTS idx_pdv_kir_izvor ON pdv_kir(izvor, izvor_id); +CREATE INDEX IF NOT EXISTS idx_pdv_kir_broj_dokumenta ON pdv_kir(broj_dokumenta); + +CREATE INDEX IF NOT EXISTS idx_pdv_kpr_izvor ON pdv_kpr(izvor, izvor_id); diff --git a/web/templates/komponente/topbar.html b/web/templates/komponente/topbar.html index b824025..f3ac0e1 100644 --- a/web/templates/komponente/topbar.html +++ b/web/templates/komponente/topbar.html @@ -21,7 +21,7 @@ {{if .AvatarPutanja}} Avatar {{else}} - {{if .Korisnik}}{{slice .Korisnik 0 2}}{{else}}NT{{end}} + {{if .Korisnik}}{{inicijali .Korisnik 2}}{{else}}NT{{end}} {{end}} diff --git a/web/templates/stranice/magacin_forma.html b/web/templates/stranice/magacin_forma.html index 55df3bf..434a78b 100644 --- a/web/templates/stranice/magacin_forma.html +++ b/web/templates/stranice/magacin_forma.html @@ -97,7 +97,7 @@
- +
diff --git a/web/templates/stranice/magacin_kartica.html b/web/templates/stranice/magacin_kartica.html index e7a706e..9ada5a4 100644 --- a/web/templates/stranice/magacin_kartica.html +++ b/web/templates/stranice/magacin_kartica.html @@ -28,12 +28,12 @@
-
+
{{.Artikal.Kolicina}} kom
trenutno stanje
- {{if gt .Artikal.KolicinMin 0}} -
min. {{.Artikal.KolicinMin}} kom
+ {{if gt .Artikal.KolicinaMin 0}} +
min. {{.Artikal.KolicinaMin}} kom
{{end}}
diff --git a/web/templates/stranice/popis.html b/web/templates/stranice/popis.html index 1f37881..d301ab3 100644 --- a/web/templates/stranice/popis.html +++ b/web/templates/stranice/popis.html @@ -282,7 +282,7 @@ {{range .Artikli}} - + {{.Naziv}} {{if .Sifra}}{{.Sifra}}{{else}}—{{end}} {{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}} diff --git a/web/templates/stranice/stanje_zaliha.html b/web/templates/stranice/stanje_zaliha.html index 33b42a3..472c1d4 100644 --- a/web/templates/stranice/stanje_zaliha.html +++ b/web/templates/stranice/stanje_zaliha.html @@ -54,12 +54,12 @@ {{range .Zalihe}} - - {{.Naziv}} + + {{.Naziv}} {{if .Sifra}}{{.Sifra}}{{else}}—{{end}} {{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}} - {{.Kolicina}} - {{.KolicinMin}} + {{.Kolicina}} + {{.KolicinaMin}} {{dinari .NabavnaCena}} {{dinari .ProdajnaCena}} {{if index $.Moduli "pdv"}}{{dinari .CenaSaPdv}}{{end}} diff --git a/web/templates/stranice/totp_provera.html b/web/templates/stranice/totp_provera.html index 3c80bee..764e374 100644 --- a/web/templates/stranice/totp_provera.html +++ b/web/templates/stranice/totp_provera.html @@ -35,6 +35,11 @@ {{if eq .Greska "1"}}
Neispravan kod. Pokušajte ponovo.
+ {{else if eq .Greska "zakljucano"}} +
+ Previše neuspelih pokušaja. IP adresa je privremeno blokirana. + {{if .Preostalo}}
Pokušajte ponovo za: {{.Preostalo}}{{end}} +
{{end}}