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