Servis: potrazivani delovi, garancija, zakljucavanje statusa

Nova tabela servisni_potrazivani_delovi — visak preko stanja ide u potrazivane, lager NE ide u minus. Merge istog artikla na nalogu. Vizuelni indikator prekoracenja u tabeli (crveno). Status zakljucan na Ceka delove dok ima potrazivanih. Garancija: kalendar sa default vrednoscu. data-refresh podrzava vise selektora. Brisanje dela brise i potrazivane. Inline animation:none za AJAX refresh.
This commit is contained in:
2026-06-22 03:17:29 +02:00
parent a66b49958c
commit 1a56f6966e
11 changed files with 475 additions and 155 deletions
+61 -58
View File
@@ -17,38 +17,39 @@ import (
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
ServisniRadoviRepo db.ServisniRadoviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
UslugeRepo db.UslugaRepository
TroskoviRepo db.TrosakRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
ServisniRadoviRepo db.ServisniRadoviRepository
ServisniPotrazivaniDeloviRepo db.ServisniPotrazivaniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
PdvStopeRepo db.PdvStopaRepository
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
Verzija string
JelDemo bool
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
// mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova
@@ -84,32 +85,33 @@ func (h *Handler) SaBazom(fn func(*sql.DB)) {
// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
return &Handler{
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
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),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
UslugeRepo: sqlite.NoviUslugaRepo(baza),
TroskoviRepo: sqlite.NoviTrosakRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
ServisniRadoviRepo: sqlite.NoviServisniRadoviRepo(baza),
ServisniPotrazivaniDeloviRepo: sqlite.NoviServisniPotrazivaniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
PdvStopeRepo: sqlite.NoviPdvStopaRepo(baza),
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
}
}
@@ -128,6 +130,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB)
h.ServisniRadoviRepo = sqlite.NoviServisniRadoviRepo(novaDB)
h.ServisniPotrazivaniDeloviRepo = sqlite.NoviServisniPotrazivaniDeloviRepo(novaDB)
h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB, h.totpKljuc)
+166 -52
View File
@@ -43,19 +43,20 @@ type PodaciFormeNaloga struct {
// PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga
type PodaciDetaljiNaloga struct {
model.PodaciStranice
Nalog model.ServisniNalog
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
Nalog model.ServisniNalog
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
ZakljucanStatus bool // onemogući promenu statusa dok ima potraživanih delova
SviStatusi []string
}
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -424,7 +425,7 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
}
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
slog.Error("greška pri učitavanju delova", "error", err)
}
@@ -463,24 +464,33 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
preostaloSve = 0
}
zakljucanStatus := false
for _, d := range delovi {
if d.Potrazivano > 0 {
zakljucanStatus = true
break
}
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Detalji naloga"
podaci := PodaciDetaljiNaloga{
PodaciStranice: ps,
Nalog: *nalog,
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,
PodaciStranice: ps,
Nalog: *nalog,
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,
ZakljucanStatus: zakljucanStatus,
SviStatusi: model.SviStatusi,
}
h.renderujTemplate(w, "servis_detalji", podaci)
@@ -525,34 +535,88 @@ func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
return
}
// prepoznaj prekoračenje magacinskog stanja PRE dodavanja (posle dodavanja
// lager se smanji); deo se svejedno ugrađuje (dozvoljen backorder/minus lager)
prekoraceno := false
// 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
imeArtikla := ""
if art, e := h.Artikli.DohvatiID(r.Context(), artikalID); e == nil && art != nil {
prekoraceno = kolicina > art.Kolicina
imeArtikla = art.Naziv
if kolicina > art.Kolicina {
ugradjeno = art.Kolicina
nedostaje = kolicina - art.Kolicina
}
}
if _, err := h.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, 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
// 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
}
}
// prekoračenje → nalog automatski u „Čeka delove" (signal da treba nabavka)
if prekoraceno {
// višak → potraživani delovi (ne skidamo sa lagera)
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)
}
middleware.SetFlash(w, r, h.DB, "greska",
"Deo „"+imeArtikla+"\" je dodat iznad stanja na magacinu — nalog je prebačen u „Čeka delove\". Naručite deo.")
if ugradjeno > 0 {
middleware.SetFlash(w, r, h.DB, "greska",
"Ugrađeno "+strconv.Itoa(ugradjeno)+" kom. artikla „"+imeArtikla+
"\", nedostaje još "+strconv.Itoa(nedostaje)+" — nalog je prebačen u „Čeka delove\".")
} else {
middleware.SetFlash(w, r, h.DB, "greska",
"Nema „"+imeArtikla+"\" na stanju — potrebno "+strconv.Itoa(nedostaje)+
" kom. Nalog je prebačen u „Čeka delove\".")
}
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// deloviSaPotrazivanima vraća ugrađene delove spojene sa potraživanima (ista tabela).
// Potraživani delovi bez ugrađenog (stanje bilo 0) se dodaju sa Kolicina=0.
func (h *Handler) deloviSaPotrazivanima(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error) {
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(ctx, nalogID)
if err != nil {
return nil, err
}
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(ctx, nalogID)
if err != nil {
return delovi, nil // ne prekidamo ako potraživani nisu dostupni
}
potrMap := make(map[int64]int)
for _, p := range potrazivani {
potrMap[p.ArtikalID] += p.Kolicina
}
for i := range delovi {
if kol, ok := potrMap[delovi[i].ArtikalID]; ok {
delovi[i].Potrazivano = kol
delete(potrMap, delovi[i].ArtikalID)
}
}
for artikalID, kol := range potrMap {
naziv := ""
if art, e := h.Artikli.DohvatiID(ctx, artikalID); e == nil && art != nil {
naziv = art.Naziv
}
delovi = append(delovi, model.ServisniDeoSaArtiklom{
ArtikalNaziv: naziv,
ServisniDeo: model.ServisniDeo{ArtikalID: artikalID},
Potrazivano: kol,
})
}
return delovi, nil
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
k, ok := h.zahtevajDozvolu(w, r, "servis.izmeni")
@@ -572,6 +636,13 @@ func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
return
}
// obriši i potraživane delove za isti artikal (inače ostaju blokirani u tabeli)
if artikalID, err := h.ServisniDeloviRepo.DohvatiArtikalID(r.Context(), deoID); err == nil {
if err := h.ServisniPotrazivaniDeloviRepo.ObrisiZaArtikal(r.Context(), nalogID, artikalID); err != nil {
slog.Error("greška pri brisanju potraživanih delova", "error", err)
}
}
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
slog.Error("greška pri brisanju dela", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
@@ -854,7 +925,7 @@ func (h *Handler) StampaServisa(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -945,7 +1016,7 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -1066,7 +1137,7 @@ func (h *Handler) StampaPredracuna(w http.ResponseWriter, r *http.Request) {
return
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
delovi, err := h.deloviSaPotrazivanima(r.Context(), id)
if err != nil {
http.Error(w, "Greška pri učitavanju delova", http.StatusInternalServerError)
return
@@ -1187,17 +1258,14 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
return
}
// zabrana izlaska iz „Čeka delove" dok god neki ugrađeni deo nema pokriće na
// zalihama (lager u minusu / backorder) — deo prvo treba nabaviti
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi —
// prvo nabaviti delove, pa obrisati iz potraživanih (ili kroz nabavku)
if nalog, e := h.ServisRepo.DohvatiID(r.Context(), id); e == nil &&
nalog.Status == model.StatusCekaDelove && noviStatus != model.StatusCekaDelove {
var bezPokrica int
_ = h.DB.QueryRowContext(r.Context(),
`SELECT COUNT(*) FROM servisni_delovi sd JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ? AND a.kolicina < 0`, id).Scan(&bezPokrica)
if bezPokrica > 0 {
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err == nil && len(potrazivani) > 0 {
middleware.SetFlash(w, r, h.DB, "greska",
"Nalog ne može da napusti „Čeka delove\" dok ima ugrađenih delova kojih nema na stanju (lager u minusu). Dopunite zalihe nabavkom.")
"Nalog ne može da napusti „Čeka delove\" dok ima delova koji nedostaju. Obrišite ih iz tabele ili dopunite zalihe.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
@@ -1211,6 +1279,52 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
}
// AzurirajGaranciju ažurira datum garancije na servisnom nalogu
func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "servis.izmeni"); !ok {
return
}
id, 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
}
var garancijaDo *time.Time
if r.FormValue("bez_garancije") != "1" {
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
t, err := time.Parse("2006-01-02", gd)
if err != nil {
http.Error(w, "Neispravan datum garancije", http.StatusBadRequest)
return
}
garancijaDo = &t
} else {
// odštiklirano „bez garancije" bez izabranog datuma → vrati default
nalog, err := h.ServisRepo.DohvatiID(r.Context(), id)
if err != nil {
slog.Error("greška pri dohvatanju naloga za garanciju", "id", id, "error", err)
} else {
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err != nil {
slog.Error("greška pri učitavanju podešavanja za garanciju", "error", err)
} else {
garancijaDo = defaultGarancija(nalog.DatumPrijema, podesavanja)
}
}
}
}
if err := h.ServisRepo.AzurirajGaranciju(r.Context(), id, garancijaDo); err != nil {
slog.Error("greška pri ažuriranju garancije", "id", id, "error", err)
http.Error(w, "Greška pri ažuriranju garancije", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// PodaciJavnogStatusa su podaci za javnu status stranicu servisnog naloga
type PodaciJavnogStatusa struct {
Nalog model.ServisniNalog