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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user