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:
@@ -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:39–48`
|
||||||
|
|
||||||
|
**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:39–48`
|
||||||
|
|
||||||
|
**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:188–191`:
|
||||||
|
```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:44–48`, `internal/handler/servis.go:1498–1500`
|
||||||
|
|
||||||
|
**Problem:** Šablon servisa tretira `cena_konacna` kao cenu rada (bez delova):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// servis.go:1498–1500
|
||||||
|
} 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:2041–2048`) 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:2034–2083`
|
||||||
|
|
||||||
|
**Problem:** Kada servisni nalog nema unesenu `cena_konacna` i prelazi u status "Preuzeto", dešava se:
|
||||||
|
|
||||||
|
1. Auto-izračun (l.2037–2049): `cena_konacna = dijagnostika + radovi + **delovi**` → snima u DB
|
||||||
|
2. Izračun naplaćenog iznosa (l.2064–2073): č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:16–23`
|
||||||
|
|
||||||
|
**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:41–43` |
|
||||||
|
| BUG-02 | Prodaja SA PDV / servis BEZ PDV | Kritičan | `izvestaj.go:39–48` |
|
||||||
|
| BUG-03 | Delovi servisa izostaju kod ručne cene | Važan | `izvestaj.go:44–48` |
|
||||||
|
| BUG-04 | Duplo računanje delova u naplaćenom iznosu | Važan | `servis.go:2034–2083` |
|
||||||
|
| BUG-05 | CSS skriva cifru na mobilnom | UX | `dashboard.html:16–23` |
|
||||||
|
|
||||||
|
> 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:2114–2133` (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:98–116`) 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:228–231`
|
||||||
|
|
||||||
|
**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:117–119`
|
||||||
|
|
||||||
|
**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 29–31. 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:46–49` (i `MesecniPrihodServis`)
|
||||||
|
|
||||||
|
**Problem:** Prihod od servisa sada je `SUM(naplaceno)`. Ali `naplaceno` je iznos naplaćen **pri preuzimanju** = `(cena_konacna + delovi) − avans` (`servis.go:2069–2073`). 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
@@ -91,7 +91,7 @@ func main() {
|
|||||||
|
|
||||||
port := os.Getenv("NTECH_PORT")
|
port := os.Getenv("NTECH_PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "3000"
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sqlite.OtvoriDB(putanjaBaze)
|
db, err := sqlite.OtvoriDB(putanjaBaze)
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
|
|||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
(SELECT SUM(ukupno) FROM prodajni_nalozi
|
(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)
|
0)
|
||||||
+ COALESCE(
|
+ COALESCE(
|
||||||
(SELECT SUM(cena_konacna) FROM servisni_nalozi
|
(SELECT SUM(naplaceno + COALESCE(avans, 0)) FROM servisni_nalozi
|
||||||
WHERE status = 'Preuzeto'
|
WHERE status = 'Preuzeto'
|
||||||
|
AND (naplaceno > 0 OR avans > 0)
|
||||||
AND substr(datum_zavrsetka, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
|
AND substr(datum_zavrsetka, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
|
||||||
0)`).Scan(&iznos)
|
0)`).Scan(&iznos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,6 +110,7 @@ func (r *sqliteIzvestajRepo) PoslednjeProdaje(ctx context.Context, limit int) ([
|
|||||||
COALESCE(kp.naziv, '') AS klijent_naziv
|
COALESCE(kp.naziv, '') AS klijent_naziv
|
||||||
FROM prodajni_nalozi pn
|
FROM prodajni_nalozi pn
|
||||||
LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id
|
LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id
|
||||||
|
WHERE pn.stornirano = 0
|
||||||
ORDER BY pn.datum DESC LIMIT ?`, limit)
|
ORDER BY pn.datum DESC LIMIT ?`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ntech: izvestaj.PoslednjeProdaje: %w", err)
|
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, `
|
return r.mesecniPrihod(ctx, `
|
||||||
SELECT substr(datum, 1, 7), SUM(ukupno)
|
SELECT substr(datum, 1, 7), SUM(ukupno)
|
||||||
FROM prodajni_nalozi
|
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")
|
GROUP BY substr(datum, 1, 7)`, "MesecniPrihodProdaja")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) {
|
func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) {
|
||||||
return r.mesecniPrihod(ctx, `
|
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
|
FROM servisni_nalozi
|
||||||
WHERE datum_zavrsetka IS NOT NULL
|
WHERE datum_zavrsetka IS NOT NULL
|
||||||
AND status = 'Preuzeto'
|
AND status = 'Preuzeto'
|
||||||
|
AND (naplaceno > 0 OR avans > 0)
|
||||||
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
|
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||||
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
|
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 klijent_prikaz kp ON kp.id = k.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
|
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
|
) p ON p.klijent_id = k.id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
|
SELECT klijent_id, SUM(naplaceno + COALESCE(avans, 0)) AS ukupno_servis, COUNT(*) AS broj_servisa
|
||||||
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
|
FROM servisni_nalozi WHERE status = 'Preuzeto' AND (naplaceno > 0 OR avans > 0) GROUP BY klijent_id
|
||||||
) s ON s.klijent_id = k.id
|
) s ON s.klijent_id = k.id
|
||||||
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
|
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
|
||||||
ORDER BY ukupno_vrednost DESC
|
ORDER BY ukupno_vrednost DESC
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"ntech/internal/model"
|
"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: "A", Kolicina: 10, KolicinMin: 5})
|
||||||
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) // kritičan
|
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5})
|
||||||
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) // kritičan, nula
|
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5})
|
||||||
|
|
||||||
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
|
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
|
||||||
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
|
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
|
||||||
@@ -36,13 +38,11 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
|
|||||||
if len(zalihe) != 2 {
|
if len(zalihe) != 2 {
|
||||||
t.Fatalf("KriticneZalihe vratio %d, očekivano 2", len(zalihe))
|
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 {
|
if zalihe[0].Kolicina != 0 {
|
||||||
t.Fatalf("prvi kritičan treba da ima količinu 0, ima %d", zalihe[0].Kolicina)
|
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) {
|
func TestIzvestajPraznaBaza(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
@@ -64,3 +64,371 @@ func TestIzvestajPraznaBaza(t *testing.T) {
|
|||||||
t.Errorf("MesecniPrihodProdaja len=%d, err=%v", len(l), err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (10−4)", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var brojArtikala, aktivniServisi, kriticnaZaliha, aktivniPodsetnici int
|
var brojArtikala, aktivniServisi, kriticnaZaliha, aktivniPodsetnici int
|
||||||
var prihodOvogMeseca float64
|
|
||||||
|
|
||||||
if n, err := h.IzvestajRepo.BrojArtikala(ctx); err != nil {
|
if n, err := h.IzvestajRepo.BrojArtikala(ctx); err != nil {
|
||||||
slog.Error("dashboard: broj artikala", "error", err)
|
slog.Error("dashboard: broj artikala", "error", err)
|
||||||
@@ -50,15 +49,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
aktivniServisi = n
|
aktivniServisi = n
|
||||||
}
|
}
|
||||||
|
|
||||||
// prihod se dohvata samo ako korisnik ima dozvolu dashboard.prihod
|
|
||||||
korisnikDash := middleware.KorisnikIzKonteksta(ctx)
|
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 {
|
if n, err := h.IzvestajRepo.BrojKriticnihZaliha(ctx); err != nil {
|
||||||
slog.Error("dashboard: kriticna zaliha", "error", err)
|
slog.Error("dashboard: kriticna zaliha", "error", err)
|
||||||
@@ -133,7 +124,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
PodaciStranice: ps,
|
PodaciStranice: ps,
|
||||||
BrojArtikala: brojArtikala,
|
BrojArtikala: brojArtikala,
|
||||||
AktivniServisi: aktivniServisi,
|
AktivniServisi: aktivniServisi,
|
||||||
PrihodOvogMeseca: prihodOvogMeseca,
|
|
||||||
KriticnaZaliha: kriticnaZaliha,
|
KriticnaZaliha: kriticnaZaliha,
|
||||||
AktivniPodsetnici: aktivniPodsetnici,
|
AktivniPodsetnici: aktivniPodsetnici,
|
||||||
PoslednjiServisi: poslednjiServisi,
|
PoslednjiServisi: poslednjiServisi,
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ func formatujMesec(yyyymm string) string {
|
|||||||
return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god)
|
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 29–31. 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
|
// PodaciIzvestaja su podaci za stranicu izveštaja
|
||||||
type PodaciIzvestaja struct {
|
type PodaciIzvestaja struct {
|
||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
@@ -108,15 +121,12 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gradimo niz za poslednjih 12 meseci (hronološki)
|
// gradimo niz za poslednjih 12 meseci (hronološki)
|
||||||
sada := time.Now()
|
|
||||||
var mesecniPrihodi []MesecniPrihod
|
var mesecniPrihodi []MesecniPrihod
|
||||||
var grafikonLabele []string
|
var grafikonLabele []string
|
||||||
var grafikonProdaja []float64
|
var grafikonProdaja []float64
|
||||||
var grafikonServis []float64
|
var grafikonServis []float64
|
||||||
|
|
||||||
for i := 11; i >= 0; i-- {
|
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
|
||||||
t := sada.AddDate(0, -i, 0)
|
|
||||||
kljuc := t.Format("2006-01")
|
|
||||||
prod := prodajaPoMesecu[kljuc]
|
prod := prodajaPoMesecu[kljuc]
|
||||||
serv := servisPoMesecu[kljuc]
|
serv := servisPoMesecu[kljuc]
|
||||||
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
|
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestKljuceviMeseci proverava da se na kraju meseca (29–31.) 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2035,16 +2035,12 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
if noviStatus == model.StatusPreuzeto {
|
if noviStatus == model.StatusPreuzeto {
|
||||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||||
if nalog != nil && nalog.CenaKonacna == nil {
|
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)
|
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
||||||
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
|
||||||
ukupno := nalog.CenaDijagnostike
|
ukupno := nalog.CenaDijagnostike
|
||||||
for _, rad := range radovi {
|
for _, rad := range radovi {
|
||||||
ukupno += rad.Ukupno()
|
ukupno += rad.Ukupno()
|
||||||
}
|
}
|
||||||
for _, d := range delovi {
|
|
||||||
ukupno += d.Ukupno()
|
|
||||||
}
|
|
||||||
h.ServisRepo.AzurirajCenuKonacnu(r.Context(), id, ukupno)
|
h.ServisRepo.AzurirajCenuKonacnu(r.Context(), id, ukupno)
|
||||||
nalog.CenaKonacna = &ukupno
|
nalog.CenaKonacna = &ukupno
|
||||||
}
|
}
|
||||||
@@ -2115,25 +2111,18 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
Izvor: "servis",
|
Izvor: "servis",
|
||||||
IzvorID: &id,
|
IzvorID: &id,
|
||||||
}
|
}
|
||||||
|
// cena_komada rada/dela je NETO; PDV se dodaje naviše po stvarnoj stopi
|
||||||
for _, r := range radovi {
|
for _, r := range radovi {
|
||||||
if r.Predlozeno {
|
if r.Predlozeno {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
osnovica := r.Ukupno() / 1.2 // 20% PDV
|
kir.DodajNeto(r.Ukupno(), r.PdvStopa)
|
||||||
pdv := r.Ukupno() - osnovica
|
|
||||||
kir.OsnovicaOpsta += osnovica
|
|
||||||
kir.PdvOpsta += pdv
|
|
||||||
kir.Ukupno += r.Ukupno()
|
|
||||||
}
|
}
|
||||||
for _, d := range delovi {
|
for _, d := range delovi {
|
||||||
if d.Predlozeno {
|
if d.Predlozeno {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
osnovica := d.Ukupno() / 1.2
|
kir.DodajNeto(d.Ukupno(), d.PdvStopa)
|
||||||
pdv := d.Ukupno() - osnovica
|
|
||||||
kir.OsnovicaOpsta += osnovica
|
|
||||||
kir.PdvOpsta += pdv
|
|
||||||
kir.Ukupno += d.Ukupno()
|
|
||||||
}
|
}
|
||||||
if kir.Ukupno > 0 {
|
if kir.Ukupno > 0 {
|
||||||
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
|
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ func TestRasporediTroskove(t *testing.T) {
|
|||||||
metod: "kolicina",
|
metod: "kolicina",
|
||||||
ocekuje: []float64{50},
|
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 {
|
for _, s := range slucajevi {
|
||||||
|
|||||||
@@ -80,6 +80,30 @@ func SumirajKir(zapisi []PdvKir) PdvKirSume {
|
|||||||
return s
|
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
|
// 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,
|
// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV,
|
||||||
// pa se osnovica izvodi deljenjem sa (1 + stopa/100).
|
// 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 {
|
if s.PdvStopa > 0 {
|
||||||
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
|
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
|
||||||
}
|
}
|
||||||
pdv := ukupnoLinija - osnovica
|
k.dodajStavku(osnovica, ukupnoLinija-osnovica, s.PdvStopa)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestKprIzNabavke(t *testing.T) {
|
||||||
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
|
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
|
||||||
stavke := []NabavkaStavkaPdv{
|
stavke := []NabavkaStavkaPdv{
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,6 @@
|
|||||||
.dash-kartica:nth-child(1) { animation-delay: 0.20s; }
|
.dash-kartica:nth-child(1) { animation-delay: 0.20s; }
|
||||||
.dash-kartica:nth-child(2) { animation-delay: 0.28s; }
|
.dash-kartica:nth-child(2) { animation-delay: 0.28s; }
|
||||||
.dash-kartica:nth-child(3) { animation-delay: 0.36s; }
|
.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>
|
</style>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
@@ -50,19 +41,6 @@
|
|||||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Aktivnih servisa</div>
|
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Aktivnih servisa</div>
|
||||||
</a>
|
</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">
|
<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">
|
<div class="dash-ikona dash-ikona-crvena">
|
||||||
|
|||||||
Reference in New Issue
Block a user