diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 5f65cc9..0e81e41 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -375,6 +375,7 @@ func main() { r.With(doz("servis.izmeni")).Post("/servis/{id}/obrisi-komentar-klijenta", h.ObrisiKomentarKlijenta) r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi", h.DodajDeloNalogu) r.With(doz("servis.izmeni")).Post("/servis/{id}/delovi/{deo_id}/obrisi", h.ObrisiDeloNaloga) + r.With(doz("servis.izmeni")).Post("/servis/{id}/predlozeni-delovi/{artikal_id}/obrisi", h.ObrisiPredlozeniDeoNaloga) r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi", h.DodajRadNalogu) r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi/{rad_id}/obrisi", h.ObrisiRadNaloga) r.Get("/izvestaji", h.Izvestaji) diff --git a/internal/db/repository.go b/internal/db/repository.go index c916089..8f2c41e 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -178,6 +178,7 @@ type KlijentRepository interface { ListaFilter(ctx context.Context, filter KlijentFilter) ([]model.Klijent, error) PrebrojiPoFilteru(ctx context.Context, filter KlijentFilter) (int, error) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) + Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) Izmeni(ctx context.Context, k *model.Klijent) error Obrisi(ctx context.Context, id int64) error @@ -201,6 +202,8 @@ type ServisRepository interface { AzurirajCenaDijagnostike(ctx context.Context, id int64, cena float64) error OdbijPopravku(ctx context.Context, id int64, cena float64) error AzurirajKomentarKlijenta(ctx context.Context, id int64, tekst string) error + SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error + ObrisiOdlukuKlijenta(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64, korisnikID *int64) error SledeciBroj(ctx context.Context) (string, error) } @@ -236,6 +239,7 @@ type ServisniPotrazivaniDeloviRepository interface { DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniPotrazivaniDeo, error) Obrisi(ctx context.Context, id int64) error ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error + ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error // ProveriIPocistiZaArtikal proverava potraživane redove za dati artikal nakon // povećanja stanja i briše/smanjuje redove koji se mogu pokriti (FIFO). // Vraća ID-eve naloga čiji je poslednji potraživani red obrisan. diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 98a29d2..19cf72f 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "database/sql" + "errors" "fmt" "ntech/internal/db" @@ -96,6 +97,115 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, return &k, nil } +// Pronadji traži postojećeg klijenta po imenu i prezimenu (fizičko) ili nazivu firme (pravno). +// Vraća nil, nil ako nije pronađen. +// Pretraga ide od najočiglednijeg ka opštijem: JMBG/PIB → ime+prezime+mesto → telefon → email → samo ime+prezime. +func (r *KlijentRepo) Pronadji(ctx context.Context, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mesto string) (*model.Klijent, error) { + var k model.Klijent + var imeN, prezimeN, jmbgN, tipIdent, nazivFirmeN, pibN, telefonN, emailN, mestoN, napomena sql.NullString + + skeniraj := func(row *sql.Row) error { + return row.Scan( + &k.ID, &k.Tip, &imeN, &prezimeN, &jmbgN, &tipIdent, &nazivFirmeN, &pibN, &telefonN, &emailN, &mestoN, &napomena, &k.DatumUnosa, + ) + } + + var row *sql.Row + + if tip == "pravno" { + // 1. PIB (najpreciznije za firmu) + if jmbg != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip = 'pravno' AND pib = ? LIMIT 1`, jmbg) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 2. Naziv firme + mesto + if nazivFirme != "" && mesto != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? AND mesto = ? LIMIT 1`, nazivFirme, mesto) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 3. Samo naziv firme + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip = 'pravno' AND naziv_firme = ? LIMIT 1`, nazivFirme) + if err := skeniraj(row); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) + } + } else { + // Fizičko lice — više nivoa + // 1. JMBG ili broj lične karte + if jmbg != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip != 'pravno' AND jmbg = ? LIMIT 1`, jmbg) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 2. Ime + prezime + mesto + if ime != "" && prezime != "" && mesto != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND mesto = ? LIMIT 1`, ime, prezime, mesto) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 3. Ime + prezime + telefon + if ime != "" && prezime != "" && telefon != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND telefon = ? LIMIT 1`, ime, prezime, telefon) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 4. Ime + prezime + email + if ime != "" && prezime != "" && email != "" { + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? AND email = ? LIMIT 1`, ime, prezime, email) + if err := skeniraj(row); err == nil { + goto popuni + } + } + // 5. Samo ime + prezime (poslednji fallback) + row = r.db.QueryRowContext(ctx, ` + SELECT id, tip, ime, prezime, jmbg, tip_identifikacije, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa + FROM klijenti WHERE tip != 'pravno' AND ime = ? AND prezime = ? LIMIT 1`, ime, prezime) + if err := skeniraj(row); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("ntech: KlijentRepo.Pronadji: %w", err) + } + } + +popuni: + k.Ime = imeN.String + k.Prezime = prezimeN.String + k.JMBG = jmbgN.String + k.TipIdentifikacije = tipIdent.String + k.NazivFirme = nazivFirmeN.String + k.PIB = pibN.String + k.Telefon = telefonN.String + k.Email = emailN.String + k.Mesto = mestoN.String + k.Napomena = napomena.String + + return &k, nil +} + // Kreiraj dodaje novog klijenta u bazu func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) { if k.Tip == "" { diff --git a/internal/db/sqlite/servis.go b/internal/db/sqlite/servis.go index e09d408..34fcccc 100644 --- a/internal/db/sqlite/servis.go +++ b/internal/db/sqlite/servis.go @@ -58,10 +58,11 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj, sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna, sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum, - sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, + sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, COALESCE(kp.naziv, '') AS klijent_naziv, (EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1) - OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)) AS ima_predlog + OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1) + OR EXISTS(SELECT 1 FROM servisni_potrazivani_delovi spd WHERE spd.nalog_id = sn.id AND spd.predlozeno = 1)) AS ima_predlog FROM servisni_nalozi sn LEFT JOIN klijent_prikaz kp ON kp.id = sn.klijent_id WHERE 1=1` @@ -107,7 +108,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, - ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta + ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke FROM servisni_nalozi WHERE id = ?`, id) var n model.ServisniNalog @@ -159,7 +160,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum, - ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta + ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke FROM servisni_nalozi WHERE javni_token = ?`, token) var n model.ServisniNalog @@ -349,6 +350,30 @@ func (r *ServisRepo) AzurirajKomentarKlijenta(ctx context.Context, id int64, tek return nil } +// ObrisiOdlukuKlijenta poništava prethodnu odluku klijenta (npr. kad se doda novi predlog). +func (r *ServisRepo) ObrisiOdlukuKlijenta(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, + "UPDATE servisni_nalozi SET odluka_klijenta = NULL, komentar_klijenta = NULL, datum_odluke = NULL WHERE id = ?", id, + ) + if err != nil { + return fmt.Errorf("ntech: ServisRepo.ObrisiOdlukuKlijenta: %w", err) + } + return nil +} + +// SacuvajOdlukuKlijenta beleži odluku klijenta (prihvaceno/odbijeno) i poruku. +// Ako je prihvaćeno, poziva PrihvatiPredlozene za delove i radove. +func (r *ServisRepo) SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error { + _, err := r.db.ExecContext(ctx, + "UPDATE servisni_nalozi SET odluka_klijenta = ?, komentar_klijenta = ?, datum_odluke = CURRENT_TIMESTAMP WHERE id = ?", + odluka, odgovor, id, + ) + if err != nil { + return fmt.Errorf("ntech: ServisRepo.SacuvajOdlukuKlijenta: %w", err) + } + return nil +} + // Obrisi briše servisni nalog po ID-u // Obrisi briše servisni nalog i vraća ugrađene delove na stanje u magacinu (u transakciji) func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error { @@ -430,9 +455,9 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er // klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string, imaPredlog *bool) error { var klijentID, tehnicarID sql.NullInt64 - var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta sql.NullString + var serijskiBroj, napomena, ostecenja, pinUredjaja, pribor, napomenaKlijentu, nalazDijagnostike, uradjeno, javniToken, komentarKlijenta, odlukaKlijenta sql.NullString var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64 - var garancijaDo, datumZavrsetka, predvidjenDatum sql.NullTime + var garancijaDo, datumZavrsetka, predvidjenDatum, datumOdluke sql.NullTime var garancijaDana sql.NullInt64 var popravkaOdbijena sql.NullInt64 @@ -440,7 +465,7 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st &n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj, &n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna, &avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum, - &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, + &ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke, } if klijentNaziv != nil { @@ -505,6 +530,11 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st n.PopravkaOdbijena = popravkaOdbijena.Int64 != 0 n.JavniToken = javniToken.String n.KomentarKlijenta = komentarKlijenta.String + n.OdlukaKlijenta = odlukaKlijenta.String + if datumOdluke.Valid { + v := datumOdluke.Time + n.DatumOdluke = &v + } return nil } diff --git a/internal/db/sqlite/servisni_delovi.go b/internal/db/sqlite/servisni_delovi.go index 3c722d5..8da3311 100644 --- a/internal/db/sqlite/servisni_delovi.go +++ b/internal/db/sqlite/servisni_delovi.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "log/slog" "ntech/internal/model" ) @@ -23,7 +24,7 @@ func NoviServisniDeloviRepo(db *sql.DB) *ServisniDeloviRepo { func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) { redovi, err := r.db.QueryContext(ctx, ` SELECT sd.id, sd.nalog_id, sd.artikal_id, sd.kolicina, sd.cena_komada, sd.datum, - sd.predlozeno, a.naziv + sd.predlozeno, a.naziv, a.sifra FROM servisni_delovi sd JOIN artikli a ON a.id = sd.artikal_id WHERE sd.nalog_id = ? @@ -38,7 +39,7 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) var d model.ServisniDeoSaArtiklom err := redovi.Scan( &d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum, - &d.Predlozeno, &d.ArtikalNaziv, + &d.Predlozeno, &d.ArtikalNaziv, &d.ArtikalSifra, ) if err != nil { return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err) @@ -53,25 +54,52 @@ func (r *ServisniDeloviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) // 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. +// +// Predloženi delovi (predlozeno=true) ne skidaju sa lagera — sve ide u potraživane +// do odluke klijenta. Tek PrihvatiPredlozene skida sa lagera. func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) { - predInt := 0 - if predlozeno { - predInt = 1 - } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: begin tx: %w", err) } defer tx.Rollback() + ugradjeno, nedostaje, err = ugradiIliPotrazujTx(ctx, tx, nalogID, artikalID, kolicina, cenaKomada, korisnikID, predlozeno) + if err != nil { + return 0, 0, err + } + + if err = tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err) + } + return ugradjeno, nedostaje, nil +} + +// ugradiIliPotrazujTx je interna verzija koja radi unutar postojeće transakcije. +func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64, predlozeno bool) (ugradjeno, nedostaje int, err error) { + // Predloženi delovi: ne skidaju sa lagera, svaki predlog je poseban red (ne merge) + if predlozeno { + slog.Info("PREDLOG_INSERT", "nalogID", nalogID, "artikalID", artikalID, "kolicina", kolicina) + _, err = tx.ExecContext(ctx, + "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 1)", + nalogID, artikalID, kolicina, cenaKomada, + ) + if err != nil { + slog.Error("PREDLOG_INSERT_ERR", "err", err) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: predlozeni: %w", err) + } + slog.Info("PREDLOG_INSERT_OK") + return 0, kolicina, nil + } + + // Ugrađeni delovi: standardna logika — skidamo sa lagera var stanjePre int 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) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err) } - // ugrađujemo najviše onoliko koliko fizički imamo; ostatak ide u potraživane ugradjeno = kolicina if ugradjeno > stanjePre { ugradjeno = stanjePre @@ -81,75 +109,69 @@ func (r *ServisniDeloviRepo) UgradiIliPotrazuj(ctx context.Context, nalogID, art } nedostaje = kolicina - ugradjeno - // 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 = ? AND predlozeno = ?", - nalogID, artikalID, predInt, + "SELECT id, kolicina FROM servisni_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0", + 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) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: 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, predlozeno) - VALUES (?, ?, ?, ?, ?)`, - nalogID, artikalID, ugradjeno, cenaKomada, predInt, + if _, err = tx.ExecContext(ctx, + "INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)", + nalogID, artikalID, ugradjeno, cenaKomada, ); err != nil { - return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: insert: %w", err) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err) } } else { - return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: proveri: %w", errRed) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: proveri: %w", errRed) } 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) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: 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) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: 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 = ? AND predlozeno = ?", - nalogID, artikalID, predInt, + "SELECT id, kolicina FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 0", + 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) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: 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, predlozeno) VALUES (?, ?, ?, ?, ?)", - nalogID, artikalID, nedostaje, cenaKomada, predInt, + "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)", + nalogID, artikalID, nedostaje, cenaKomada, ); err != nil { - return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani insert: %w", err) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani insert: %w", err) } } else { - return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: potraživani proveri: %w", errRed) + return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: potraživani proveri: %w", errRed) } } - if err = tx.Commit(); err != nil { - return 0, 0, fmt.Errorf("ntech: ServisniDeloviRepo.UgradiIliPotrazuj: commit: %w", err) - } return ugradjeno, nedostaje, nil } @@ -207,6 +229,7 @@ func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *i // PrihvatiPredlozene prebacuje sve predložene delove (i potraživane) naloga u // ugrađene (predlozeno → 0) — poziva se kad klijent prihvati predlog popravke. +// Sada zaista skida sa lagera: predloženi delovi prethodno nisu dirali lager. func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int64) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -214,15 +237,51 @@ func (r *ServisniDeloviRepo) PrihvatiPredlozene(ctx context.Context, nalogID int } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, - "UPDATE servisni_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID, - ); err != nil { - return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: delovi: %w", err) + // Dohvati sve predložene potraživane delove + redovi, err := tx.QueryContext(ctx, + "SELECT id, artikal_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", + nalogID, + ) + if err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: dohvati predlozene: %w", err) } + defer redovi.Close() + + type predlozeniStavka struct { + id int64 + artikalID int64 + kolicina int + cenaKomada float64 + } + var stavke []predlozeniStavka + for redovi.Next() { + var s predlozeniStavka + if err := redovi.Scan(&s.id, &s.artikalID, &s.kolicina, &s.cenaKomada); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: scan: %w", err) + } + stavke = append(stavke, s) + } + + // Za svaki predloženi deo: probaj da ugradiš (skine sa lagera koliko može) + for _, s := range stavke { + _, _, err := ugradiIliPotrazujTx(ctx, tx, nalogID, s.artikalID, s.kolicina, s.cenaKomada, nil, false) + if err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: ugradi: %w", err) + } + } + + // Obriši predložene potraživane (prebačeni su u ugrađene/potraživane bez predlozeno) if _, err := tx.ExecContext(ctx, - "UPDATE servisni_potrazivani_delovi SET predlozeno = 0 WHERE nalog_id = ? AND predlozeno = 1", nalogID, + "DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID, ); err != nil { - return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: potraživani: %w", err) + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi potraživane: %w", err) + } + + // Očisti i servisni_delovi sa predlozeno=1 (zaostali iz starije verzije) + if _, err := tx.ExecContext(ctx, + "DELETE FROM servisni_delovi WHERE nalog_id = ? AND predlozeno = 1", nalogID, + ); err != nil { + return fmt.Errorf("ntech: ServisniDeloviRepo.PrihvatiPredlozene: obrisi delove: %w", err) } if err := tx.Commit(); err != nil { diff --git a/internal/db/sqlite/servisni_potrazivani_delovi.go b/internal/db/sqlite/servisni_potrazivani_delovi.go index 6b3ab96..078f9bf 100644 --- a/internal/db/sqlite/servisni_potrazivani_delovi.go +++ b/internal/db/sqlite/servisni_potrazivani_delovi.go @@ -54,6 +54,18 @@ func (r *ServisniPotrazivaniDeloviRepo) Obrisi(ctx context.Context, id int64) er return nil } +// ObrisiPredlozeneZaArtikal briše samo predlozene redove (predlozeno=1) za dati artikal na nalogu +func (r *ServisniPotrazivaniDeloviRepo) ObrisiPredlozeneZaArtikal(ctx context.Context, nalogID, artikalID int64) error { + _, err := r.db.ExecContext(ctx, + "DELETE FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND artikal_id = ? AND predlozeno = 1", + nalogID, artikalID, + ) + if err != nil { + return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal: %w", err) + } + return nil +} + // ObrisiZaArtikal briše sve potraživane delove za dati artikal na datom nalogu func (r *ServisniPotrazivaniDeloviRepo) ObrisiZaArtikal(ctx context.Context, nalogID, artikalID int64) error { _, err := r.db.ExecContext(ctx, @@ -96,7 +108,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con } redovi, err := tx.QueryContext(ctx, - "SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? ORDER BY datum", + "SELECT id, nalog_id, kolicina, cena_komada FROM servisni_potrazivani_delovi WHERE artikal_id = ? AND predlozeno = 0 ORDER BY datum", artikalID, ) if err != nil { @@ -165,7 +177,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con for nalogID := range obrisaniNalozi { var preostalo int if err := tx.QueryRowContext(ctx, - "SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ?", nalogID, + "SELECT COUNT(*) FROM servisni_potrazivani_delovi WHERE nalog_id = ? AND predlozeno = 0", nalogID, ).Scan(&preostalo); err != nil { return nil, fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal: prebroji: %w", err) } @@ -187,7 +199,7 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq var postojeciID int64 var postojeciKol int err := 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 = ? AND predlozeno = 0", nalogID, artikalID, ).Scan(&postojeciID, &postojeciKol) diff --git a/internal/db/sqlite/servisni_potrazivani_delovi_test.go b/internal/db/sqlite/servisni_potrazivani_delovi_test.go index 35a6f23..deab773 100644 --- a/internal/db/sqlite/servisni_potrazivani_delovi_test.go +++ b/internal/db/sqlite/servisni_potrazivani_delovi_test.go @@ -124,6 +124,50 @@ func TestPotrazivaniDelimicnoPokrivanje(t *testing.T) { } } +// TestPotrazivaniPredlozeniSeNePokrivaju: predloženi delovi (predlozeno=1) ne smeju +// biti auto-konvertovani u ugrađene niti smeti otključavanje naloga. +func TestPotrazivaniPredlozeniSeNePokrivaju(t *testing.T) { + db := testDB(t) + ctx := context.Background() + + if _, err := db.ExecContext(ctx, "INSERT INTO artikli (id, naziv, kolicina) VALUES (1, 'GPU', 5)"); 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', 'U dijagnostici')", + ); err != nil { + t.Fatalf("insert nalog: %v", err) + } + // predloženi deo serviser predlaže klijentu (predlozeno=1) — ne sme biti auto-ugradjen + if _, err := db.ExecContext(ctx, + "INSERT INTO servisni_potrazivani_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (1, 1, 3, 100, 1)", + ); err != nil { + t.Fatalf("insert predlozeni: %v", err) + } + + potrRepo := NoviServisniPotrazivaniDeloviRepo(db) + otkljucani, err := potrRepo.ProveriIPocistiZaArtikal(ctx, 1) + if err != nil { + t.Fatalf("ProveriIPocistiZaArtikal: %v", err) + } + // nalog se NE otključava — predlozeni redovi se ignorišu + if len(otkljucani) != 0 { + t.Fatalf("nalog ne sme da se otključa zbog predlozenih redova, dobijeno %v", otkljucani) + } + // predlozeni red mora ostati nepromenjen + if k := skalarInt(t, db, "SELECT kolicina FROM servisni_potrazivani_delovi WHERE nalog_id=1 AND predlozeno=1"); k != 3 { + t.Fatalf("predlozeni deo mora ostati 3, dobijeno %d", k) + } + // magacin se ne sme dirljati + if stanje := skalarInt(t, db, "SELECT kolicina FROM artikli WHERE id=1"); stanje != 5 { + t.Fatalf("stanje magacina mora ostati 5, dobijeno %d", stanje) + } + // u servisni_delovi ne sme biti ništa ubačeno + if k := skalarInt(t, db, "SELECT COUNT(*) FROM servisni_delovi WHERE nalog_id=1"); k != 0 { + t.Fatalf("servisni_delovi mora biti prazan, dobijeno %d", k) + } +} + // 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() diff --git a/internal/db/sqlite/servisni_radovi.go b/internal/db/sqlite/servisni_radovi.go index 28a1e82..bf8a8ed 100644 --- a/internal/db/sqlite/servisni_radovi.go +++ b/internal/db/sqlite/servisni_radovi.go @@ -21,8 +21,11 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo { // DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) { redovi, err := r.db.QueryContext(ctx, ` - SELECT id, nalog_id, COALESCE(usluga_id, 0), naziv, kolicina, cena_komada, datum, predlozeno - FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID) + SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno, + COALESCE(u.sifra, '') + FROM servis_radovi sr + LEFT JOIN usluge u ON u.id = sr.usluga_id + WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID) if err != nil { return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err) } @@ -31,7 +34,7 @@ func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) var radovi []model.ServisniRad for redovi.Next() { var rad model.ServisniRad - if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno); err != nil { + if err := redovi.Scan(&rad.ID, &rad.NalogID, &rad.UslugaID, &rad.Naziv, &rad.Kolicina, &rad.CenaKomada, &rad.Datum, &rad.Predlozeno, &rad.Sifra); err != nil { return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err) } radovi = append(radovi, rad) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 31282b9..8f7803e 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -535,8 +535,11 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { continue } for _, nalogID := range otkljucani { - if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil { - slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err) + // reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd. + if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove { + if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil { + slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err) + } } } } @@ -747,11 +750,13 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { imeArtikla = art.Naziv } - // forma „Predloženi delovi" šalje predlozeno=1; forma „Ugrađeni delovi" ne šalje - predlozeno := r.FormValue("predlozeno") == "1" - + // predlog ako status nije „Primljeno" (forma više ne određuje — server odlučuje) + nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) + predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno + slog.Info("DODAJ_DEO_IN", "status", nalog.Status, "predlozeno", predlozeno, "kol", kolicina) // 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, predlozeno) + slog.Info("DODAJ_DEO_OUT", "ugradjeno", ugradjeno, "nedostaje", nedostaje, "err", err) if err != nil { slog.Error("greška pri dodavanju dela", "error", err) middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela.") @@ -759,8 +764,9 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { return } - // ako nešto nedostaje, prebaci nalog u „Čeka delove" i obavesti korisnika - if nedostaje > 0 { + // ako nešto nedostaje kod ugrađenog dela, prebaci nalog u „Čeka delove". + // Za predložene delove ne menjamo status — to su samo predlozi, ne blokiraju rad. + if nedostaje > 0 && !predlozeno { if e := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusCekaDelove); e != nil { slog.Error("greška pri prebacivanju naloga u Čeka delove", "error", e) } @@ -775,6 +781,16 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) { } } + if nedostaje > 0 && predlozeno { + middleware.SetFlash(w, r, h.DB, "uspeh", + "Predlog sačuvan — "+strconv.Itoa(kolicina)+" kom. „"+imeArtikla+ + "\" čeka odobrenje klijenta.") + // novi predlog poništava prethodnu odluku klijenta + if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil { + slog.Error("greška pri brisanju odluke klijenta", "error", err) + } + } + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther) } @@ -795,26 +811,43 @@ func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]m artikalID int64 predlozeno bool } - potrMap := make(map[kljucDela]int) + type potrVrednost struct { + kolicina int + cena float64 + } + potrMap := make(map[kljucDela]potrVrednost) for _, p := range potrazivani { - potrMap[kljucDela{p.ArtikalID, p.Predlozeno}] += p.Kolicina + k := kljucDela{p.ArtikalID, p.Predlozeno} + v := potrMap[k] + v.kolicina += p.Kolicina + if v.cena == 0 && p.CenaKomada > 0 { + v.cena = p.CenaKomada + } + potrMap[k] = v } for i := range delovi { k := kljucDela{delovi[i].ArtikalID, delovi[i].Predlozeno} - if kol, ok := potrMap[k]; ok { - delovi[i].Potrazivano = kol + if v, ok := potrMap[k]; ok { + delovi[i].Potrazivano = v.kolicina delete(potrMap, k) } } - for k, kol := range potrMap { + for k, v := range potrMap { naziv := "" + sifra := "" if art, e := h.Artikli.DohvatiID(ctx, k.artikalID); e == nil && art != nil { naziv = art.Naziv + sifra = art.Sifra } delovi = append(delovi, model.ServisniDeoSaArtiklom{ ArtikalNaziv: naziv, - ServisniDeo: model.ServisniDeo{ArtikalID: k.artikalID, Predlozeno: k.predlozeno}, - Potrazivano: kol, + ArtikalSifra: sifra, + ServisniDeo: model.ServisniDeo{ + ArtikalID: k.artikalID, + Kolicina: v.kolicina, + CenaKomada: v.cena, + Predlozeno: k.predlozeno, + }, }) } return delovi, nil @@ -853,6 +886,28 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) } +// ObrisiPredlozeniDeoNaloga briše predlozene redove (predlozeno=1) za dati artikal na nalogu +func (h *Handler) ObrisiPredlozeniDeoNaloga(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok { + return + } + nalogID, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) + return + } + artikalID, err := parseID(chi.URLParam(r, "artikal_id")) + if err != nil { + http.Error(w, "Neispravan ID artikla", http.StatusBadRequest) + return + } + if err := h.ServisniPotrazivaniDeloviRepo.ObrisiPredlozeneZaArtikal(r.Context(), nalogID, artikalID); err != nil { + slog.Error("greška pri brisanju predloženog dela", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju predloženog dela.") + } + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) +} + // DodajRadNalogu dodaje stavku rada (uslugu) na nalog; naziv i cena se snapshot-uju func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) { if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok { @@ -898,8 +953,9 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) { return } - // forma „Predložene usluge" šalje predlozeno=1; forma „Radovi" ne šalje - predlozeno := r.FormValue("predlozeno") == "1" + // predlog ako status nije „Primljeno" (server odlučuje, ne forma) + nalog, _ := h.ServisRepo.DohvatiID(r.Context(), nalogID) + predlozeno := nalog != nil && nalog.Status != model.StatusPrimljeno if _, err := h.ServisniRadoviRepo.Dodaj(r.Context(), nalogID, uslugaID, usluga.Naziv, kolicina, cena, predlozeno); err != nil { slog.Error("greška pri dodavanju rada", "error", err) @@ -908,6 +964,13 @@ func (h *Handler) DodajRadNalogu(w http.ResponseWriter, r *http.Request) { return } + // novi predlog poništava prethodnu odluku klijenta + if predlozeno { + if err := h.ServisRepo.ObrisiOdlukuKlijenta(r.Context(), nalogID); err != nil { + slog.Error("greška pri brisanju odluke klijenta", "error", err) + } + } + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther) } @@ -1052,10 +1115,26 @@ func (h *Handler) mozdaKreirajKlijenta(ctx context.Context, r *http.Request, nal tipIdent = "jmbg" // prazan unos — podrazumevani tip radi konzistentnosti baze } + prezime := strings.TrimSpace(r.FormValue("prezime")) + telefon := strings.TrimSpace(r.FormValue("telefon")) + mestoLok := strings.TrimSpace(r.FormValue("mesto")) + + // prvo proveri da li klijent već postoji u bazi + postojeci, err := h.KlijentiRepo.Pronadji(ctx, tip, ime, prezime, nazivFirme, jmbg, telefon, email, mestoLok) + if err != nil { + slog.Error("greška pri pretrazi klijenta", "error", err) + return "Došlo je do greške pri proveri klijenta. Pokušajte ponovo." + } + if postojeci != nil { + nalog.KlijentID = &postojeci.ID + return "" + } + + // klijent ne postoji — kreiraj novog klijent := model.Klijent{ Tip: tip, Ime: ime, - Prezime: strings.TrimSpace(r.FormValue("prezime")), + Prezime: prezime, JMBG: jmbg, TipIdentifikacije: tipIdent, NazivFirme: nazivFirme, @@ -2008,24 +2087,24 @@ func (h *Handler) OdbijPopravku(w http.ResponseWriter, r *http.Request) { // PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga type PodaciJavnogStatusa struct { - Nalog model.ServisniNalog - NazivFirme string - LogoPutanja string - Podnazlov string - Adresa string - Telefon string - PIB string - MaticniBroj string - Radovi []model.ServisniRad // ugrađeni/odobreni - ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni - UkupnoSve float64 // zbir ugrađenih (radovi + delovi) - PredlozeniRadovi []model.ServisniRad - PredlozeniDelovi []model.ServisniDeoSaArtiklom - UkupnoPredlog float64 // zbir predloženih (radovi + delovi) - ImaPredlog bool - UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo - Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno") - SviStatusi []string + Nalog model.ServisniNalog + NazivFirme string + LogoPutanja string + Podnazlov string + Adresa string + Telefon string + PIB string + MaticniBroj string + Radovi []model.ServisniRad // ugrađeni/odobreni + ServisniDelovi []model.ServisniDeoSaArtiklom // ugrađeni/odobreni + UkupnoSve float64 // zbir ugrađenih (radovi + delovi) + PredlozeniRadovi []model.ServisniRad + PredlozeniDelovi []model.ServisniDeoSaArtiklom + UkupnoPredlog float64 // zbir predloženih (radovi + delovi) + ImaPredlog bool + UkupnoSaPredlogom float64 // ukupno ugrađeno + predloženo + Odgovoreno bool // klijent već odgovorio (nema predloga, nije „Primljeno") + SviStatusi []string } // ServisJavniStatus prikazuje javnu status stranicu — dostupna bez prijave putem QR koda @@ -2077,24 +2156,24 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) { podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{ - Nalog: *nalog, - NazivFirme: podesavanja["naziv_firme"], - LogoPutanja: logoZaDokument(podesavanja), - Podnazlov: podesavanja["podnazlov"], - Adresa: podesavanja["adresa"], - Telefon: podesavanja["telefon"], - PIB: podesavanja["pib"], - MaticniBroj: podesavanja["maticni_broj"], - Radovi: ugrRadovi, - ServisniDelovi: ugrDelovi, - UkupnoSve: ukupnoSve, - PredlozeniRadovi: predRadovi, - PredlozeniDelovi: predDelovi, - UkupnoPredlog: ukupnoPredlog, + Nalog: *nalog, + NazivFirme: podesavanja["naziv_firme"], + LogoPutanja: logoZaDokument(podesavanja), + Podnazlov: podesavanja["podnazlov"], + Adresa: podesavanja["adresa"], + Telefon: podesavanja["telefon"], + PIB: podesavanja["pib"], + MaticniBroj: podesavanja["maticni_broj"], + Radovi: ugrRadovi, + ServisniDelovi: ugrDelovi, + UkupnoSve: ukupnoSve, + PredlozeniRadovi: predRadovi, + PredlozeniDelovi: predDelovi, + UkupnoPredlog: ukupnoPredlog, ImaPredlog: len(predRadovi) > 0 || len(predDelovi) > 0, UkupnoSaPredlogom: ukupnoSve + ukupnoPredlog, - Odgovoreno: !(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno, - SviStatusi: model.SviStatusi, + Odgovoreno: nalog.OdlukaKlijenta != "" || (!(len(predRadovi) > 0 || len(predDelovi) > 0) && nalog.Status != model.StatusPrimljeno), + SviStatusi: model.SviStatusi, }) } @@ -2136,6 +2215,12 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) { return } + // odluka je već doneta — ne dozvoljavamo ponovno glasanje + if nalog.OdlukaKlijenta != "" { + http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) + return + } + if err := h.ServisniRadoviRepo.PrihvatiPredlozene(r.Context(), nalog.ID); err != nil { slog.Error("greška pri prihvatanju predloženih radova", "error", err, "nalog_id", nalog.ID) http.Error(w, "Greška pri prihvatanju predloga.", http.StatusInternalServerError) @@ -2147,11 +2232,9 @@ func (h *Handler) ServisJavniPrihvati(w http.ResponseWriter, r *http.Request) { return } - // sačuvaj komentar klijenta - if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" { - if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil { - slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID) - } + komentar := strings.TrimSpace(r.FormValue("komentar")) + if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "prihvaceno", komentar); err != nil { + slog.Error("greška pri čuvanju odluke klijenta", "error", err, "nalog_id", nalog.ID) } http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) @@ -2199,6 +2282,12 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) { return } + // odluka je već doneta — ne dozvoljavamo ponovno glasanje + if nalog.OdlukaKlijenta != "" { + http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) + return + } + // brišemo predložene stavke — klijent ih je odbio if err := h.ServisniRadoviRepo.ObrisiPredlozene(r.Context(), nalog.ID); err != nil { slog.Error("greška pri brisanju predloženih radova", "error", err, "nalog_id", nalog.ID) @@ -2207,11 +2296,9 @@ func (h *Handler) ServisJavniOdbij(w http.ResponseWriter, r *http.Request) { slog.Error("greška pri brisanju predloženih delova", "error", err, "nalog_id", nalog.ID) } - // sačuvaj komentar klijenta - if komentar := strings.TrimSpace(r.FormValue("komentar")); komentar != "" { - if err := h.ServisRepo.AzurirajKomentarKlijenta(r.Context(), nalog.ID, komentar); err != nil { - slog.Error("greška pri čuvanju komentara klijenta", "error", err, "nalog_id", nalog.ID) - } + komentar := strings.TrimSpace(r.FormValue("komentar")) + if err := h.ServisRepo.SacuvajOdlukuKlijenta(r.Context(), nalog.ID, "odbijeno", komentar); err != nil { + slog.Error("greška pri čuvanju odluke klijenta", "error", err, "nalog_id", nalog.ID) } http.Redirect(w, r, "/status/"+token, http.StatusSeeOther) diff --git a/internal/model/servis.go b/internal/model/servis.go index c7df942..76c61dc 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -50,11 +50,13 @@ type ServisniNalog struct { PinUredjaja string Pribor string NapomenaKlijentu string - NalazDijagnostike string // šta je serviser utvrdio pregledom (dijagnoza + predlog popravke) - Uradjeno string // šta je serviser stvarno uradio tokom popravke - CenaDijagnostike float64 // taksa kad klijent ne prihvati popravku; 0 = ne naplaćuje se - PopravkaOdbijena bool // klijent odbio popravku posle dijagnostike → naplaćuje se samo dijagnostika - KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga + NalazDijagnostike string // šta je serviser utvrdio pregledom (dijagnoza + predlog popravke) + Uradjeno string // šta je serviser stvarno uradio tokom popravke + CenaDijagnostike float64 // taksa kad klijent ne prihvati popravku; 0 = ne naplaćuje se + PopravkaOdbijena bool // klijent odbio popravku posle dijagnostike → naplaćuje se samo dijagnostika + KomentarKlijenta string // poruka klijenta uz prihvatanje/odbijanje predloga + OdlukaKlijenta string // '' / 'prihvaceno' / 'odbijeno' + DatumOdluke *time.Time // kada je klijent doneo odluku JavniToken string } @@ -78,6 +80,7 @@ func (d ServisniDeo) Ukupno() float64 { type ServisniDeoSaArtiklom struct { ServisniDeo ArtikalNaziv string + ArtikalSifra string Potrazivano int // komada koji nedostaju (iz servisni_potrazivani_delovi), 0 ako nema } @@ -102,6 +105,7 @@ type ServisniRad struct { NalogID int64 UslugaID int64 // 0 ako je usluga u međuvremenu obrisana iz cenovnika Naziv string + Sifra string Kolicina float64 CenaKomada float64 Datum string diff --git a/migrations/079_servis_odluka_klijenta.sql b/migrations/079_servis_odluka_klijenta.sql new file mode 100644 index 0000000..a557bd8 --- /dev/null +++ b/migrations/079_servis_odluka_klijenta.sql @@ -0,0 +1,3 @@ +-- odluka klijenta o predloženim delovima/uslugama (prihvatanje/odbijanje) +ALTER TABLE servisni_nalozi ADD COLUMN odluka_klijenta TEXT DEFAULT ''; +ALTER TABLE servisni_nalozi ADD COLUMN datum_odluke DATETIME; diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index e1cd856..e2e0d4f 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -9,6 +9,8 @@ .detalji-kartica:nth-child(3) { animation-delay: 0.20s; } .detalji-kartica:nth-child(4) { animation-delay: 0.28s; } .detalji-kartica:nth-child(5) { animation-delay: 0.36s; } + /* bez animacija na detaljima — smetaju pri dodavanju/brisanju stavki */ + #sadrzaj .kartica.animiraj { animation: none; } {{end}} @@ -158,8 +160,8 @@ Nalepnica (reprint) {{end}} - {{/* nalog gotov/preuzet — nema više izmena */}} - {{if and (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}} + {{/* nalog u popravci ili gotov/preuzet — nema više izmena */}} + {{if and (ne .Nalog.Status "U popravci") (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}} Izmeni nalog @@ -441,6 +443,7 @@ {{end}} +
{{/* kartice radova/delova se ne prikazuju kad je popravka odbijena (samo dijagnostika) ni kad je nalog „Završeno"/„Preuzeto" (posao gotov — sume ostaju u kartici Ukupno) */}} {{if and (not .Nalog.PopravkaOdbijena) (ne .Nalog.Status "Završeno") (ne .Nalog.Status "Preuzeto")}} @@ -452,7 +455,7 @@
- {{if index .Dozvole "servis.izmeni"}} + {{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "U popravci")}}
{{if .ZakljucaniUgradjeni}}{{end}} @@ -487,6 +490,7 @@ Usluga + Šifra Kol. Cena/kom Ukupno @@ -497,6 +501,7 @@ {{range .ServisniRadovi}} {{.Naziv}} + {{.Sifra}} {{.Kolicina}} {{dinari .CenaKomada}} din {{dinari .Ukupno}} din @@ -517,11 +522,12 @@ {{range .PredlozeniRadovi}} {{.Naziv}} Predlog + {{.Sifra}} {{.Kolicina}} {{dinari .CenaKomada}} din {{dinari .Ukupno}} din - {{if index $.Dozvole "servis.izmeni"}} + {{if and (index $.Dozvole "servis.izmeni") (ne $.Nalog.Status "U popravci")}} + style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:16px;padding:2px 6px;border-radius:4px;">×
+ {{else if .ArtikalID}} +
+ + +
+ {{end}} {{end}} @@ -665,8 +686,32 @@ {{end}} + {{end}} + {{/* kraj stavke-blok */}} @@ -739,7 +784,8 @@ document.addEventListener('change', function (e) { // cenu i prikaz „U magacinu". Običan + + {{range .Tehnicari}} + + {{end}} + + @@ -237,20 +251,6 @@ - - -
- - -
{{end}} diff --git a/web/templates/stranice/servis_predracun.html b/web/templates/stranice/servis_predracun.html index a6ac956..2cca71a 100644 --- a/web/templates/stranice/servis_predracun.html +++ b/web/templates/stranice/servis_predracun.html @@ -53,14 +53,19 @@ .strana { padding: 16px 20px; max-width: 100%; } .dugme-stampa { display: none !important; } @page { size: A4; margin: 12mm 14mm; } + .tabela tr { break-inside: avoid; } + .tabela .ukupno-red { break-before: avoid; } + .tabela .ukupno-red td { break-before: avoid; } + .tabela thead { display: table-header-group; } + .zavrsni-blok { break-inside: avoid; } /* red tabele se ne sme prelomiti preko granice stranice */ .tabela tr { break-inside: avoid; } /* „Ukupno radovi/delovi" se ne sme odvojiti od stavki iznad — bez preloma pre njega */ .tabela .ukupno-red { break-before: avoid; } .tabela .ukupno-red td { break-before: avoid; } - /* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */ + /* zaglavlje tabele se ponavlja na svakoj stranici */ .tabela thead { display: table-header-group; } - /* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani — bez preloma */ + /* od „Ukupno (procena)" do potpisa sve ostaje na jednoj strani */ .zavrsni-blok { break-inside: avoid; } } .dugme-stampa { position: fixed; bottom: 24px; right: 24px; background: #111; color: #fff; border: none; padding: 12px 22px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } diff --git a/web/templates/stranice/servis_radni_nalog.html b/web/templates/stranice/servis_radni_nalog.html index ce0e4a1..599d194 100644 --- a/web/templates/stranice/servis_radni_nalog.html +++ b/web/templates/stranice/servis_radni_nalog.html @@ -50,15 +50,21 @@ .upis-veliki { min-height: 110px; } .upis-labela { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; } - /* zavrsetak — vreme i potpis */ - .zavrsetak { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 28px; padding-top: 16px; border-top: 0.5px solid #ccc; } - .linija { border-top: 1px solid #888; margin-top: 40px; padding-top: 6px; font-size: 11px; color: #555; } + /* potpis */ + .zavrsetak { margin-top: 14px; padding-top: 10px; border-top: 0.5px solid #ccc; } + .linija { border-top: 1px solid #888; margin-top: 24px; padding-top: 4px; font-size: 11px; color: #555; } @media print { body { font-size: 12px; } - .strana { padding: 16px 20px; max-width: 100%; } + .strana { padding: 12px 16px; max-width: 100%; } .dugme-stampa { display: none !important; } - @page { size: A4; margin: 12mm 14mm; } + @page { size: A4; margin: 8mm 10mm; } + .odeljak { margin-bottom: 10px; } + .zaglavlje { margin-bottom: 12px; } + .podaci-grid, .podaci-grid-2 { gap: 8px; } + .upis-veliki { min-height: 80px; } + .upis-okvir { min-height: 50px; } + .zavrsetak { margin-top: 10px; padding-top: 8px; break-inside: avoid; } /* red tabele se ne sme prelomiti preko granice stranice — prelom pada između stavki */ .tabela tr { break-inside: avoid; } /* zaglavlje tabele se ponavlja na svakoj stranici kad tabela pređe na novu */ @@ -220,12 +226,8 @@ {{end}} - +
-
-
Datum završetka / utrošeno vreme
-
 
-
Potpis servisera
 
diff --git a/web/templates/stranice/servis_status_javni.html b/web/templates/stranice/servis_status_javni.html index 6ae70a4..749a372 100644 --- a/web/templates/stranice/servis_status_javni.html +++ b/web/templates/stranice/servis_status_javni.html @@ -251,6 +251,7 @@ {{if .ImaPredlog}} + {{if not .Odgovoreno}}
Predlog popravke @@ -290,11 +291,21 @@
{{end}} + {{end}} {{if .Odgovoreno}} -
+
+ {{if eq .Nalog.OdlukaKlijenta "prihvaceno"}} +
✅ Prihvatili ste predlog
+ {{else if eq .Nalog.OdlukaKlijenta "odbijeno"}} +
✕ Niste prihvatili predlog
+ {{else}}
Hvala na odgovoru!
-
Serviser je obavešten o vašoj odluci.
+ {{end}} + {{if .Nalog.KomentarKlijenta}} +
{{.Nalog.KomentarKlijenta}}
+ {{end}} +
Serviser je obavešten o vašoj odluci.
{{end}}