9 Commits
Author SHA1 Message Date
Dasko 87699ff5ff Merge grane develop: bezbednosne popravke, refaktor i README ažuriranja 2026-07-04 01:36:02 +02:00
Dasko 48f9ba8435 README: ažurirani opisi funkcionalnosti i nove sekcije
- 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
2026-07-04 01:35:57 +02:00
Dasko fba3cb15a6 Refaktor #23: zajedničko zaglavlje servisnih štampanih dokumenata
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.
2026-07-04 00:58:15 +02:00
Dasko e99dd50273 Prava ispravka: usluge/troskovi.datum_unosa promenjen na DATETIME u šemi
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.
2026-07-04 00:09:32 +02:00
Dasko 295fe0b9ff Ispravka: Usluge/Troškovi se nisu učitavali (regresija iz DatumUnosa time.Time izmene)
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.
2026-07-03 23:42:30 +02:00
Dasko 45ed942c56 Merge grane fix/code-review-nalazi: bezbednosne i integritetne popravke iz pregleda koda 2026-07-03 23:31:48 +02:00
Dasko d9f337617b Nizak prioritet iz pregleda koda: indeksi, greške, sitne popravke
- 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.
2026-07-03 23:31:00 +02:00
Dasko 127cebea3c Srednji prioritet iz pregleda koda: konzistentnost, refaktor, sitne popravke
- 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
2026-07-03 23:21:29 +02:00
Dasko 49296e4315 Bezbednosne i integritetne popravke iz pregleda koda
- 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)
2026-07-03 23:00:44 +02:00
52 changed files with 710 additions and 459 deletions
+68 -3
View File
@@ -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ć
+68 -3
View File
@@ -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ć
+58 -27
View File
@@ -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)
+12 -1
View File
@@ -29,7 +29,18 @@ func ProveriLozinku(hash, lozinka string) bool {
// dummyHash je bcrypt heš fiksne vrednosti, izračunat jednom pri pokretanju.
// 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 —
+2 -1
View File
@@ -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
+8
View File
@@ -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
+59 -14
View File
@@ -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 {
+3 -3
View File
@@ -57,7 +57,7 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
func (r *sqliteIzvestajRepo) BrojKriticnihZaliha(ctx context.Context) (int, error) {
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)
}
+3 -3
View File
@@ -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)
+18
View File
@@ -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
}
+12
View File
@@ -122,6 +122,8 @@ func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirm
FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg)
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)
+60
View File
@@ -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
}
+16 -13
View File
@@ -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
+7 -7
View File
@@ -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,
+6 -97
View File
@@ -294,53 +294,8 @@ func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, koris
}
// vraćanje stanja u magacin
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 {
+4 -93
View File
@@ -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,
+3 -4
View File
@@ -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 {
+9 -1
View File
@@ -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
}
+23 -2
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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"
+2 -2
View File
@@ -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 != "" {
+17 -2
View File
@@ -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
}
+8 -5
View File
@@ -999,16 +999,18 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
klijent := &http.Client{Timeout: 5 * time.Second}
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, `<div class="fisk-status greska">&#10007; Nije dostupan — %s</div>`, html.EscapeString(err.Error()))
fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Nije dostupan — proverite adresu i mrežu</div>`)
return
}
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, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error()))
fmt.Fprint(w, `<div class="fisk-status greska">&#10007; Neispravan odgovor servera</div>`)
return
}
@@ -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, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s — %s</div>`,
html.EscapeString(addr), html.EscapeString(err.Error()))
fmt.Fprintf(w, `<div class="fisk-status greska">&#10007; Emulator nije dostupan na %s</div>`,
html.EscapeString(addr))
return
}
defer conn.Close()
@@ -1090,7 +1093,7 @@ func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
bePort := os.Getenv("BE_PORT")
+50 -11
View File
@@ -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,
+7 -4
View File
@@ -1049,12 +1049,15 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
}
// predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje)
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.")
+4 -2
View File
@@ -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"},
}
+6 -1
View File
@@ -149,7 +149,12 @@ func RequireDozvolaMut(proveri func(ctx context.Context, uloga, akcija string) b
}
}
// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić
// postaviFlashGresku upisuje jednokratnu poruku o grešci u kolačić. Namerno
// odvojen mehanizam od SetFlash/GetFlash (flash.go): ove funkcije se pozivaju
// iz middleware-a koji nema pristup *sql.DB, a redirect je uvek na /dashboard
// (jedino mesto koje čita ovaj kolačić) — pa kolačić bez DB upisa dovoljan.
// SetFlash/GetFlash (kolona sesije.flash) koristi se u handlerima koji imaju
// DB i redirect na proizvoljnu stranicu.
func postaviFlashGresku(w http.ResponseWriter, poruka string) {
http.SetCookie(w, &http.Cookie{
Name: "ntech_flash_greska",
+2 -1
View File
@@ -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,
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -78,7 +78,7 @@ type StanjeZalihaRed struct {
Sifra string
Kategorija string
Kolicina int
KolicinMin int
KolicinaMin int
NabavnaCena float64
ProdajnaCena float64
CenaSaPdv float64
+8 -11
View File
@@ -37,8 +37,14 @@ func (k PdvKir) OslobodenUkupno() float64 {
// OznakaPoreskogBroja vraća „JMBG" ako uneti broj ima 13 cifara (fizičko lice),
// 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).
+3 -1
View File
@@ -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
}
+3 -1
View File
@@ -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 {
+19
View File
@@ -0,0 +1,19 @@
-- Indeksi na FK kolonama koje se često JOIN-uju/filtriraju (detalji naloga, izveštaji).
-- Bez indeksa ove tabele rade full table scan koji raste sa prometom.
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_nalog_id ON stavke_prodaje(nalog_id);
CREATE INDEX IF NOT EXISTS idx_stavke_prodaje_artikal_id ON stavke_prodaje(artikal_id);
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_nalog_id ON servisni_delovi(nalog_id);
CREATE INDEX IF NOT EXISTS idx_servisni_delovi_artikal_id ON servisni_delovi(artikal_id);
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_nalog_id ON servisni_potrazivani_delovi(nalog_id);
CREATE INDEX IF NOT EXISTS idx_servisni_potrazivani_delovi_artikal_id ON servisni_potrazivani_delovi(artikal_id);
CREATE INDEX IF NOT EXISTS idx_magacinske_promene_artikal_id ON magacinske_promene(artikal_id);
CREATE INDEX IF NOT EXISTS idx_stavke_nabavke_nabavka_id ON stavke_nabavke(nabavka_id);
CREATE INDEX IF NOT EXISTS idx_pdv_kir_izvor ON pdv_kir(izvor, izvor_id);
CREATE INDEX IF NOT EXISTS idx_pdv_kir_broj_dokumenta ON pdv_kir(broj_dokumenta);
CREATE INDEX IF NOT EXISTS idx_pdv_kpr_izvor ON pdv_kpr(izvor, izvor_id);
@@ -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);
@@ -0,0 +1,36 @@
{{define "zaglavljeFirma"}}
<!-- LEVO: firma — logo na vrhu, tekst ispod -->
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
{{end}}
{{define "zaglavljeKlijent"}}
<!-- klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -21,7 +21,7 @@
{{if .AvatarPutanja}}
<img src="{{.AvatarPutanja}}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;display:block;">
{{else}}
{{if .Korisnik}}{{slice .Korisnik 0 2}}{{else}}NT{{end}}
{{if .Korisnik}}{{inicijali .Korisnik 2}}{{else}}NT{{end}}
{{end}}
</div>
<div id="avatar-meni" style="display:none;position:fixed;background:var(--kartica);backdrop-filter:none;-webkit-backdrop-filter:none;border:0.5px solid var(--ivica);border-radius:10px;box-shadow:var(--senka);min-width:160px;z-index:9999;overflow:hidden;transform-origin:top right;">
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
</div>
{{if .Lista}}
+1 -1
View File
@@ -90,7 +90,7 @@
</form>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
{{if index .Dozvole "fiskal.zakljucenje"}}
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
+6 -4
View File
@@ -130,10 +130,12 @@
</dialog>
{{end}}
{{if index $.Dozvole "kategorija.obrisi"}}
<a href="/magacin/kategorije/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?">
Obriši
</a>
<form method="POST" action="/magacin/kategorije/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovu kategoriju?">
Obriši
</button>
</form>
{{end}}
</div>
{{else}}
+24 -16
View File
@@ -126,15 +126,19 @@
{{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</a>
<form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</button>
</form>
{{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</a>
<form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</button>
</form>
{{end}}
{{end}}
</div>
@@ -176,15 +180,19 @@
{{end}}{{end}}
{{if index $.Dozvole "artikal.obrisi"}}
{{if $.PrikazArhivirani}}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin/vrati/{{.ID}}" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</a>
<form method="POST" action="/magacin/vrati/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-sekundarno-malo"
data-potvrda="Vratiti ovaj artikal u aktivnu listu?">
Vrati
</button>
</form>
{{else}}
<a href="/magacin/obrisi/{{.ID}}" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</a>
<form method="POST" action="/magacin/obrisi/{{.ID}}" style="display:inline;">
<button type="submit" class="btn-obrisi-malo"
data-potvrda="Da li ste sigurni da želite da obrišete ovaj artikal?">
Obriši
</button>
</form>
{{end}}
{{end}}
</div>
+1 -1
View File
@@ -97,7 +97,7 @@
</div>
<div>
<label class="polje-labela">Minimalna količina</label>
<input type="number" name="kolicina_min" value="{{.Artikal.KolicinMin}}" min="0" style="width:100%;">
<input type="number" name="kolicina_min" value="{{.Artikal.KolicinaMin}}" min="0" style="width:100%;">
</div>
</div>
+3 -3
View File
@@ -28,12 +28,12 @@
</div>
</div>
<div style="text-align:right;">
<div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinMin}}#dc2626{{else}}var(--tekst-glavni){{end}};">
<div style="font-size:24px;font-weight:700;color:{{if le .Artikal.Kolicina .Artikal.KolicinaMin}}#dc2626{{else}}var(--tekst-glavni){{end}};">
{{.Artikal.Kolicina}} kom
</div>
<div style="font-size:12px;color:var(--tekst-slabi);">trenutno stanje</div>
{{if gt .Artikal.KolicinMin 0}}
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinMin}} kom</div>
{{if gt .Artikal.KolicinaMin 0}}
<div style="font-size:11px;color:var(--tekst-slabi);margin-top:2px;">min. {{.Artikal.KolicinaMin}} kom</div>
{{end}}
</div>
</div>
+1 -1
View File
@@ -282,7 +282,7 @@
</thead>
<tbody>
{{range .Artikli}}
<tr class="{{if le .Kolicina .KolicinMin}}red-kriticno{{end}}" id="red-{{.ID}}">
<tr class="{{if le .Kolicina .KolicinaMin}}red-kriticno{{end}}" id="red-{{.ID}}">
<td class="kart-naslov" style="font-weight:500;">{{.Naziv}}</td>
<td class="kol-sek" data-label="Šifra" style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td class="kol-sek" data-label="Kategorija" style="font-size:12px;color:var(--tekst-slabi);">{{if .KategorijaNaziv}}{{.KategorijaNaziv}}{{else}}—{{end}}</td>
@@ -53,19 +53,7 @@
<!-- zaglavlje -->
<div class="zaglavlje">
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
{{template "zaglavljeFirma" .}}
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
<div class="dok-tip">Eskalacioni list — praćenje rokova</div>
+2 -31
View File
@@ -83,37 +83,8 @@
<!-- zaglavlje -->
<div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod -->
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
<!-- SREDINA: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
{{template "zaglavljeFirma" .}}
{{template "zaglavljeKlijent" .}}
<!-- DESNO: barkod na vrhu, ispod datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
+2 -31
View File
@@ -77,20 +77,7 @@
<!-- zaglavlje -->
<div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod -->
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
{{template "zaglavljeFirma" .}}
<!-- SREDINA: datumi (levo) + QR (skroz desno) -->
<div class="zg-okvir" style="flex:1.3;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
@@ -103,23 +90,7 @@
{{if .QRKod}}<img src="data:image/png;base64,{{.QRKod}}" width="92" height="92" alt="QR {{.Nalog.BrojNaloga}}" style="image-rendering:pixelated;border:1px solid #ddd;border-radius:4px;flex-shrink:0;">{{end}}
</div>
</div>
<!-- DESNO: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
{{template "zaglavljeKlijent" .}}
</div>
<!-- uređaj i traženo -->
+2 -31
View File
@@ -75,20 +75,7 @@
<!-- zaglavlje -->
<div class="zaglavlje">
<!-- LEVO: firma — logo na vrhu, tekst ispod -->
<div class="zg-okvir">
{{if .LogoPutanja}}
<img src="{{.LogoPutanja}}" alt="Logo" style="max-height:54px;max-width:100%;object-fit:contain;display:block;margin-bottom:8px;">
{{else}}
<div class="firma-naziv">{{if .NazivFirme}}{{.NazivFirme}}{{else}}— naziv firme —{{end}}</div>
{{end}}
<div class="firma-info">
{{if .Podnazlov}}{{.Podnazlov}}<br>{{end}}
{{if .Adresa}}{{.Adresa}}<br>{{end}}
{{if .Telefon}}Tel: {{telefon .Telefon}}<br>{{end}}
{{if .PIB}}PIB: {{.PIB}}{{end}}{{if .MaticniBroj}}{{if .PIB}} &middot; {{end}}MB: {{.MaticniBroj}}{{end}}
</div>
</div>
{{template "zaglavljeFirma" .}}
<!-- SREDINA: barkod na vrhu, ispod dokument i datumi (QR je u sekciji „Uređaj na servisu") -->
<div class="zg-okvir" style="flex:1.3;">
{{if .Barkod}}<img src="data:image/png;base64,{{.Barkod}}" alt="Barkod {{.Nalog.BrojNaloga}}" style="display:block;width:100%;max-height:46px;object-fit:contain;margin-bottom:10px;">{{end}}
@@ -99,23 +86,7 @@
<div class="dok-datum">Predviđen rok: {{.Nalog.PredvidjenDatum.Format "02.01.2006."}}</div>
{{end}}
</div>
<!-- DESNO: klijent -->
<div class="zg-okvir">
<div class="strana-tip">Klijent</div>
{{if .KlijentNaziv}}
<div class="strana-naziv">{{.KlijentNaziv}}</div>
<div class="strana-info">
{{if .Klijent}}
{{if .Klijent.Telefon}}Tel: {{telefon .Klijent.Telefon}}<br>{{end}}
{{if .Klijent.Email}}{{.Klijent.Email}}<br>{{end}}
{{if .Klijent.Mesto}}{{.Klijent.Mesto}}<br>{{end}}
{{if .Klijent.PIB}}PIB: {{.Klijent.PIB}}{{end}}
{{end}}
</div>
{{else}}
<div class="strana-naziv prazno">— klijent nije naveden —</div>
{{end}}
</div>
{{template "zaglavljeKlijent" .}}
</div>
<!-- uređaj (QR skroz desno — klijent ga skenira da prati status) -->
+4 -4
View File
@@ -54,12 +54,12 @@
</thead>
<tbody>
{{range .Zalihe}}
<tr {{if le .Kolicina .KolicinMin}}style="background:rgba(220,38,38,0.05);"{{end}}>
<td style="font-weight:500;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Naziv}}</td>
<tr {{if le .Kolicina .KolicinaMin}}style="background:rgba(220,38,38,0.05);"{{end}}>
<td style="font-weight:500;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Naziv}}</td>
<td style="font-family:monospace;font-size:12px;color:var(--tekst-slabi);">{{if .Sifra}}{{.Sifra}}{{else}}—{{end}}</td>
<td style="font-size:12px;color:var(--tekst-slabi);">{{if .Kategorija}}{{.Kategorija}}{{else}}—{{end}}</td>
<td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td>
<td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinMin}}</td>
<td style="text-align:right;font-weight:600;{{if le .Kolicina .KolicinaMin}}color:#dc2626;{{end}}">{{.Kolicina}}</td>
<td style="text-align:right;font-size:12px;color:var(--tekst-slabi);">{{.KolicinaMin}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .NabavnaCena}}</td>
<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .ProdajnaCena}}</td>
{{if index $.Moduli "pdv"}}<td style="text-align:right;font-family:monospace;font-size:12px;">{{dinari .CenaSaPdv}}</td>{{end}}
+5
View File
@@ -35,6 +35,11 @@
{{if eq .Greska "1"}}
<div class="greska">Neispravan kod. Pokušajte ponovo.</div>
{{else if eq .Greska "zakljucano"}}
<div class="greska">
Previše neuspelih pokušaja. IP adresa je privremeno blokirana.
{{if .Preostalo}}<br>Pokušajte ponovo za: <strong>{{.Preostalo}}</strong>{{end}}
</div>
{{end}}
<form method="POST" action="/prijava/totp">