From 7516f49571921b44d42641188ddd8277e6fbcf2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Mon, 22 Jun 2026 16:13:16 +0200 Subject: [PATCH] =?UTF-8?q?Servis:=20ispravka=20logike=20potra=C5=BEivanih?= =?UTF-8?q?=20delova=20(5=20bugova)=20-=20BUG=201:=20pokriveni=20deo=20se?= =?UTF-8?q?=20sada=20prebacuje=20u=20ugra=C4=91ene=20delove=20(servisni=5F?= =?UTF-8?q?delovi),=20ne=20samo=20skida=20sa=20magacina=20=E2=80=94=20roba?= =?UTF-8?q?=20vi=C5=A1e=20ne=20nestaje=20iz=20evidencije=20-=20BUG=202:=20?= =?UTF-8?q?DodajDeloNalogu=20sada=20atomi=C4=8Dno=20(UgradiIliPotrazuj)=20?= =?UTF-8?q?=C4=8Dita=20stanje=20i=20ugra=C4=91uje=20u=20istoj=20transakcij?= =?UTF-8?q?i=20=E2=80=94=20nema=20TOCTOU,=20lager=20ne=20ide=20u=20minus?= =?UTF-8?q?=20-=20BUG=204:=20direktna=20izmena=20koli=C4=8Dine=20artikla?= =?UTF-8?q?=20bele=C5=BEi=20magacinski=20trag=20(korekcija)=20-=20BUG=205:?= =?UTF-8?q?=20gre=C5=A1ka=20pri=20dodavanju=20dela=20se=20propagira=20kori?= =?UTF-8?q?sniku,=20ne=20guta=20se=20-=20uklonjen=20mrtav=20kod=20(Dodaj,?= =?UTF-8?q?=20DodajIliUvecaj)=20sa=20pogre=C5=A1nim=20komentarom=20o=20min?= =?UTF-8?q?usu=20-=20migracija=20068:=20cena=5Fkomada=20u=20potra=C5=BEiva?= =?UTF-8?q?nim=20delovima=20-=20test=20TestPotrazivaniTok=20pokriva=20ceo?= =?UTF-8?q?=20tok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/repository.go | 6 +- internal/db/sqlite/servisni_delovi.go | 140 ++++++++++-------- .../db/sqlite/servisni_potrazivani_delovi.go | 112 +++++++------- .../servisni_potrazivani_delovi_test.go | 85 +++++++++++ internal/handler/magacin_forma.go | 14 +- internal/handler/servis.go | 31 ++-- internal/model/servis.go | 17 ++- migrations/068_potrazivani_cena.sql | 3 + 8 files changed, 264 insertions(+), 144 deletions(-) create mode 100644 internal/db/sqlite/servisni_potrazivani_delovi_test.go create mode 100644 migrations/068_potrazivani_cena.sql diff --git a/internal/db/repository.go b/internal/db/repository.go index 8c9a630..90d81ae 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -210,7 +210,10 @@ type ProdajaRepository interface { // ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu type ServisniDeloviRepository interface { 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) Obrisi(ctx context.Context, id int64, korisnikID *int64) error } @@ -218,7 +221,6 @@ type ServisniDeloviRepository interface { // ServisniPotrazivaniDeloviRepository definiše operacije nad delovima koji nedostaju type ServisniPotrazivaniDeloviRepository interface { 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 ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error // ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 645c021..3885a82 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -49,84 +49,104 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) return rezultat, nil } -// Dodaj dodaje jedan artikal u servisni nalog, smanjuje stanje u magacinu i beleži promenu -func (r *ServisniDeloviRepo) Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error) { +// UgradiIliPotrazuj atomično (jedna transakcija) ugrađuje ono što fizički ima na +// 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) 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() - var naziv string var stanjePre int - err = tx.QueryRowContext(ctx, - "SELECT naziv, kolicina FROM artikli WHERE id = ?", artikalID, - ).Scan(&naziv, &stanjePre) - if err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err) + if err = tx.QueryRowContext(ctx, + "SELECT kolicina FROM artikli WHERE id = ?", artikalID, + ).Scan(&stanjePre); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: dohvati stanje: %w", err) } - // dozvoljavamo backorder: deo se može ugraditi i kad nema dovoljno na stanju - // (lager tada ide u minus kao signal da treba nabavka); handler nalog prebacuje - // u „Čeka delove". Zato ovde NE odbijamo prekoračenje. - stanjePosle := stanjePre - kolicina - _, 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) + // ugrađujemo najviše onoliko koliko fizički imamo; ostatak ide u potraživane + ugradjeno = kolicina + if ugradjeno > stanjePre { + ugradjeno = stanjePre } + if ugradjeno < 0 { + ugradjeno = 0 + } + nedostaje = kolicina - ugradjeno - // ako isti artikal već postoji na nalogu — saberi količinu i osveži cenu; - // u suprotnom kreiraj novi red (bez dupliranja istog artikla) - var deoID int64 - 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 { - // već postoji — saberi - novaKol := postojeciKol + kolicina - _, err = tx.ExecContext(ctx, - "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?", - novaKol, cenaKomada, postojeciID, - ) - if err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: merge update: %w", err) + // ugradi ono što imamo: merge u servisni_delovi, skini sa magacina, zabeleži promenu + if ugradjeno > 0 { + var postojeciID int64 + var postojeciKol int + errRed := tx.QueryRowContext(ctx, + "SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ?", + nalogID, artikalID, + ).Scan(&postojeciID, &postojeciKol) + if errRed == nil { + if _, err = tx.ExecContext(ctx, + "UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?", + postojeciKol+ugradjeno, cenaKomada, postojeciID, + ); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: merge: %w", err) + } + } else if errors.Is(errRed, sql.ErrNoRows) { + if _, err = tx.ExecContext(ctx, ` + INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada) + VALUES (?, ?, ?, ?)`, + nalogID, artikalID, ugradjeno, cenaKomada, + ); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err) + } + } else { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: proveri: %w", errRed) } - deoID = postojeciID - } else if errors.Is(err, sql.ErrNoRows) { - // novi artikal na nalogu — insert - rezultat, err := tx.ExecContext(ctx, ` - INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada) - VALUES (?, ?, ?, ?)`, - nalogID, artikalID, kolicina, cenaKomada, - ) - if err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: insert: %w", err) + + stanjePosle := stanjePre - ugradjeno + if _, err = tx.ExecContext(ctx, + "UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID, + ); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: update stanje: %w", err) } - deoID, err = rezultat.LastInsertId() - if err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: last insert id: %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) } - } else { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: proveri postojanje: %w", err) } - err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis, - -kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "") - if err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: magacin: %w", err) + // višak → potraživani delovi (ne skidamo sa lagera dok ne stigne) + 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) + } } - if err := tx.Commit(); err != nil { - return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: commit: %w", err) + if err = tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err) } - - return deoID, nil + return ugradjeno, nedostaje, nil } // Obrisi uklanja servisni deo i vraća količinu na stanje u magacinu diff --git a/internal/db/sqlite/servisni_potrazivani_delovi.go b/internal/db/sqlite/servisni_potrazivani_delovi.go index d7acb5b..8e3d093 100644 --- a/internal/db/sqlite/servisni_potrazivani_delovi.go +++ b/internal/db/sqlite/servisni_potrazivani_delovi.go @@ -22,7 +22,7 @@ func NoviServisniPotrazivaniDeloviRepo(db *sql.DB) *ServisniPotrazivaniDeloviRep // DohvatiZaNalog vraća sve potraživane delove za dati servisni nalog func (r *ServisniPotrazivaniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) { 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 FROM servisni_potrazivani_delovi spd 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 for redovi.Next() { 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 { 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 } -// 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 func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) error { _, 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, - "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, ) if err != nil { @@ -147,11 +107,12 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con id int64 nalogID int64 kolicina int + cena float64 } var lista []red for redovi.Next() { 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() return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: scan: %w", err) } @@ -171,24 +132,30 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con break } if dostupno >= p.kolicina { - // ceo red je pokriven — obriši ga i skini punu količinu sa magacina - 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) + // ceo red je pokriven — prebaci ga u ugrađene delove, skini sa magacina, obriši red + if err := r.ugradiUNalog(ctx, tx, p.nalogID, artikalID, p.kolicina, p.cena); err != nil { + return nil, err } if err := r.skiniSaMagacina(ctx, tx, artikalID, p.kolicina, &stanjeMagacin, p.nalogID); err != nil { 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{}{} dostupno -= p.kolicina } else { - // delimično pokrivanje — sve dostupno ide na nalog; skini dostupno sa magacina - 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) + // delimično pokrivanje — sve dostupno ide na nalog; prebaci u ugrađene, skini sa magacina + if err := r.ugradiUNalog(ctx, tx, p.nalogID, artikalID, dostupno, p.cena); err != nil { + return nil, err } if err := r.skiniSaMagacina(ctx, tx, artikalID, dostupno, &stanjeMagacin, p.nalogID); err != nil { 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 } } @@ -213,6 +180,49 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con 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 // 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. diff --git a/internal/db/sqlite/servisni_potrazivani_delovi_test.go b/internal/db/sqlite/servisni_potrazivani_delovi_test.go new file mode 100644 index 0000000..4f1ff41 --- /dev/null +++ b/internal/db/sqlite/servisni_potrazivani_delovi_test.go @@ -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 +} diff --git a/internal/handler/magacin_forma.go b/internal/handler/magacin_forma.go index 7f40aa7..0fef929 100644 --- a/internal/handler/magacin_forma.go +++ b/internal/handler/magacin_forma.go @@ -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 if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil { http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError) 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 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) @@ -277,7 +289,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) { } // ako je stanje poraslo, proveri potraživane servisne delove - if artikal.Kolicina > staraKolicina { + if novaKolicina > staraKolicina { otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), id) if err != nil { slog.Error("provera potraživanih delova nije uspela", "artikal_id", id, "error", err) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index ee8ab29..19214ff 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -535,36 +535,23 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { return } - // proveri stanje na magacinu: ako tražimo više nego što ima, ugrađujemo samo - // 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 + // ime artikla za poruke korisniku imeArtikla := "" if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil { 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) - if ugradjeno > 0 { - if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, ugradjeno, cena, &k.ID); err != nil { - slog.Error("greška pri dodavanju dela", "error", err) - middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") - http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) - return - } + // atomično: ugradi ono što imamo (skida sa lagera, ne ide u minus), višak u potraživane + ugradjeno, nedostaje, err := h.ServisniDeloviRepo.UgradiIliPotrazuj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID) + if err != nil { + slog.Error("greška pri dodavanju dela", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + 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 _, 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 { slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e) } diff --git a/internal/model/servis.go b/internal/model/servis.go index 2cc0340..184cd08 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -9,9 +9,9 @@ import ( const ( StatusPrimljeno = "Primljeno" StatusCekaDelove = "Čeka delove" - StatusUPopravci = "U popravci" - StatusZavrseno = "Završeno" - StatusPreuzeto = "Preuzeto" + StatusUPopravci = "U popravci" + StatusZavrseno = "Završeno" + StatusPreuzeto = "Preuzeto" ) // SviStatusi je uređena lista statusa za prikaz u dropdownu @@ -72,11 +72,12 @@ type ServisniDeoSaArtiklom struct { // ServisniPotrazivaniDeo beleži artikle koji nedostaju za servisni nalog — // količina koja se traži a nije na stanju; ne skida se sa lagera dok ne stigne type ServisniPotrazivaniDeo struct { - ID int64 - NalogID int64 - ArtikalID int64 - Kolicina int - Datum time.Time + ID int64 + NalogID int64 + ArtikalID int64 + Kolicina int + CenaKomada float64 + Datum time.Time // za prikaz: ArtikalNaziv string } diff --git a/migrations/068_potrazivani_cena.sql b/migrations/068_potrazivani_cena.sql new file mode 100644 index 0000000..85396fc --- /dev/null +++ b/migrations/068_potrazivani_cena.sql @@ -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;