diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 31efa12..a870920 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -91,7 +91,7 @@ func main() { port := os.Getenv("NTECH_PORT") if port == "" { - port = "8080" + port = "3000" } db, err := sqlite.OtvoriDB(putanjaBaze) diff --git a/internal/db/sqlite/izvestaj.go b/internal/db/sqlite/izvestaj.go index a7b18ec..a300f3d 100644 --- a/internal/db/sqlite/izvestaj.go +++ b/internal/db/sqlite/izvestaj.go @@ -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 diff --git a/internal/db/sqlite/izvestaj_test.go b/internal/db/sqlite/izvestaj_test.go index 71f346e..5d009d0 100644 --- a/internal/db/sqlite/izvestaj_test.go +++ b/internal/db/sqlite/izvestaj_test.go @@ -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) + } +} diff --git a/internal/db/sqlite/nivelacija_test.go b/internal/db/sqlite/nivelacija_test.go new file mode 100644 index 0000000..0f97d51 --- /dev/null +++ b/internal/db/sqlite/nivelacija_test.go @@ -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) + } +} diff --git a/internal/db/sqlite/prodaja_kreiraj_test.go b/internal/db/sqlite/prodaja_kreiraj_test.go new file mode 100644 index 0000000..5b65ec5 --- /dev/null +++ b/internal/db/sqlite/prodaja_kreiraj_test.go @@ -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 +} diff --git a/internal/db/sqlite/prodaja_storno_test.go b/internal/db/sqlite/prodaja_storno_test.go new file mode 100644 index 0000000..38f7478 --- /dev/null +++ b/internal/db/sqlite/prodaja_storno_test.go @@ -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) + } +} diff --git a/internal/db/sqlite/servis_status_test.go b/internal/db/sqlite/servis_status_test.go new file mode 100644 index 0000000..6b1c3c7 --- /dev/null +++ b/internal/db/sqlite/servis_status_test.go @@ -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) + } +} diff --git a/internal/db/sqlite/servisni_delovi_test.go b/internal/db/sqlite/servisni_delovi_test.go new file mode 100644 index 0000000..9b156dd --- /dev/null +++ b/internal/db/sqlite/servisni_delovi_test.go @@ -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) + } +} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index faa5242..4521119 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -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, diff --git a/internal/handler/izvestaji.go b/internal/handler/izvestaji.go index a599a6a..9df5055 100644 --- a/internal/handler/izvestaji.go +++ b/internal/handler/izvestaji.go @@ -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{ diff --git a/internal/handler/izvestaji_test.go b/internal/handler/izvestaji_test.go new file mode 100644 index 0000000..304abc4 --- /dev/null +++ b/internal/handler/izvestaji_test.go @@ -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 + } + }) + } +} diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 0af7a52..977ad99 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -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 { diff --git a/internal/model/nabavka_test.go b/internal/model/nabavka_test.go index b836a86..bd97c4b 100644 --- a/internal/model/nabavka_test.go +++ b/internal/model/nabavka_test.go @@ -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 { diff --git a/internal/model/pdv_evidencija.go b/internal/model/pdv_evidencija.go index 0813593..074c520 100644 --- a/internal/model/pdv_evidencija.go +++ b/internal/model/pdv_evidencija.go @@ -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 } diff --git a/internal/model/pdv_evidencija_test.go b/internal/model/pdv_evidencija_test.go index a206764..5287feb 100644 --- a/internal/model/pdv_evidencija_test.go +++ b/internal/model/pdv_evidencija_test.go @@ -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{ diff --git a/internal/model/servis_test.go b/internal/model/servis_test.go new file mode 100644 index 0000000..4018d63 --- /dev/null +++ b/internal/model/servis_test.go @@ -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()) + } +} diff --git a/web/templates/stranice/dashboard.html b/web/templates/stranice/dashboard.html index 0658537..15afea5 100644 --- a/web/templates/stranice/dashboard.html +++ b/web/templates/stranice/dashboard.html @@ -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; - } {{ end }} @@ -50,19 +41,6 @@
Aktivnih servisa
- {{ if index .Dozvole "dashboard.prihod" }} - -
- - - - - -
-
{{ dinariCeli .PrihodOvogMeseca }} din
-
Prihod ovog meseca
-
- {{ end }}