From 49296e431567080406c713aa88ca04114a330fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 3 Jul 2026 23:00:44 +0200 Subject: [PATCH 1/7] Bezbednosne i integritetne popravke iz pregleda koda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - foreign_keys pragma po konekciji (DSN _pragma) — ranije aktivna na samo delu konekcija iz pula, rizik siročadi u bazi pri brisanju - busy_timeout + _txlock=immediate za konkurentan pristup SQLite bazi - ispravljen DnevniPrometMaloprodaje (DATE() -> substr) — dnevni pazar za KIR/KPO je bio uvek nula - rate-limit (isti IP-bruteforce brojač) dodat na TOTP/2FA verifikaciju - ispravljen nil-pointer u DodajDeloNalogu + uklonjeni debug logovi - izvuciIP prihvata X-Real-IP/X-Forwarded-For samo sa poverljive (loopback/privatne) adrese — sprečava zaobilaženje bruteforce brave - ispravljen naziv dozvole podesavanja.izmena -> podesavanja.izmeni - brisanje artikala/kategorija prebačeno sa GET na POST (CSRF zaštita) --- cmd/ntech/main.go | 6 +-- internal/db/sqlite/migracije.go | 29 ++++++----- internal/db/sqlite/prodaja.go | 4 +- internal/db/sqlite/servisni_delovi.go | 4 -- internal/handler/podesavanja.go | 2 +- internal/handler/prijava.go | 61 +++++++++++++++++++----- internal/handler/servis.go | 11 +++-- internal/handler/utils_test.go | 6 ++- web/templates/stranice/kategorije.html | 10 ++-- web/templates/stranice/magacin.html | 40 +++++++++------- web/templates/stranice/totp_provera.html | 5 ++ 11 files changed, 118 insertions(+), 60 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 610d6e9..04a42d9 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -349,8 +349,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 +359,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/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/prodaja.go b/internal/db/sqlite/prodaja.go index c405dc2..ce8278a 100644 --- a/internal/db/sqlite/prodaja.go +++ b/internal/db/sqlite/prodaja.go @@ -446,7 +446,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 +458,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/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 08487ad..e58d216 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 } diff --git a/internal/handler/podesavanja.go b/internal/handler/podesavanja.go index 77a4988..9bcbe80 100644 --- a/internal/handler/podesavanja.go +++ b/internal/handler/podesavanja.go @@ -1090,7 +1090,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/web/templates/stranice/kategorije.html b/web/templates/stranice/kategorije.html index 3da31dc..d5f1324 100644 --- a/web/templates/stranice/kategorije.html +++ b/web/templates/stranice/kategorije.html @@ -130,10 +130,12 @@ {{end}} {{if index $.Dozvole "kategorija.obrisi"}} - - Obriši - +
+ +
{{end}} {{else}} diff --git a/web/templates/stranice/magacin.html b/web/templates/stranice/magacin.html index 5ac61d9..2725337 100644 --- a/web/templates/stranice/magacin.html +++ b/web/templates/stranice/magacin.html @@ -126,15 +126,19 @@ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} {{if $.PrikazArhivirani}} - - Vrati - +
+ +
{{else}} - - Obriši - +
+ +
{{end}} {{end}} @@ -176,15 +180,19 @@ {{end}}{{end}} {{if index $.Dozvole "artikal.obrisi"}} {{if $.PrikazArhivirani}} - - Vrati - +
+ +
{{else}} - - Obriši - +
+ +
{{end}} {{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}}
From 127cebea3c0d7a02812fc4c76846d315f22348df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 3 Jul 2026 23:21:29 +0200 Subject: [PATCH 2/7] Srednji prioritet iz pregleda koda: konzistentnost, refaktor, sitne popravke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - konstantno-vremensko poređenje CSRF tokena i kartica-PIN-a - CSRF grupa proširena na /prijava, /prijava/totp, /setup - router-level dozvola na svim /admin/podesavanja/* i modul("fiskalizacija") na fiskal.pazar rutama - KlijentRepo.Pronadji razlikuje sql.ErrNoRows od stvarnih DB grešaka - izdvojen zajednički vratiStavkeNaStanje umesto 4 duplirana bloka (ProdajaRepo/ServisRepo Storno/Obrisi) - datumi u KIR/KPR sada se upisuju/porede kao "2006-01-02", usklađeno sa KPO - ArtikalRepo.DohvatiVise (batch dohvat) — ukida N+1 upite u SacuvajNabavku - KategorijaRepo.Obrisi umesto direktnog SQL-a u handleru, sa ErrKategorijaUUpotrebi porukom - KolicinMin -> KolicinaMin (tipfeler), Trosak/Usluga.DatumUnosa string -> time.Time - dedup OznakaPoreskogBroja (KIR/KPR) - inicijali() template funkcija (rune, ne bajtovi) za avatar inicijale --- cmd/ntech/main.go | 46 ++++++---- internal/be/kartica.go | 3 +- internal/db/repository.go | 8 ++ internal/db/sqlite/artikal.go | 73 ++++++++++++--- internal/db/sqlite/izvestaj.go | 2 +- internal/db/sqlite/izvestaj_test.go | 6 +- internal/db/sqlite/kategorija.go | 18 ++++ internal/db/sqlite/klijent.go | 12 +++ internal/db/sqlite/magacin.go | 60 +++++++++++++ internal/db/sqlite/pdv_evidencija.go | 14 +-- internal/db/sqlite/prodaja.go | 99 +-------------------- internal/db/sqlite/servis.go | 97 +------------------- internal/handler/kategorija.go | 10 ++- internal/handler/kes.go | 9 ++ internal/handler/magacin_forma.go | 4 +- internal/handler/nabavka.go | 19 +++- internal/middleware/csrf.go | 3 +- internal/model/artikal.go | 2 +- internal/model/izvestaj.go | 2 +- internal/model/pdv_evidencija.go | 19 ++-- internal/model/trosak.go | 4 +- internal/model/usluga.go | 4 +- web/templates/komponente/topbar.html | 2 +- web/templates/stranice/magacin_forma.html | 2 +- web/templates/stranice/magacin_kartica.html | 6 +- web/templates/stranice/popis.html | 2 +- web/templates/stranice/stanje_zaliha.html | 8 +- 27 files changed, 270 insertions(+), 264 deletions(-) diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 04a42d9..e2b7a1e 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -238,14 +238,21 @@ 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) + // 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) @@ -283,18 +290,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) 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..4652e55 100644 --- a/internal/db/sqlite/izvestaj.go +++ b/internal/db/sqlite/izvestaj.go @@ -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/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 ce8278a..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) } } 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/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/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/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/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/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_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}} From d9f337617be7d8714212208ff2381ca6f9ce80ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 3 Jul 2026 23:31:00 +0200 Subject: [PATCH 3/7] =?UTF-8?q?Nizak=20prioritet=20iz=20pregleda=20koda:?= =?UTF-8?q?=20indeksi,=20gre=C5=A1ke,=20sitne=20popravke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migracija 102: indeksi na FK kolonama (stavke_prodaje, servisni_delovi, servisni_potrazivani_delovi, magacinske_promene, stavke_nabavke, pdv_kir, pdv_kpr) — potvrđeno kreiranje i idempotentnost - BrojKriticnihZaliha/KriticneZalihe filtriraju samo proizvode i aktivne artikle, usklađeno sa ArtikalRepo.Lista(SamoKriticni) - url.QueryEscape na query string u magacin.go/klijent.go (paginacija) - TestFiskalizacije/BeStatus vraćaju generičku poruku, detalj u slog - SetFlash loguje grešku umesto tihog ignorisanja - os.WriteFile/os.MkdirAll u main.go proveravaju grešku - dummyHash generisanje panikuje na startu ako bcrypt ikad zakaže (umesto tihog sloma anti-enumeracije) - PrihvatiPredlozene proverava redovi.Err() - /healthz endpoint (DB ping) - hx-boost na "Osveži" linkovima (blokirane_ip, fiskal_pazar) - MaxBytesReader (4KB) na javne /status/{token}/* POST rute - komentar u auth.go zašto postoje dva flash mehanizma (namerno, ne spojeno) Provereno: jePrivatnaAdresa ne radi DNS lookup (nema net.LookupIP u repou), pa DNS-rebinding nalaz iz pregleda nije primenljiv na trenutni kod. --- cmd/ntech/main.go | 33 ++++++++++++++++++++---- internal/auth/auth.go | 13 +++++++++- internal/db/sqlite/izvestaj.go | 4 +-- internal/db/sqlite/servisni_delovi.go | 3 +++ internal/handler/klijent.go | 5 ++-- internal/handler/magacin.go | 5 ++-- internal/handler/podesavanja.go | 11 +++++--- internal/middleware/auth.go | 7 ++++- internal/middleware/flash.go | 7 +++-- migrations/102_indeksi_fk_kolona.sql | 19 ++++++++++++++ web/templates/stranice/blokirane_ip.html | 2 +- web/templates/stranice/fiskal_pazar.html | 2 +- 12 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 migrations/102_indeksi_fk_kolona.sql diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index e2b7a1e..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,6 +242,16 @@ func main() { http.FileServer(http.FS(staticFS)).ServeHTTP(w, req) }))) + // 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) @@ -254,9 +268,18 @@ func main() { // 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 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/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go index 4652e55..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) diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index e58d216..e965981 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -259,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/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/podesavanja.go b/internal/handler/podesavanja.go index 9bcbe80..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() 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/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/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/stranice/blokirane_ip.html b/web/templates/stranice/blokirane_ip.html index f2980e0..f938f67 100644 --- a/web/templates/stranice/blokirane_ip.html +++ b/web/templates/stranice/blokirane_ip.html @@ -7,7 +7,7 @@
IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave - Osveži + Osveži
{{if .Lista}} diff --git a/web/templates/stranice/fiskal_pazar.html b/web/templates/stranice/fiskal_pazar.html index e624895..df51c62 100644 --- a/web/templates/stranice/fiskal_pazar.html +++ b/web/templates/stranice/fiskal_pazar.html @@ -90,7 +90,7 @@
- Osveži + Osveži {{if index .Dozvole "fiskal.zakljucenje"}}
Date: Fri, 3 Jul 2026 23:42:30 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Ispravka:=20Usluge/Tro=C5=A1kovi=20se=20nis?= =?UTF-8?q?u=20u=C4=8Ditavali=20(regresija=20iz=20DatumUnosa=20time.Time?= =?UTF-8?q?=20izmene)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usluge.datum_unosa i troskovi.datum_unosa su TEXT kolone (ne DATETIME) — modernc.org/sqlite driver za njih vraća string, ne time.Time, pa je Scan direktno u *time.Time pucao sa "unsupported Scan ... string into *time.Time". Dodat parseDatumUnosa helper (ručni time.Parse), koriste ga skenirajUslugu/skenirajTrosak umesto oslanjanja na driver auto-konverziju. --- internal/db/sqlite/trosak.go | 6 +++++- internal/db/sqlite/usluga.go | 6 +++++- internal/db/sqlite/utils.go | 8 ++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/db/sqlite/trosak.go b/internal/db/sqlite/trosak.go index cbed94f..93cb340 100644 --- a/internal/db/sqlite/trosak.go +++ b/internal/db/sqlite/trosak.go @@ -26,11 +26,15 @@ func skenirajTrosak(s interface { var t model.Trosak var sifra sql.NullString var arhiviran int - if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &t.DatumUnosa); err != nil { + var datumUnosa string + if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &datumUnosa); err != nil { return t, err } t.Sifra = sifra.String t.Arhiviran = arhiviran == 1 + if parsed, err := parseDatumUnosa(datumUnosa); err == nil { + t.DatumUnosa = parsed + } return t, nil } diff --git a/internal/db/sqlite/usluga.go b/internal/db/sqlite/usluga.go index 8db77c6..e421431 100644 --- a/internal/db/sqlite/usluga.go +++ b/internal/db/sqlite/usluga.go @@ -26,11 +26,15 @@ func skenirajUslugu(s interface { var u model.Usluga var sifra sql.NullString var arhiviran int - if err := s.Scan(&u.ID, &sifra, &u.Naziv, &u.Kategorija, &u.JedinicaMere, &u.Cena, &u.PdvStopa, &u.Opis, &arhiviran, &u.DatumUnosa); err != nil { + var datumUnosa string + if err := s.Scan(&u.ID, &sifra, &u.Naziv, &u.Kategorija, &u.JedinicaMere, &u.Cena, &u.PdvStopa, &u.Opis, &arhiviran, &datumUnosa); err != nil { return u, err } u.Sifra = sifra.String u.Arhiviran = arhiviran == 1 + if t, err := parseDatumUnosa(datumUnosa); err == nil { + u.DatumUnosa = t + } return u, nil } diff --git a/internal/db/sqlite/utils.go b/internal/db/sqlite/utils.go index 4f058ab..77ec141 100644 --- a/internal/db/sqlite/utils.go +++ b/internal/db/sqlite/utils.go @@ -55,3 +55,11 @@ func nullDateString(v *time.Time) sql.NullString { } return sql.NullString{String: v.Format("2006-01-02"), Valid: true} } + +// parseDatumUnosa parsira "YYYY-MM-DD HH:MM:SS" iz kolone deklarisane kao TEXT +// (npr. usluge/troskovi.datum_unosa, `DEFAULT (datetime('now'))`). Driver +// vraća string umesto time.Time za TEXT kolone (za razliku od DATETIME kolona +// koje driver sam parsira), pa Scan ne može direktno u *time.Time. +func parseDatumUnosa(s string) (time.Time, error) { + return time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC) +} From e99dd5027347ee3072c109c54db58580cec243ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 4 Jul 2026 00:09:32 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Prava=20ispravka:=20usluge/troskovi.datum?= =?UTF-8?q?=5Funosa=20promenjen=20na=20DATETIME=20u=20=C5=A1emi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umesto ručnog parsiranja (prethodni commit), migracija 103 rekreira obe tabele sa DATETIME kolonom (kao svuda drugde u šemi) — SQLite ne podržava ALTER COLUMN, pa je urađeno kreiraj-novu/prebaci-podatke/obriši-staru/ preimenuj. Fizički format podataka se ne menja (SQLite nema pravi numerički afinitet za DATETIME nad ovakvim stringom), menja se samo deklarisani tip kolone koji driver čita da odluči string vs time.Time pri Scan-u. Uklonjen parseDatumUnosa workaround iz utils.go — skenirajUslugu/skenirajTrosak sada rade identično kao svi ostali skenirajX helperi (direktan Scan u time.Time). Potvrđeno: migracija testirana na praznoj i na postojećoj bazi sa podacima (6 usluga, ID-jevi i datumi netaknuti), /usluge i /troskovi vraćaju 200. --- internal/db/sqlite/trosak.go | 6 +-- internal/db/sqlite/usluga.go | 6 +-- internal/db/sqlite/utils.go | 8 ---- ...3_usluge_troskovi_datum_unosa_datetime.sql | 39 +++++++++++++++++++ 4 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 migrations/103_usluge_troskovi_datum_unosa_datetime.sql diff --git a/internal/db/sqlite/trosak.go b/internal/db/sqlite/trosak.go index 93cb340..cbed94f 100644 --- a/internal/db/sqlite/trosak.go +++ b/internal/db/sqlite/trosak.go @@ -26,15 +26,11 @@ func skenirajTrosak(s interface { var t model.Trosak var sifra sql.NullString var arhiviran int - var datumUnosa string - if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &datumUnosa); err != nil { + if err := s.Scan(&t.ID, &sifra, &t.Naziv, &t.Cena, &t.Opis, &arhiviran, &t.DatumUnosa); err != nil { return t, err } t.Sifra = sifra.String t.Arhiviran = arhiviran == 1 - if parsed, err := parseDatumUnosa(datumUnosa); err == nil { - t.DatumUnosa = parsed - } return t, nil } diff --git a/internal/db/sqlite/usluga.go b/internal/db/sqlite/usluga.go index e421431..8db77c6 100644 --- a/internal/db/sqlite/usluga.go +++ b/internal/db/sqlite/usluga.go @@ -26,15 +26,11 @@ func skenirajUslugu(s interface { var u model.Usluga var sifra sql.NullString var arhiviran int - var datumUnosa string - if err := s.Scan(&u.ID, &sifra, &u.Naziv, &u.Kategorija, &u.JedinicaMere, &u.Cena, &u.PdvStopa, &u.Opis, &arhiviran, &datumUnosa); err != nil { + if err := s.Scan(&u.ID, &sifra, &u.Naziv, &u.Kategorija, &u.JedinicaMere, &u.Cena, &u.PdvStopa, &u.Opis, &arhiviran, &u.DatumUnosa); err != nil { return u, err } u.Sifra = sifra.String u.Arhiviran = arhiviran == 1 - if t, err := parseDatumUnosa(datumUnosa); err == nil { - u.DatumUnosa = t - } return u, nil } diff --git a/internal/db/sqlite/utils.go b/internal/db/sqlite/utils.go index 77ec141..4f058ab 100644 --- a/internal/db/sqlite/utils.go +++ b/internal/db/sqlite/utils.go @@ -55,11 +55,3 @@ func nullDateString(v *time.Time) sql.NullString { } return sql.NullString{String: v.Format("2006-01-02"), Valid: true} } - -// parseDatumUnosa parsira "YYYY-MM-DD HH:MM:SS" iz kolone deklarisane kao TEXT -// (npr. usluge/troskovi.datum_unosa, `DEFAULT (datetime('now'))`). Driver -// vraća string umesto time.Time za TEXT kolone (za razliku od DATETIME kolona -// koje driver sam parsira), pa Scan ne može direktno u *time.Time. -func parseDatumUnosa(s string) (time.Time, error) { - return time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC) -} diff --git a/migrations/103_usluge_troskovi_datum_unosa_datetime.sql b/migrations/103_usluge_troskovi_datum_unosa_datetime.sql new file mode 100644 index 0000000..d478e9f --- /dev/null +++ b/migrations/103_usluge_troskovi_datum_unosa_datetime.sql @@ -0,0 +1,39 @@ +-- usluge.datum_unosa i troskovi.datum_unosa su bile deklarisane kao TEXT, za +-- razliku od svih ostalih tabela (DATETIME) — modernc.org/sqlite driver zato +-- vraća goli string umesto time.Time pri Scan-u. Fizički format podataka se ne +-- menja (SQLite nema stvarni numerički afinitet za DATETIME nad ovakvim +-- stringom), samo deklarisani tip kolone — pa je rekreacija tabele bezbedna +-- bez transformacije vrednosti. + +CREATE TABLE usluge_novo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sifra TEXT, + naziv TEXT NOT NULL, + kategorija TEXT NOT NULL DEFAULT '', + jedinica_mere TEXT NOT NULL DEFAULT 'usluga', + cena REAL NOT NULL DEFAULT 0, + pdv_stopa REAL NOT NULL DEFAULT 20, + opis TEXT NOT NULL DEFAULT '', + arhiviran INTEGER NOT NULL DEFAULT 0, + datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO usluge_novo (id, sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis, arhiviran, datum_unosa) + SELECT id, sifra, naziv, kategorija, jedinica_mere, cena, pdv_stopa, opis, arhiviran, datum_unosa FROM usluge; +DROP TABLE usluge; +ALTER TABLE usluge_novo RENAME TO usluge; +CREATE INDEX IF NOT EXISTS idx_usluge_arhiviran ON usluge(arhiviran); + +CREATE TABLE troskovi_novo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sifra TEXT, + naziv TEXT NOT NULL, + cena REAL NOT NULL DEFAULT 0, + opis TEXT NOT NULL DEFAULT '', + arhiviran INTEGER NOT NULL DEFAULT 0, + datum_unosa DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +INSERT INTO troskovi_novo (id, sifra, naziv, cena, opis, arhiviran, datum_unosa) + SELECT id, sifra, naziv, cena, opis, arhiviran, datum_unosa FROM troskovi; +DROP TABLE troskovi; +ALTER TABLE troskovi_novo RENAME TO troskovi; +CREATE INDEX IF NOT EXISTS idx_troskovi_arhiviran ON troskovi(arhiviran); From fba3cb15a6194fbc0fef697c97b23bcc5ba5be7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 4 Jul 2026 00:58:15 +0200 Subject: [PATCH 6/7] =?UTF-8?q?Refaktor=20#23:=20zajedni=C4=8Dko=20zaglavl?= =?UTF-8?q?je=20servisnih=20=C5=A1tampanih=20dokumenata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Izdvojen partial web/templates/komponente/servis_zaglavlje.html sa dva {{define}} bloka (zaglavljeFirma, zaglavljeKlijent) — koriste ih revers, predračun, otpremnica (oba bloka) i eskalacioni list (samo firma; klijent blok tamo ima drugačiji markup/stil pa nije spojen). kes.go: dodata standaloneExtraFajlovi mapa da standalone šabloni mogu da uključe dodatne partial fajlove uz svoj glavni (i u produkcionom kešu i u dev lazy-parse putanji). Vizuelno provereno u browseru (sva 4 dokumenta, servis nalog SN-2606-007) — identičan izgled kao pre refaktora, ispravan redosled kolona po dokumentu. --- internal/handler/kes.go | 16 +++++++-- .../komponente/servis_zaglavlje.html | 36 +++++++++++++++++++ .../stranice/servis_eskalacioni_list.html | 14 +------- web/templates/stranice/servis_otpremnica.html | 33 ++--------------- web/templates/stranice/servis_predracun.html | 33 ++--------------- web/templates/stranice/servis_revers.html | 33 ++--------------- 6 files changed, 57 insertions(+), 108 deletions(-) create mode 100644 web/templates/komponente/servis_zaglavlje.html diff --git a/internal/handler/kes.go b/internal/handler/kes.go index c6d1fe9..515809c 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -48,6 +48,16 @@ var standaloneIme = []string{ "prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "servis_eskalacioni_list", "fiskal_verifikacija", "popis_stampa", } +// standaloneExtraFajlovi su dodatni fajlovi (partial-i sa {{define}}) koje treba +// parsirati uz standalone šablon — npr. zajedničko zaglavlje servisnih dokumenata, +// koje se deli između revers/predračun/otpremnica/eskalacioni list. +var standaloneExtraFajlovi = map[string][]string{ + "servis_revers": {"web/templates/komponente/servis_zaglavlje.html"}, + "servis_predracun": {"web/templates/komponente/servis_zaglavlje.html"}, + "servis_otpremnica": {"web/templates/komponente/servis_zaglavlje.html"}, + "servis_eskalacioni_list": {"web/templates/komponente/servis_zaglavlje.html"}, +} + // sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima. // dict gradi mapu iz parova ključ/vrednost — koristi se da se jednom partialu // prosledi više vrednosti (npr. {{template "x" (dict "ID" .ID "Lista" $.Lista)}}). @@ -157,7 +167,8 @@ func KreirajKes(fsys fs.FS) (map[string]*template.Template, error) { for _, ime := range standaloneIme { // ime+".html" mora biti ime roota da bi Execute() pronašlo sadržaj fajla - t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, "web/templates/stranice/"+ime+".html") + fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...) + t, err := template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(fsys, fajlovi...) if err != nil { return nil, fmt.Errorf("kes: %s: %w", ime, err) } @@ -219,7 +230,8 @@ func (h *Handler) renderujStandalone(w http.ResponseWriter, ime string, podaci a } else { var err error // kao u kreirajKes: root mora biti ime+".html" i moraju biti registrovane šablonske funkcije - if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, "web/templates/stranice/"+ime+".html"); err != nil { + fajlovi := append([]string{"web/templates/stranice/" + ime + ".html"}, standaloneExtraFajlovi[ime]...) + if tmpl, err = template.New(ime+".html").Funcs(sablonskeFunkcije).ParseFS(h.TemplatesFS, fajlovi...); err != nil { slog.Error("greška pri parsiranju šablona", "ime", ime, "error", err) http.Error(w, "Greška pri učitavanju stranice", http.StatusInternalServerError) return diff --git a/web/templates/komponente/servis_zaglavlje.html b/web/templates/komponente/servis_zaglavlje.html new file mode 100644 index 0000000..2b5d9cc --- /dev/null +++ b/web/templates/komponente/servis_zaglavlje.html @@ -0,0 +1,36 @@ +{{define "zaglavljeFirma"}} + +
+ {{if .LogoPutanja}} + Logo + {{else}} +
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
+ {{end}} +
+ {{if .Podnazlov}}{{.Podnazlov}}
{{end}} + {{if .Adresa}}{{.Adresa}}
{{end}} + {{if .Telefon}}Tel: {{telefon .Telefon}}
{{end}} + {{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} · {{end}}MB: {{.MaticniBroj}}{{end}} +
+
+{{end}} + +{{define "zaglavljeKlijent"}} + +
+
Klijent
+ {{if .KlijentNaziv}} +
{{.KlijentNaziv}}
+
+ {{if .Klijent}} + {{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}
{{end}} + {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} + {{if .Klijent.Mesto}}{{.Klijent.Mesto}}
{{end}} + {{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}} + {{end}} +
+ {{else}} +
— klijent nije naveden —
+ {{end}} +
+{{end}} diff --git a/web/templates/stranice/servis_eskalacioni_list.html b/web/templates/stranice/servis_eskalacioni_list.html index c7b9283..1ec2f1f 100644 --- a/web/templates/stranice/servis_eskalacioni_list.html +++ b/web/templates/stranice/servis_eskalacioni_list.html @@ -53,19 +53,7 @@
-
- {{if .LogoPutanja}} - Logo - {{else}} -
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
- {{end}} -
- {{if .Podnazlov}}{{.Podnazlov}}
{{end}} - {{if .Adresa}}{{.Adresa}}
{{end}} - {{if .Telefon}}Tel: {{telefon .Telefon}}
{{end}} - {{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} · {{end}}MB: {{.MaticniBroj}}{{end}} -
-
+ {{template "zaglavljeFirma" .}}
{{if .Barkod}}Barkod {{.Nalog.BrojNaloga}}{{end}}
Eskalacioni list — praćenje rokova
diff --git a/web/templates/stranice/servis_otpremnica.html b/web/templates/stranice/servis_otpremnica.html index e7158b2..79d4d1b 100644 --- a/web/templates/stranice/servis_otpremnica.html +++ b/web/templates/stranice/servis_otpremnica.html @@ -83,37 +83,8 @@
- -
- {{if .LogoPutanja}} - Logo - {{else}} -
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
- {{end}} -
- {{if .Podnazlov}}{{.Podnazlov}}
{{end}} - {{if .Adresa}}{{.Adresa}}
{{end}} - {{if .Telefon}}Tel: {{telefon .Telefon}}
{{end}} - {{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} · {{end}}MB: {{.MaticniBroj}}{{end}} -
-
- -
-
Klijent
- {{if .KlijentNaziv}} -
{{.KlijentNaziv}}
-
- {{if .Klijent}} - {{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}
{{end}} - {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} - {{if .Klijent.Mesto}}{{.Klijent.Mesto}}
{{end}} - {{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}} - {{end}} -
- {{else}} -
— klijent nije naveden —
- {{end}} -
+ {{template "zaglavljeFirma" .}} + {{template "zaglavljeKlijent" .}}
{{if .Barkod}}Barkod {{.Nalog.BrojNaloga}}{{end}} diff --git a/web/templates/stranice/servis_predracun.html b/web/templates/stranice/servis_predracun.html index 71959da..c9fb6db 100644 --- a/web/templates/stranice/servis_predracun.html +++ b/web/templates/stranice/servis_predracun.html @@ -77,20 +77,7 @@
- -
- {{if .LogoPutanja}} - Logo - {{else}} -
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
- {{end}} -
- {{if .Podnazlov}}{{.Podnazlov}}
{{end}} - {{if .Adresa}}{{.Adresa}}
{{end}} - {{if .Telefon}}Tel: {{telefon .Telefon}}
{{end}} - {{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} · {{end}}MB: {{.MaticniBroj}}{{end}} -
-
+ {{template "zaglavljeFirma" .}}
@@ -103,23 +90,7 @@ {{if .QRKod}}QR {{.Nalog.BrojNaloga}}{{end}}
- -
-
Klijent
- {{if .KlijentNaziv}} -
{{.KlijentNaziv}}
-
- {{if .Klijent}} - {{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}
{{end}} - {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} - {{if .Klijent.Mesto}}{{.Klijent.Mesto}}
{{end}} - {{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}} - {{end}} -
- {{else}} -
— klijent nije naveden —
- {{end}} -
+ {{template "zaglavljeKlijent" .}}
diff --git a/web/templates/stranice/servis_revers.html b/web/templates/stranice/servis_revers.html index cbae051..f2e6b3b 100644 --- a/web/templates/stranice/servis_revers.html +++ b/web/templates/stranice/servis_revers.html @@ -75,20 +75,7 @@
- -
- {{if .LogoPutanja}} - Logo - {{else}} -
{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}
- {{end}} -
- {{if .Podnazlov}}{{.Podnazlov}}
{{end}} - {{if .Adresa}}{{.Adresa}}
{{end}} - {{if .Telefon}}Tel: {{telefon .Telefon}}
{{end}} - {{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} · {{end}}MB: {{.MaticniBroj}}{{end}} -
-
+ {{template "zaglavljeFirma" .}}
{{if .Barkod}}Barkod {{.Nalog.BrojNaloga}}{{end}} @@ -99,23 +86,7 @@
Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}
{{end}}
- -
-
Klijent
- {{if .KlijentNaziv}} -
{{.KlijentNaziv}}
-
- {{if .Klijent}} - {{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}
{{end}} - {{if .Klijent.Email}}{{.Klijent.Email}}
{{end}} - {{if .Klijent.Mesto}}{{.Klijent.Mesto}}
{{end}} - {{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}} - {{end}} -
- {{else}} -
— klijent nije naveden —
- {{end}} -
+ {{template "zaglavljeKlijent" .}}
From 48f9ba8435cfe8722db3791fa4b06c5988f8724c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 4 Jul 2026 01:35:57 +0200 Subject: [PATCH 7/7] =?UTF-8?q?README:=20a=C5=BEurirani=20opisi=20funkcion?= =?UTF-8?q?alnosti=20i=20nove=20sekcije?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ispravljena Go verzija zahteva (1.24 -> 1.26, usklađeno sa go.mod) - dodate BE_ENABLED/BE_PORT env varijable - nova sekcija "Kako radi"/"How It Works" — arhitektura, tok zahteva, javne stranice za klijente, štampani dokumenti - nove sekcije Health Check, Testing, Security Notes, License - fiskalizacija prebačena iz "U toku" u "Implementirano" — bila je zastarela (opisivala kao "u planu" nešto što je u potpunosti implementirano i integrisano); "U toku" sada tačnije opisuje da nedostaje samo test na pravom sertifikovanom uređaju - eksplicitno pomenut QR kod + mobilno praćenje statusa servisa (ranije nejasno "putem jedinstvenog linka"), ispravljeno pogrešno pominjanje obaveštenja koja nisu implementirana - dodat EAN/barkod po artiklu i skeniranje barkoda u prodaji --- Readme.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++--- Readme_sr.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/Readme.md b/Readme.md index 2dd58eb..f98bfc0 100644 --- a/Readme.md +++ b/Readme.md @@ -23,6 +23,22 @@ The goal is simple: everything the repair shop needs to track is located in one --- +## How It Works + +**Server-rendered, no build step.** Pages are rendered on the server with Go's `html/template` (auto-escaping, no client-side templating engine). [HTMX](https://htmx.org) swaps page fragments over plain HTTP for SPA-like navigation, and [Alpine.js](https://alpinejs.dev) handles small bits of client-side interactivity (live totals, dynamic form rows, autocomplete). There is no `npm`/webpack build — the browser gets plain HTML/CSS/JS, and a single page load is enough to use the whole app. + +**Single binary, no runtime dependencies.** Templates, static assets (CSS/JS/images) and SQL migrations are embedded into the compiled binary with `go:embed`. Deploying is copying one file (or running one Docker image) — there's no separate asset build, no template files to ship alongside the executable. In development the same code reads straight from disk instead, so template/CSS/JS edits are visible on refresh without a rebuild. + +**Request flow.** `chi` routes each request through a middleware chain — security headers → CSRF (double-submit cookie) → session lookup → role/permission check — before it reaches a handler. Permission checks run at the router level (so a route can't accidentally ship unprotected) and again inside the handler as defense in depth. Handlers talk to the database through a repository layer (plain SQL, no ORM) and pass plain Go structs to templates. + +**Database.** SQLite via a pure-Go driver (`modernc.org/sqlite`, no CGO) is the default and only dependency — a single file, no separate database server to run or back up. Every startup applies any new SQL migration files in order and records them in a `migracije` table, so upgrades are just "replace the binary and restart." An optional PostgreSQL backend (via `pgx/v5`) is planned for multi-user setups. + +**Client-facing public pages.** Two flows don't require a login, only a unique unguessable token in the URL: the service-status page (a client can check repair progress and get a QR code straight from their receipt) and the parts/service proposal approval page (a client accepts or rejects an estimate with a comment). Both are capability-based — whoever has the link has access to that one order, nothing else. + +**Printable documents.** Work orders, pre-invoices, dispatch notes, return slips, and device labels are separate, self-contained HTML pages styled for A4 printing (`@media print`), each with a "Print" button that opens the browser's native print dialog — no PDF library, no headless-browser rendering step. Documents that don't fit one page paginate themselves client-side (measuring content height and inserting page breaks with running page numbers). + +--- + ## Features ### Implemented @@ -39,12 +55,13 @@ The goal is simple: everything the repair shop needs to track is located in one - Login attempt logging — history by user, IP, reason, date - Users and roles — admin panel, user management - Inventory — items, categories, filtering, critical stock levels, per-item stock card, supplier links, item transfers +- Barcode (EAN) per item — searchable by barcode in inventory; in the sales screen, scanning a barcode (any USB/Bluetooth scanner that types + Enter) looks the item up and adds it to the order automatically - Service orders: - Intake form, status bar, archive - Diagnostic workflow — fault description, technician notes, work done, diagnostic fee - Parts and services — used items deducted from stock; suggested items (proposal to client) - Client proposal approval — client receives a public link (QR code) to accept or reject a parts/service proposal with a comment - - Public status page — client can check order status and receive notifications via a unique link + - Public status tracking via QR code — the device label and every printed document carry a QR code the client scans with their phone; it opens a mobile-optimized page (no login, no app) showing the current repair status, which they can revisit any time by the same link/code - Documents — work order, pre-invoice (estimate), dispatch note, return slip, device label (QR + Code128 barcode) - Pickup with payment — tracks payment method and advance amount - Guarantee period, expected completion date, technician assignment, client notes @@ -58,6 +75,7 @@ The goal is simple: everything the repair shop needs to track is located in one - VAT records (KIR/KPR) — books of issued and received invoices, auto-filled from sales and procurement - VAT calculation per period + mapping to the PP-PDV form; imports (customs declaration) tracked in fields 006/106 - VAT rate code list +- **Fiscalization (ESIR/L-PFR)** — full Go client for the Teron fiscal device API: connection test, invoice issuing (sale/service, including advances and refunds on cancellation), daily till summary, end-of-day closure, PDF fiscal reports, QR-code invoice verification (public `/v/` page), automatic retry on failed fiscalization with a visible error state, and a card-emulator status/reset panel (talks to the signing device over TCP). Currently verified against the Teron mock server (`Fisk/`); not yet tested against real certified hardware. - Clients and suppliers — contact database - Reminders — records with deadlines - Reports — revenue overview, inventory status, inventory value report, stock movement list, stocktake (physical count) @@ -74,7 +92,7 @@ The goal is simple: everything the repair shop needs to track is located in one ### In Progress -- **Fiscalization (ESIR/PFR)** — Teron L-PFR mock server included in `Fisk/`; Go client integration planned +- **Fiscalization on real hardware** — the full flow (issuing, refunds, daily till, closure, reports) is implemented and verified against the Teron mock server; testing against a real certified L-PFR device is still pending. ### Planned @@ -104,7 +122,7 @@ The goal is simple: everything the repair shop needs to track is located in one ### Requirements -- Go 1.24 or newer +- Go 1.26 or newer - Git ### Steps @@ -158,6 +176,8 @@ The application reads environment variables on startup. In development, place th | `NTECH_DSN` | — | PostgreSQL connection string | | `NTECH_SECRET` | — | Session signing key (min. 32 bytes); auto-generated if missing | | `NTECH_TOTP_KEY` | — | AES-256 key for TOTP secret encryption; auto-generated if missing | +| `BE_ENABLED` | `true` | Enables the built-in card-emulator (fiscalization signing device) | +| `BE_PORT` | `4567` | TCP port for the built-in card-emulator | `NTECH_SECRET` and `NTECH_TOTP_KEY` are generated automatically on the first run and saved to `ntech.env`. **Back this file up** — losing `NTECH_TOTP_KEY` invalidates all 2FA secrets stored in the database. @@ -323,6 +343,20 @@ Demo also requires HTTPS (Caddy or similar) because Secure cookies are enabled. --- +### Health Check + +The app exposes an unauthenticated `GET /healthz` endpoint that pings the database and returns `200 OK` (or `503` if the database is unreachable). Use it for a Docker `HEALTHCHECK` or a reverse-proxy/orchestrator liveness probe: + +```yaml +healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/healthz"] + interval: 30s + timeout: 3s + retries: 3 +``` + +--- + ## Project Structure ``` @@ -348,3 +382,34 @@ ntech/ ├── go.mod └── go.sum ``` + +--- + +## Testing + +The project has unit and integration tests (against a real SQLite database) covering crypto, RBAC, login flows, form validators, and reports. + +```bash +go test ./... +``` + +Migrations are numbered SQL files (`migrations/NNN_description.sql`) applied in order at startup and tracked in a `migracije` table, so they run exactly once and are safe to ship inside the same binary. + +--- + +## Security Notes + +- Sessions are server-side (random token in an `HttpOnly`, `SameSite=Strict` cookie), not JWT — revocation is immediate (delete the row). +- CSRF tokens and the card-emulator PIN are compared in constant time (`crypto/subtle`). +- Brute-force locking applies to both the password step and the TOTP/backup-code step, keyed by client IP. +- `X-Real-IP` / `X-Forwarded-For` are only trusted when the connection itself comes from a loopback or private address (i.e. a reverse proxy on the same host/Docker network) — otherwise the raw connection IP is used, so the header can't be spoofed from the internet to bypass the lockout. +- TOTP secrets are encrypted at rest (AES-256-GCM); the key (`NTECH_TOTP_KEY`) is kept outside the database. +- This is a single-tenant, single-organization application by design — there is no cross-tenant data isolation to reason about. + +See [`SECURITY.md`](SECURITY.md) for how to report a vulnerability. + +--- + +## License + +[MIT](LICENSE) © Dalibor Marković diff --git a/Readme_sr.md b/Readme_sr.md index bacf6e3..87d011e 100644 --- a/Readme_sr.md +++ b/Readme_sr.md @@ -23,6 +23,22 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b --- +## Kako radi + +**Serversko renderovanje, bez build koraka.** Stranice se renderuju na serveru preko Go-ovog `html/template` (automatski escape, bez klijentskog šablonskog engine-a). [HTMX](https://htmx.org) menja delove stranice preko običnog HTTP-a za SPA-nalik navigaciju, a [Alpine.js](https://alpinejs.dev) pokriva manje delove klijentske interaktivnosti (live zbirovi, dinamički redovi u formama, autocomplete). Nema `npm`/webpack build korak — brauzer dobija čist HTML/CSS/JS, i jedno učitavanje stranice je dovoljno da se koristi cela aplikacija. + +**Jedan binarni fajl, bez runtime zavisnosti.** Šabloni, statika (CSS/JS/slike) i SQL migracije su ugrađeni u kompajlirani binarni fajl preko `go:embed`. Deployment znači kopiranje jednog fajla (ili pokretanje jednog Docker image-a) — nema odvojenog build-a statike, nema fajlova šablona koje treba nositi uz izvršni fajl. U razvojnom modu isti kod čita direktno sa diska, pa su izmene šablona/CSS/JS-a odmah vidljive posle osvežavanja stranice, bez rebuild-a. + +**Tok zahteva.** `chi` ruter provlači svaki zahtev kroz lanac middleware-a — bezbednosni headeri → CSRF (double-submit cookie) → provera sesije → provera uloge/dozvole — pre nego što stigne do handlera. Provera dozvola se izvršava na nivou rutera (da ruta ne bi slučajno ostala nezaštićena) i ponovo unutar handlera kao dodatni sloj zaštite. Handleri komuniciraju sa bazom kroz repository sloj (čist SQL, bez ORM-a) i prosleđuju obične Go strukture šablonima. + +**Baza podataka.** SQLite preko čistog Go drajvera (`modernc.org/sqlite`, bez CGO-a) je podrazumevana i jedina zavisnost — jedan fajl, bez odvojenog servera baze koji treba pokretati ili bekapovati. Svako pokretanje primeni sve nove SQL migracione fajlove po redu i upiše ih u tabelu `migracije`, pa je nadogradnja samo "zameni binarni fajl i restartuj". Opcioni PostgreSQL backend (preko `pgx/v5`) je planiran za višekorisnička okruženja. + +**Javne stranice za klijente.** Dva toka ne zahtevaju prijavu, samo jedinstven token koji se ne može pogoditi u URL-u: stranica statusa servisa (klijent prati napredak popravke i dobija QR kod direktno sa reversa) i stranica odobravanja predloga delova/usluga (klijent prihvata ili odbija procenu uz komentar). Oba su capability-bazirana — ko god ima link ima pristup tom jednom nalogu, ničemu drugom. + +**Štampani dokumenti.** Radni nalog, predračun, otpremnica, revers i nalepnica uređaja su zasebne, samostalne HTML stranice stilizovane za A4 štampu (`@media print`), svaka sa dugmetom „Štampaj" koje otvara nativni dijalog za štampu u brauzeru — bez PDF biblioteke, bez headless-browser koraka za renderovanje. Dokumenti koji ne stanu na jednu stranicu sami se paginiraju na strani klijenta (merenjem visine sadržaja i ubacivanjem prekida strane sa brojevima strana). + +--- + ## Funkcionalnosti ### Implementirano @@ -39,12 +55,13 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b - Evidencija pokušaja prijave — istorija po korisniku, IP, razlog, datum - Korisnici i uloge — admin panel, upravljanje korisnicima - Magacin — artikli, kategorije, filtriranje, kritični nivoi zaliha, magacinska kartica po artiklu, veza sa dobavljačima, premeštanje artikala +- Barkod (EAN) po artiklu — pretraživ u magacinu; na ekranu prodaje, skeniranje barkoda (bilo kojim USB/Bluetooth skenerom koji „kuca" kod + Enter) automatski pronalazi artikal i dodaje ga u nalog - Servisni nalozi: - Forma prijema, statusna traka, arhiva - Tok dijagnostike — opis kvara, napomene servisera, urađeno, cena dijagnostike - Delovi i radovi — ugrađeni artikli se skidaju sa lagera; predloženi artikli (ponuda klijentu) - Odobravanje predloga — klijent dobija javni link (QR kod) da prihvati ili odbije predlog sa komentarom - - Javna statusna stranica — klijent prati status naloga putem jedinstvenog linka + - Praćenje statusa putem QR koda — nalepnica na uređaju i svaki štampani dokument nose QR kod koji klijent skenira telefonom; otvara se mobilno optimizovana stranica (bez prijave, bez aplikacije) sa trenutnim statusom popravke, kojoj klijent može ponovo da pristupi u bilo kom trenutku istim linkom/kodom - Dokumenti — radni nalog, predračun, otpremnica, revers, nalepnica za uređaj (QR + Code128 barkod) - Preuzimanje sa naplatom — način plaćanja i iznos avansa - Garancija, predviđen datum završetka, serviser, napomena klijentu @@ -58,6 +75,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b - PDV evidencija (KIR/KPR) — knjige izdatih i primljenih računa, automatsko punjenje iz prodaje i nabavke - PDV obračun za period + mapiranje na obrazac PP-PDV; uvoz robe (JCI) se vodi u poljima 006/106 - Šifarnik PDV stopa +- **Fiskalizacija (ESIR/L-PFR)** — pun Go klijent za Teron API fiskalnog uređaja: test konekcije, izdavanje računa (prodaja/servis, uključujući avanse i refund pri stornu), dnevni pazar, zaključenje fiskalnog dana, PDF fiskalni izveštaji, QR verifikacija računa (javna `/v/` stranica), automatski retry pri neuspešnoj fiskalizaciji sa vidljivim statusom greške, i panel za status/reset kartica-emulatora (komunicira sa uređajem za potpisivanje preko TCP-a). Trenutno provereno protiv Teron mock servera (`Fisk/`); nije još testirano na pravom sertifikovanom uređaju. - Klijenti i dobavljači — baza kontakata - Podsetnici — evidencija sa rokom - Izveštaji — pregled prihoda, stanje magacina, vrednost zaliha, prometni list, popis (inventura) @@ -74,7 +92,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b ### U toku -- **Fiskalizacija (ESIR/PFR)** — Teron L-PFR mock server dostupan u `Fisk/`; integracija Go klijenta u planu +- **Fiskalizacija na pravom uređaju** — ceo tok (izdavanje, refund, dnevni pazar, zaključenje, izveštaji) je implementiran i proveren protiv Teron mock servera; testiranje na pravom sertifikovanom L-PFR uređaju još nije urađeno. ### Planirano @@ -104,7 +122,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b ### Zahtevi -- Go 1.24 ili noviji +- Go 1.26 ili noviji - Git ### Koraci @@ -158,6 +176,8 @@ Fajl `ntech.env` se **ne commituje** u Git. | `NTECH_DSN` | — | PostgreSQL connection string | | `NTECH_SECRET` | — | Ključ za potpisivanje sesija (min. 32 bajta); auto-generiše se | | `NTECH_TOTP_KEY` | — | AES-256 ključ za šifrovanje TOTP tajni; auto-generiše se | +| `BE_ENABLED` | `true` | Uključuje ugrađeni kartica-emulator (uređaj za potpisivanje pri fiskalizaciji) | +| `BE_PORT` | `4567` | TCP port ugrađenog kartica-emulatora | `NTECH_SECRET` i `NTECH_TOTP_KEY` se automatski generišu pri prvom pokretanju i upisuju u `ntech.env`. **Sačuvaj backup ovog fajla** — gubitak `NTECH_TOTP_KEY` onemogućuje prijavu svim korisnicima koji imaju 2FA. @@ -323,6 +343,20 @@ Demo takođe zahteva HTTPS (Caddy ili slično) jer su Secure kolačići uključe --- +### Health check + +Aplikacija izlaže neautentifikovan `GET /healthz` endpoint koji proverava dostupnost baze i vraća `200 OK` (ili `503` ako baza nije dostupna). Koristiti za Docker `HEALTHCHECK` ili proveru živosti u reverse proxy-ju/orkestratoru: + +```yaml +healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8000/healthz"] + interval: 30s + timeout: 3s + retries: 3 +``` + +--- + ## Struktura projekta ``` @@ -348,3 +382,34 @@ ntech/ ├── go.mod └── go.sum ``` + +--- + +## Testiranje + +Projekat ima jedinične i integracione testove (nad pravom SQLite bazom) koji pokrivaju kripto funkcije, RBAC, tokove prijave, validatore formi i izveštaje. + +```bash +go test ./... +``` + +Migracije su numerisani SQL fajlovi (`migrations/NNN_opis.sql`) koji se primenjuju redom pri pokretanju i prate se u tabeli `migracije` — izvršavaju se tačno jednom i bezbedno je da putuju unutar istog binarnog fajla. + +--- + +## Bezbednosne napomene + +- Sesije se čuvaju na serveru (nasumičan token u `HttpOnly`, `SameSite=Strict` kolačiću), ne JWT — opoziv je trenutan (brisanje reda). +- CSRF token i PIN kartica-emulatora se porede konstantno-vremenski (`crypto/subtle`). +- Bruteforce zaključavanje važi i za korak lozinke i za korak TOTP/rezervnog koda, po IP adresi klijenta. +- `X-Real-IP` / `X-Forwarded-For` se veruje samo kada sama konekcija dolazi sa loopback ili privatne adrese (tj. reverse proxy na istom hostu/Docker mreži) — inače se koristi sirovi IP konekcije, pa se header ne može lažirati sa interneta radi zaobilaženja zaključavanja. +- TOTP tajne su šifrovane u mirovanju (AES-256-GCM); ključ (`NTECH_TOTP_KEY`) se čuva van baze. +- Ovo je namerno jednokorisnička/jednoorganizaciona aplikacija — nema izolacije podataka između više firmi (multi-tenant) o kojoj treba brinuti. + +Pogledaj [`SECURITY.md`](SECURITY.md) za način prijave bezbednosnog propusta. + +--- + +## Licenca + +[MIT](LICENSE) © Dalibor Marković