Merge grane fix/code-review-nalazi: bezbednosne i integritetne popravke iz pregleda koda

This commit is contained in:
2026-07-03 23:31:48 +02:00
44 changed files with 478 additions and 345 deletions
+58 -27
View File
@@ -58,7 +58,9 @@ func main() {
// kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard // kreiraj prazan fajl ako ne postoji da se ne pokrene setup wizard
if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" { if env := os.Getenv("NTECH_ENV"); env == "production" || env == "demo" {
if _, err := os.Stat(envFajl); os.IsNotExist(err) { 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) godotenv.Load(envFajl)
@@ -141,7 +143,9 @@ func main() {
napraviBackup(db, putanjaBaze, max) 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 := handler.Novi(db, totpKljuc)
h.Verzija = Verzija h.Verzija = Verzija
@@ -238,18 +242,44 @@ func main() {
http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) http.FileServer(http.FS(staticFS)).ServeHTTP(w, req)
}))) })))
// javne rute (bez autentifikacije) // health check — bez autentifikacije, za monitoring/orkestraciju (Docker healthcheck i sl.)
r.Get("/prijava", h.PrikazPrijave) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
r.Post("/prijava", h.Prijava) if err := db.PingContext(r.Context()); err != nil {
r.Get("/prijava/totp", h.PrikazTotp) http.Error(w, "baza nedostupna", http.StatusServiceUnavailable)
r.Post("/prijava/totp", h.VerifikujTotp) return
r.Get("/setup", h.PrikazSetupa) }
r.Post("/setup", h.SacuvajSetup) 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) 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.Get("/status/{token}", h.ServisJavniStatus)
r.Post("/status/{token}/prihvati", h.ServisJavniPrihvati) r.Group(func(r chi.Router) {
r.Post("/status/{token}/odbij", h.ServisJavniOdbij) // telo javnih POST-ova (komentar klijenta) ograničeno na 4KB — nema razloga da bude veće
r.Post("/status/{token}/odluka-odabrano", h.ServisJavniOdlukaOdabrano) 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) r.Get("/v/", h.FiskalVerifikacija)
// zaštićene rute — zahtevaju prijavljenog korisnika // zaštićene rute — zahtevaju prijavljenog korisnika
@@ -283,18 +313,19 @@ func main() {
}) })
r.Get("/dashboard", h.Dashboard) r.Get("/dashboard", h.Dashboard)
r.Get("/podesavanja", h.Podesavanja) r.Get("/podesavanja", h.Podesavanja)
r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) pregledPodesavanja := ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "podesavanja.pregled")
r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.With(pregledPodesavanja).Get("/admin/podesavanja/opste", h.PodesavanjaOpste)
r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) r.With(pregledPodesavanja).Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled)
r.Get("/admin/podesavanja/servis", h.PodesavanjaServis) r.With(pregledPodesavanja).Get("/admin/podesavanja/sistem", h.PodesavanjaSistem)
r.Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija) r.With(pregledPodesavanja).Get("/admin/podesavanja/servis", h.PodesavanjaServis)
r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije) r.With(pregledPodesavanja).Get("/admin/podesavanja/fiskalizacija", h.PodesavanjaFiskalizacija)
r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus) r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije)
r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit) r.With(pregledPodesavanja).Get("/podesavanja/fiskalizacija/be-status", h.BeStatus)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit)
r.With(doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj) r.With(modul("fiskalizacija"), ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan) r.With(modul("fiskalizacija"), doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope) 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/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}/izmeni", h.IzmeniPdvStopu)
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) 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/sledeca-sifra", h.PredlogSifre)
r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal) r.Get("/magacin/izmeni/{id}", h.IzmeniArtikal)
r.With(doz("artikal.izmeni")).Post("/magacin/izmeni/{id}", h.SacuvajIzmenuArtikla) 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")).Post("/magacin/obrisi/{id}", h.ObrisiArtikal)
r.With(doz("artikal.obrisi")).Get("/magacin/vrati/{id}", h.VratiArtikal) 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/dodaj", h.DodajDobavljacaArtiklu)
r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla) r.With(doz("artikal.izmeni")).Post("/magacin/kartica/{id}/dobavljac/obrisi", h.ObrisiDobavljacaArtikla)
r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal) r.With(doz("artikal.premesti")).Post("/magacin/premesti/{id}", h.PremestiArtikal)
@@ -359,7 +390,7 @@ func main() {
r.Get("/magacin/kategorije", h.Kategorije) r.Get("/magacin/kategorije", h.Kategorije)
r.With(doz("kategorija.dodaj")).Post("/magacin/kategorije/dodaj", h.DodajKategoriju) 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.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", h.Nabavke)
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka) r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "nabavka.pregled")).Get("/nabavke/nova", h.NovaNabavka)
r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku) r.With(doz("nabavka.dodaj")).Post("/nabavke/nova", h.SacuvajNabavku)
+12 -1
View File
@@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool {
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju. // dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
// Koristi ga IzjednaciVremeProvere kada korisnik ne postoji. // 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 // 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 — // odgovora bilo isto kao kod postojećeg korisnika sa pogrešnom lozinkom —
+2 -1
View File
@@ -5,6 +5,7 @@ package be
import ( import (
"context" "context"
"crypto/subtle"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -254,7 +255,7 @@ func (k *Kartica) cmdCertificate() map[string]any {
func (k *Kartica) cmdVerifyPin(pin string) map[string]any { func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() 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"} return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"}
} }
k.pinUnesen = true k.pinUnesen = true
+8
View File
@@ -12,11 +12,17 @@ import (
// (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira. // (prodaja, nabavka, magacinske promene ili servisni nalozi). Tada se artikal arhivira.
var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi") 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 // ArtikalRepository definiše operacije nad artiklima
type ArtikalRepository interface { type ArtikalRepository interface {
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error) Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error) PrebrojiPoFilteru(ctx context.Context, filter ArtikalFilter) (int, error)
DohvatiID(ctx context.Context, id int64) (*model.Artikal, 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) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error)
Izmeni(ctx context.Context, a *model.Artikal) error Izmeni(ctx context.Context, a *model.Artikal) error
// AzurirajCene menja samo nabavnu i prodajnu cenu (kalkulacija pri nabavci) // 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) DohvatiID(ctx context.Context, id int64) (*model.Kategorija, error)
Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error)
Izmeni(ctx context.Context, k *model.Kategorija) 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 // PdvStopaRepository definiše operacije nad šifarnikom PDV stopa
+59 -14
View File
@@ -92,7 +92,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
err := redovi.Scan( err := redovi.Scan(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, &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.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
&a.KategorijaNaziv, &katMarza, &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) // 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) rezultat = append(rezultat, a)
} }
@@ -126,24 +126,23 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
return rezultat, nil return rezultat, nil
} }
// DohvatiID vraća jedan artikal po ID-u const artikalKolone = `id, kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min,
func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal, error) { 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 a model.Artikal
var kategorijaID sql.NullInt64 var kategorijaID sql.NullInt64
var sifra, barkod sql.NullString var sifra, barkod sql.NullString
var marza sql.NullFloat64 var marza sql.NullFloat64
var arhiviran int var arhiviran int
err := r.db.QueryRowContext(ctx, ` if err := scan(
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(
&a.ID, &kategorijaID, &sifra, &barkod, &a.Naziv, &a.Opis, &a.Tip, &a.JedinicaMere, &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.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.CenaSaPdv, &marza, &a.Napomena, &a.DatumUnosa, &arhiviran,
) ); err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
} }
a.Arhiviran = arhiviran == 1 a.Arhiviran = arhiviran == 1
@@ -163,6 +162,52 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
return &a, nil 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 // Kreiraj dodaje novi artikal u bazu
func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) { func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, error) {
var sifra, barkod any 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, (kategorija_id, sifra, barkod, naziv, opis, tip, jedinica_mere, kolicina, kolicina_min, lokacija,
nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena) nabavna_cena, prodajna_cena, pdv_stopa, cena_sa_pdv, marza, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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, a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
) )
if err != nil { 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 = ? nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, cena_sa_pdv = ?, marza = ?, napomena = ?
WHERE id = ?`, WHERE id = ?`,
a.KategorijaID, sifra, barkod, a.Naziv, a.Opis, a.Tip, a.JedinicaMere, 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, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
) )
if err != nil { if err != nil {
+3 -3
View File
@@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) { func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
var n int var n int
err := r.db.QueryRowContext(ctx, 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 { if err != nil {
return 0, fmt.Errorf("ntech: izvestaj.BrojKriticnihZaliha: %w", err) 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) { func (r *sqliteIzvestajRepo) KriticneZalihe(ctx context.Context, limit int) ([]model.ZalihaRed, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT naziv, kolicina, kolicina_min FROM artikli 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) ORDER BY kolicina ASC LIMIT ?`, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.KriticneZalihe: %w", err) 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() { for rows.Next() {
var s model.StanjeZalihaRed var s model.StanjeZalihaRed
if err := rows.Scan(&s.Naziv, &s.Sifra, &s.Kategorija, 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 { &s.CenaSaPdv, &s.VrednostZalihe, &s.VrednostSaPdv); err != nil {
return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err) return nil, fmt.Errorf("ntech: IzvestajRepo.StanjeZaliha: scan: %w", err)
} }
+3 -3
View File
@@ -20,9 +20,9 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
t.Fatalf("Kreiraj: %v", err) t.Fatalf("Kreiraj: %v", err)
} }
} }
dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinaMin: 5})
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinaMin: 5})
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinaMin: 5})
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 { if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err) t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
+18
View File
@@ -3,9 +3,13 @@ package sqlite
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"ntech/internal/db"
"ntech/internal/model" "ntech/internal/model"
mosqlite "modernc.org/sqlite"
) )
// KategorijaRepo je SQLite implementacija KategorijaRepository interfejsa // KategorijaRepo je SQLite implementacija KategorijaRepository interfejsa
@@ -109,3 +113,17 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error
} }
return nil 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
}
+12
View File
@@ -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) FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 2. Naziv firme + mesto // 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) FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 3. Samo naziv firme // 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) FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 2. Ime + prezime + mesto // 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) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 3. Ime + prezime + telefon // 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) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 4. Ime + prezime + email // 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) FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email)
if err := skeniraj(row); err == nil { if err := skeniraj(row); err == nil {
goto popuni goto popuni
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err)
} }
} }
// 5. Samo ime + prezime (poslednji fallback) // 5. Samo ime + prezime (poslednji fallback)
+60
View File
@@ -100,3 +100,63 @@ func zabeleziMagacinPromenu(
} }
return nil 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
}
+16 -13
View File
@@ -12,23 +12,26 @@ import (
_ "modernc.org/sqlite" _ "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) { 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 { if err != nil {
return nil, fmt.Errorf("ntech: OtvoriDB: %w", err) return nil, fmt.Errorf("ntech: OtvoriDB: %w", err)
} }
if err := db.Ping(); err != nil {
pragme := []string{ return nil, fmt.Errorf("ntech: OtvoriDB: %w", err)
"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)
}
} }
return db, nil return db, nil
+7 -7
View File
@@ -33,11 +33,11 @@ func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKi
args := []any{} args := []any{}
if !od.IsZero() { if !od.IsZero() {
upit += " AND datum_prometa >= ?" upit += " AND datum_prometa >= ?"
args = append(args, od) args = append(args, od.Format("2006-01-02"))
} }
if !do.IsZero() { if !do.IsZero() {
upit += " AND datum_prometa <= ?" upit += " AND datum_prometa <= ?"
args = append(args, do) args = append(args, do.Format("2006-01-02"))
} }
upit += " ORDER BY datum_prometa ASC, id ASC" 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, osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) 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.KupacNaziv, k.KupacPib, k.KupacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena, 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{} args := []any{}
if !od.IsZero() { if !od.IsZero() {
upit += " AND datum_prometa >= ?" upit += " AND datum_prometa >= ?"
args = append(args, od) args = append(args, od.Format("2006-01-02"))
} }
if !do.IsZero() { if !do.IsZero() {
upit += " AND datum_prometa <= ?" upit += " AND datum_prometa <= ?"
args = append(args, do) args = append(args, do.Format("2006-01-02"))
} }
upit += " ORDER BY datum_prometa ASC, id ASC" 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) { func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) {
var datumPlacanja any var datumPlacanja any
if k.DatumPlacanja != nil { if k.DatumPlacanja != nil {
datumPlacanja = *k.DatumPlacanja datumPlacanja = k.DatumPlacanja.Format("2006-01-02")
} }
uvoz := 0 uvoz := 0
if k.Uvoz { 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, osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id, uvoz
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) 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.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto,
k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna,
k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena,
+6 -97
View File
@@ -294,53 +294,8 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
} }
// vraćanje stanja u magacin // vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, razlog); err != nil {
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ProdajaRepo.Storno: %w", err)
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)
}
} }
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -374,54 +329,8 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) e
} }
if !stornirano { if !stornirano {
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "stavke_prodaje", id, korisnikID, "brisanje prodajnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ProdajaRepo.Obrisi: %w", err)
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)
}
} }
} }
@@ -446,7 +355,7 @@ func (r *ProdajaRepo) DnevniPrometMaloprodaje(ctx context.Context, datum string)
// broj naloga tog dana // broj naloga tog dana
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM prodajni_nalozi 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) ).Scan(&p.BrojNaloga)
if err != nil { if err != nil {
return p, fmt.Errorf("ntech: ProdajaRepo.DnevniPrometMaloprodaje: count: %w", err) 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) SELECT s.pdv_stopa, SUM(s.cena_bez_pdv * s.kolicina), SUM(s.pdv_iznos * s.kolicina)
FROM stavke_prodaje s FROM stavke_prodaje s
JOIN prodajni_nalozi p ON p.id = s.nalog_id 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, GROUP BY s.pdv_stopa`, datum,
) )
if err != nil { if err != nil {
+4 -93
View File
@@ -394,53 +394,8 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
defer tx.Rollback() defer tx.Rollback()
// pokupi ugrađene delove pre brisanja (CASCADE bi ih obrisao bez povraćaja) // pokupi ugrađene delove pre brisanja (CASCADE bi ih obrisao bez povraćaja)
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "brisanje servisnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ServisRepo.Obrisi: %w", err)
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)
}
} }
// potraživani delovi nemaju ON DELETE CASCADE — ručno ih čistimo da brisanje ne padne na FK // 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") return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran")
} }
redovi, err := tx.QueryContext(ctx, if err := vratiStavkeNaStanje(ctx, tx, "servisni_delovi", id, korisnikID, "storno servisnog naloga"); err != nil {
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id) return fmt.Errorf("ntech: ServisRepo.Storno: %w", err)
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 := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
+3 -4
View File
@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"ntech/internal/model" "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) { 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) // Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge)
if predlozeno { if predlozeno {
slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina)
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)", "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)",
nalogID, artikalID, kolicina, cenaKomada, nalogID, artikalID, kolicina, cenaKomada,
) )
if err != nil { if err != nil {
slog.Error("PREDLOG_INSERT_ERR", "err", err)
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err) return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err)
} }
slog.Info("PREDLOG_INSERT_OK")
return 0, kolicina, nil return 0, kolicina, nil
} }
@@ -263,6 +259,9 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int
} }
stavke = append(stavke, s) 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) // Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može)
for _, s := range stavke { for _, s := range stavke {
+9 -1
View File
@@ -1,11 +1,14 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"ntech/internal/db"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model" "ntech/internal/model"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -159,7 +162,12 @@ func (h *Handler) ObrisiKategoriju(w http.ResponseWriter, r *http.Request) {
return 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) http.Error(w, "Greška pri brisanju kategorije", http.StatusInternalServerError)
return return
} }
+9
View File
@@ -129,6 +129,15 @@ var sablonskeFunkcije = template.FuncMap{
}, },
// zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima // zbirF64 vraća zbir dva float64 — za aritmetiku u šablonima
"zbirF64": func(a, b float64) float64 { return a + b }, "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 // KreirajKes parsuje sve šablone iz fsys i vraća ih keširane u mapi
+3 -2
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
@@ -82,10 +83,10 @@ func (h *Handler) Klijenti(w http.ResponseWriter, r *http.Request) {
queryDelići := "" queryDelići := ""
if pretraga != "" { if pretraga != "" {
queryDelići += "&pretraga=" + pretraga queryDelići += "&pretraga=" + url.QueryEscape(pretraga)
} }
if tipFilter != "" { if tipFilter != "" {
queryDelići += "&tip=" + tipFilter queryDelići += "&tip=" + url.QueryEscape(tipFilter)
} }
stranicaPrev := stranicaBr - 1 stranicaPrev := stranicaBr - 1
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"ntech/internal/db" "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) // izgradi query string za paginaciju (čuva filtere)
queryDelići := "" queryDelići := ""
if v := filter.Pretraga; v != "" { if v := filter.Pretraga; v != "" {
queryDelići += "&pretraga=" + v queryDelići += "&pretraga=" + url.QueryEscape(v)
} }
if katIDStr != "" { if katIDStr != "" {
queryDelići += "&kategorija=" + katIDStr queryDelići += "&kategorija=" + url.QueryEscape(katIDStr)
} }
if filter.SamoKriticni { if filter.SamoKriticni {
queryDelići += "&kriticni=1" queryDelići += "&kriticni=1"
+2 -2
View File
@@ -374,13 +374,13 @@ func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artik
if err != nil || v < 0 { if err != nil || v < 0 {
return artikal, "Minimalna količina mora biti pozitivan broj." return artikal, "Minimalna količina mora biti pozitivan broj."
} }
artikal.KolicinMin = v artikal.KolicinaMin = v
} }
// usluge i troškovi nemaju stanje na lageru // usluge i troškovi nemaju stanje na lageru
if !artikal.PratiLager() { if !artikal.PratiLager() {
artikal.Kolicina = 0 artikal.Kolicina = 0
artikal.KolicinMin = 0 artikal.KolicinaMin = 0
} }
if c := r.FormValue("nabavna_cena"); c != "" { if c := r.FormValue("nabavna_cena"); c != "" {
+17 -2
View File
@@ -196,12 +196,27 @@ func (h *Handler) SacuvajNabavku(w http.ResponseWriter, r *http.Request) {
return 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 // automatski zavedi u KPR ako je firma PDV obveznik; PDV se izvodi iz stope artikla
if h.modulUkljucen(r.Context(), "pdv") { if h.modulUkljucen(r.Context(), "pdv") {
var stavkePdv []model.NabavkaStavkaPdv var stavkePdv []model.NabavkaStavkaPdv
for _, s := range stavke { for _, s := range stavke {
var stopa float64 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 stopa = a.PdvStopa
} }
stavkePdv = append(stavkePdv, model.NabavkaStavkaPdv{ 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 // stara prodajna i tekuća (ponderisana) nabavna — nabavnu zadržavamo
var staraProdajna, nabavna float64 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 staraProdajna = a.ProdajnaCena
nabavna = a.NabavnaCena nabavna = a.NabavnaCena
} }
+8 -5
View File
@@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
klijent := &http.Client{Timeout: 5 * time.Second} klijent := &http.Client{Timeout: 5 * time.Second}
resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese) resp, err := klijent.Get(statusURL) // lgtm[go/request-forgery] -- host je validiran kroz jePrivatnaAdresa (privatne/localhost adrese)
if err != nil { if err != nil {
slog.Error("test fiskalizacije: konekcija nije uspela", "url", statusURL, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Nije dostupan — %s</div>`, html.EscapeString(err.Error())) fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Nije dostupan — proverite adresu i mrežu</div>`)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
var status map[string]any var status map[string]any
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error())) fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera</div>`)
return return
} }
@@ -1041,9 +1043,10 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second) conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil { if err != nil {
slog.Error("BeStatus: emulator nedostupan", "addr", addr, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`, fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s</div>`,
html.EscapeString(addr), html.EscapeString(err.Error())) html.EscapeString(addr))
return return
} }
defer conn.Close() 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. // BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) { 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 return
} }
bePort := os.Getenv("BE_PORT") bePort := os.Getenv("BE_PORT")
+50 -11
View File
@@ -186,6 +186,23 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
return 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") kod := r.FormValue("kod")
validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna) validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna)
if !validan { if !validan {
@@ -196,10 +213,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
} }
} }
if !validan { 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) http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
return return
} }
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnik.KorisnickoIme, true)
novoIstice := time.Now().Add(trajanjeSeije) novoIstice := time.Now().Add(trajanjeSeije)
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil { if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther) 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 return fmt.Sprintf("%d sek", sek), true
} }
// izvuciIP čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja), // izvuciIP čita pravi IP klijenta iz X-Real-IP/X-Forwarded-For, ALI samo ako
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr // 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 { 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) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { 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 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 { func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{ return &http.Cookie{
Name: imeKolacica, Name: imeKolacica,
+7 -4
View File
@@ -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) // predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) nalog, err := h.ServisRepo.DohvatiID(r.Context(), nalogID)
predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno if err != nil || nalog == nil {
slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina) 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 // 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) 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 { if err != nil {
slog.Error("greška pri dodavanju dela", "error", err) slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
+4 -2
View File
@@ -13,8 +13,10 @@ func TestIzvuciIP(t *testing.T) {
remoteAddr string remoteAddr string
ocek 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"}, {"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"},
{"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 (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 bez porta kad nema zaglavlja", "", "", "5.5.5.5:1234", "5.5.5.5"},
{"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"}, {"RemoteAddr kakav jeste ako nije host:port", "", "", "neispravan", "neispravan"},
} }
+6 -1
View File
@@ -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) { func postaviFlashGresku(w http.ResponseWriter, poruka string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "ntech_flash_greska", Name: "ntech_flash_greska",
+2 -1
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"os" "os"
@@ -69,7 +70,7 @@ func CsrfMiddleware(next http.Handler) http.Handler {
if submitted == "" { if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token") submitted = r.Header.Get("X-CSRF-Token")
} }
if token == "" || submitted != token { if token == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
http.Error(w, http.Error(w,
"Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.", "Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.",
http.StatusForbidden, http.StatusForbidden,
+5 -2
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"ntech/internal/model" "ntech/internal/model"
@@ -18,9 +19,11 @@ func SetFlash(w http.ResponseWriter, r *http.Request, db *sql.DB, tip, poruka st
if err != nil { if err != nil {
return return
} }
db.ExecContext(r.Context(), if _, err := db.ExecContext(r.Context(),
`UPDATE sesije SET flash = ? WHERE token = ?`, `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 // GetFlash čita i atomično briše flash poruku iz aktivne sesije
+1 -1
View File
@@ -20,7 +20,7 @@ type Artikal struct {
Tip string // proizvod | usluga | trosak Tip string // proizvod | usluga | trosak
JedinicaMere string // kom, sat, set, m, l, kg ... JedinicaMere string // kom, sat, set, m, l, kg ...
Kolicina int Kolicina int
KolicinMin int KolicinaMin int
Lokacija string Lokacija string
NabavnaCena float64 NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
+1 -1
View File
@@ -78,7 +78,7 @@ type StanjeZalihaRed struct {
Sifra string Sifra string
Kategorija string Kategorija string
Kolicina int Kolicina int
KolicinMin int KolicinaMin int
NabavnaCena float64 NabavnaCena float64
ProdajnaCena float64 ProdajnaCena float64
CenaSaPdv float64 CenaSaPdv float64
+8 -11
View File
@@ -37,8 +37,14 @@ func (k PdvKir) OslobodenUkupno() float64 {
// OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice), // OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice),
// inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara). // inače „PIB" (pravno lice / preduzetnik — PIB ima 9 cifara).
func (k PdvKir) OznakaPoreskogBroja() string { 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 cifre := 0
for _, r := range k.KupacPib { for _, r := range poreskiBroj {
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
cifre++ 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). // OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
func (k PdvKpr) OznakaPoreskogBroja() string { func (k PdvKpr) OznakaPoreskogBroja() string {
cifre := 0 return oznakaPoreskogBroja(k.DobavljacPib)
for _, r := range k.DobavljacPib {
if r >= '0' && r <= '9' {
cifre++
}
}
if cifre == 13 {
return "JMBG"
}
return "PIB"
} }
// PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige). // PdvKprSume su zbirovi kolona KPR-a (za red „ukupno" u pregledu knjige).
+3 -1
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
// Trosak je stavka šifrarnika vrsta troškova (npr. Prevoz, Carina, Ambalaža). // 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 // 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. // koštanja. NIJE evidencija rashoda firme (struja, kirija) — to je poseban modul.
@@ -10,5 +12,5 @@ type Trosak struct {
Cena float64 Cena float64
Opis string Opis string
Arhiviran bool Arhiviran bool
DatumUnosa string DatumUnosa time.Time
} }
+3 -1
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
// Usluga je stavka cenovnika usluga (npr. čišćenje laptopa, zamena ventilatora). // 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 — // 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. // ima samo cenu usluge i PDV stopu. Kategorija je tekstualna oznaka.
@@ -13,7 +15,7 @@ type Usluga struct {
PdvStopa float64 PdvStopa float64
Opis string Opis string
Arhiviran bool Arhiviran bool
DatumUnosa string DatumUnosa time.Time
} }
func (u Usluga) CenaSaPdv() float64 { func (u Usluga) CenaSaPdv() float64 {
+19
View File
@@ -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);
+1 -1
View File
@@ -21,7 +21,7 @@
{{if .AvatarPutanja}} {{if .AvatarPutanja}}
<img src="{{.AvatarPutanja}}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;display:block;"> <img src="{{.AvatarPutanja}}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;display:block;">
{{else}} {{else}}
{{if .Korisnik}}{{slice .Korisnik 0 2}}{{else}}NT{{end}} {{if .Korisnik}}{{inicijali .Korisnik 2}}{{else}}NT{{end}}
{{end}} {{end}}
</div> </div>
<div id="avatar-meni" style="display:none;position:fixed;background:var(--kartica);backdrop-filter:none;-webkit-backdrop-filter:none;border:0.5px solid var(--ivica);border-radius:10px;box-shadow:var(--senka);min-width:160px;z-index:9999;overflow:hidden;transform-origin:top right;"> <div id="avatar-meni" style="display:none;position:fixed;background:var(--kartica);backdrop-filter:none;-webkit-backdrop-filter:none;border:0.5px solid var(--ivica);border-radius:10px;box-shadow:var(--senka);min-width:160px;z-index:9999;overflow:hidden;transform-origin:top right;">
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="kartica animiraj" style="padding: 0; overflow: hidden"> <div class="kartica animiraj" style="padding: 0; overflow: hidden">
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between"> <div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span> <span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
</div> </div>
{{if .Lista}} {{if .Lista}}
+1 -1
View File
@@ -90,7 +90,7 @@
</form> </form>
<div style="display:flex;gap:10px;flex-wrap:wrap;"> <div style="display:flex;gap:10px;flex-wrap:wrap;">
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a> <a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
{{if index .Dozvole "fiskal.zakljucenje"}} {{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload <form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
+6 -4
View File
@@ -130,10 +130,12 @@
</dialog> </dialog>
{{end}} {{end}}
{{if index $.Dozvole "kategorija.obrisi"}} {{if index $.Dozvole "kategorija.obrisi"}}
<a href="/magacin/kategorije/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/kategorije/obrisi/{{.ID}}" style="display:inline;">
data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?"> <button type="submit" class="btn-obrisi-malo"
Obriši data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?">
</a> Obriši
</button>
</form>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
+24 -16
View File
@@ -126,15 +126,19 @@
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}} {{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo" <form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
data-potvrda="Vratiti ovaj artikal u aktivnu listu?"> <button type="submit" class="btn-sekundarno-malo"
Vrati data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
</a> Vrati
</button>
</form>
{{else}} {{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> <button type="submit" class="btn-obrisi-malo"
Obriši data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
</a> Obriši
</button>
</form>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@@ -176,15 +180,19 @@
{{end}}{{end}} {{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}} {{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}} {{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo" <form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
data-potvrda="Vratiti ovaj artikal u aktivnu listu?"> <button type="submit" class="btn-sekundarno-malo"
Vrati data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
</a> Vrati
</button>
</form>
{{else}} {{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo" <form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?"> <button type="submit" class="btn-obrisi-malo"
Obriši data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
</a> Obriši
</button>
</form>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
+1 -1
View File
@@ -97,7 +97,7 @@
</div> </div>
<div> <div>
<label class="polje-labela">Minimalna količina</label> <label class="polje-labela">Minimalna količina</label>
<input type="number" name="kolicina_min" value="{{.Artikal.KolicinMin}}" min="0" style="width:100%;"> <input type="number" name="kolicina_min" value="{{.Artikal.KolicinaMin}}" min="0" style="width:100%;">
</div> </div>
</div> </div>
+3 -3
View File
@@ -28,12 +28,12 @@
</div> </div>
</div> </div>
<div style="text-align:right;"> <div style="text-align:right;">
<div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinMin}}#dc2626{{else}}var(--tekst-glavni){{end}};"> <div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinaMin}}#dc2626{{else}}var(--tekst-glavni){{end}};">
{{.Artikal.Kolicina}} kom {{.Artikal.Kolicina}} kom
</div> </div>
<div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div> <div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div>
{{if gt .Artikal.KolicinMin 0}} {{if gt .Artikal.KolicinaMin 0}}
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinMin}} kom</div> <div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinaMin}} kom</div>
{{end}} {{end}}
</div> </div>
</div> </div>
+1 -1
View File
@@ -282,7 +282,7 @@
</thead> </thead>
<tbody> <tbody>
{{range .Artikli}} {{range .Artikli}}
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}"> <tr class="{{if le .Kolicina .KolicinaMin}}red-kriticno{{end}}" id="red-{{.ID}}">
<td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td> <td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td>
<td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td> <td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td> <td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
+4 -4
View File
@@ -54,12 +54,12 @@
</thead> </thead>
<tbody> <tbody>
{{range .Zalihe}} {{range .Zalihe}}
<tr {{if le .Kolicina .KolicinMin}}style="background:rgba(220,38,38,0.05);"{{end}}> <tr {{if le .Kolicina .KolicinaMin}}style="background:rgba(220,38,38,0.05);"{{end}}>
<td style="font-weight:500;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Naziv}}</td> <td style="font-weight:500;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Naziv}}</td>
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td> <td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td> <td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td>
<td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td> <td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td>
<td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinMin}}</td> <td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinaMin}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td> <td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td> <td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td>
{{if index $.Moduli "pdv"}}<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .CenaSaPdv}}</td>{{end}} {{if index $.Moduli "pdv"}}<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .CenaSaPdv}}</td>{{end}}
+5
View File
@@ -35,6 +35,11 @@
{{if eq .Greska "1"}} {{if eq .Greska "1"}}
<div class="greska">Neispravan kod. Pokušajte ponovo.</div> <div class="greska">Neispravan kod. Pokušajte ponovo.</div>
{{else if eq .Greska "zakljucano"}}
<div class="greska">
Previše neuspelih pokušaja. IP adresa je privremeno blokirana.
{{if .Preostalo}}<br>Pokušajte ponovo za: <strong>{{.Preostalo}}</strong>{{end}}
</div>
{{end}} {{end}}
<form method="POST" action="/prijava/totp"> <form method="POST" action="/prijava/totp">