Compare commits
9
Commits
v0.1.74
...
87699ff5ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87699ff5ff
|
||
|
|
48f9ba8435
|
||
|
|
fba3cb15a6
|
||
|
|
e99dd50273
|
||
|
|
295fe0b9ff
|
||
|
|
45ed942c56
|
||
|
|
d9f337617b
|
||
|
|
127cebea3c
|
||
|
|
49296e4315
|
@@ -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
@@ -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
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">✗ Nije dostupan — %s</div>`, html.EscapeString(err.Error()))
|
||||
fmt.Fprint(w, `<div class="fisk-status greska">✗ 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">✗ Neispravan odgovor servera — %s</div>`, html.EscapeString(err.Error()))
|
||||
fmt.Fprint(w, `<div class="fisk-status greska">✗ 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">✗ Emulator nije dostupan na %s — %s</div>`,
|
||||
html.EscapeString(addr), html.EscapeString(err.Error()))
|
||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ 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
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,7 @@ type StanjeZalihaRed struct {
|
||||
Sifra string
|
||||
Kategorija string
|
||||
Kolicina int
|
||||
KolicinMin int
|
||||
KolicinaMin int
|
||||
NabavnaCena float64
|
||||
ProdajnaCena float64
|
||||
CenaSaPdv float64
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}} · {{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}}
|
||||
@@ -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;">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}} · {{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>
|
||||
|
||||
@@ -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}} · {{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}}
|
||||
|
||||
@@ -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}} · {{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 -->
|
||||
|
||||
@@ -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}} · {{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) -->
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user