Dashboard/izveštaji: ispravka obračuna prihoda + KIR servisa, testovi

- Prihod meseca: izbačeni stornirani nalozi, servis preko naplaceno+avans
- Servis KIR: neto cena + PDV po stvarnoj stopi (PdvKir.DodajNeto), bez hardkodovanih 20%
- TopKlijenti: samo preuzeti nalozi sa naplatom/avansom
- Izveštaji: mesečni ključevi sidreni na 1. u mesecu (bez preliva dana)
- BUG-04: konačna cena servisa bez dvostrukog računanja delova
- Dashboard: uklonjena kartica „Prihod ovog meseca"
- Default port 3000
- Dodati testovi: prihod/storno/delovi/nivelacija/KIR/mesečni ključevi
This commit is contained in:
2026-06-27 09:41:07 +02:00
parent ff59c3edcf
commit 99ae077082
18 changed files with 1619 additions and 76 deletions
+234
View File
@@ -0,0 +1,234 @@
# Bug lista — Dashboard "Prihod ovog meseca"
Istraživanje logike `PrihodTekuciMesec` i sve komponente koje utiču na prikazanu cifru.
---
## BUG-01 — Stornirani prodajni nalozi ulaze u prihod ⚠️ KRITIČAN
**Fajl:** `internal/db/sqlite/izvestaj.go:3948`
**Problem:** SQL upit koji računa prihod ne filtrira stornirane naloge:
```sql
SELECT SUM(ukupno) FROM prodajni_nalozi
WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')
-- ❌ nema: AND stornirano = 0
```
Kolona `stornirano INTEGER NOT NULL DEFAULT 0` postoji od migracije `035_pos_faza1.sql`. Kada se nalog stornira, `stornirano = 1` i magacin se vraća, ali iznos ostaje u prihodu.
**Posledica:** Svaki stornirani nalog uvećava prikazani mesečni prihod za iznos koji nije stvarno naplaćen. Na kraju meseca razlika može biti značajna ako ima više storniranja.
**Isti propust na još tri mesta u istom fajlu:**
- `MesecniPrihodProdaja` (l.146) — grafikon prihoda po mesecima
- `PoslednjeProdaje` (l.104) — lista poslednjih prodaja na dashboardu
- `TopKlijenti` (l.220) — rang lista kupaca po vrednosti
**Ispravka:** Dodati `AND stornirano = 0` u sve četiri SQL SELECT-e.
---
## BUG-02 — Mešanje PDV-a: prodaja bruto, servis neto ⚠️ KRITIČAN
**Fajl:** `internal/db/sqlite/izvestaj.go:3948`
**Problem:** Zbir spaja dve veličine koje nisu uporedive:
| Izvor | Kolona | PDV |
|---|---|---|
| `prodajni_nalozi.ukupno` | `kolicina × cena_po_komadu` | **SA PDV-om** (bruto) |
| `servisni_nalozi.cena_konacna` | `dijagnostika + radovi + delovi` | **BEZ PDV-a** (neto) |
Prodajna cena se unosi kao maloprodajna (bruto) cena — to potvrđuje `handler/prodaja.go:188191`:
```go
ukupno += float64(s.Kolicina) * s.CenaPoKomadu // bruto
nalog.Ukupno = ukupno
```
`cena_konacna` za servis se auto-računa koristeći `rad.Ukupno()` i `deo.Ukupno()` (model `servis.go:77,127`) koji vraćaju `kolicina × cena_komada` — neto cenu bez PDV-a.
**Posledica:** Prikazana cifra nije ni ukupan prihod sa PDV-om ni ukupan neto prihod. Za firme sa PDV-om (stopa 20%) to može biti razlika od 20% na delu koji dolazi iz servisa.
**Ispravka:** Odlučiti se za jedinstven standard (preporučeno: **sa PDV-om**) i koristiti `cena_sa_pdv` kolone / `UkupnoSaPdv()` metode konzistentno.
---
## BUG-03 — Delovi servisa ne ulaze u prihod kada je cena ručno uneta ⚠️ VAŽAN
**Fajl:** `internal/db/sqlite/izvestaj.go:4448`, `internal/handler/servis.go:14981500`
**Problem:** Šablon servisa tretira `cena_konacna` kao cenu rada (bez delova):
```go
// servis.go:14981500
} else if nalog.CenaKonacna != nil {
ukupnoSve = *nalog.CenaKonacna + ukupnoDelovi // delovi se DODAJU na cenu_konacnu
```
Kada korisnik ručno unese `cena_konacna` (polje "Cena rada"), u bazu ide samo vrednost rada — delovi su odvojeni. SQL za prihod uzima samo `cena_konacna`:
```sql
SELECT SUM(cena_konacna) FROM servisni_nalozi WHERE status = 'Preuzeto' ...
```
**Posledica:** Vrednost svih ugrađenih delova na servisnim nalozima sa ručno unetom `cena_konacna` **ne ulazi u prikazani prihod**. Prihod je manji od stvarnog.
**Napomena:** Kada je status prelaz na "Preuzeto" i `cena_konacna` je bila `NULL`, auto-izračun (`servis.go:20412048`) uključuje delove u `cena_konacna`. Dakle ponašanje zavisi od toga da li je korisnik uneo cenu ručno ili je sistem auto-izračunao — nedoslednost u definiciji polja.
---
## BUG-04 — Duplo računanje delova u naplaćenom iznosu pri auto-izračunu ⚠️ VAŽAN
**Fajl:** `internal/handler/servis.go:20342083`
**Problem:** Kada servisni nalog nema unesenu `cena_konacna` i prelazi u status "Preuzeto", dešava se:
1. Auto-izračun (l.20372049): `cena_konacna = dijagnostika + radovi + **delovi**` → snima u DB
2. Izračun naplaćenog iznosa (l.20642073): čita novu `cena_konacna` iz DB, pa dodaje `ukupnoDelovi` još jednom:
```go
nalog.CenaKonacna = &ukupno // ukupno već sadrži delove (l.2049)
// ...
nalog, _ = h.ServisRepo.DohvatiID(...) // čita iz DB — cena_konacna uključuje delove
iznos = *nalog.CenaKonacna + ukupnoDelovi // ← delovi se broje DVAPUT
```
**Posledica:** Iznos koji se fiskalizuje i beleži kao naplaćen veći je od stvarno dugovanog (delovi duplo). Ovo je greška u fiskalnom računu.
**Reprodukcija:** Napraviti servisni nalog, dodati deo (npr. 1.000 din), ne unositi cenu rada, promeniti status u "Preuzeto" bez popunjavanja forme. Naplaćeni iznos biće 2.000 umesto 1.000.
---
## BUG-05 — CSS skriva cifru prihoda — UX propust
**Fajl:** `web/templates/stranice/dashboard.html:1623`
**Problem:** Cifra prihoda ima `opacity: 0` po defaultu i prikazuje se tek posle **1 sekunde hover-a**:
```css
.prihod-cifra {
opacity: 0;
transition: opacity 0.3s ease;
}
.dash-stat:hover .prihod-cifra {
opacity: 1;
transition-delay: 1s; /* ← 1 sekunda čekanja */
}
```
Labela "Prihod ovog meseca" je vidljiva, ali cifra nije. Na mobilnim uređajima (bez hover) cifra je **trajno nevidljiva**.
**Posledica:** Korisnik ne može pročitati prihod na mobilnom uređaju. Na desktopu mora da zna da treba da čeka sekund na kartici.
---
## Sažetak
| # | Opis | Ozbiljnost | Fajl |
|---|---|---|---|
| BUG-01 | Stornirani nalozi u prihodu | Kritičan | `izvestaj.go:4143` |
| BUG-02 | Prodaja SA PDV / servis BEZ PDV | Kritičan | `izvestaj.go:3948` |
| BUG-03 | Delovi servisa izostaju kod ručne cene | Važan | `izvestaj.go:4448` |
| BUG-04 | Duplo računanje delova u naplaćenom iznosu | Važan | `servis.go:20342083` |
| BUG-05 | CSS skriva cifru na mobilnom | UX | `dashboard.html:1623` |
> BUG-01 do BUG-05 su ISPRAVLJENI u ovoj sesiji.
---
# Novi nalazi — pregled koda 2026-06-27
Drugi krug pregleda, posle ispravki BUG-01..05.
> **BUG-06 do BUG-10 su ISPRAVLJENI i pokriveni testovima** (2026-06-27).
>
> | # | Ispravka | Test |
> |---|---|---|
> | 06/07 | `PdvKir.DodajNeto` — neto osnovica + PDV po stvarnoj stopi; servis KIR ga koristi | `TestPdvKirDodajNeto` |
> | 08 | `TopKlijenti` servis: `naplaceno+avans WHERE status='Preuzeto'` | `TestTopKlijenti_SamoPreuzetiNalozi` |
> | 09 | `kljuceviMeseci` sidri na 1. u mesecu | `TestKljuceviMeseci` |
> | 10 | prihod servisa = `SUM(naplaceno + COALESCE(avans,0))`, filter `(naplaceno>0 OR avans>0)` | `TestPrihodTekuciMesec_ServisSaAvansom`, `...PotpunoAvansiran`, `...GarancijaNeUlazi` |
## BUG-06 — Servis KIR: neto cena tretirana kao bruto ⚠️ KRITIČAN (poreski)
**Fajl:** `internal/handler/servis.go:21142133` (auto-upis u KIR pri prelasku u „Preuzeto")
**Problem:** Kod računa osnovicu i PDV deljenjem sa 1.2, tretirajući `r.Ukupno()` kao bruto:
```go
osnovica := r.Ukupno() / 1.2 // 20% PDV
pdv := r.Ukupno() - osnovica
kir.Ukupno += r.Ukupno()
```
Ali `cena_komada` rada/dela je **NETO** (bez PDV-a) — potvrđeno u `servisni_radovi.go:40`:
```go
rad.CenaSaPdv = rad.CenaKomada * (1 + rad.PdvStopa/100)
```
Dakle `r.Ukupno() = kolicina × cena_komada` je već osnovica (neto). Deljenjem neto vrednosti sa 1.2 dobija se **premala osnovica**, a PDV se računa na pogrešnu (umanjenu) bazu. `kir.Ukupno` je zapravo neto, iako kolona „Ukupno" treba da bude bruto sa PDV-om.
**Primer (rad 1000 din neto, 20%):**
- Tačno: osnovica 1000, PDV 200, ukupno 1200
- Kod daje: osnovica 833.33, PDV 166.67, ukupno 1000
**Poređenje:** `KirIzProdaje` (`model/pdv_evidencija.go:98116`) to radi **ispravno** — ali tamo je `cena_po_komadu` bruto, pa je deljenje opravdano. Servis je samo prekopirao obrazac bez korekcije za neto cenu.
**Ispravka:** Za servis koristiti `r.UkupnoSaPdv()` kao bruto, ili direktno: `osnovica = r.Ukupno()`, `pdv = r.Ukupno() * stopa/100`.
---
## BUG-07 — Servis KIR: hardkodovana stopa 20%, ignoriše PdvStopa ⚠️ VAŽAN (poreski)
**Fajl:** `internal/handler/servis.go:2118, 2128`
**Problem:** Stopa je fiksirana na `/ 1.2` (20%) iako i rad i deo imaju polje `PdvStopa`. Sve stavke se sabiraju u `OsnovicaOpsta`/`PdvOpsta`, bez razvrstavanja na opštu (20%), posebnu (10%) i oslobođen promet.
`KirIzProdaje` to radi ispravno preko `switch s.PdvStopa { case 20 … case 10 … default … }`. Servisni KIR ne. Usluga/deo sa 10% ili 0% PDV biće pogrešno evidentiran.
**Ispravka:** Razvrstati po `rad.PdvStopa` / `deo.PdvStopa` kao u `KirIzProdaje`.
---
## BUG-08 — TopKlijenti: servis bez statusa + neto cena, nedosledno ⚠️ SREDNJI
**Fajl:** `internal/db/sqlite/izvestaj.go:228231`
**Problem:** Podupit za servis i dalje koristi:
```sql
SELECT klijent_id, SUM(cena_konacna) FROM servisni_nalozi
WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
```
- **Nema filtera statusa** → broji i naloge koji još nisu preuzeti („U popravci", „Čeka delove"), pa i one koji nikad neće biti naplaćeni.
- Koristi **neto** `cena_konacna` (bez delova, posle BUG-04 fixa), dok dashboard i mesečni grafikon sada koriste **bruto** `naplaceno`.
Posledica: isti klijent ima različitu „ukupnu vrednost" na različitim ekranima.
**Ispravka:** Uskladiti sa ostatkom: `SUM(naplaceno) WHERE status='Preuzeto' AND naplaceno > 0`. (Prodajni podupit je već usklađen sa `stornirano = 0`.)
---
## BUG-09 — Izveštaji: 12-mesečni grafikon preskače mesece na kraju meseca ⚠️ LATENTAN
**Fajl:** `internal/handler/izvestaji.go:117119`
**Problem:** Petlja gradi ključeve meseci preko `sada.AddDate(0, -i, 0)`. Go-ov `AddDate` normalizuje prelivanje dana: npr. 31. mart 1 mesec = „31. februar" → 3. mart. Na 29/30/31. u mesecu neki mesec se **duplira**, a drugi (npr. februar) dobije ključ koji se nikad ne generiše → prikaže 0.
Ne manifestuje se danas (27.), ali se javlja svakog 2931. u mesecu.
**Ispravka:** Računati ključ preko prvog u mesecu, npr. `time.Date(god, mesec, 1, …)` sa ručnim oduzimanjem meseci, ili `AddDate` nad `BeginningOfMonth`.
---
## BUG-10 — Avans se ne uračunava u prihod (regresija od fixa BUG-02/03) ⚠️ VAŽAN
**Fajl:** `internal/db/sqlite/izvestaj.go:4649` (i `MesecniPrihodServis`)
**Problem:** Prihod od servisa sada je `SUM(naplaceno)`. Ali `naplaceno` je iznos naplaćen **pri preuzimanju** = `(cena_konacna + delovi) avans` (`servis.go:20692073`). Avans je naplaćen ranije i nigde se ne evidentira kao zaseban prihod.
Dve posledice:
1. Za naloge sa avansom, deo prihoda pokriven avansom **nedostaje** iz „prihoda meseca". Stari kod (`cena_konacna`) je taj deo uključivao.
2. Filter `naplaceno > 0` (dodat da izbaci garancijske popravke) **potpuno izbacuje** naloge gde je avans pokrio ceo iznos (`naplaceno = 0`) — iako su plaćeni.
**Napomena:** Ovo je svesni kompromis mog prethodnog fixa (`naplaceno` je gotovinski tačan za naloge bez avansa). Treba odlučiti definiciju prihoda. Ako je cilj ukupan prihod: `SUM(naplaceno + COALESCE(avans, 0))` za `status='Preuzeto'`, uz uslov `(naplaceno > 0 OR avans > 0)` umesto samo `naplaceno > 0`.
+1 -1
View File
@@ -91,7 +91,7 @@ func main() {
port := os.Getenv("NTECH_PORT")
if port == "" {
port = "8080"
port = "3000"
}
db, err := sqlite.OtvoriDB(putanjaBaze)
+12 -7
View File
@@ -39,11 +39,13 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(
(SELECT SUM(ukupno) FROM prodajni_nalozi
WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
WHERE stornirano = 0
AND substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
0)
+ COALESCE(
(SELECT SUM(cena_konacna) FROM servisni_nalozi
(SELECT SUM(naplaceno + COALESCE(avans, 0)) FROM servisni_nalozi
WHERE status = 'Preuzeto'
AND (naplaceno > 0 OR avans > 0)
AND substr(datum_zavrsetka, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
0)`).Scan(&iznos)
if err != nil {
@@ -108,6 +110,7 @@ func (r *sqliteIzvestajRepo) PoslednjeProdaje(ctx context.Context, limit int) ([
COALESCE(kp.naziv, '') AS klijent_naziv
FROM prodajni_nalozi pn
LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id
WHERE pn.stornirano = 0
ORDER BY pn.datum DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("ntech: izvestaj.PoslednjeProdaje: %w", err)
@@ -146,16 +149,18 @@ func (r *sqliteIzvestajRepo) MesecniPrihodProdaja(ctx context.Context) ([]model.
return r.mesecniPrihod(ctx, `
SELECT substr(datum, 1, 7), SUM(ukupno)
FROM prodajni_nalozi
WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
WHERE stornirano = 0
AND substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum, 1, 7)`, "MesecniPrihodProdaja")
}
func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) {
return r.mesecniPrihod(ctx, `
SELECT substr(datum_zavrsetka, 1, 7), SUM(COALESCE(cena_konacna, 0))
SELECT substr(datum_zavrsetka, 1, 7), SUM(naplaceno + COALESCE(avans, 0))
FROM servisni_nalozi
WHERE datum_zavrsetka IS NOT NULL
AND status = 'Preuzeto'
AND (naplaceno > 0 OR avans > 0)
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
}
@@ -218,11 +223,11 @@ func (r *sqliteIzvestajRepo) TopKlijenti(ctx context.Context, limit int) ([]mode
LEFT JOIN klijent_prikaz kp ON kp.id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
FROM prodajni_nalozi GROUP BY klijent_id
FROM prodajni_nalozi WHERE stornirano = 0 GROUP BY klijent_id
) p ON p.klijent_id = k.id
LEFT JOIN (
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
SELECT klijent_id, SUM(naplaceno + COALESCE(avans, 0)) AS ukupno_servis, COUNT(*) AS broj_servisa
FROM servisni_nalozi WHERE status = 'Preuzeto' AND (naplaceno > 0 OR avans > 0) GROUP BY klijent_id
) s ON s.klijent_id = k.id
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
ORDER BY ukupno_vrednost DESC
+372 -4
View File
@@ -2,6 +2,8 @@ package sqlite
import (
"context"
"database/sql"
"fmt"
"testing"
"ntech/internal/model"
@@ -19,8 +21,8 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
}
}
dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5})
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) // kritičan
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) // kritičan, nula
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5})
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5})
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
@@ -36,13 +38,11 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
if len(zalihe) != 2 {
t.Fatalf("KriticneZalihe vratio %d, očekivano 2", len(zalihe))
}
// sortirano po količini rastuće → prvi je onaj sa 0
if zalihe[0].Kolicina != 0 {
t.Fatalf("prvi kritičan treba da ima količinu 0, ima %d", zalihe[0].Kolicina)
}
}
// prazna baza — brojači 0, liste prazne, bez greške
func TestIzvestajPraznaBaza(t *testing.T) {
ctx := context.Background()
db := testDB(t)
@@ -64,3 +64,371 @@ func TestIzvestajPraznaBaza(t *testing.T) {
t.Errorf("MesecniPrihodProdaja len=%d, err=%v", len(l), err)
}
}
// ─── BUG-01: stornirani prodajni nalozi ne smeju ući u prihod ───────────────
func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-T-001', 1000.0, 0, date('now','localtime'))`)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-T-002', 500.0, 1, date('now','localtime'))`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 1000.0 {
t.Errorf("prihod = %.2f; očekivano 1000.00 — stornirani nalog (500) ne sme da uđe", prihod)
}
}
func TestMesecniPrihodProdaja_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-M-001', 2000.0, 0, date('now','localtime'))`)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-M-002', 300.0, 1, date('now','localtime'))`)
meseci, err := izv.MesecniPrihodProdaja(ctx)
if err != nil {
t.Fatalf("MesecniPrihodProdaja: %v", err)
}
if len(meseci) != 1 {
t.Fatalf("očekivan 1 mesec, dobijeno %d", len(meseci))
}
if meseci[0].Iznos != 2000.0 {
t.Errorf("iznos = %.2f; očekivano 2000.00 — stornirani (300) ne sme da uđe", meseci[0].Iznos)
}
}
func TestPoslednjeProdaje_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-P-001', 100.0, 0, date('now','localtime'))`)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-P-002', 200.0, 1, date('now','localtime'))`)
lista, err := izv.PoslednjeProdaje(ctx, 10)
if err != nil {
t.Fatalf("PoslednjeProdaje: %v", err)
}
if len(lista) != 1 {
t.Fatalf("lista ima %d redova, očekivan 1 — stornirani ne sme da uđe", len(lista))
}
if lista[0].BrojNaloga != "PR-P-001" {
t.Errorf("pogrešan nalog: %s", lista[0].BrojNaloga)
}
}
func TestTopKlijenti_IgnorisuSeStornirani(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO klijenti (ime, prezime) VALUES ('Pera', 'Perić')`)
var klijentID int64
if err := baza.QueryRowContext(ctx, `SELECT id FROM klijenti LIMIT 1`).Scan(&klijentID); err != nil {
t.Fatalf("dohvati klijent id: %v", err)
}
mustExec(t, baza, fmt.Sprintf(
`INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum, klijent_id)
VALUES ('PR-K-001', 5000.0, 0, date('now','localtime'), %d)`, klijentID))
mustExec(t, baza, fmt.Sprintf(
`INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum, klijent_id)
VALUES ('PR-K-002', 1000.0, 1, date('now','localtime'), %d)`, klijentID))
top, err := izv.TopKlijenti(ctx, 5)
if err != nil {
t.Fatalf("TopKlijenti: %v", err)
}
if len(top) != 1 {
t.Fatalf("top lista ima %d redova, očekivan 1", len(top))
}
if top[0].UkupnoVrednost != 5000.0 {
t.Errorf("vrednost = %.2f; očekivano 5000.00 — stornirani (1000) ne sme da uđe", top[0].UkupnoVrednost)
}
}
// ─── BUG-02/03: servis prihod koristi naplaceno (bruto, uključuje delove) ───
func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
// cena_konacna=800 (neto, bez delova), naplaceno=1500 (bruto sa delovima)
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-T-001', 'Telefon', 'Kvar', 'Preuzeto', 800.0, 1500.0, date('now'), date('now','localtime'), 'tok1')`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 1500.0 {
t.Errorf("prihod = %.2f; očekivano 1500.00 (naplaceno, ne cena_konacna 800)", prihod)
}
}
func TestPrihodTekuciMesec_ServisNaplaceno0NeUlazi(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
// garancijska popravka — cena_konacna postoji ali naplaceno=0
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-T-002', 'Laptop', 'Kvar', 'Preuzeto', 1200.0, 0, date('now'), date('now','localtime'), 'tok2')`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 0.0 {
t.Errorf("prihod = %.2f; očekivano 0 (naplaceno=0 ne sme da uđe)", prihod)
}
}
func TestMesecniPrihodServis_KoristiNaplaceno(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-M-001', 'Tablet', 'Kvar', 'Preuzeto', 500.0, 900.0, date('now'), date('now','localtime'), 'tok3')`)
meseci, err := izv.MesecniPrihodServis(ctx)
if err != nil {
t.Fatalf("MesecniPrihodServis: %v", err)
}
if len(meseci) != 1 {
t.Fatalf("očekivan 1 mesec, dobijeno %d", len(meseci))
}
if meseci[0].Iznos != 900.0 {
t.Errorf("iznos = %.2f; očekivano 900.00 (naplaceno, ne cena_konacna 500)", meseci[0].Iznos)
}
}
// ─── BUG-10: avans se uračunava u prihod (naplaceno + avans) ─────────────────
func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
// avans 400 (ranije), naplaceno 600 (ostatak pri preuzimanju) → ukupno 1000
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, avans, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-AV-001', 'PC', 'Kvar', 'Preuzeto', 1000.0, 400.0, 600.0, date('now'), date('now','localtime'), 'tokav1')`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 1000.0 {
t.Errorf("prihod = %.2f; očekivano 1000.00 (naplaceno 600 + avans 400)", prihod)
}
}
func TestPrihodTekuciMesec_ServisPotpunoAvansiran(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
// avans pokrio ceo iznos → naplaceno=0, ali prihod NIJE 0 (avans 1000)
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, avans, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-AV-002', 'Laptop', 'Kvar', 'Preuzeto', 1000.0, 1000.0, 0.0, date('now'), date('now','localtime'), 'tokav2')`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 1000.0 {
t.Errorf("prihod = %.2f; očekivano 1000.00 (potpuno avansiran ne sme da nestane)", prihod)
}
}
// garancija/besplatna popravka: naplaceno=0 i avans=NULL → ne ulazi u prihod
func TestPrihodTekuciMesec_GarancijaNeUlazi(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-GAR-001', 'Telefon', 'Garancija', 'Preuzeto', 0.0, 0.0, date('now'), date('now','localtime'), 'tokgar')`)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
if prihod != 0.0 {
t.Errorf("prihod = %.2f; očekivano 0 (garancija bez naplate i avansa)", prihod)
}
}
// ─── BUG-08: TopKlijenti broji samo preuzete naloge sa naplatom/avansom ──────
func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
mustExec(t, baza, `INSERT INTO klijenti (ime, prezime) VALUES ('Mika', 'Mikić')`)
var klijentID int64
if err := baza.QueryRowContext(ctx, `SELECT id FROM klijenti LIMIT 1`).Scan(&klijentID); err != nil {
t.Fatalf("dohvati klijent id: %v", err)
}
// preuzet nalog sa naplatom 800 — broji se
mustExec(t, baza, fmt.Sprintf(`INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token, klijent_id)
VALUES ('SR-TK-001', 'PC', 'Kvar', 'Preuzeto', 800.0, 800.0, date('now'), date('now','localtime'), 'toktk1', %d)`, klijentID))
// nalog u popravci sa cena_konacna 5000 — NE sme da se broji (nije preuzet)
mustExec(t, baza, fmt.Sprintf(`INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, javni_token, klijent_id)
VALUES ('SR-TK-002', 'Laptop', 'Kvar', 'U popravci', 5000.0, 0.0, date('now'), 'toktk2', %d)`, klijentID))
top, err := izv.TopKlijenti(ctx, 5)
if err != nil {
t.Fatalf("TopKlijenti: %v", err)
}
if len(top) != 1 {
t.Fatalf("top lista ima %d redova, očekivan 1", len(top))
}
if top[0].UkupnoVrednost != 800.0 {
t.Errorf("vrednost = %.2f; očekivano 800.00 (samo preuzet nalog, ne 5000 u popravci)", top[0].UkupnoVrednost)
}
}
// ─── Kombinovani test: prodaja (bez storniranih) + servis (naplaceno) ────────
func TestPrihodTekuciMesec_Kombinovani(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
izv := NoviIzvestajRepo(baza)
// prodaja: 1000 normalna + 400 stornirana
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-C-001', 1000.0, 0, date('now','localtime'))`)
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
VALUES ('PR-C-002', 400.0, 1, date('now','localtime'))`)
// servis: naplaceno=600 (cena_konacna=200 neto)
mustExec(t, baza, `INSERT INTO servisni_nalozi
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
VALUES ('SR-C-001', 'PC', 'Kvar', 'Preuzeto', 200.0, 600.0, date('now'), date('now','localtime'), 'tok4')`)
// 1000 (prodaja) + 600 (servis naplaceno) = 1600; 400 storniranih ne ulazi
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
const ocekivano = 1600.0
if prihod != ocekivano {
t.Errorf("prihod = %.2f; očekivano %.2f", prihod, ocekivano)
}
}
// ─── BUG-04: auto-izračun cena_konacna ne sme da uključuje delove ────────────
//
// Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u
// Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa
// proveravamo da li naplaceno (= cena_konacna + delovi) nije dupliran.
func TestServisAutoIzracunCeneKonacne_BezDuplogDela(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
servisRepo := NoviServisRepo(baza)
radoviRepo := NoviServisniRadoviRepo(baza)
izv := NoviIzvestajRepo(baza)
// kreiraj nalog bez cene_konacne
nalogID, err := servisRepo.Kreiraj(ctx, &model.ServisniNalog{
BrojNaloga: "SR-BUG04",
Uredjaj: "Monitor",
OpisKvara: "Ne pali",
Status: "U popravci",
CenaDijagnostike: 200.0,
})
if err != nil {
t.Fatalf("Kreiraj nalog: %v", err)
}
// dodaj rad direktno u bazu (200 din)
mustExec(t, baza, fmt.Sprintf(
`INSERT INTO servis_radovi (nalog_id, naziv, kolicina, cena_komada, predlozeno)
VALUES (%d, 'Zamena baterijice', 1, 200.0, 0)`, nalogID))
// dodaj deo direktno u bazu (300 din) — potreban je artikal u bazi
mustExec(t, baza, `INSERT INTO artikli (naziv, kolicina, prodajna_cena) VALUES ('Baterija', 5, 300.0)`)
var artID int64
_ = baza.QueryRowContext(ctx, `SELECT id FROM artikli WHERE naziv='Baterija'`).Scan(&artID)
mustExec(t, baza, fmt.Sprintf(
`INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID))
// simuliramo ispravnu handler logiku (BUG-04 fix):
// cena_konacna = dijagnostika + radovi (BEZ delova)
cenaKonacna := 200.0 + 200.0 // dijagnostika + rad
if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil {
t.Fatalf("AzurirajCenuKonacnu: %v", err)
}
// iznos za naplatu = cena_konacna + delovi (bez dupliranja)
iznosNaplacen := cenaKonacna + 300.0 // = 700
if err := servisRepo.SacuvajNaplatu(ctx, nalogID, "Gotovina", iznosNaplacen); err != nil {
t.Fatalf("SacuvajNaplatu: %v", err)
}
// postavi datum_zavrsetka i status Preuzeto
if err := servisRepo.AzurirajStatus(ctx, nalogID, "Preuzeto"); err != nil {
t.Fatalf("AzurirajStatus: %v", err)
}
// prihod treba da bude tačno 700 (nije 1000 = 400+300+300 duplikat)
prihod, err := izv.PrihodTekuciMesec(ctx)
if err != nil {
t.Fatalf("PrihodTekuciMesec: %v", err)
}
const ocekivano = 700.0
if prihod != ocekivano {
t.Errorf("prihod = %.2f; očekivano %.2f (400 rad+dijag + 300 deo, bez duplikata)", prihod, ocekivano)
}
// proveri i da cena_konacna nije kontaminirana delovima
nalog, err := servisRepo.DohvatiID(ctx, nalogID)
if err != nil || nalog == nil {
t.Fatalf("DohvatiID: %v", err)
}
if nalog.CenaKonacna == nil {
t.Fatal("cena_konacna je nil — nije sačuvana")
}
if *nalog.CenaKonacna != 400.0 {
t.Errorf("cena_konacna = %.2f; očekivano 400.00 (dijagnostika+rad, bez delova)", *nalog.CenaKonacna)
}
_ = radoviRepo // korišćen indirektno kroz bazu
}
// ─── helper ──────────────────────────────────────────────────────────────────
func mustExec(t *testing.T, db *sql.DB, query string) {
t.Helper()
if _, err := db.ExecContext(context.Background(), query); err != nil {
t.Fatalf("mustExec: %v\nSQL: %s", err, query)
}
}
+100
View File
@@ -0,0 +1,100 @@
package sqlite
import (
"context"
"errors"
"testing"
"ntech/internal/model"
)
// TestPromeniCenu_AzuriraCenuIUpisujeNivelaciju: PromeniCenu čita staru cenu iz
// baze, menja prodajnu cenu i kreira nivelacioni zapis u jednoj transakciji.
func TestPromeniCenu_AzuriraCenuIUpisujeNivelaciju(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
nivRepo := NoviNivelacijaRepo(baza)
artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{Naziv: "Miš", ProdajnaCena: 1000})
niv, err := nivRepo.PromeniCenu(ctx, artID, 1200, "sezonska korekcija", nil)
if err != nil {
t.Fatalf("PromeniCenu: %v", err)
}
// povratna vrednost
if niv.StaraCena != 1000 {
t.Errorf("StaraCena = %.2f, očekivano 1000", niv.StaraCena)
}
if niv.NovaCena != 1200 {
t.Errorf("NovaCena = %.2f, očekivano 1200", niv.NovaCena)
}
if niv.Izvor != "rucno" {
t.Errorf("Izvor = %q, očekivano \"rucno\"", niv.Izvor)
}
// artikal u bazi mora imati novu cenu
a, _ := artRepo.DohvatiID(ctx, artID)
if a.ProdajnaCena != 1200 {
t.Errorf("prodajna_cena u bazi = %.2f, očekivano 1200", a.ProdajnaCena)
}
// nivelacioni zapis mora postojati u bazi
var stara, nova float64
var izvor string
err = baza.QueryRowContext(ctx,
"SELECT stara_cena, nova_cena, izvor FROM nivelacije WHERE artikal_id = ?", artID,
).Scan(&stara, &nova, &izvor)
if err != nil {
t.Fatalf("nivelacioni zapis nije pronađen: %v", err)
}
if stara != 1000 || nova != 1200 || izvor != "rucno" {
t.Errorf("zapis: stara=%.2f nova=%.2f izvor=%q, očekivano 1000/1200/rucno", stara, nova, izvor)
}
}
// TestPromeniCenu_NepostojeciArtikal: pokušaj promene cene nepostojećeg artikla
// vraća ErrArtikalNePostoji, baza ostaje neizmenjena.
func TestPromeniCenu_NepostojeciArtikal(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
nivRepo := NoviNivelacijaRepo(baza)
_, err := nivRepo.PromeniCenu(ctx, 9999, 500, "test", nil)
if !errors.Is(err, ErrArtikalNePostoji) {
t.Errorf("greška = %v, očekivano ErrArtikalNePostoji", err)
}
// nivelacioni zapis ne sme biti kreiran
var br int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM nivelacije").Scan(&br)
if br != 0 {
t.Errorf("nivelacije = %d, očekivano 0 (transakcija rollback)", br)
}
}
// TestPromeniCenu_VisePromena: svaka promena čita prethodnu cenu kao "staru".
func TestPromeniCenu_VisePromena(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
nivRepo := NoviNivelacijaRepo(baza)
artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{Naziv: "Tastatura", ProdajnaCena: 800})
nivRepo.PromeniCenu(ctx, artID, 900, "korekcija 1", nil)
niv2, _ := nivRepo.PromeniCenu(ctx, artID, 750, "korekcija 2", nil)
// druga promena čita 900 (ne originalni 800) kao staru cenu
if niv2.StaraCena != 900 {
t.Errorf("StaraCena druge promene = %.2f, očekivano 900", niv2.StaraCena)
}
// ukupno 2 zapisa u nivelacije
var br int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM nivelacije WHERE artikal_id = ?", artID).Scan(&br)
if br != 2 {
t.Errorf("broj nivelacijskih zapisa = %d, očekivano 2", br)
}
}
+126
View File
@@ -0,0 +1,126 @@
package sqlite
import (
"context"
"testing"
"time"
"ntech/internal/model"
)
// TestProdajaKreiraj_UslugaNeSkidaLager: stavka tipa "usluga" ne prati lager —
// količina artikla se ne menja, nema magacinske promene.
func TestProdajaKreiraj_UslugaNeSkidaLager(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
uslugaID, err := artRepo.Kreiraj(ctx, &model.Artikal{Naziv: "Dijagnostika", Tip: model.TipUsluga})
if err != nil {
t.Fatalf("Kreiraj usluga: %v", err)
}
_, err = prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
BrojNaloga: "PR-U-001", Ukupno: 500, NacinPlacanja: "gotovina", Datum: time.Now(),
}, []model.StavkaProdaje{
{ArtikalID: uslugaID, Kolicina: 1, CenaPoKomadu: 500},
}, nil)
if err != nil {
t.Fatalf("Kreiraj prodaja: %v", err)
}
// količina usluge ostaje 0 — nije praćena
a, _ := artRepo.DohvatiID(ctx, uslugaID)
if a.Kolicina != 0 {
t.Errorf("usluga kolicina = %d, očekivano 0 (usluge ne prate lager)", a.Kolicina)
}
// nema magacinske promene za uslugu
var br int
baza.QueryRowContext(ctx,
"SELECT COUNT(*) FROM magacinske_promene WHERE artikal_id = ?", uslugaID,
).Scan(&br)
if br != 0 {
t.Errorf("magacinske_promene = %d, očekivano 0 za uslugu", br)
}
}
// TestProdajaKreiraj_NedovoljnoStanja: tražena kolicina > stanje →
// vraća ErrNedovoljnoKolicine, nalog se ne kreira, stanje ne menja.
func TestProdajaKreiraj_NedovoljnoStanja(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{
Naziv: "Monitor", Tip: model.TipProizvod, Kolicina: 2,
})
_, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
BrojNaloga: "PR-N-001", Ukupno: 1000, NacinPlacanja: "gotovina", Datum: time.Now(),
}, []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 5, CenaPoKomadu: 200}, // traži 5, ima 2
}, nil)
if err == nil {
t.Fatal("očekivana greška ErrNedovoljnoKolicine, nije vraćena")
}
// stanje ostaje nepromenjeno
a, _ := artRepo.DohvatiID(ctx, artID)
if a.Kolicina != 2 {
t.Errorf("stanje = %d, očekivano 2 (transakcija rollback)", a.Kolicina)
}
// nalog nije kreiran
var brNaloga int
baza.QueryRowContext(ctx, "SELECT COUNT(*) FROM prodajni_nalozi WHERE broj_naloga='PR-N-001'").Scan(&brNaloga)
if brNaloga != 0 {
t.Errorf("nalog kreiran uprkos grešci — očekivano 0 naloga")
}
}
// TestProdajaKreiraj_PdvAutoKalkulacija: kada CenaBezPdv=0 a PdvStopa>0,
// cena_bez_pdv i pdv_iznos se automatski računaju iz bruto cene.
func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID, _ := artRepo.Kreiraj(ctx, &model.Artikal{
Naziv: "Tastatura", Tip: model.TipProizvod, Kolicina: 10,
})
// bruto cena 1200, PDV 20% → neto = 1200/1.2 = 1000, pdv_iznos = 200
_, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
}, []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20},
// CenaBezPdv=0 namerno — treba da se auto-izračuna
}, nil)
if err != nil {
t.Fatalf("Kreiraj: %v", err)
}
var cenaBezPdv, pdvIznos float64
baza.QueryRowContext(ctx,
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = (SELECT id FROM prodajni_nalozi WHERE broj_naloga='PR-PDV-001')",
).Scan(&cenaBezPdv, &pdvIznos)
const ocekivanaNeto = 1000.0
const ocekivanPdv = 200.0
if absF(cenaBezPdv-ocekivanaNeto) > 0.01 {
t.Errorf("cena_bez_pdv = %.4f, očekivano %.2f", cenaBezPdv, ocekivanaNeto)
}
if absF(pdvIznos-ocekivanPdv) > 0.01 {
t.Errorf("pdv_iznos = %.4f, očekivano %.2f", pdvIznos, ocekivanPdv)
}
}
func absF(x float64) float64 {
if x < 0 {
return -x
}
return x
}
+274
View File
@@ -0,0 +1,274 @@
package sqlite
import (
"context"
"testing"
"time"
"ntech/internal/model"
)
// ─── helpers ─────────────────────────────────────────────────────────────────
// seedArtikalSaStanjem kreira artikal sa zadatom količinom i vraća njegov ID.
func seedArtikalSaStanjem(t *testing.T, ctx context.Context, repo *ArtikalRepo, naziv string, kolicina int) int64 {
t.Helper()
id, err := repo.Kreiraj(ctx, &model.Artikal{
Naziv: naziv,
Kolicina: kolicina,
Tip: model.TipProizvod,
})
if err != nil {
t.Fatalf("seedArtikalSaStanjem %q: %v", naziv, err)
}
return id
}
// seedUsluga kreira artikal tipa "usluga" (bez praćenja lagera).
func seedUsluga(t *testing.T, ctx context.Context, repo *ArtikalRepo, naziv string) int64 {
t.Helper()
id, err := repo.Kreiraj(ctx, &model.Artikal{
Naziv: naziv,
Tip: model.TipUsluga,
})
if err != nil {
t.Fatalf("seedUsluga %q: %v", naziv, err)
}
return id
}
// prodajNalog kreira prodajni nalog sa zadatim stavkama i vraća ID naloga.
func prodajNalog(t *testing.T, ctx context.Context, repo *ProdajaRepo, broj string, stavke []model.StavkaProdaje) int64 {
t.Helper()
ukupno := 0.0
for _, s := range stavke {
ukupno += float64(s.Kolicina) * s.CenaPoKomadu
}
id, err := repo.Kreiraj(ctx, &model.ProdajniNalog{
BrojNaloga: broj,
Ukupno: ukupno,
NacinPlacanja: "gotovina",
Datum: time.Now(),
}, stavke, nil)
if err != nil {
t.Fatalf("prodajNalog %q: %v", broj, err)
}
return id
}
// dohvatiKolicinu čita trenutnu količinu artikla iz baze.
func dohvatiKolicinu(t *testing.T, ctx context.Context, repo *ArtikalRepo, id int64) int {
t.Helper()
a, err := repo.DohvatiID(ctx, id)
if err != nil || a == nil {
t.Fatalf("dohvatiKolicinu id=%d: %v", id, err)
}
return a.Kolicina
}
// ─── testovi ─────────────────────────────────────────────────────────────────
// Osnovni slučaj: storno vraća tačnu količinu u magacin.
func TestStornoVracaKolicinuUMagacin(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID := seedArtikalSaStanjem(t, ctx, artRepo, "Punjač", 10)
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-001", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 3, CenaPoKomadu: 100},
})
// poslije prodaje: 10 - 3 = 7
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 7 {
t.Fatalf("posle prodaje: kolicina = %d, očekivano 7", got)
}
if err := prodRepo.Storno(ctx, nalogID, "test storno", nil); err != nil {
t.Fatalf("Storno: %v", err)
}
// posle storna: 7 + 3 = 10 (prvobitno stanje)
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 10 {
t.Errorf("posle storna: kolicina = %d, očekivano 10", got)
}
}
// Nalog sa više stavki — sve se vraćaju.
func TestStornoViseSlavki(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
aID := seedArtikalSaStanjem(t, ctx, artRepo, "Miš", 20)
bID := seedArtikalSaStanjem(t, ctx, artRepo, "Tastatura", 15)
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-002", []model.StavkaProdaje{
{ArtikalID: aID, Kolicina: 5, CenaPoKomadu: 50},
{ArtikalID: bID, Kolicina: 3, CenaPoKomadu: 80},
})
if err := prodRepo.Storno(ctx, nalogID, "povrat", nil); err != nil {
t.Fatalf("Storno: %v", err)
}
if got := dohvatiKolicinu(t, ctx, artRepo, aID); got != 20 {
t.Errorf("Miš: kolicina = %d, očekivano 20", got)
}
if got := dohvatiKolicinu(t, ctx, artRepo, bID); got != 15 {
t.Errorf("Tastatura: kolicina = %d, očekivano 15", got)
}
}
// Usluga (tip != "proizvod") — storno je dozvoljen ali se količina ne vraća.
func TestStornoUslugaNeVracaKolicinu(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
// proizvod koji ima lager
artID := seedArtikalSaStanjem(t, ctx, artRepo, "Kabl", 10)
// usluga koja nema lager
uslugaID := seedUsluga(t, ctx, artRepo, "Instalacija")
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-003", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 200},
{ArtikalID: uslugaID, Kolicina: 1, CenaPoKomadu: 500},
})
if err := prodRepo.Storno(ctx, nalogID, "test", nil); err != nil {
t.Fatalf("Storno: %v", err)
}
// proizvod se vraća: 10 - 2 + 2 = 10
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 10 {
t.Errorf("Kabl: kolicina = %d, očekivano 10", got)
}
// usluga nema količinu — proveri da nije pogrešno promenjena
usluga, _ := artRepo.DohvatiID(ctx, uslugaID)
if usluga.Kolicina != 0 {
t.Errorf("Instalacija (usluga): kolicina = %d, očekivano 0 (usluge nemaju lager)", usluga.Kolicina)
}
}
// Dupli storno — drugi pokušaj mora da vrati grešku.
func TestStornoVecStorniran(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID := seedArtikalSaStanjem(t, ctx, artRepo, "USB hub", 5)
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-004", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 300},
})
if err := prodRepo.Storno(ctx, nalogID, "prvi", nil); err != nil {
t.Fatalf("prvi Storno: %v", err)
}
// drugi storno mora da vrati grešku
if err := prodRepo.Storno(ctx, nalogID, "drugi", nil); err == nil {
t.Error("drugi Storno nije vratio grešku — trebalo je da odbije dupli storno")
}
// količina ne sme biti duplo vraćena: 5 - 1 + 1 = 5, ne 6
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 5 {
t.Errorf("posle duplog storna: kolicina = %d, očekivano 5 (nema duplog vraćanja)", got)
}
}
// Storno pa Obrisi — Obrisi ne sme ponovo da vrati robu (storno je već vratio).
func TestObrisiPosleStornaNeVracaDuplo(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID := seedArtikalSaStanjem(t, ctx, artRepo, "Adapter", 8)
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-005", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 150},
})
// storno vraća 2 kom: 8 - 2 + 2 = 8
if err := prodRepo.Storno(ctx, nalogID, "storno pre brisanja", nil); err != nil {
t.Fatalf("Storno: %v", err)
}
// brisanje storniranog naloga — ne sme ponovo da vrati robu
if err := prodRepo.Obrisi(ctx, nalogID, nil); err != nil {
t.Fatalf("Obrisi: %v", err)
}
// mora ostati 8, ne 10 (8 + 2 duplo)
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 8 {
t.Errorf("posle storno+obrisi: kolicina = %d, očekivano 8 (ne 10)", got)
}
}
// Magacinska promena se beleži pri stornu (tip = PromenaPovracaj).
func TestStornoBeležiMagacinskuPromenu(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID := seedArtikalSaStanjem(t, ctx, artRepo, "Monitor", 3)
nalogID := prodajNalog(t, ctx, prodRepo, "PR-S-006", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 500},
})
if err := prodRepo.Storno(ctx, nalogID, "povrat kupca", nil); err != nil {
t.Fatalf("Storno: %v", err)
}
// proveravamo magacinsku promenu direktno u bazi
var tipPromene string
var promenaKolicine int
err := baza.QueryRowContext(ctx,
`SELECT tip_promene, promena_kolicine FROM magacinske_promene
WHERE artikal_id = ? AND referentni_id = ? AND tip_promene = ?`,
artID, nalogID, model.PromenaPovracaj,
).Scan(&tipPromene, &promenaKolicine)
if err != nil {
t.Fatalf("magacinska promena nije pronađena: %v", err)
}
if tipPromene != model.PromenaPovracaj {
t.Errorf("tip_promene = %q, očekivano %q", tipPromene, model.PromenaPovracaj)
}
if promenaKolicine != 1 {
t.Errorf("promena_kolicine = %d, očekivano 1", promenaKolicine)
}
}
// Delimični storno: dva naloga za isti artikal — storno prvog ne dira drugi.
func TestStornoDvaOdvojenaNaloga(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
artRepo := NoviArtikalRepo(baza)
prodRepo := NoviProdajaRepo(baza)
artID := seedArtikalSaStanjem(t, ctx, artRepo, "SSD", 10)
nalog1 := prodajNalog(t, ctx, prodRepo, "PR-S-007", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 3, CenaPoKomadu: 200},
})
prodajNalog(t, ctx, prodRepo, "PR-S-008", []model.StavkaProdaje{
{ArtikalID: artID, Kolicina: 2, CenaPoKomadu: 200},
})
// posle oba: 10 - 3 - 2 = 5
// storniramo samo prvi
if err := prodRepo.Storno(ctx, nalog1, "greška", nil); err != nil {
t.Fatalf("Storno nalog1: %v", err)
}
// 5 + 3 (vraćen nalog1) = 8; nalog2 (2 kom) ostaje prodat
if got := dohvatiKolicinu(t, ctx, artRepo, artID); got != 8 {
t.Errorf("posle storna prvog: kolicina = %d, očekivano 8", got)
}
}
+102
View File
@@ -0,0 +1,102 @@
package sqlite
import (
"context"
"testing"
"ntech/internal/model"
)
// TestAzurirajStatus_DatumZavrsetka: datum_zavrsetka se setuje samo kad
// nalog prelazi u Završeno ili Preuzeto, a ne za ostale statuse.
func TestAzurirajStatus_DatumZavrsetka(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
nalogID, _ := repo.Kreiraj(ctx, &model.ServisniNalog{
BrojNaloga: "SN-ST-001", Uredjaj: "PC", OpisKvara: "kvar", Status: "Primljeno",
})
// prelazak u "U popravci" — datum_zavrsetka ne sme biti postavljen
repo.AzurirajStatus(ctx, nalogID, "U popravci")
nalog, _ := repo.DohvatiID(ctx, nalogID)
if nalog.DatumZavrsetka != nil {
t.Errorf("U popravci: datum_zavrsetka = %v, očekivano nil", nalog.DatumZavrsetka)
}
// prelazak u "Završeno" — datum_zavrsetka se postavlja
repo.AzurirajStatus(ctx, nalogID, "Završeno")
nalog, _ = repo.DohvatiID(ctx, nalogID)
if nalog.DatumZavrsetka == nil {
t.Error("Završeno: datum_zavrsetka je nil, očekivano da bude postavljen")
}
// drugi prelazak u "Preuzeto" — datum_zavrsetka se čuva (COALESCE, ne menja)
prvoDatum := nalog.DatumZavrsetka
repo.AzurirajStatus(ctx, nalogID, "Preuzeto")
nalog, _ = repo.DohvatiID(ctx, nalogID)
if nalog.DatumZavrsetka == nil {
t.Error("Preuzeto: datum_zavrsetka je nil")
}
if !nalog.DatumZavrsetka.Equal(*prvoDatum) {
t.Errorf("datum_zavrsetka promenjen: bio %v, sada %v (COALESCE treba da čuva original)", prvoDatum, nalog.DatumZavrsetka)
}
}
// TestAzurirajStatus_ResetPopravkaOdbijena: svaka promena statusa resetuje
// popravka_odbijena na 0, čak i ako je pre bila 1.
func TestAzurirajStatus_ResetPopravkaOdbijena(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
nalogID, _ := repo.Kreiraj(ctx, &model.ServisniNalog{
BrojNaloga: "SN-ST-002", Uredjaj: "Laptop", OpisKvara: "kvar", Status: "U dijagnostici",
})
// postavi popravka_odbijena=1 direktno
baza.ExecContext(ctx, "UPDATE servisni_nalozi SET popravka_odbijena=1 WHERE id=?", nalogID)
nalog, _ := repo.DohvatiID(ctx, nalogID)
if !nalog.PopravkaOdbijena {
t.Fatal("setup: popravka_odbijena treba da bude true")
}
// promena statusa → reset
repo.AzurirajStatus(ctx, nalogID, "U popravci")
nalog, _ = repo.DohvatiID(ctx, nalogID)
if nalog.PopravkaOdbijena {
t.Error("posle AzurirajStatus: popravka_odbijena treba da bude false")
}
}
// TestOdbijPopravku: status → Završeno, popravka_odbijena=1, datum_zavrsetka postavljen.
func TestOdbijPopravku(t *testing.T) {
ctx := context.Background()
baza := testDB(t)
repo := NoviServisRepo(baza)
nalogID, _ := repo.Kreiraj(ctx, &model.ServisniNalog{
BrojNaloga: "SN-ST-003", Uredjaj: "Telefon", OpisKvara: "razbijen ekran", Status: "U dijagnostici",
})
const cenaDijag = 1500.0
if err := repo.OdbijPopravku(ctx, nalogID, cenaDijag); err != nil {
t.Fatalf("OdbijPopravku: %v", err)
}
nalog, _ := repo.DohvatiID(ctx, nalogID)
if nalog.Status != model.StatusZavrseno {
t.Errorf("status = %q, očekivano %q", nalog.Status, model.StatusZavrseno)
}
if !nalog.PopravkaOdbijena {
t.Error("popravka_odbijena treba da bude true")
}
if nalog.DatumZavrsetka == nil {
t.Error("datum_zavrsetka treba da bude postavljen")
}
if nalog.CenaDijagnostike != cenaDijag {
t.Errorf("cena_dijagnostike = %.2f, očekivano %.2f", nalog.CenaDijagnostike, cenaDijag)
}
}
+148
View File
@@ -0,0 +1,148 @@
package sqlite
import (
"context"
"testing"
)
// TestUgradiIliPotrazuj_SveUgradjeno: stanje >= tražena količina →
// sve se ugradi, magacin skinut, nedostaje=0, nema potraživanog reda.
func TestUgradiIliPotrazuj_SveUgradjeno(t *testing.T) {
db := testDB(t)
ctx := context.Background()
db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'RAM', 10)")
db.ExecContext(ctx, "INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'Primljeno')")
repo := NoviServisniDeloviRepo(db)
ugradjeno, nedostaje, err := repo.UgradiIliPotrazuj(ctx, 1, 1, 4, 500, nil, false)
if err != nil {
t.Fatalf("UgradiIliPotrazuj: %v", err)
}
if ugradjeno != 4 || nedostaje != 0 {
t.Errorf("očekivano ugradjeno=4 nedostaje=0, dobijeno ugradjeno=%d nedostaje=%d", ugradjeno, nedostaje)
}
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 6 {
t.Errorf("stanje magacina = %d, očekivano 6 (104)", stanje)
}
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_delovi WHERE nalog_id=1 AND artikal_id=1"); k != 4 {
t.Errorf("servisni_delovi kolicina = %d, očekivano 4", k)
}
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id=1"); k != 0 {
t.Errorf("servisni_potrazivani_delovi = %d, očekivano 0 (nema nedostajućih)", k)
}
}
// TestUgradiIliPotrazuj_MergePostojeceg: isti artikal dodat drugi put →
// kolicina u servisni_delovi se sabira (UPDATE), ne pravi se novi red.
func TestUgradiIliPotrazuj_MergePostojeceg(t *testing.T) {
db := testDB(t)
ctx := context.Background()
db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'SSD', 10)")
db.ExecContext(ctx, "INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'Primljeno')")
repo := NoviServisniDeloviRepo(db)
// prvi put: ugradi 2
repo.UgradiIliPotrazuj(ctx, 1, 1, 2, 200, nil, false)
// drugi put: ugradi još 3 (istog artikla)
repo.UgradiIliPotrazuj(ctx, 1, 1, 3, 200, nil, false)
// mora biti JEDAN red sa kolicinom 5, ne dva reda
if redova := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1 AND artikal_id=1"); redova != 1 {
t.Errorf("broj redova = %d, očekivano 1 (merge, ne duplikat)", redova)
}
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_delovi WHERE nalog_id=1 AND artikal_id=1"); k != 5 {
t.Errorf("kolicina = %d, očekivano 5 (2+3)", k)
}
// magacin: 10 2 3 = 5
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 5 {
t.Errorf("stanje magacina = %d, očekivano 5", stanje)
}
}
// TestUgradiIliPotrazuj_Predlozeno: predlozeno=true → ide kao predlog servisu
// (ne skida sa lagera, ugradjeno=0, nedostaje=tražena kolicina).
func TestUgradiIliPotrazuj_Predlozeno(t *testing.T) {
db := testDB(t)
ctx := context.Background()
db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'GPU', 5)")
db.ExecContext(ctx, "INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'U dijagnostici')")
repo := NoviServisniDeloviRepo(db)
ugradjeno, nedostaje, err := repo.UgradiIliPotrazuj(ctx, 1, 1, 3, 1000, nil, true)
if err != nil {
t.Fatalf("UgradiIliPotrazuj (predlozeno): %v", err)
}
if ugradjeno != 0 || nedostaje != 3 {
t.Errorf("očekivano ugradjeno=0 nedostaje=3, dobijeno ugradjeno=%d nedostaje=%d", ugradjeno, nedostaje)
}
// magacin se NE sme dirljati — ostaje 5
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 5 {
t.Errorf("stanje magacina = %d, očekivano 5 (predlog ne skida robu)", stanje)
}
// u servisni_delovi ne sme biti ništa
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1"); k != 0 {
t.Errorf("servisni_delovi = %d, očekivano 0 (predlog ne ugrađuje)", k)
}
// u potrazivani_delovi mora biti red sa predlozeno=1
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=1"); k != 3 {
t.Errorf("potrazivani kolicina = %d, očekivano 3", k)
}
}
// TestUgradiIliPotrazuj_NemaNaStanju: stanje=0 →
// ugradjeno=0, sve ide u potraživane, magacin ostaje 0.
func TestUgradiIliPotrazuj_NemaNaStanju(t *testing.T) {
db := testDB(t)
ctx := context.Background()
db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'Ekran', 0)")
db.ExecContext(ctx, "INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'Telefon', 'pukao ekran', 'Primljeno')")
repo := NoviServisniDeloviRepo(db)
ugradjeno, nedostaje, err := repo.UgradiIliPotrazuj(ctx, 1, 1, 2, 3000, nil, false)
if err != nil {
t.Fatalf("UgradiIliPotrazuj: %v", err)
}
if ugradjeno != 0 || nedostaje != 2 {
t.Errorf("očekivano ugradjeno=0 nedostaje=2, dobijeno ugradjeno=%d nedostaje=%d", ugradjeno, nedostaje)
}
// magacin ne sme biti negativan
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 0 {
t.Errorf("stanje magacina = %d, očekivano 0 (ne ide u minus)", stanje)
}
// potraživani red kreiran sa kolicinom 2
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=0"); k != 2 {
t.Errorf("potrazivani kolicina = %d, očekivano 2", k)
}
// u servisni_delovi nema ništa
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1"); k != 0 {
t.Errorf("servisni_delovi = %d, očekivano 0", k)
}
}
// TestUgradiIliPotrazuj_MergePotrazivanihDelova: isti artikal nedostaje dva puta →
// kolicina u servisni_potrazivani_delovi se sabira, ne pravi se novi red.
func TestUgradiIliPotrazuj_MergePotrazivanihDelova(t *testing.T) {
db := testDB(t)
ctx := context.Background()
db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'Baterija', 0)")
db.ExecContext(ctx, "INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'Telefon', 'ne puni', 'Primljeno')")
repo := NoviServisniDeloviRepo(db)
// prvi zahtev za 2 — sve ide u potraživane
repo.UgradiIliPotrazuj(ctx, 1, 1, 2, 800, nil, false)
// drugi zahtev za još 1 — treba merge, ne novi red
repo.UgradiIliPotrazuj(ctx, 1, 1, 1, 800, nil, false)
if redova := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=0"); redova != 1 {
t.Errorf("broj redova = %d, očekivano 1 (merge, ne duplikat)", redova)
}
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=0"); k != 3 {
t.Errorf("kolicina = %d, očekivano 3 (2+1)", k)
}
}
-10
View File
@@ -36,7 +36,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
}
var brojArtikala, aktivniServisi, kriticnaZaliha, aktivniPodsetnici int
var prihodOvogMeseca float64
if n, err := h.IzvestajRepo.BrojArtikala(ctx); err != nil {
slog.Error("dashboard: broj artikala", "error", err)
@@ -50,15 +49,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
aktivniServisi = n
}
// prihod se dohvata samo ako korisnik ima dozvolu dashboard.prihod
korisnikDash := middleware.KorisnikIzKonteksta(ctx)
if h.DozvoleRepo.ImaDozvolu(ctx, korisnikDash.Uloga, "dashboard.prihod") {
if v, err := h.IzvestajRepo.PrihodTekuciMesec(ctx); err != nil {
slog.Error("dashboard: prihod ovog meseca", "error", err)
} else {
prihodOvogMeseca = v
}
}
if n, err := h.IzvestajRepo.BrojKriticnihZaliha(ctx); err != nil {
slog.Error("dashboard: kriticna zaliha", "error", err)
@@ -133,7 +124,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
BrojArtikala: brojArtikala,
AktivniServisi: aktivniServisi,
PrihodOvogMeseca: prihodOvogMeseca,
KriticnaZaliha: kriticnaZaliha,
AktivniPodsetnici: aktivniPodsetnici,
PoslednjiServisi: poslednjiServisi,
+14 -4
View File
@@ -28,6 +28,19 @@ func formatujMesec(yyyymm string) string {
return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god)
}
// kljuceviMeseci vraća ključeve ("2006-01") za poslednjih n meseci zaključno sa
// mesecom datuma `sada`, hronološki (najstariji prvi). Sidri na prvi u mesecu —
// inače AddDate prelije dan (npr. 31. mart 1 mesec = „31. feb" → 3. mart) i neki
// mesec bi se preskočio ili duplirao na 2931. u mesecu.
func kljuceviMeseci(sada time.Time, n int) []string {
prvi := time.Date(sada.Year(), sada.Month(), 1, 0, 0, 0, 0, sada.Location())
kljucevi := make([]string, 0, n)
for i := n - 1; i >= 0; i-- {
kljucevi = append(kljucevi, prvi.AddDate(0, -i, 0).Format("2006-01"))
}
return kljucevi
}
// PodaciIzvestaja su podaci za stranicu izveštaja
type PodaciIzvestaja struct {
model.PodaciStranice
@@ -108,15 +121,12 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
}
// gradimo niz za poslednjih 12 meseci (hronološki)
sada := time.Now()
var mesecniPrihodi []MesecniPrihod
var grafikonLabele []string
var grafikonProdaja []float64
var grafikonServis []float64
for i := 11; i >= 0; i-- {
t := sada.AddDate(0, -i, 0)
kljuc := t.Format("2006-01")
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
prod := prodajaPoMesecu[kljuc]
serv := servisPoMesecu[kljuc]
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"testing"
"time"
)
// TestKljuceviMeseci proverava da se na kraju meseca (2931.) nijedan mesec ne
// preskoči niti duplira — klasičan AddBug sa prelivom dana.
func TestKljuceviMeseci(t *testing.T) {
slucajevi := []struct {
naziv string
sada time.Time
n int
ocekuje []string
}{
{
// 31. mart: bez sidrenja na 1. dan, "31. feb" bi se prelio u mart i
// februar bi nestao. Mora dati uredan niz jan..mart.
naziv: "31. mart — bez preskakanja februara",
sada: time.Date(2026, 3, 31, 12, 0, 0, 0, time.UTC),
n: 3,
ocekuje: []string{"2026-01", "2026-02", "2026-03"},
},
{
// 31. maj unazad: mart/april imaju 31/30 dana
naziv: "31. maj — uredan niz unazad",
sada: time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC),
n: 4,
ocekuje: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
},
{
// prelaz preko godine
naziv: "30. januar — prelaz preko godine",
sada: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
n: 3,
ocekuje: []string{"2025-11", "2025-12", "2026-01"},
},
{
// puna godina (12 meseci), bez duplikata
naziv: "12 meseci, bez duplikata",
sada: time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC),
n: 12,
ocekuje: []string{
"2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12",
"2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06",
},
},
}
for _, s := range slucajevi {
t.Run(s.naziv, func(t *testing.T) {
dobijeno := kljuceviMeseci(s.sada, s.n)
if len(dobijeno) != len(s.ocekuje) {
t.Fatalf("dužina = %d, očekivano %d (%v)", len(dobijeno), len(s.ocekuje), dobijeno)
}
vidjeni := map[string]bool{}
for i := range s.ocekuje {
if dobijeno[i] != s.ocekuje[i] {
t.Errorf("[%d] = %q, očekivano %q", i, dobijeno[i], s.ocekuje[i])
}
if vidjeni[dobijeno[i]] {
t.Errorf("mesec %q se duplira", dobijeno[i])
}
vidjeni[dobijeno[i]] = true
}
})
}
}
+4 -15
View File
@@ -2035,16 +2035,12 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
if noviStatus == model.StatusPreuzeto {
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
if nalog != nil && nalog.CenaKonacna == nil {
// auto-izračunaj: dijagnostika + radovi + delovi
// auto-izračunaj: dijagnostika + radovi (delovi se računaju posebno)
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
ukupno := nalog.CenaDijagnostike
for _, rad := range radovi {
ukupno += rad.Ukupno()
}
for _, d := range delovi {
ukupno += d.Ukupno()
}
h.ServisRepo.AzurirajCenuKonacnu(r.Context(), id, ukupno)
nalog.CenaKonacna = &ukupno
}
@@ -2115,25 +2111,18 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
Izvor: "servis",
IzvorID: &id,
}
// cena_komada rada/dela je NETO; PDV se dodaje naviše po stvarnoj stopi
for _, r := range radovi {
if r.Predlozeno {
continue
}
osnovica := r.Ukupno() / 1.2 // 20% PDV
pdv := r.Ukupno() - osnovica
kir.OsnovicaOpsta += osnovica
kir.PdvOpsta += pdv
kir.Ukupno += r.Ukupno()
kir.DodajNeto(r.Ukupno(), r.PdvStopa)
}
for _, d := range delovi {
if d.Predlozeno {
continue
}
osnovica := d.Ukupno() / 1.2
pdv := d.Ukupno() - osnovica
kir.OsnovicaOpsta += osnovica
kir.PdvOpsta += pdv
kir.Ukupno += d.Ukupno()
kir.DodajNeto(d.Ukupno(), d.PdvStopa)
}
if kir.Ukupno > 0 {
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
+20
View File
@@ -56,6 +56,26 @@ func TestRasporediTroskove(t *testing.T) {
metod: "kolicina",
ocekuje: []float64{50},
},
{
// negativan trošak (odobreni popust) — cena ostaje nepromenjena
naziv: "negativan trosak",
stavke: stavke,
trosak: -100,
metod: "vrednost",
ocekuje: []float64{100, 200},
},
{
// trošak 10 na 3 stavke po kolicini (9 kom ukupno) → 10/9/3 ≈ 1.111/kom
naziv: "zaokruzivanje na 2 decimale",
stavke: []StavkaNabavke{
{Kolicina: 3, CenaPoKomadu: 100},
{Kolicina: 3, CenaPoKomadu: 200},
{Kolicina: 3, CenaPoKomadu: 50},
},
trosak: 10,
metod: "kolicina",
ocekuje: []float64{101.11, 201.11, 51.11},
},
}
for _, s := range slucajevi {
+25 -13
View File
@@ -80,6 +80,30 @@ func SumirajKir(zapisi []PdvKir) PdvKirSume {
return s
}
// dodajStavku razvrstava jednu stavku (već izračunata osnovica + PDV) po stopi u
// odgovarajuće kolone KIR-a i uvećava ukupan (bruto) iznos.
func (k *PdvKir) dodajStavku(osnovica, pdv, stopa float64) {
switch stopa {
case 20:
k.OsnovicaOpsta += osnovica
k.PdvOpsta += pdv
case 10:
k.OsnovicaPosebna += osnovica
k.PdvPosebna += pdv
default:
// 0% / oslobođeno — osnovica u oslobođen promet sa pravom na odbitak
k.OslobodenSaPravom += osnovica
}
k.Ukupno += osnovica + pdv
}
// DodajNeto dodaje stavku zadatu NETO osnovicom i PDV stopom — za izvore gde je
// cena bez PDV-a (servisni rad/deo: cena_komada je neto, PDV se dodaje naviše).
// PDV se izvodi kao osnovica × stopa/100, a ukupno postaje bruto (osnovica + PDV).
func (k *PdvKir) DodajNeto(osnovica, stopa float64) {
k.dodajStavku(osnovica, osnovica*stopa/100, stopa)
}
// KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi
// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV,
// pa se osnovica izvodi deljenjem sa (1 + stopa/100).
@@ -101,19 +125,7 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
if s.PdvStopa > 0 {
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
}
pdv := ukupnoLinija - osnovica
switch s.PdvStopa {
case 20:
k.OsnovicaOpsta += osnovica
k.PdvOpsta += pdv
case 10:
k.OsnovicaPosebna += osnovica
k.PdvPosebna += pdv
default:
// 0% / oslobođeno — osnovica bez PDV-a u oslobođen promet sa pravom na odbitak
k.OslobodenSaPravom += osnovica
}
k.Ukupno += ukupnoLinija
k.dodajStavku(osnovica, ukupnoLinija-osnovica, s.PdvStopa)
}
return k
}
+29
View File
@@ -41,6 +41,35 @@ func TestKirIzProdaje(t *testing.T) {
}
}
// TestPdvKirDodajNeto: osnovica zadata kao NETO (servisni rad/deo) se NE deli,
// PDV se dodaje naviše po stvarnoj stopi i razvrstava u opštu/posebnu/oslobođen.
func TestPdvKirDodajNeto(t *testing.T) {
var k PdvKir
// rad 1000 neto @ 20% → osnovica 1000 (NE 833.33!), PDV 200, ukupno bruto 1200
k.DodajNeto(1000, 20)
if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) {
t.Errorf("20%%: osnovica=%v pdv=%v, očekivano 1000/200 (neto se ne deli)", k.OsnovicaOpsta, k.PdvOpsta)
}
// deo 1000 neto @ 10% → posebna osnovica 1000, PDV 100
k.DodajNeto(1000, 10)
if !blizu(k.OsnovicaPosebna, 1000) || !blizu(k.PdvPosebna, 100) {
t.Errorf("10%%: osnovica=%v pdv=%v, očekivano 1000/100", k.OsnovicaPosebna, k.PdvPosebna)
}
// usluga 500 neto @ 0% → oslobođen promet, bez PDV
k.DodajNeto(500, 0)
if !blizu(k.OslobodenSaPravom, 500) {
t.Errorf("0%%: oslobođeno=%v, očekivano 500", k.OslobodenSaPravom)
}
// ukupno (bruto) = 1200 + 1100 + 500 = 2800
if !blizu(k.Ukupno, 2800) {
t.Errorf("ukupno=%v, očekivano 2800 (1200+1100+500)", k.Ukupno)
}
}
func TestKprIzNabavke(t *testing.T) {
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
stavke := []NabavkaStavkaPdv{
+89
View File
@@ -0,0 +1,89 @@
package model
import "testing"
func TestServisniNalog_PreostaloZaNaplatu(t *testing.T) {
slucajevi := []struct {
naziv string
konacna *float64
avans *float64
ocekivano *float64
}{
{
naziv: "cena_konacna nil → nil (nije uneta)",
konacna: nil,
avans: nil,
ocekivano: nil,
},
{
naziv: "bez avansa → puna cena",
konacna: ptr(2000),
avans: nil,
ocekivano: ptr(2000),
},
{
naziv: "avans manji od cene → razlika",
konacna: ptr(2000),
avans: ptr(500),
ocekivano: ptr(1500),
},
{
naziv: "avans jednak ceni → 0",
konacna: ptr(2000),
avans: ptr(2000),
ocekivano: ptr(0),
},
{
naziv: "avans veći od cene → 0 (ne ide u minus)",
konacna: ptr(1000),
avans: ptr(1500),
ocekivano: ptr(0),
},
}
for _, s := range slucajevi {
t.Run(s.naziv, func(t *testing.T) {
n := ServisniNalog{CenaKonacna: s.konacna, Avans: s.avans}
dobijeno := n.PreostaloZaNaplatu()
if s.ocekivano == nil {
if dobijeno != nil {
t.Errorf("dobijeno %v, očekivano nil", *dobijeno)
}
return
}
if dobijeno == nil {
t.Fatalf("dobijeno nil, očekivano %.2f", *s.ocekivano)
}
if !jednako(*dobijeno, *s.ocekivano) {
t.Errorf("dobijeno %.2f, očekivano %.2f", *dobijeno, *s.ocekivano)
}
})
}
}
func TestServisniDeo_Ukupno(t *testing.T) {
d := ServisniDeo{Kolicina: 3, CenaKomada: 150}
if !jednako(d.Ukupno(), 450) {
t.Errorf("Ukupno = %.2f, očekivano 450", d.Ukupno())
}
}
func TestServisniDeoSaArtiklom_UkupnoSaPdv(t *testing.T) {
d := ServisniDeoSaArtiklom{
ServisniDeo: ServisniDeo{Kolicina: 2},
CenaSaPdv: 600,
}
if !jednako(d.UkupnoSaPdv(), 1200) {
t.Errorf("UkupnoSaPdv = %.2f, očekivano 1200", d.UkupnoSaPdv())
}
}
func TestServisniRad_UkupnoIUkupnoSaPdv(t *testing.T) {
r := ServisniRad{Kolicina: 2, CenaKomada: 1000, PdvStopa: 20, CenaSaPdv: 1200}
if !jednako(r.Ukupno(), 2000) {
t.Errorf("Ukupno = %.2f, očekivano 2000", r.Ukupno())
}
if !jednako(r.UkupnoSaPdv(), 2400) {
t.Errorf("UkupnoSaPdv = %.2f, očekivano 2400", r.UkupnoSaPdv())
}
}
-22
View File
@@ -12,15 +12,6 @@
.dash-kartica:nth-child(1) { animation-delay: 0.20s; }
.dash-kartica:nth-child(2) { animation-delay: 0.28s; }
.dash-kartica:nth-child(3) { animation-delay: 0.36s; }
/* prihod cifra — sakrivena, prikazuje se posle 1s hovera */
.prihod-cifra {
opacity: 0;
transition: opacity 0.3s ease;
}
.dash-stat:hover .prihod-cifra {
opacity: 1;
transition-delay: 1s;
}
</style>
{{ end }}
@@ -50,19 +41,6 @@
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Aktivnih servisa</div>
</a>
{{ if index .Dozvole "dashboard.prihod" }}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/izvestaji" class="kartica dash-stat animiraj kartica-link">
<div class="dash-ikona dash-ikona-narandzasta">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
</div>
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);"><span class="prihod-cifra">{{ dinariCeli .PrihodOvogMeseca }} din</span></div>
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Prihod ovog meseca</div>
</a>
{{ end }}
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin?kriticni=1" class="kartica dash-stat animiraj kartica-link">
<div class="dash-ikona dash-ikona-crvena">