Servis: ispravka logike potraživanih delova (5 bugova)

- BUG 1: pokriveni deo se sada prebacuje u ugrađene delove (servisni_delovi),
ne samo skida sa magacina — roba više ne nestaje iz evidencije
- BUG 2: DodajDeloNalogu sada atomično (UgradiIliPotrazuj) čita stanje i
ugrađuje u istoj transakciji — nema TOCTOU, lager ne ide u minus
- BUG 4: direktna izmena količine artikla beleži magacinski trag (korekcija)
- BUG 5: greška pri dodavanju dela se propagira korisniku, ne guta se
- uklonjen mrtav kod (Dodaj, DodajIliUvecaj) sa pogrešnim komentarom o minusu
- migracija 068: cena_komada u potraživanim delovima
- test TestPotrazivaniTok pokriva ceo tok
This commit is contained in:
2026-06-22 16:13:16 +02:00
parent b3739e3210
commit 7516f49571
8 changed files with 264 additions and 144 deletions
+4 -2
View File
@@ -210,7 +210,10 @@ type ProdajaRepository interface {
// ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu // ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu
type ServisniDeloviRepository interface { type ServisniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error)
Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error) // UgradiIliPotrazuj atomično (jedna transakcija) čita stanje magacina, ugrađuje
// ono što fizički ima (skida sa lagera, lager NE ide u minus), a višak beleži u
// potraživane delove. Vraća koliko je ugrađeno i koliko nedostaje.
UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (ugradjeno, nedostaje int, err error)
DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error) DohvatiArtikalID(ctx context.Context, deoID int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error
} }
@@ -218,7 +221,6 @@ type ServisniDeloviRepository interface {
// ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju // ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju
type ServisniPotrazivaniDeloviRepository interface { type ServisniPotrazivaniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error)
DodajIliUvecaj(ctx context.Context, nalogID, artikalID int64, kolicina int) (int64, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error
// ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon // ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon
+71 -51
View File
@@ -49,84 +49,104 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64)
return rezultat, nil return rezultat, nil
} }
// Dodaj dodaje jedan artikal u servisni nalog, smanjuje stanje u magacinu i beleži promenu // UgradiIliPotrazuj atomično (jedna transakcija) ugrađuje ono što fizički ima na
func (r *ServisniDeloviRepo) Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error) { // stanju, a višak beleži u potraživane delove. Stanje se čita i koristi unutar iste
// transakcije pa lager NIKAD ne ide u minus (nema TOCTOU između čitanja i upisa).
// Vraća koliko je ugrađeno i koliko nedostaje.
func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (ugradjeno, nedostaje int, err error) {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: begin tx: %w", err) return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: begin tx: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
var naziv string
var stanjePre int var stanjePre int
err = tx.QueryRowContext(ctx, if err = tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", artikalID, "SELECT kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&naziv, &stanjePre) ).Scan(&stanjePre); err != nil {
if err != nil { return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: dohvati stanje: %w", err)
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err)
} }
// dozvoljavamo backorder: deo se može ugraditi i kad nema dovoljno na stanju // ugrađujemo najviše onoliko koliko fizički imamo; ostatak ide u potraživane
// (lager tada ide u minus kao signal da treba nabavka); handler nalog prebacuje ugradjeno = kolicina
// u „Čeka delove". Zato ovde NE odbijamo prekoračenje. if ugradjeno > stanjePre {
stanjePosle := stanjePre - kolicina ugradjeno = stanjePre
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: update stanje: %w", err)
} }
if ugradjeno < 0 {
ugradjeno = 0
}
nedostaje = kolicina - ugradjeno
// ako isti artikal već postoji na nalogu — saberi količinu i osveži cenu; // ugradi ono što imamo: merge u servisni_delovi, skini sa magacina, zabeleži promenu
// u suprotnom kreiraj novi red (bez dupliranja istog artikla) if ugradjeno > 0 {
var deoID int64
var postojeciID int64 var postojeciID int64
var postojeciKol int var postojeciKol int
err = tx.QueryRowContext(ctx, errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?", "SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID, nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol) ).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if err == nil { if _, err = tx.ExecContext(ctx,
// već postoji — saberi
novaKol := postojeciKol + kolicina
_, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?", "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
novaKol, cenaKomada, postojeciID, postojeciKol+ugradjeno, cenaKomada, postojeciID,
) ); err != nil {
if err != nil { return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: merge: %w", err)
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: merge update: %w", err)
} }
deoID = postojeciID } else if errors.Is(errRed, sql.ErrNoRows) {
} else if errors.Is(err, sql.ErrNoRows) { if _, err = tx.ExecContext(ctx, `
// novi artikal na nalogu — insert
rezultat, err := tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada) INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada, nalogID, artikalID, ugradjeno, cenaKomada,
) ); err != nil {
if err != nil { return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err)
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: insert: %w", err)
}
deoID, err = rezultat.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: last insert id: %w", err)
} }
} else { } else {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: proveri postojanje: %w", err) return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: proveri: %w", errRed)
} }
err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis, stanjePosle := stanjePre - ugradjeno
-kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "") if _, err = tx.ExecContext(ctx,
if err != nil { "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: magacin: %w", err) ); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: update stanje: %w", err)
}
if err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis,
-ugradjeno, stanjePre, stanjePosle, nalogID, korisnikID, ""); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: magacin: %w", err)
}
} }
if err := tx.Commit(); err != nil { // višak → potraživani delovi (ne skidamo sa lagera dok ne stigne)
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: commit: %w", err) if nedostaje > 0 {
var postojeciID int64
var postojeciKol int
errRed := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if errRed == nil {
if _, err = tx.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+nedostaje, cenaKomada, postojeciID,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani update: %w", err)
}
} else if errors.Is(errRed, sql.ErrNoRows) {
if _, err = tx.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada) VALUES (?, ?, ?, ?)",
nalogID, artikalID, nedostaje, cenaKomada,
); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err)
}
} else {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani proveri: %w", errRed)
}
} }
return deoID, nil if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err)
}
return ugradjeno, nedostaje, nil
} }
// Obrisi uklanja servisni deo i vraća količinu na stanje u magacinu // Obrisi uklanja servisni deo i vraća količinu na stanje u magacinu
@@ -22,7 +22,7 @@ func NoviServisniPotrazivaniDeloviRepo(db *sql.DB) *ServisniPotrazivaniDeloviRep
// DohvatiZaNalog vraća sve potraživane delove za dati servisni nalog // DohvatiZaNalog vraća sve potraživane delove za dati servisni nalog
func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) { func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) {
redovi, err := r.db.QueryContext(ctx, ` redovi, err := r.db.QueryContext(ctx, `
SELECT spd.id, spd.nalog_id, spd.artikal_id, spd.kolicina, spd.datum, SELECT spd.id, spd.nalog_id, spd.artikal_id, spd.kolicina, spd.cena_komada, spd.datum,
a.naziv a.naziv
FROM servisni_potrazivani_delovi spd FROM servisni_potrazivani_delovi spd
JOIN artikli a ON a.id = spd.artikal_id JOIN artikli a ON a.id = spd.artikal_id
@@ -36,7 +36,7 @@ func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalo
var rezultat []model.ServisniPotrazivaniDeo var rezultat []model.ServisniPotrazivaniDeo
for redovi.Next() { for redovi.Next() {
var d model.ServisniPotrazivaniDeo var d model.ServisniPotrazivaniDeo
err := redovi.Scan(&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.Datum, &d.ArtikalNaziv) err := redovi.Scan(&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum, &d.ArtikalNaziv)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err) return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DohvatiZaNalog: scan: %w", err)
} }
@@ -45,46 +45,6 @@ func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalo
return rezultat, nil return rezultat, nil
} }
// DodajIliUvecaj dodaje novi potraživani deo ili uvećava količinu ako već postoji.
// Vraća ID reda (novog ili postojećeg).
func (r *ServisniPotrazivaniDeloviRepo) DodajIliUvecaj(ctx context.Context, nalogID, artikalID int64, kolicina int) (int64, error) {
var postojeciID int64
var postojeciKol int
err := r.db.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if err == nil {
novaKol := postojeciKol + kolicina
_, err = r.db.ExecContext(ctx,
"UPDATE servisni_potrazivani_delovi SET kolicina = ? WHERE id = ?",
novaKol, postojeciID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: update: %w", err)
}
return postojeciID, nil
}
if errors.Is(err, sql.ErrNoRows) {
rez, err := r.db.ExecContext(ctx,
"INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina) VALUES (?, ?, ?)",
nalogID, artikalID, kolicina,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: insert: %w", err)
}
id, err := rez.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: last insert id: %w", err)
}
return id, nil
}
return 0, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.DodajIliUvecaj: proveri: %w", err)
}
// Obrisi uklanja potraživani deo po ID-u // Obrisi uklanja potraživani deo po ID-u
func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) error { func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM servisni_potrazivani_delovi WHERE id = ?", id) _, err := r.db.ExecContext(ctx, "DELETE FROM servisni_potrazivani_delovi WHERE id = ?", id)
@@ -136,7 +96,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
} }
redovi, err := tx.QueryContext(ctx, redovi, err := tx.QueryContext(ctx,
"SELECT id, nalog_id, kolicina FROM servisni_potrazivani_delovi WHERE artikal_id = ? ORDER BY datum", "SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? ORDER BY datum",
artikalID, artikalID,
) )
if err != nil { if err != nil {
@@ -147,11 +107,12 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
id int64 id int64
nalogID int64 nalogID int64
kolicina int kolicina int
cena float64
} }
var lista []red var lista []red
for redovi.Next() { for redovi.Next() {
var p red var p red
if err := redovi.Scan(&p.id, &p.nalogID, &p.kolicina); err != nil { if err := redovi.Scan(&p.id, &p.nalogID, &p.kolicina, &p.cena); err != nil {
redovi.Close() redovi.Close()
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: scan: %w", err) return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: scan: %w", err)
} }
@@ -171,24 +132,30 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
break break
} }
if dostupno >= p.kolicina { if dostupno >= p.kolicina {
// ceo red je pokriven — obriši ga i skini punu količinu sa magacina // ceo red je pokriven — prebaci ga u ugrađene delove, skini sa magacina, obriši red
if _, err := tx.ExecContext(ctx, "DELETE FROM servisni_potrazivani_delovi WHERE id = ?", p.id); err != nil { if err := r.ugradiUNalog(ctx, tx, p.nalogID, artikalID, p.kolicina, p.cena); err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: delete: %w", err) return nil, err
} }
if err := r.skiniSaMagacina(ctx, tx, artikalID, p.kolicina, &stanjeMagacin, p.nalogID); err != nil { if err := r.skiniSaMagacina(ctx, tx, artikalID, p.kolicina, &stanjeMagacin, p.nalogID); err != nil {
return nil, err return nil, err
} }
if _, err := tx.ExecContext(ctx, "DELETE FROM servisni_potrazivani_delovi WHERE id = ?", p.id); err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: delete: %w", err)
}
obrisaniNalozi[p.nalogID] = struct{}{} obrisaniNalozi[p.nalogID] = struct{}{}
dostupno -= p.kolicina dostupno -= p.kolicina
} else { } else {
// delimično pokrivanje — sve dostupno ide na nalog; skini dostupno sa magacina // delimično pokrivanje — sve dostupno ide na nalog; prebaci u ugrađene, skini sa magacina
novaKol := p.kolicina - dostupno if err := r.ugradiUNalog(ctx, tx, p.nalogID, artikalID, dostupno, p.cena); err != nil {
if _, err := tx.ExecContext(ctx, "UPDATE servisni_potrazivani_delovi SET kolicina = ? WHERE id = ?", novaKol, p.id); err != nil { return nil, err
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: update: %w", err)
} }
if err := r.skiniSaMagacina(ctx, tx, artikalID, dostupno, &stanjeMagacin, p.nalogID); err != nil { if err := r.skiniSaMagacina(ctx, tx, artikalID, dostupno, &stanjeMagacin, p.nalogID); err != nil {
return nil, err return nil, err
} }
novaKol := p.kolicina - dostupno
if _, err := tx.ExecContext(ctx, "UPDATE servisni_potrazivani_delovi SET kolicina = ? WHERE id = ?", novaKol, p.id); err != nil {
return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: update: %w", err)
}
dostupno = 0 dostupno = 0
} }
} }
@@ -213,6 +180,49 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
return otkljucani, nil return otkljucani, nil
} }
// ugradiUNalog prebacuje pokrivenu količinu u ugrađene delove (servisni_delovi):
// uvećava postojeći red za isti artikal na nalogu ili kreira novi. Radi unutar
// prosleđene transakcije. Ne dira magacin — to radi skiniSaMagacina zasebno.
func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64) error {
var postojeciID int64
var postojeciKol int
err := tx.QueryRowContext(ctx,
"SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?",
nalogID, artikalID,
).Scan(&postojeciID, &postojeciKol)
if err == nil {
// ne pregazi postojeću cenu nulom (npr. legacy potraživani red bez cene)
if cenaKomada > 0 {
_, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
postojeciKol+kolicina, cenaKomada, postojeciID,
)
} else {
_, err = tx.ExecContext(ctx,
"UPDATE servisni_delovi SET kolicina = ? WHERE id = ?",
postojeciKol+kolicina, postojeciID,
)
}
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: update: %w", err)
}
return nil
}
if errors.Is(err, sql.ErrNoRows) {
_, err = tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada,
)
if err != nil {
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: insert: %w", err)
}
return nil
}
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: proveri: %w", err)
}
// skiniSaMagacina umanjuje stanje artikla za datu količinu (jer odlazi na servisni // skiniSaMagacina umanjuje stanje artikla za datu količinu (jer odlazi na servisni
// nalog) i upisuje promenu u magacinski trag tipa PromenaIzlazServis. Radi unutar // nalog) i upisuje promenu u magacinski trag tipa PromenaIzlazServis. Radi unutar
// prosleđene transakcije; *stanje drži tekuće stanje radi tačnog stanjePre/stanjePosle. // prosleđene transakcije; *stanje drži tekuće stanje radi tačnog stanjePre/stanjePosle.
@@ -0,0 +1,85 @@
package sqlite
import (
"context"
"database/sql"
"testing"
)
// TestPotrazivaniTok pokriva ceo tok: ugradnja sa manjkom → potraživani deo →
// dolazak robe → pokrivanje (prebacivanje u ugrađene, skidanje magacina, otključavanje).
func TestPotrazivaniTok(t *testing.T) {
db := testDB(t)
ctx := context.Background()
// artikal sa stanjem 2, nalog u statusu Primljeno
if _, err := db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'Test CPU', 2)"); err != nil {
t.Fatalf("insert artikal: %v", err)
}
if _, err := db.ExecContext(ctx,
"INSERT INTO servisni_nalozi (id, broj_naloga, uredjaj, opis_kvara, status) VALUES (1, 'SN-1', 'PC', 'kvar', 'Primljeno')",
); err != nil {
t.Fatalf("insert nalog: %v", err)
}
deloviRepo := NoviServisniDeloviRepo(db)
potrRepo := NoviServisniPotrazivaniDeloviRepo(db)
// traži 5, na stanju 2 → ugradi 2, nedostaje 3
ugradjeno, nedostaje, err := deloviRepo.UgradiIliPotrazuj(ctx, 1, 1, 5, 100, nil)
if err != nil {
t.Fatalf("UgradiIliPotrazuj: %v", err)
}
if ugradjeno != 2 || nedostaje != 3 {
t.Fatalf("očekivano ugradjeno=2 nedostaje=3, dobijeno ugradjeno=%d nedostaje=%d", ugradjeno, nedostaje)
}
// stanje magacina mora biti 0 (skinuto 2), NE u minus
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 0 {
t.Fatalf("posle ugradnje stanje očekivano 0, dobijeno %d", stanje)
}
// ugrađeni deo: 2 kom; potraživani: 3 kom
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_delovi WHERE nalog_id=1 AND artikal_id=1"); k != 2 {
t.Fatalf("ugrađeni deo očekivano 2, dobijeno %d", k)
}
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND artikal_id=1"); k != 3 {
t.Fatalf("potraživani očekivano 3, dobijeno %d", k)
}
// stigla roba: nabavka 5 kom → stanje 0+5=5
if _, err := db.ExecContext(ctx, "UPDATE artikli SET kolicina = kolicina + 5 WHERE id=1"); err != nil {
t.Fatalf("nabavka: %v", err)
}
// pokrivanje
otkljucani, err := potrRepo.ProveriIPocistiZaArtikal(ctx, 1)
if err != nil {
t.Fatalf("ProveriIPocistiZaArtikal: %v", err)
}
if len(otkljucani) != 1 || otkljucani[0] != 1 {
t.Fatalf("očekivan otključan nalog [1], dobijeno %v", otkljucani)
}
// ugrađeni deo sada 5 kom (2 + 3 pokriveno)
if k := skalarInt(t, db, "SELECT kolicina FROM servisni_delovi WHERE nalog_id=1 AND artikal_id=1"); k != 5 {
t.Fatalf("ugrađeni deo posle pokrivanja očekivano 5, dobijeno %d", k)
}
// potraživanih više nema
if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id=1"); k != 0 {
t.Fatalf("potraživani treba da su prazni, ostalo %d", k)
}
// stanje magacina: 5 3 (skinuto pri pokrivanju) = 2
if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 2 {
t.Fatalf("stanje posle pokrivanja očekivano 2, dobijeno %d", stanje)
}
}
// skalarInt vraća jednu celobrojnu vrednost iz upita (za proveru stanja u testu)
func skalarInt(t *testing.T, db *sql.DB, upit string) int {
t.Helper()
var v int
if err := db.QueryRow(upit).Scan(&v); err != nil {
t.Fatalf("skalarInt %q: %v", upit, err)
}
return v
}
+13 -1
View File
@@ -251,12 +251,24 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
} }
} }
// promenu količine sprovodimo kroz KorigujKolicinu da bi se zabeležio magacinski
// trag (korekcija); Izmeni menja samo metapodatke, ne dira stanje
novaKolicina := artikal.Kolicina
artikal.Kolicina = staraKolicina
artikal.ID = id artikal.ID = id
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError)
return return
} }
// ako se količina promenila, koriguj stanje uz magacinski trag
if novaKolicina != staraKolicina {
if e := h.Artikli.KorigujKolicinu(r.Context(), id, novaKolicina, &k.ID, "izmena artikla"); e != nil {
slog.Error("korekcija količine pri izmeni artikla nije uspela", "artikal_id", id, "error", e)
}
}
// ažuriraj dobavljače artikla prema formi // ažuriraj dobavljače artikla prema formi
if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil { if e := h.Artikli.PostaviDobavljaceArtikla(r.Context(), id, citajDobavljaceForme(r)); e != nil {
slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e) slog.Error("čuvanje dobavljača artikla nije uspelo", "artikal_id", id, "error", e)
@@ -277,7 +289,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
} }
// ako je stanje poraslo, proveri potraživane servisne delove // ako je stanje poraslo, proveri potraživane servisne delove
if artikal.Kolicina > staraKolicina { if novaKolicina > staraKolicina {
otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), id) otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), id)
if err != nil { if err != nil {
slog.Error("provera potraživanih delova nije uspela", "artikal_id", id, "error", err) slog.Error("provera potraživanih delova nije uspela", "artikal_id", id, "error", err)
+5 -18
View File
@@ -535,36 +535,23 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return return
} }
// proveri stanje na magacinu: ako tražimo više nego što ima, ugrađujemo samo // ime artikla za poruke korisniku
// ono što je na stanju, a razliku beležimo u potraživane delove (ne skida se
// sa lagera dok ne stigne); lager NIKAD ne ide u minus
ugradjeno := kolicina
nedostaje := 0
imeArtikla := "" imeArtikla := ""
if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil { if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil {
imeArtikla = art.Naziv imeArtikla = art.Naziv
if kolicina > art.Kolicina {
ugradjeno = art.Kolicina
nedostaje = kolicina - art.Kolicina
}
} }
// ugrađujemo samo ono što fizički imamo (ako ima > 0) // atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane
if ugradjeno > 0 { ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID)
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, ugradjeno, cena, &k.ID); err != nil { if err != nil {
slog.Error("greška pri dodavanju dela", "error", err) slog.Error("greška pri dodavanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return return
} }
}
// višak → potraživani delovi (ne skidamo sa lagera) // ako nešto nedostaje, prebaci nalog u „Čeka delove" i obavesti korisnika
if nedostaje > 0 { if nedostaje > 0 {
if _, err := h.ServisniPotrazivaniDeloviRepo.DodajIliUvecaj(r.Context(), nalogID, artikalID, nedostaje); err != nil {
slog.Error("greška pri beleženju potraživanog dela", "error", err)
}
// automatski prebaci nalog u „Čeka delove"
if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil { if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil {
slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e) slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e)
} }
+1
View File
@@ -76,6 +76,7 @@ type ServisniPotrazivaniDeo struct {
NalogID int64 NalogID int64
ArtikalID int64 ArtikalID int64
Kolicina int Kolicina int
CenaKomada float64
Datum time.Time Datum time.Time
// za prikaz: // za prikaz:
ArtikalNaziv string ArtikalNaziv string
+3
View File
@@ -0,0 +1,3 @@
-- cena po komadu za potraživani deo, da bi pri pokrivanju (kad roba stigne)
-- deo mogao da se prebaci u ugrađene delove sa ispravnom cenom
ALTER TABLE servisni_potrazivani_delovi ADD COLUMN cena_komada REAL NOT NULL DEFAULT 0;