diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 5272345..cf33c5b 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -361,6 +361,8 @@ func main() { r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus) 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}/radovi", h.DodajRadNalogu) + r.With(doz("servis.izmeni")).Post("/servis/{id}/radovi/{rad_id}/obrisi", h.ObrisiRadNaloga) r.Get("/izvestaji", h.Izvestaji) r.Get("/izvestaji/prometni-list", h.PrometniListMagacina) r.Get("/izvestaji/stanje-zaliha", h.StanjeZalihaIzvestaj) diff --git a/internal/db/repository.go b/internal/db/repository.go index dfb77e4..e36b983 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -213,6 +213,13 @@ type ServisniDeloviRepository interface { Obrisi(ctx context.Context, id int64, korisnikID *int64) error } +// ServisniRadoviRepository definiše operacije nad radovima (uslugama) na nalogu +type ServisniRadoviRepository interface { + DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) + Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64) (int64, error) + Obrisi(ctx context.Context, id int64) error +} + // MagacinskePromeneRepository definiše operacije nad revizijskim tragom magacina type MagacinskePromeneRepository interface { Lista(ctx context.Context, artikalID *int64, limit int) ([]model.MagacinskaPromenaSaDetaljem, error) diff --git a/internal/db/sqlite/servisni_radovi.go b/internal/db/sqlite/servisni_radovi.go new file mode 100644 index 0000000..2d37ab1 --- /dev/null +++ b/internal/db/sqlite/servisni_radovi.go @@ -0,0 +1,69 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "ntech/internal/model" +) + +// ServisniRadoviRepo je SQLite implementacija db.ServisniRadoviRepository +type ServisniRadoviRepo struct { + db *sql.DB +} + +// NoviServisniRadoviRepo kreira repozitorijum radova nad datom bazom +func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo { + return &ServisniRadoviRepo{db: baza} +} + +// 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 + FROM servis_radovi WHERE nalog_id = ? ORDER BY id`, nalogID) + if err != nil { + return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err) + } + defer redovi.Close() + + 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); err != nil { + return nil, fmt.Errorf("ntech: ServisniRadoviRepo.DohvatiZaNalog: %w", err) + } + radovi = append(radovi, rad) + } + return radovi, redovi.Err() +} + +// Dodaj upisuje jednu stavku rada na nalog (radovi ne diraju lager) +func (r *ServisniRadoviRepo) Dodaj(ctx context.Context, nalogID, uslugaID int64, naziv string, kolicina, cenaKomada float64) (int64, error) { + var uid any + if uslugaID > 0 { + uid = uslugaID + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO servis_radovi (nalog_id, usluga_id, naziv, kolicina, cena_komada) + VALUES (?, ?, ?, ?, ?)`, + nalogID, uid, naziv, kolicina, cenaKomada, + ) + if err != nil { + return 0, fmt.Errorf("ntech: ServisniRadoviRepo.Dodaj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: ServisniRadoviRepo.Dodaj: last insert id: %w", err) + } + return id, nil +} + +// Obrisi uklanja jednu stavku rada sa naloga +func (r *ServisniRadoviRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM servis_radovi WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: ServisniRadoviRepo.Obrisi: %w", err) + } + return nil +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e6b76db..4747194 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -28,6 +28,7 @@ type Handler struct { KlijentiRepo db.KlijentRepository ServisRepo db.ServisRepository ServisniDeloviRepo db.ServisniDeloviRepository + ServisniRadoviRepo db.ServisniRadoviRepository MagacinskePromeneRepo db.MagacinskePromeneRepository ProdajaRepo db.ProdajaRepository KorisniciRepo db.KorisniciRepository @@ -94,6 +95,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { KlijentiRepo: sqlite.NoviKlijentRepo(baza), ServisRepo: sqlite.NoviServisRepo(baza), ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza), + ServisniRadoviRepo: sqlite.NoviServisniRadoviRepo(baza), MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza), ProdajaRepo: sqlite.NoviProdajaRepo(baza), KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc), @@ -125,6 +127,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB) h.ServisRepo = sqlite.NoviServisRepo(novaDB) h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB) + h.ServisniRadoviRepo = sqlite.NoviServisniRadoviRepo(novaDB) h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB) h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB) h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc) diff --git a/internal/handler/servis.go b/internal/handler/servis.go index 684a35f..e215dfb 100644 --- a/internal/handler/servis.go +++ b/internal/handler/servis.go @@ -47,9 +47,12 @@ type PodaciDetaljiNaloga struct { KlijentNaziv string TehnicarNaziv string ServisniDelovi []model.ServisniDeoSaArtiklom + ServisniRadovi []model.ServisniRad Artikli []model.ArtikalSaKategorijom + Usluge []model.Usluga Sacuvano bool UkupnoDelovi float64 + UkupnoRadovi float64 UkupnoSve float64 PreostaloSve float64 SviStatusi []string @@ -426,27 +429,38 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { slog.Error("greška pri učitavanju delova", "error", err) } + radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id) + if err != nil { + slog.Error("greška pri učitavanju radova", "error", err) + } + appdb := appdbPkg.ArtikalFilter{} artikli, err := h.Artikli.Lista(r.Context(), appdb) if err != nil { slog.Error("greška pri učitavanju artikala", "error", err) } - var ukupnoDelovi float64 + usluge, err := h.UslugeRepo.Lista(r.Context(), appdbPkg.UslugaFilter{}) + if err != nil { + slog.Error("greška pri učitavanju usluga", "error", err) + } + + var ukupnoDelovi, ukupnoRadovi float64 for _, d := range delovi { ukupnoDelovi += d.Ukupno() } - var ukupnoSve, preostaloSve float64 - if nalog.CenaKonacna != nil { - ukupnoSve = *nalog.CenaKonacna + ukupnoDelovi - avans := 0.0 - if nalog.Avans != nil { - avans = *nalog.Avans - } - preostaloSve = ukupnoSve - avans - if preostaloSve < 0 { - preostaloSve = 0 - } + for _, rad := range radovi { + ukupnoRadovi += rad.Ukupno() + } + // cena rada = zbir radova (usluga); ukupno za klijenta = radovi + delovi + ukupnoSve := ukupnoRadovi + ukupnoDelovi + avans := 0.0 + if nalog.Avans != nil { + avans = *nalog.Avans + } + preostaloSve := ukupnoSve - avans + if preostaloSve < 0 { + preostaloSve = 0 } ps := h.popuniPodaciStranice(r, podesavanja) @@ -458,9 +472,12 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) { KlijentNaziv: klijentNaziv, TehnicarNaziv: tehnicarNaziv, ServisniDelovi: delovi, + ServisniRadovi: radovi, Artikli: artikli, + Usluge: usluge, Sacuvano: r.URL.Query().Get("sacuvano") == "1", UkupnoDelovi: ukupnoDelovi, + UkupnoRadovi: ukupnoRadovi, UkupnoSve: ukupnoSve, PreostaloSve: preostaloSve, SviStatusi: model.SviStatusi, @@ -544,6 +561,86 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) { 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 { + return + } + + nalogID, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "Neispravan ID naloga", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + + uslugaID, err := strconv.ParseInt(r.FormValue("usluga_id"), 10, 64) + if err != nil || uslugaID <= 0 { + middleware.SetFlash(w, r, h.DB, "greska", "Izaberite uslugu.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + return + } + + usluga, err := h.UslugeRepo.DohvatiID(r.Context(), uslugaID) + if err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Izabrana usluga nije pronađena.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + return + } + + kolicina, err := strconv.ParseFloat(r.FormValue("kolicina"), 64) + if err != nil || kolicina <= 0 { + middleware.SetFlash(w, r, h.DB, "greska", "Količina mora biti pozitivan broj.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + return + } + + cena, err := strconv.ParseFloat(r.FormValue("cena_komada"), 64) + if err != nil || cena < 0 { + middleware.SetFlash(w, r, h.DB, "greska", "Cena mora biti pozitivan broj.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + return + } + + if _, err := h.ServisniRadoviRepo.Dodaj(r.Context(), nalogID, uslugaID, usluga.Naziv, kolicina, cena); err != nil { + slog.Error("greška pri dodavanju rada", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju rada.") + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther) +} + +// ObrisiRadNaloga uklanja stavku rada sa naloga +func (h *Handler) ObrisiRadNaloga(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 + } + + radID, err := parseID(chi.URLParam(r, "rad_id")) + if err != nil { + http.Error(w, "Neispravan ID rada", http.StatusBadRequest) + return + } + + if err := h.ServisniRadoviRepo.Obrisi(r.Context(), radID); err != nil { + slog.Error("greška pri brisanju rada", "error", err) + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju rada.") + } + http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther) +} + // parseFormuNaloga čita i validira polja iz HTTP forme func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) { uredjaj := strings.TrimSpace(r.FormValue("uredjaj")) diff --git a/internal/model/servis.go b/internal/model/servis.go index 4ea0899..5fee20d 100644 --- a/internal/model/servis.go +++ b/internal/model/servis.go @@ -70,6 +70,23 @@ type ServisniDeoSaArtiklom struct { ArtikalNaziv string } +// ServisniRad predstavlja jednu stavku rada (uslugu) na servisnom nalogu. +// Naziv i cena su snapshot iz cenovnika usluga; cena se može menjati po nalogu. +type ServisniRad struct { + ID int64 + NalogID int64 + UslugaID int64 // 0 ako je usluga u međuvremenu obrisana iz cenovnika + Naziv string + Kolicina float64 + CenaKomada float64 + Datum string +} + +// Ukupno vraća ukupnu vrednost rada (kolicina × cena) +func (r ServisniRad) Ukupno() float64 { + return r.Kolicina * r.CenaKomada +} + // ServisniNalogSaKlijentom proširuje ServisniNalog sa nazivom klijenta za prikaz u listi type ServisniNalogSaKlijentom struct { ServisniNalog diff --git a/migrations/066_servis_radovi.sql b/migrations/066_servis_radovi.sql new file mode 100644 index 0000000..7e80c15 --- /dev/null +++ b/migrations/066_servis_radovi.sql @@ -0,0 +1,14 @@ +-- Radovi (usluge) na servisnom nalogu: stavke rada izabrane iz cenovnika usluga. +-- Naziv i cena se snapshot-uju (kopiraju) na nalog jer se cena može menjati po +-- nalogu i usluga u cenovniku može kasnije biti izmenjena. Radovi NE diraju lager. +-- Zbir radova = cena rada na nalogu. +CREATE TABLE IF NOT EXISTS servis_radovi ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nalog_id INTEGER NOT NULL REFERENCES servisni_nalozi(id) ON DELETE CASCADE, + usluga_id INTEGER, + naziv TEXT NOT NULL, + kolicina REAL NOT NULL DEFAULT 1, + cena_komada REAL NOT NULL DEFAULT 0, + datum TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_servis_radovi_nalog ON servis_radovi(nalog_id); diff --git a/web/templates/stranice/servis_detalji.html b/web/templates/stranice/servis_detalji.html index 1486e03..9dceb5f 100644 --- a/web/templates/stranice/servis_detalji.html +++ b/web/templates/stranice/servis_detalji.html @@ -45,7 +45,7 @@
{{template "status-badge-detalji" .Nalog.Status}} -
+ +
+
+ + + + {{range .Usluge}} + +
+
+ + +
+
+ + +
+
+
-
-
Procena do
-
- {{if .Nalog.CenaDo}}{{.Nalog.CenaDoStr}} din{{else}}—{{end}} -
-
-
-
Cena rada
-
- {{if .Nalog.CenaKonacna}}{{.Nalog.CenaKonacnaStr}} din{{else}}—{{end}} -
-
- {{if gt .UkupnoDelovi 0.0}} -
-
Ugrađeni delovi
-
- {{dinari .UkupnoDelovi}} din -
-
- {{end}} - {{if .Nalog.CenaKonacna}} -
-
Ukupno
-
- {{dinari .UkupnoSve}} din -
-
- {{end}} -
-
Avans
-
- {{if .Nalog.Avans}}{{.Nalog.AvansStr}} din{{else}}—{{end}} -
-
- {{if .Nalog.CenaKonacna}} -
-
Za naplatu
-
- {{dinari .PreostaloSve}} din -
-
- {{end}} +
+ {{end}} + + +
+ {{if .ServisniRadovi}} + + + + + + + + + + + + {{range .ServisniRadovi}} + + + + + + + + {{end}} + +
UslugaKol.Cena/komUkupno
{{.Naziv}}{{.Kolicina}}{{dinari .CenaKomada}} din{{dinari .Ukupno}} din + {{if index $.Dozvole "servis.izmeni"}} +
+ + +
+ {{end}} +
+ {{else}} +
+ Nema unetih radova. Dodaj uslugu iz cenovnika. +
+ {{end}}
@@ -225,17 +247,17 @@ {{if index .Dozvole "servis.izmeni"}} -
- + +
- + + + {{range .Artikli}} +
@@ -243,7 +265,7 @@
- +
@@ -253,6 +275,7 @@ {{end}} +
{{if .ServisniDelovi}} @@ -273,8 +296,8 @@
{{dinari .Ukupno}} din {{if index $.Dozvole "servis.izmeni"}} - - + +