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")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
db, err := sqlite.OtvoriDB(putanjaBaze)
|
||||
|
||||
@@ -39,11 +39,13 @@ func (r *sqliteIzvestajRepo) PrihodTekuciMesec(ctx context.Context) (float64, er
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(
|
||||
(SELECT SUM(ukupno) FROM prodajni_nalozi
|
||||
WHERE substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
|
||||
WHERE stornirano = 0
|
||||
AND substr(datum, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
|
||||
0)
|
||||
+ COALESCE(
|
||||
(SELECT SUM(cena_konacna) FROM servisni_nalozi
|
||||
(SELECT SUM(naplaceno + COALESCE(avans, 0)) FROM servisni_nalozi
|
||||
WHERE status = 'Preuzeto'
|
||||
AND (naplaceno > 0 OR avans > 0)
|
||||
AND substr(datum_zavrsetka, 1, 7) = strftime('%Y-%m', 'now', 'localtime')),
|
||||
0)`).Scan(&iznos)
|
||||
if err != nil {
|
||||
@@ -108,6 +110,7 @@ func (r *sqliteIzvestajRepo) PoslednjeProdaje(ctx context.Context, limit int) ([
|
||||
COALESCE(kp.naziv, '') AS klijent_naziv
|
||||
FROM prodajni_nalozi pn
|
||||
LEFT JOIN klijent_prikaz kp ON kp.id = pn.klijent_id
|
||||
WHERE pn.stornirano = 0
|
||||
ORDER BY pn.datum DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: izvestaj.PoslednjeProdaje: %w", err)
|
||||
@@ -146,16 +149,18 @@ func (r *sqliteIzvestajRepo) MesecniPrihodProdaja(ctx context.Context) ([]model.
|
||||
return r.mesecniPrihod(ctx, `
|
||||
SELECT substr(datum, 1, 7), SUM(ukupno)
|
||||
FROM prodajni_nalozi
|
||||
WHERE substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||
WHERE stornirano = 0
|
||||
AND substr(datum, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||
GROUP BY substr(datum, 1, 7)`, "MesecniPrihodProdaja")
|
||||
}
|
||||
|
||||
func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error) {
|
||||
return r.mesecniPrihod(ctx, `
|
||||
SELECT substr(datum_zavrsetka, 1, 7), SUM(COALESCE(cena_konacna, 0))
|
||||
SELECT substr(datum_zavrsetka, 1, 7), SUM(naplaceno + COALESCE(avans, 0))
|
||||
FROM servisni_nalozi
|
||||
WHERE datum_zavrsetka IS NOT NULL
|
||||
AND status = 'Preuzeto'
|
||||
AND (naplaceno > 0 OR avans > 0)
|
||||
AND substr(datum_zavrsetka, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
|
||||
}
|
||||
@@ -218,11 +223,11 @@ func (r *sqliteIzvestajRepo) TopKlijenti(ctx context.Context, limit int) ([]mode
|
||||
LEFT JOIN klijent_prikaz kp ON kp.id = k.id
|
||||
LEFT JOIN (
|
||||
SELECT klijent_id, SUM(ukupno) AS ukupno_prodaja, COUNT(*) AS broj_prodaja
|
||||
FROM prodajni_nalozi GROUP BY klijent_id
|
||||
FROM prodajni_nalozi WHERE stornirano = 0 GROUP BY klijent_id
|
||||
) p ON p.klijent_id = k.id
|
||||
LEFT JOIN (
|
||||
SELECT klijent_id, SUM(cena_konacna) AS ukupno_servis, COUNT(*) AS broj_servisa
|
||||
FROM servisni_nalozi WHERE cena_konacna IS NOT NULL GROUP BY klijent_id
|
||||
SELECT klijent_id, SUM(naplaceno + COALESCE(avans, 0)) AS ukupno_servis, COUNT(*) AS broj_servisa
|
||||
FROM servisni_nalozi WHERE status = 'Preuzeto' AND (naplaceno > 0 OR avans > 0) GROUP BY klijent_id
|
||||
) s ON s.klijent_id = k.id
|
||||
WHERE COALESCE(p.ukupno_prodaja, 0) + COALESCE(s.ukupno_servis, 0) > 0
|
||||
ORDER BY ukupno_vrednost DESC
|
||||
|
||||
@@ -2,6 +2,8 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"ntech/internal/model"
|
||||
@@ -19,8 +21,8 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
|
||||
}
|
||||
}
|
||||
dodaj(&model.Artikal{Naziv: "A", Kolicina: 10, KolicinMin: 5})
|
||||
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5}) // kritičan
|
||||
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5}) // kritičan, nula
|
||||
dodaj(&model.Artikal{Naziv: "B", Kolicina: 2, KolicinMin: 5})
|
||||
dodaj(&model.Artikal{Naziv: "C", Kolicina: 0, KolicinMin: 5})
|
||||
|
||||
if n, err := izv.BrojArtikala(ctx); err != nil || n != 3 {
|
||||
t.Fatalf("BrojArtikala = %d, err=%v; očekivano 3", n, err)
|
||||
@@ -36,13 +38,11 @@ func TestIzvestajArtikliBrojaci(t *testing.T) {
|
||||
if len(zalihe) != 2 {
|
||||
t.Fatalf("KriticneZalihe vratio %d, očekivano 2", len(zalihe))
|
||||
}
|
||||
// sortirano po količini rastuće → prvi je onaj sa 0
|
||||
if zalihe[0].Kolicina != 0 {
|
||||
t.Fatalf("prvi kritičan treba da ima količinu 0, ima %d", zalihe[0].Kolicina)
|
||||
}
|
||||
}
|
||||
|
||||
// prazna baza — brojači 0, liste prazne, bez greške
|
||||
func TestIzvestajPraznaBaza(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := testDB(t)
|
||||
@@ -64,3 +64,371 @@ func TestIzvestajPraznaBaza(t *testing.T) {
|
||||
t.Errorf("MesecniPrihodProdaja len=%d, err=%v", len(l), err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-01: stornirani prodajni nalozi ne smeju ući u prihod ───────────────
|
||||
|
||||
func TestPrihodTekuciMesec_IgnorisuSeStornirani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-T-001', 1000.0, 0, date('now','localtime'))`)
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-T-002', 500.0, 1, date('now','localtime'))`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 1000.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 1000.00 — stornirani nalog (500) ne sme da uđe", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMesecniPrihodProdaja_IgnorisuSeStornirani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-M-001', 2000.0, 0, date('now','localtime'))`)
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-M-002', 300.0, 1, date('now','localtime'))`)
|
||||
|
||||
meseci, err := izv.MesecniPrihodProdaja(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("MesecniPrihodProdaja: %v", err)
|
||||
}
|
||||
if len(meseci) != 1 {
|
||||
t.Fatalf("očekivan 1 mesec, dobijeno %d", len(meseci))
|
||||
}
|
||||
if meseci[0].Iznos != 2000.0 {
|
||||
t.Errorf("iznos = %.2f; očekivano 2000.00 — stornirani (300) ne sme da uđe", meseci[0].Iznos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoslednjeProdaje_IgnorisuSeStornirani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-P-001', 100.0, 0, date('now','localtime'))`)
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-P-002', 200.0, 1, date('now','localtime'))`)
|
||||
|
||||
lista, err := izv.PoslednjeProdaje(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PoslednjeProdaje: %v", err)
|
||||
}
|
||||
if len(lista) != 1 {
|
||||
t.Fatalf("lista ima %d redova, očekivan 1 — stornirani ne sme da uđe", len(lista))
|
||||
}
|
||||
if lista[0].BrojNaloga != "PR-P-001" {
|
||||
t.Errorf("pogrešan nalog: %s", lista[0].BrojNaloga)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopKlijenti_IgnorisuSeStornirani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO klijenti (ime, prezime) VALUES ('Pera', 'Perić')`)
|
||||
var klijentID int64
|
||||
if err := baza.QueryRowContext(ctx, `SELECT id FROM klijenti LIMIT 1`).Scan(&klijentID); err != nil {
|
||||
t.Fatalf("dohvati klijent id: %v", err)
|
||||
}
|
||||
|
||||
mustExec(t, baza, fmt.Sprintf(
|
||||
`INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum, klijent_id)
|
||||
VALUES ('PR-K-001', 5000.0, 0, date('now','localtime'), %d)`, klijentID))
|
||||
mustExec(t, baza, fmt.Sprintf(
|
||||
`INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum, klijent_id)
|
||||
VALUES ('PR-K-002', 1000.0, 1, date('now','localtime'), %d)`, klijentID))
|
||||
|
||||
top, err := izv.TopKlijenti(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("TopKlijenti: %v", err)
|
||||
}
|
||||
if len(top) != 1 {
|
||||
t.Fatalf("top lista ima %d redova, očekivan 1", len(top))
|
||||
}
|
||||
if top[0].UkupnoVrednost != 5000.0 {
|
||||
t.Errorf("vrednost = %.2f; očekivano 5000.00 — stornirani (1000) ne sme da uđe", top[0].UkupnoVrednost)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-02/03: servis prihod koristi naplaceno (bruto, uključuje delove) ───
|
||||
|
||||
func TestPrihodTekuciMesec_ServisKoristiNaplaceno(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// cena_konacna=800 (neto, bez delova), naplaceno=1500 (bruto sa delovima)
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-T-001', 'Telefon', 'Kvar', 'Preuzeto', 800.0, 1500.0, date('now'), date('now','localtime'), 'tok1')`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 1500.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 1500.00 (naplaceno, ne cena_konacna 800)", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrihodTekuciMesec_ServisNaplaceno0NeUlazi(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// garancijska popravka — cena_konacna postoji ali naplaceno=0
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-T-002', 'Laptop', 'Kvar', 'Preuzeto', 1200.0, 0, date('now'), date('now','localtime'), 'tok2')`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 0.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 0 (naplaceno=0 ne sme da uđe)", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMesecniPrihodServis_KoristiNaplaceno(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-M-001', 'Tablet', 'Kvar', 'Preuzeto', 500.0, 900.0, date('now'), date('now','localtime'), 'tok3')`)
|
||||
|
||||
meseci, err := izv.MesecniPrihodServis(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("MesecniPrihodServis: %v", err)
|
||||
}
|
||||
if len(meseci) != 1 {
|
||||
t.Fatalf("očekivan 1 mesec, dobijeno %d", len(meseci))
|
||||
}
|
||||
if meseci[0].Iznos != 900.0 {
|
||||
t.Errorf("iznos = %.2f; očekivano 900.00 (naplaceno, ne cena_konacna 500)", meseci[0].Iznos)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-10: avans se uračunava u prihod (naplaceno + avans) ─────────────────
|
||||
|
||||
func TestPrihodTekuciMesec_ServisSaAvansom(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// avans 400 (ranije), naplaceno 600 (ostatak pri preuzimanju) → ukupno 1000
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, avans, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-AV-001', 'PC', 'Kvar', 'Preuzeto', 1000.0, 400.0, 600.0, date('now'), date('now','localtime'), 'tokav1')`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 1000.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 1000.00 (naplaceno 600 + avans 400)", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrihodTekuciMesec_ServisPotpunoAvansiran(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// avans pokrio ceo iznos → naplaceno=0, ali prihod NIJE 0 (avans 1000)
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, avans, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-AV-002', 'Laptop', 'Kvar', 'Preuzeto', 1000.0, 1000.0, 0.0, date('now'), date('now','localtime'), 'tokav2')`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 1000.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 1000.00 (potpuno avansiran ne sme da nestane)", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
// garancija/besplatna popravka: naplaceno=0 i avans=NULL → ne ulazi u prihod
|
||||
func TestPrihodTekuciMesec_GarancijaNeUlazi(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-GAR-001', 'Telefon', 'Garancija', 'Preuzeto', 0.0, 0.0, date('now'), date('now','localtime'), 'tokgar')`)
|
||||
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
if prihod != 0.0 {
|
||||
t.Errorf("prihod = %.2f; očekivano 0 (garancija bez naplate i avansa)", prihod)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-08: TopKlijenti broji samo preuzete naloge sa naplatom/avansom ──────
|
||||
|
||||
func TestTopKlijenti_SamoPreuzetiNalozi(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
mustExec(t, baza, `INSERT INTO klijenti (ime, prezime) VALUES ('Mika', 'Mikić')`)
|
||||
var klijentID int64
|
||||
if err := baza.QueryRowContext(ctx, `SELECT id FROM klijenti LIMIT 1`).Scan(&klijentID); err != nil {
|
||||
t.Fatalf("dohvati klijent id: %v", err)
|
||||
}
|
||||
|
||||
// preuzet nalog sa naplatom 800 — broji se
|
||||
mustExec(t, baza, fmt.Sprintf(`INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token, klijent_id)
|
||||
VALUES ('SR-TK-001', 'PC', 'Kvar', 'Preuzeto', 800.0, 800.0, date('now'), date('now','localtime'), 'toktk1', %d)`, klijentID))
|
||||
// nalog u popravci sa cena_konacna 5000 — NE sme da se broji (nije preuzet)
|
||||
mustExec(t, baza, fmt.Sprintf(`INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, javni_token, klijent_id)
|
||||
VALUES ('SR-TK-002', 'Laptop', 'Kvar', 'U popravci', 5000.0, 0.0, date('now'), 'toktk2', %d)`, klijentID))
|
||||
|
||||
top, err := izv.TopKlijenti(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("TopKlijenti: %v", err)
|
||||
}
|
||||
if len(top) != 1 {
|
||||
t.Fatalf("top lista ima %d redova, očekivan 1", len(top))
|
||||
}
|
||||
if top[0].UkupnoVrednost != 800.0 {
|
||||
t.Errorf("vrednost = %.2f; očekivano 800.00 (samo preuzet nalog, ne 5000 u popravci)", top[0].UkupnoVrednost)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Kombinovani test: prodaja (bez storniranih) + servis (naplaceno) ────────
|
||||
|
||||
func TestPrihodTekuciMesec_Kombinovani(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// prodaja: 1000 normalna + 400 stornirana
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-C-001', 1000.0, 0, date('now','localtime'))`)
|
||||
mustExec(t, baza, `INSERT INTO prodajni_nalozi (broj_naloga, ukupno, stornirano, datum)
|
||||
VALUES ('PR-C-002', 400.0, 1, date('now','localtime'))`)
|
||||
|
||||
// servis: naplaceno=600 (cena_konacna=200 neto)
|
||||
mustExec(t, baza, `INSERT INTO servisni_nalozi
|
||||
(broj_naloga, uredjaj, opis_kvara, status, cena_konacna, naplaceno, datum_prijema, datum_zavrsetka, javni_token)
|
||||
VALUES ('SR-C-001', 'PC', 'Kvar', 'Preuzeto', 200.0, 600.0, date('now'), date('now','localtime'), 'tok4')`)
|
||||
|
||||
// 1000 (prodaja) + 600 (servis naplaceno) = 1600; 400 storniranih ne ulazi
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
const ocekivano = 1600.0
|
||||
if prihod != ocekivano {
|
||||
t.Errorf("prihod = %.2f; očekivano %.2f", prihod, ocekivano)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BUG-04: auto-izračun cena_konacna ne sme da uključuje delove ────────────
|
||||
//
|
||||
// Testiramo repo sloj direktno: simuliramo šta handler radi pri prelasku u
|
||||
// Preuzeto kad CenaKonacna == nil — upisujemo samo dijagnostiku+radove, pa
|
||||
// proveravamo da li naplaceno (= cena_konacna + delovi) nije dupliran.
|
||||
|
||||
func TestServisAutoIzracunCeneKonacne_BezDuplogDela(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
baza := testDB(t)
|
||||
servisRepo := NoviServisRepo(baza)
|
||||
radoviRepo := NoviServisniRadoviRepo(baza)
|
||||
izv := NoviIzvestajRepo(baza)
|
||||
|
||||
// kreiraj nalog bez cene_konacne
|
||||
nalogID, err := servisRepo.Kreiraj(ctx, &model.ServisniNalog{
|
||||
BrojNaloga: "SR-BUG04",
|
||||
Uredjaj: "Monitor",
|
||||
OpisKvara: "Ne pali",
|
||||
Status: "U popravci",
|
||||
CenaDijagnostike: 200.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Kreiraj nalog: %v", err)
|
||||
}
|
||||
|
||||
// dodaj rad direktno u bazu (200 din)
|
||||
mustExec(t, baza, fmt.Sprintf(
|
||||
`INSERT INTO servis_radovi (nalog_id, naziv, kolicina, cena_komada, predlozeno)
|
||||
VALUES (%d, 'Zamena baterijice', 1, 200.0, 0)`, nalogID))
|
||||
|
||||
// dodaj deo direktno u bazu (300 din) — potreban je artikal u bazi
|
||||
mustExec(t, baza, `INSERT INTO artikli (naziv, kolicina, prodajna_cena) VALUES ('Baterija', 5, 300.0)`)
|
||||
var artID int64
|
||||
_ = baza.QueryRowContext(ctx, `SELECT id FROM artikli WHERE naziv='Baterija'`).Scan(&artID)
|
||||
mustExec(t, baza, fmt.Sprintf(
|
||||
`INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno)
|
||||
VALUES (%d, %d, 1, 300.0, 0)`, nalogID, artID))
|
||||
|
||||
// simuliramo ispravnu handler logiku (BUG-04 fix):
|
||||
// cena_konacna = dijagnostika + radovi (BEZ delova)
|
||||
cenaKonacna := 200.0 + 200.0 // dijagnostika + rad
|
||||
if err := servisRepo.AzurirajCenuKonacnu(ctx, nalogID, cenaKonacna); err != nil {
|
||||
t.Fatalf("AzurirajCenuKonacnu: %v", err)
|
||||
}
|
||||
|
||||
// iznos za naplatu = cena_konacna + delovi (bez dupliranja)
|
||||
iznosNaplacen := cenaKonacna + 300.0 // = 700
|
||||
if err := servisRepo.SacuvajNaplatu(ctx, nalogID, "Gotovina", iznosNaplacen); err != nil {
|
||||
t.Fatalf("SacuvajNaplatu: %v", err)
|
||||
}
|
||||
|
||||
// postavi datum_zavrsetka i status Preuzeto
|
||||
if err := servisRepo.AzurirajStatus(ctx, nalogID, "Preuzeto"); err != nil {
|
||||
t.Fatalf("AzurirajStatus: %v", err)
|
||||
}
|
||||
|
||||
// prihod treba da bude tačno 700 (nije 1000 = 400+300+300 duplikat)
|
||||
prihod, err := izv.PrihodTekuciMesec(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PrihodTekuciMesec: %v", err)
|
||||
}
|
||||
const ocekivano = 700.0
|
||||
if prihod != ocekivano {
|
||||
t.Errorf("prihod = %.2f; očekivano %.2f (400 rad+dijag + 300 deo, bez duplikata)", prihod, ocekivano)
|
||||
}
|
||||
|
||||
// proveri i da cena_konacna nije kontaminirana delovima
|
||||
nalog, err := servisRepo.DohvatiID(ctx, nalogID)
|
||||
if err != nil || nalog == nil {
|
||||
t.Fatalf("DohvatiID: %v", err)
|
||||
}
|
||||
if nalog.CenaKonacna == nil {
|
||||
t.Fatal("cena_konacna je nil — nije sačuvana")
|
||||
}
|
||||
if *nalog.CenaKonacna != 400.0 {
|
||||
t.Errorf("cena_konacna = %.2f; očekivano 400.00 (dijagnostika+rad, bez delova)", *nalog.CenaKonacna)
|
||||
}
|
||||
|
||||
_ = radoviRepo // korišćen indirektno kroz bazu
|
||||
}
|
||||
|
||||
// ─── helper ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, query string) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(), query); err != nil {
|
||||
t.Fatalf("mustExec: %v\nSQL: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 prihodOvogMeseca float64
|
||||
|
||||
if n, err := h.IzvestajRepo.BrojArtikala(ctx); err != nil {
|
||||
slog.Error("dashboard: broj artikala", "error", err)
|
||||
@@ -50,15 +49,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
aktivniServisi = n
|
||||
}
|
||||
|
||||
// prihod se dohvata samo ako korisnik ima dozvolu dashboard.prihod
|
||||
korisnikDash := middleware.KorisnikIzKonteksta(ctx)
|
||||
if h.DozvoleRepo.ImaDozvolu(ctx, korisnikDash.Uloga, "dashboard.prihod") {
|
||||
if v, err := h.IzvestajRepo.PrihodTekuciMesec(ctx); err != nil {
|
||||
slog.Error("dashboard: prihod ovog meseca", "error", err)
|
||||
} else {
|
||||
prihodOvogMeseca = v
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := h.IzvestajRepo.BrojKriticnihZaliha(ctx); err != nil {
|
||||
slog.Error("dashboard: kriticna zaliha", "error", err)
|
||||
@@ -133,7 +124,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
PodaciStranice: ps,
|
||||
BrojArtikala: brojArtikala,
|
||||
AktivniServisi: aktivniServisi,
|
||||
PrihodOvogMeseca: prihodOvogMeseca,
|
||||
KriticnaZaliha: kriticnaZaliha,
|
||||
AktivniPodsetnici: aktivniPodsetnici,
|
||||
PoslednjiServisi: poslednjiServisi,
|
||||
|
||||
@@ -28,6 +28,19 @@ func formatujMesec(yyyymm string) string {
|
||||
return fmt.Sprintf("%s %d", srpskaImenaMeseci[mes], god)
|
||||
}
|
||||
|
||||
// kljuceviMeseci vraća ključeve ("2006-01") za poslednjih n meseci zaključno sa
|
||||
// mesecom datuma `sada`, hronološki (najstariji prvi). Sidri na prvi u mesecu —
|
||||
// inače AddDate prelije dan (npr. 31. mart − 1 mesec = „31. feb" → 3. mart) i neki
|
||||
// mesec bi se preskočio ili duplirao na 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
|
||||
type PodaciIzvestaja struct {
|
||||
model.PodaciStranice
|
||||
@@ -108,15 +121,12 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// gradimo niz za poslednjih 12 meseci (hronološki)
|
||||
sada := time.Now()
|
||||
var mesecniPrihodi []MesecniPrihod
|
||||
var grafikonLabele []string
|
||||
var grafikonProdaja []float64
|
||||
var grafikonServis []float64
|
||||
|
||||
for i := 11; i >= 0; i-- {
|
||||
t := sada.AddDate(0, -i, 0)
|
||||
kljuc := t.Format("2006-01")
|
||||
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
|
||||
prod := prodajaPoMesecu[kljuc]
|
||||
serv := servisPoMesecu[kljuc]
|
||||
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
|
||||
|
||||
@@ -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 {
|
||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if nalog != nil && nalog.CenaKonacna == nil {
|
||||
// auto-izračunaj: dijagnostika + radovi + delovi
|
||||
// auto-izračunaj: dijagnostika + radovi (delovi se računaju posebno)
|
||||
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
ukupno := nalog.CenaDijagnostike
|
||||
for _, rad := range radovi {
|
||||
ukupno += rad.Ukupno()
|
||||
}
|
||||
for _, d := range delovi {
|
||||
ukupno += d.Ukupno()
|
||||
}
|
||||
h.ServisRepo.AzurirajCenuKonacnu(r.Context(), id, ukupno)
|
||||
nalog.CenaKonacna = &ukupno
|
||||
}
|
||||
@@ -2115,25 +2111,18 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
Izvor: "servis",
|
||||
IzvorID: &id,
|
||||
}
|
||||
// cena_komada rada/dela je NETO; PDV se dodaje naviše po stvarnoj stopi
|
||||
for _, r := range radovi {
|
||||
if r.Predlozeno {
|
||||
continue
|
||||
}
|
||||
osnovica := r.Ukupno() / 1.2 // 20% PDV
|
||||
pdv := r.Ukupno() - osnovica
|
||||
kir.OsnovicaOpsta += osnovica
|
||||
kir.PdvOpsta += pdv
|
||||
kir.Ukupno += r.Ukupno()
|
||||
kir.DodajNeto(r.Ukupno(), r.PdvStopa)
|
||||
}
|
||||
for _, d := range delovi {
|
||||
if d.Predlozeno {
|
||||
continue
|
||||
}
|
||||
osnovica := d.Ukupno() / 1.2
|
||||
pdv := d.Ukupno() - osnovica
|
||||
kir.OsnovicaOpsta += osnovica
|
||||
kir.PdvOpsta += pdv
|
||||
kir.Ukupno += d.Ukupno()
|
||||
kir.DodajNeto(d.Ukupno(), d.PdvStopa)
|
||||
}
|
||||
if kir.Ukupno > 0 {
|
||||
if _, e := h.PdvKirRepo.Kreiraj(r.Context(), &kir); e != nil {
|
||||
|
||||
@@ -56,6 +56,26 @@ func TestRasporediTroskove(t *testing.T) {
|
||||
metod: "kolicina",
|
||||
ocekuje: []float64{50},
|
||||
},
|
||||
{
|
||||
// negativan trošak (odobreni popust) — cena ostaje nepromenjena
|
||||
naziv: "negativan trosak",
|
||||
stavke: stavke,
|
||||
trosak: -100,
|
||||
metod: "vrednost",
|
||||
ocekuje: []float64{100, 200},
|
||||
},
|
||||
{
|
||||
// trošak 10 na 3 stavke po kolicini (9 kom ukupno) → 10/9/3 ≈ 1.111/kom
|
||||
naziv: "zaokruzivanje na 2 decimale",
|
||||
stavke: []StavkaNabavke{
|
||||
{Kolicina: 3, CenaPoKomadu: 100},
|
||||
{Kolicina: 3, CenaPoKomadu: 200},
|
||||
{Kolicina: 3, CenaPoKomadu: 50},
|
||||
},
|
||||
trosak: 10,
|
||||
metod: "kolicina",
|
||||
ocekuje: []float64{101.11, 201.11, 51.11},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range slucajevi {
|
||||
|
||||
@@ -80,6 +80,30 @@ func SumirajKir(zapisi []PdvKir) PdvKirSume {
|
||||
return s
|
||||
}
|
||||
|
||||
// dodajStavku razvrstava jednu stavku (već izračunata osnovica + PDV) po stopi u
|
||||
// odgovarajuće kolone KIR-a i uvećava ukupan (bruto) iznos.
|
||||
func (k *PdvKir) dodajStavku(osnovica, pdv, stopa float64) {
|
||||
switch stopa {
|
||||
case 20:
|
||||
k.OsnovicaOpsta += osnovica
|
||||
k.PdvOpsta += pdv
|
||||
case 10:
|
||||
k.OsnovicaPosebna += osnovica
|
||||
k.PdvPosebna += pdv
|
||||
default:
|
||||
// 0% / oslobođeno — osnovica u oslobođen promet sa pravom na odbitak
|
||||
k.OslobodenSaPravom += osnovica
|
||||
}
|
||||
k.Ukupno += osnovica + pdv
|
||||
}
|
||||
|
||||
// DodajNeto dodaje stavku zadatu NETO osnovicom i PDV stopom — za izvore gde je
|
||||
// cena bez PDV-a (servisni rad/deo: cena_komada je neto, PDV se dodaje naviše).
|
||||
// PDV se izvodi kao osnovica × stopa/100, a ukupno postaje bruto (osnovica + PDV).
|
||||
func (k *PdvKir) DodajNeto(osnovica, stopa float64) {
|
||||
k.dodajStavku(osnovica, osnovica*stopa/100, stopa)
|
||||
}
|
||||
|
||||
// KirIzProdaje gradi KIR zapis iz prodaje: stavke se grupišu po PDV stopi
|
||||
// (20→opšta, 10→posebna, ostalo→oslobođeno). CenaPoKomadu je prodajna cena SA PDV,
|
||||
// pa se osnovica izvodi deljenjem sa (1 + stopa/100).
|
||||
@@ -101,19 +125,7 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
|
||||
if s.PdvStopa > 0 {
|
||||
osnovica = ukupnoLinija / (1 + s.PdvStopa/100)
|
||||
}
|
||||
pdv := ukupnoLinija - osnovica
|
||||
switch s.PdvStopa {
|
||||
case 20:
|
||||
k.OsnovicaOpsta += osnovica
|
||||
k.PdvOpsta += pdv
|
||||
case 10:
|
||||
k.OsnovicaPosebna += osnovica
|
||||
k.PdvPosebna += pdv
|
||||
default:
|
||||
// 0% / oslobođeno — osnovica bez PDV-a u oslobođen promet sa pravom na odbitak
|
||||
k.OslobodenSaPravom += osnovica
|
||||
}
|
||||
k.Ukupno += ukupnoLinija
|
||||
k.dodajStavku(osnovica, ukupnoLinija-osnovica, s.PdvStopa)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -41,6 +41,35 @@ func TestKirIzProdaje(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPdvKirDodajNeto: osnovica zadata kao NETO (servisni rad/deo) se NE deli,
|
||||
// PDV se dodaje naviše po stvarnoj stopi i razvrstava u opštu/posebnu/oslobođen.
|
||||
func TestPdvKirDodajNeto(t *testing.T) {
|
||||
var k PdvKir
|
||||
|
||||
// rad 1000 neto @ 20% → osnovica 1000 (NE 833.33!), PDV 200, ukupno bruto 1200
|
||||
k.DodajNeto(1000, 20)
|
||||
if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) {
|
||||
t.Errorf("20%%: osnovica=%v pdv=%v, očekivano 1000/200 (neto se ne deli)", k.OsnovicaOpsta, k.PdvOpsta)
|
||||
}
|
||||
|
||||
// deo 1000 neto @ 10% → posebna osnovica 1000, PDV 100
|
||||
k.DodajNeto(1000, 10)
|
||||
if !blizu(k.OsnovicaPosebna, 1000) || !blizu(k.PdvPosebna, 100) {
|
||||
t.Errorf("10%%: osnovica=%v pdv=%v, očekivano 1000/100", k.OsnovicaPosebna, k.PdvPosebna)
|
||||
}
|
||||
|
||||
// usluga 500 neto @ 0% → oslobođen promet, bez PDV
|
||||
k.DodajNeto(500, 0)
|
||||
if !blizu(k.OslobodenSaPravom, 500) {
|
||||
t.Errorf("0%%: oslobođeno=%v, očekivano 500", k.OslobodenSaPravom)
|
||||
}
|
||||
|
||||
// ukupno (bruto) = 1200 + 1100 + 500 = 2800
|
||||
if !blizu(k.Ukupno, 2800) {
|
||||
t.Errorf("ukupno=%v, očekivano 2800 (1200+1100+500)", k.Ukupno)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKprIzNabavke(t *testing.T) {
|
||||
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
|
||||
stavke := []NabavkaStavkaPdv{
|
||||
|
||||
@@ -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(2) { animation-delay: 0.28s; }
|
||||
.dash-kartica:nth-child(3) { animation-delay: 0.36s; }
|
||||
/* prihod cifra — sakrivena, prikazuje se posle 1s hovera */
|
||||
.prihod-cifra {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.dash-stat:hover .prihod-cifra {
|
||||
opacity: 1;
|
||||
transition-delay: 1s;
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
@@ -50,19 +41,6 @@
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Aktivnih servisa</div>
|
||||
</a>
|
||||
|
||||
{{ if index .Dozvole "dashboard.prihod" }}
|
||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/izvestaji" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-narandzasta">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ea580c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style="font-size:22px;font-weight:500;color:var(--tekst-glavni);"><span class="prihod-cifra">{{ dinariCeli .PrihodOvogMeseca }} din</span></div>
|
||||
<div style="font-size:14px;color:var(--tekst-glavni);margin-top:4px;font-weight:500;">Prihod ovog meseca</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin?kriticni=1" class="kartica dash-stat animiraj kartica-link">
|
||||
<div class="dash-ikona dash-ikona-crvena">
|
||||
|
||||
Reference in New Issue
Block a user