Sprečeno dupliranje prodaje/servisa, alat za usklađivanje šifri artikla, ispravke osvežavanja stranica, zaštita od duplikata kategorije/artikla/usluge/troška
- Prodaja i servis: broj naloga se generiše unutar transakcije upisa umesto unapred u handleru, čime se otklanja duplo kreiranje naloga pri dvostrukom slanju forme - Servis izmena: uklonjen sudar generičkog i sopstvenog auto-snimanja forme (dva paralelna POST-a) - Magacin: novi alat 'Uskladi šifre sa kategorijama' za masovnu ispravku šifri artikala posle uvođenja šifarnika po kategoriji - Ispravljeno da forme na 9 stranica (kategorije, admin korisnici/dozvole, profil, podešavanja) posle akcije osveže prikaz umesto da ostanu zastarele do F5 - Kategorija, artikal, usluga i trošak: sprečeno dupliranje naziva/šifre/koda (UNIQUE indeksi + razumljive poruke grešaka)
This commit is contained in:
@@ -16,6 +16,19 @@ var ErrArtikalUUpotrebi = errors.New("ntech: artikal je u upotrebi")
|
||||
// referencirana od strane artikla.
|
||||
var ErrKategorijaUUpotrebi = errors.New("ntech: kategorija je u upotrebi")
|
||||
|
||||
// ErrKategorijaDuplikat se vraća kad naziv ili kôd kategorije već postoji
|
||||
// kod druge kategorije (vidi migraciju 104_kategorije_unique.sql).
|
||||
var ErrKategorijaDuplikat = errors.New("ntech: kategorija sa tim nazivom ili kodom već postoji")
|
||||
|
||||
// ErrArtikalDuplikatSifre se vraća kad šifra ili barkod artikla već postoji
|
||||
// kod drugog artikla (vidi migraciju 055_artikal_sifra_barkod.sql).
|
||||
var ErrArtikalDuplikatSifre = errors.New("ntech: šifra ili barkod artikla već postoji kod drugog artikla")
|
||||
|
||||
// ErrUslugaDuplikatSifre i ErrTrosakDuplikatSifre se vraćaju kad šifra usluge/
|
||||
// troška već postoji kod drugog reda (vidi migraciju 105_usluge_troskovi_sifra_unique.sql).
|
||||
var ErrUslugaDuplikatSifre = errors.New("ntech: šifra usluge već postoji")
|
||||
var ErrTrosakDuplikatSifre = errors.New("ntech: šifra troška već postoji")
|
||||
|
||||
// ArtikalRepository definiše operacije nad artiklima
|
||||
type ArtikalRepository interface {
|
||||
Lista(ctx context.Context, filter ArtikalFilter) ([]model.ArtikalSaKategorijom, error)
|
||||
|
||||
@@ -227,6 +227,9 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
|
||||
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrArtikalDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
|
||||
}
|
||||
|
||||
@@ -259,6 +262,9 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
|
||||
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.CenaSaPdv, a.Marza, a.Napomena, a.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrArtikalDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ func (r *KategorijaRepo) Lista(ctx context.Context) ([]model.Kategorija, error)
|
||||
return rezultat, nil
|
||||
}
|
||||
|
||||
// jeUnique proverava da li greška iz SQLite drajvera potiče od povrede
|
||||
// UNIQUE indeksa (naziv ili kôd kategorije već postoji — vidi migraciju
|
||||
// 104_kategorije_unique.sql).
|
||||
func jeUnique(err error) bool {
|
||||
var sqliteErr *mosqlite.Error
|
||||
return errors.As(err, &sqliteErr) && sqliteErr.Code() == 2067 // SQLITE_CONSTRAINT_UNIQUE
|
||||
}
|
||||
|
||||
// Kreiraj dodaje novu kategoriju
|
||||
func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) {
|
||||
var kod any
|
||||
@@ -64,6 +72,9 @@ func (r *KategorijaRepo) Kreiraj(ctx context.Context, k *model.Kategorija) (int6
|
||||
k.Naziv, k.Opis, kod, k.Marza,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrKategorijaDuplikat
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: KategorijaRepo.Kreiraj: %w", err)
|
||||
}
|
||||
|
||||
@@ -109,6 +120,9 @@ func (r *KategorijaRepo) Izmeni(ctx context.Context, k *model.Kategorija) error
|
||||
k.Naziv, k.Opis, kod, k.Marza, k.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrKategorijaDuplikat
|
||||
}
|
||||
return fmt.Errorf("ntech: KategorijaRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -23,18 +23,29 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca
|
||||
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
return sledeciBrojProdaje(ctx, r.db)
|
||||
}
|
||||
|
||||
// sledeciBrojProdaje čita i generiše sledeći broj naloga preko prosleđenog
|
||||
// izvršioca upita (r.db ili tx). Kreiraj je poziva NAD ISTOM transakcijom
|
||||
// u kojoj upisuje nalog — pošto konekcija koristi _txlock=immediate, write
|
||||
// lock se drži od BeginTx, pa je ovo čitanje-pa-upis atomsko i bez race-a
|
||||
// između dva konkurentna/duplirana zahteva.
|
||||
func sledeciBrojProdaje(ctx context.Context, q interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}) (string, error) {
|
||||
sada := time.Now()
|
||||
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
uzorak := prefiks + "%"
|
||||
|
||||
var sledeci int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||
FROM prodajni_nalozi
|
||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
|
||||
return "", fmt.Errorf("ntech: sledeciBrojProdaje: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%04d", prefiks, sledeci), nil
|
||||
@@ -184,6 +195,16 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// broj naloga se generiše OVDE, unutar iste transakcije kao insert —
|
||||
// ne sme se prosleđivati kao unapred generisana vrednost iz handlera,
|
||||
// jer bi dupliran/ponovljen POST zahtev tada napravio dva zasebna,
|
||||
// validna naloga sa istim stavkama umesto da drugi bude odbijen.
|
||||
brojNaloga, err := sledeciBrojProdaje(ctx, tx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: broj naloga: %w", err)
|
||||
}
|
||||
n.BrojNaloga = brojNaloga
|
||||
|
||||
// insert zaglavlja naloga pre stavki da bismo imali nalogID za magacin
|
||||
rezultat, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
|
||||
|
||||
@@ -98,8 +98,10 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
||||
{ArtikalID: artID, Kolicina: 1, CenaPoKomadu: 1200, PdvStopa: 20},
|
||||
// CenaBezPdv=0 namerno — treba da se auto-izračuna
|
||||
}
|
||||
_, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
||||
BrojNaloga: "PR-PDV-001", Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
||||
// BrojNaloga se namerno NE prosleđuje — Kreiraj ga uvek sam generiše
|
||||
// (broj_naloga se ne veruje pozivaocu, vidi ProdajaRepo.Kreiraj)
|
||||
nalogID, err := prodRepo.Kreiraj(ctx, &model.ProdajniNalog{
|
||||
Ukupno: 1200, NacinPlacanja: "gotovina", Datum: time.Now(),
|
||||
}, stavke, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Kreiraj: %v", err)
|
||||
@@ -107,7 +109,7 @@ func TestProdajaKreiraj_PdvAutoKalkulacija(t *testing.T) {
|
||||
|
||||
var cenaBezPdv, pdvIznos float64
|
||||
baza.QueryRowContext(ctx,
|
||||
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = (SELECT id FROM prodajni_nalozi WHERE broj_naloga='PR-PDV-001')",
|
||||
"SELECT cena_bez_pdv, pdv_iznos FROM stavke_prodaje WHERE nalog_id = ?", nalogID,
|
||||
).Scan(&cenaBezPdv, &pdvIznos)
|
||||
|
||||
const ocekivanaNeto = 1000.0
|
||||
|
||||
@@ -31,8 +31,16 @@ func NoviServisRepo(db *sql.DB) *ServisRepo {
|
||||
}
|
||||
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca.
|
||||
// Koristi se samo za PRIKAZ predloga na praznoj formi (NoviNalog) — stvarni
|
||||
// broj koji se upisuje generiše Kreiraj iznova, unutar svoje transakcije.
|
||||
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
return sledeciBrojServisa(ctx, r.db)
|
||||
}
|
||||
|
||||
func sledeciBrojServisa(ctx context.Context, q interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}) (string, error) {
|
||||
sada := time.Now()
|
||||
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
@@ -40,12 +48,12 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
|
||||
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
||||
var sledeci int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(CAST(SUBSTR(broj_naloga, 9) AS INTEGER)), 0) + 1
|
||||
FROM servisni_nalozi
|
||||
WHERE broj_naloga LIKE ?`, uzorak).Scan(&sledeci)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ntech: ServisRepo.SledeciBroj: %w", err)
|
||||
return "", fmt.Errorf("ntech: sledeciBrojServisa: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%03d", prefiks, sledeci), nil
|
||||
@@ -120,14 +128,30 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token
|
||||
// Kreiraj upisuje novi servisni nalog u bazu i generiše javni token.
|
||||
// Broj naloga se generiše OVDE, unutar transakcije upisa, i ne veruje se
|
||||
// vrednosti iz forme (koja je samo predlog prikazan pri otvaranju prazne
|
||||
// forme) — tako dupliran/ponovljen POST zahtev ne pravi dva zasebna naloga
|
||||
// sa istim brojem/sadržajem.
|
||||
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
|
||||
token, err := generisiJavniToken()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: token: %w", err)
|
||||
}
|
||||
|
||||
rezultat, err := r.db.ExecContext(ctx, `
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
brojNaloga, err := sledeciBrojServisa(ctx, tx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: broj naloga: %w", err)
|
||||
}
|
||||
n.BrojNaloga = brojNaloga
|
||||
|
||||
rezultat, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO servisni_nalozi
|
||||
(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_zavrsetka, predvidjen_datum,
|
||||
@@ -150,6 +174,10 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: last insert id: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: commit: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ func (r *TrosakRepo) Kreiraj(ctx context.Context, t *model.Trosak) (int64, error
|
||||
sifra, t.Naziv, t.Cena, t.Opis,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrTrosakDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: TrosakRepo.Kreiraj: %w", err)
|
||||
}
|
||||
id, err := rez.LastInsertId()
|
||||
@@ -117,6 +120,9 @@ func (r *TrosakRepo) Izmeni(ctx context.Context, t *model.Trosak) error {
|
||||
sifra, t.Naziv, t.Cena, t.Opis, t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrTrosakDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: TrosakRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -117,6 +117,9 @@ func (r *UslugaRepo) Kreiraj(ctx context.Context, u *model.Usluga) (int64, error
|
||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return 0, db.ErrUslugaDuplikatSifre
|
||||
}
|
||||
return 0, fmt.Errorf("ntech: UslugaRepo.Kreiraj: %w", err)
|
||||
}
|
||||
id, err := rez.LastInsertId()
|
||||
@@ -138,6 +141,9 @@ func (r *UslugaRepo) Izmeni(ctx context.Context, u *model.Usluga) error {
|
||||
sifra, u.Naziv, u.Kategorija, u.JedinicaMere, u.Cena, u.PdvStopa, u.Opis, u.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if jeUnique(err) {
|
||||
return db.ErrUslugaDuplikatSifre
|
||||
}
|
||||
return fmt.Errorf("ntech: UslugaRepo.Izmeni: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,9 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -88,6 +90,178 @@ func (h *Handler) DodeliSifreArtiklima(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PromenaSifre je jedna stavka plana usklađivanja šifri — artikal čija
|
||||
// trenutna šifra ne odgovara prefiksu njegove kategorije, sa predloženom
|
||||
// novom šifrom.
|
||||
type PromenaSifre struct {
|
||||
ArtikalID int64
|
||||
Naziv string
|
||||
KategorijaNaziv string
|
||||
StaraSifra string
|
||||
NovaSifra string
|
||||
}
|
||||
|
||||
// izracunajPlanUskladjivanjaSifri prolazi kroz sve artikle (po ID-u, redosled
|
||||
// unosa) i za svaki proverava da li šifra odgovara PREFIKS-NNNN obrascu
|
||||
// njegove kategorije (kod kategorije, ili "ART" ako artikal nema kategoriju).
|
||||
// Artikli iz kategorija bez postavljenog koda se preskaču. Novi broj je prvi
|
||||
// slobodan (počev od 0001) za taj prefiks — uzima u obzir i šifre koje već
|
||||
// postoje kod drugih artikala i one dodeljene ranije u istom prolazu.
|
||||
func izracunajPlanUskladjivanjaSifri(artikli []model.ArtikalSaKategorijom, kategorije []model.Kategorija) []PromenaSifre {
|
||||
// Lista artikala dolazi poređana po nazivu — kandidati se obrađuju po ID-u
|
||||
// (redosled unosa), pa se pravi sopstvena poređana kopija.
|
||||
artikli = append([]model.ArtikalSaKategorijom(nil), artikli...)
|
||||
sort.Slice(artikli, func(i, j int) bool { return artikli[i].ID < artikli[j].ID })
|
||||
|
||||
kodPoKategoriji := make(map[int64]string, len(kategorije))
|
||||
for _, k := range kategorije {
|
||||
kodPoKategoriji[k.ID] = k.Kod
|
||||
}
|
||||
|
||||
// zauzeti[prefiks] = skup već iskorišćenih brojeva (iz postojećih šifri svih artikala)
|
||||
zauzeti := make(map[string]map[int]bool)
|
||||
zauzmi := func(prefiks string, broj int) {
|
||||
if zauzeti[prefiks] == nil {
|
||||
zauzeti[prefiks] = make(map[int]bool)
|
||||
}
|
||||
zauzeti[prefiks][broj] = true
|
||||
}
|
||||
parsirajBroj := func(sifra, prefiks string) (int, bool) {
|
||||
rep := prefiks + "-"
|
||||
if !strings.HasPrefix(sifra, rep) {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(sifra[len(rep):])
|
||||
if err != nil || n < 1 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
prvoSlobodan := func(prefiks string) int {
|
||||
broj := 1
|
||||
for zauzeti[prefiks][broj] {
|
||||
broj++
|
||||
}
|
||||
return broj
|
||||
}
|
||||
|
||||
// popiši postojeće brojeve po prefiksu iz svih trenutnih šifri (oblika PREFIKS-NNNN)
|
||||
for _, a := range artikli {
|
||||
if a.Sifra == "" {
|
||||
continue
|
||||
}
|
||||
i := strings.LastIndex(a.Sifra, "-")
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
prefiks, rep := a.Sifra[:i], a.Sifra[i+1:]
|
||||
if n, err := strconv.Atoi(rep); err == nil && n >= 1 {
|
||||
zauzmi(prefiks, n)
|
||||
}
|
||||
}
|
||||
|
||||
var plan []PromenaSifre
|
||||
for _, a := range artikli {
|
||||
ocekivaniPrefiks := "ART"
|
||||
if a.KategorijaID != nil {
|
||||
kod, ima := kodPoKategoriji[*a.KategorijaID]
|
||||
if !ima || kod == "" {
|
||||
continue // kategorija bez koda — preskoči
|
||||
}
|
||||
ocekivaniPrefiks = kod
|
||||
}
|
||||
|
||||
if _, tacna := parsirajBroj(a.Sifra, ocekivaniPrefiks); tacna {
|
||||
continue // šifra već odgovara obrascu za ovu kategoriju
|
||||
}
|
||||
|
||||
broj := prvoSlobodan(ocekivaniPrefiks)
|
||||
zauzmi(ocekivaniPrefiks, broj)
|
||||
novaSifra := fmt.Sprintf("%s-%04d", ocekivaniPrefiks, broj)
|
||||
|
||||
plan = append(plan, PromenaSifre{
|
||||
ArtikalID: a.ID,
|
||||
Naziv: a.Naziv,
|
||||
KategorijaNaziv: a.KategorijaNaziv,
|
||||
StaraSifra: a.Sifra,
|
||||
NovaSifra: novaSifra,
|
||||
})
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// PodaciUskladjivanjaSifri su podaci za stranicu pregleda usklađivanja šifri
|
||||
type PodaciUskladjivanjaSifri struct {
|
||||
model.PodaciStranice
|
||||
Plan []PromenaSifre
|
||||
}
|
||||
|
||||
// PregledUskladjivanjaSifri prikazuje spisak artikala čija šifra ne odgovara
|
||||
// prefiksu njihove kategorije, sa predlogom nove šifre — pre potvrde.
|
||||
func (h *Handler) PregledUskladjivanjaSifri(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
podesavanja, err := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
artikli, err := h.Artikli.Lista(r.Context(), db.ArtikalFilter{})
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju artikala", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
kategorije, err := h.KategorijeRepo.Lista(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju kategorija", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Usklađivanje šifri"
|
||||
|
||||
h.renderujTemplate(w, "kategorije_uskladi_sifre", PodaciUskladjivanjaSifri{
|
||||
PodaciStranice: ps,
|
||||
Plan: izracunajPlanUskladjivanjaSifri(artikli, kategorije),
|
||||
})
|
||||
}
|
||||
|
||||
// PotvrdiUskladjivanjeSifri prima POST sa tačnim spiskom promena prikazanim
|
||||
// na stranici pregleda (artikal_id[] / nova_sifra[]) i upisuje ih. Ne računa
|
||||
// plan iznova — primenjuje tačno ono što je pregledano i potvrđeno.
|
||||
func (h *Handler) PotvrdiUskladjivanjeSifri(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "artikal.izmeni"); !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idovi := r.Form["artikal_id"]
|
||||
sifre := r.Form["nova_sifra"]
|
||||
|
||||
broj := 0
|
||||
for i := 0; i < len(idovi) && i < len(sifre); i++ {
|
||||
id, err := strconv.ParseInt(idovi[i], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := h.Artikli.AzurirajSifru(r.Context(), id, sifre[i]); err != nil {
|
||||
continue
|
||||
}
|
||||
broj++
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/magacin/kategorije?sacuvano=1&sifre_dodeljene="+strconv.Itoa(broj), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// DodajKategoriju prima POST i čuva novu kategoriju
|
||||
func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "kategorija.dodaj"); !ok {
|
||||
@@ -112,6 +286,11 @@ func (h *Handler) DodajKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if _, err := h.KategorijeRepo.Kreiraj(r.Context(), k); err != nil {
|
||||
if errors.Is(err, db.ErrKategorijaDuplikat) {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.")
|
||||
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Greška pri čuvanju kategorije", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -150,6 +329,11 @@ func (h *Handler) IzmeniKategoriju(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.KategorijeRepo.Izmeni(r.Context(), k); err != nil {
|
||||
if errors.Is(err, db.ErrKategorijaDuplikat) {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Kategorija sa tim nazivom ili kodom već postoji.")
|
||||
http.Redirect(w, r, "/magacin/kategorije", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Greška pri čuvanju izmene kategorije", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
func TestIzracunajPlanUskladjivanjaSifri_PopunjavaPrazninu(t *testing.T) {
|
||||
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||
artikli := []model.ArtikalSaKategorijom{
|
||||
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"},
|
||||
{Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "KOMP-0003"}, KategorijaNaziv: "Kompjuteri"},
|
||||
{Artikal: model.Artikal{ID: 3, KategorijaID: p(1), Sifra: "STARA-ŠIFRA"}, KategorijaNaziv: "Kompjuteri"},
|
||||
}
|
||||
|
||||
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan = %d stavki, očekivano 1", len(plan))
|
||||
}
|
||||
if plan[0].ArtikalID != 3 || plan[0].NovaSifra != "KOMP-0002" {
|
||||
t.Errorf("plan[0] = %+v, očekivano ArtikalID=3 NovaSifra=KOMP-0002", plan[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIzracunajPlanUskladjivanjaSifri_KategorijaBezKodaSePreskace(t *testing.T) {
|
||||
kategorije := []model.Kategorija{{ID: 1, Naziv: "Ram memorije", Kod: ""}}
|
||||
artikli := []model.ArtikalSaKategorijom{
|
||||
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "NASUMICNO"}, KategorijaNaziv: "Ram memorije"},
|
||||
}
|
||||
|
||||
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||
|
||||
if len(plan) != 0 {
|
||||
t.Fatalf("plan = %d stavki, očekivano 0 (kategorija bez koda)", len(plan))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIzracunajPlanUskladjivanjaSifri_VecIspravnaSifraSePreskace(t *testing.T) {
|
||||
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||
artikli := []model.ArtikalSaKategorijom{
|
||||
{Artikal: model.Artikal{ID: 1, KategorijaID: p(1), Sifra: "KOMP-0001"}, KategorijaNaziv: "Kompjuteri"},
|
||||
}
|
||||
|
||||
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||
|
||||
if len(plan) != 0 {
|
||||
t.Fatalf("plan = %d stavki, očekivano 0 (šifra već ispravna)", len(plan))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIzracunajPlanUskladjivanjaSifri_BezKategorijeIdeUnderArt(t *testing.T) {
|
||||
kategorije := []model.Kategorija{}
|
||||
artikli := []model.ArtikalSaKategorijom{
|
||||
{Artikal: model.Artikal{ID: 1, KategorijaID: nil, Sifra: "ART-0001"}},
|
||||
{Artikal: model.Artikal{ID: 2, KategorijaID: nil, Sifra: "NEBITNO"}},
|
||||
}
|
||||
|
||||
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan = %d stavki, očekivano 1", len(plan))
|
||||
}
|
||||
if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "ART-0002" {
|
||||
t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=ART-0002", plan[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIzracunajPlanUskladjivanjaSifri_RedosledPoID(t *testing.T) {
|
||||
kategorije := []model.Kategorija{{ID: 1, Naziv: "Kompjuteri", Kod: "KOMP"}}
|
||||
artikli := []model.ArtikalSaKategorijom{
|
||||
{Artikal: model.Artikal{ID: 5, KategorijaID: p(1), Sifra: "X"}, KategorijaNaziv: "Kompjuteri"},
|
||||
{Artikal: model.Artikal{ID: 2, KategorijaID: p(1), Sifra: "Y"}, KategorijaNaziv: "Kompjuteri"},
|
||||
}
|
||||
|
||||
plan := izracunajPlanUskladjivanjaSifri(artikli, kategorije)
|
||||
|
||||
if len(plan) != 2 {
|
||||
t.Fatalf("plan = %d stavki, očekivano 2", len(plan))
|
||||
}
|
||||
// ulaz je namerno dat u redosledu ID 5 pa 2 — funkcija sama sortira po ID-u
|
||||
// pre dodele brojeva, pa 2 (manji ID) mora dobiti prvi (manji) broj
|
||||
if plan[0].ArtikalID != 2 || plan[0].NovaSifra != "KOMP-0001" {
|
||||
t.Errorf("plan[0] = %+v, očekivano ArtikalID=2 NovaSifra=KOMP-0001", plan[0])
|
||||
}
|
||||
if plan[1].ArtikalID != 5 || plan[1].NovaSifra != "KOMP-0002" {
|
||||
t.Errorf("plan[1] = %+v, očekivano ArtikalID=5 NovaSifra=KOMP-0002", plan[1])
|
||||
}
|
||||
}
|
||||
|
||||
func p(id int64) *int64 { return &id }
|
||||
@@ -23,7 +23,7 @@ var saSidebar = []string{
|
||||
"dashboard",
|
||||
"dobavljaci", "dobavljac_forma",
|
||||
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
||||
"kategorije",
|
||||
"kategorije", "kategorije_uskladi_sifre",
|
||||
"klijenti", "klijent_forma",
|
||||
"magacin", "magacin_forma", "magacin_kartica",
|
||||
"nabavke", "nabavka_forma", "nabavka_detalji",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
@@ -121,7 +123,32 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id, err := h.Artikli.Kreiraj(r.Context(), &artikal)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri čuvanju artikla", http.StatusInternalServerError)
|
||||
poruka := "Greška pri čuvanju artikla."
|
||||
if errors.Is(err, db.ErrArtikalDuplikatSifre) {
|
||||
poruka = "Šifra ili barkod već postoji kod drugog artikla."
|
||||
} else {
|
||||
slog.Error("čuvanje artikla nije uspelo", "error", err)
|
||||
}
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
katIDStr := ""
|
||||
if artikal.KategorijaID != nil {
|
||||
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||
}
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Novi artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Dobavljaci: dobavljaci,
|
||||
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||
Greska: poruka,
|
||||
Izmena: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
artikal.ID = id
|
||||
@@ -262,7 +289,32 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
artikal.ID = id
|
||||
if err := h.Artikli.Izmeni(r.Context(), &artikal); err != nil {
|
||||
http.Error(w, "Greška pri čuvanju izmene", http.StatusInternalServerError)
|
||||
poruka := "Greška pri čuvanju izmene."
|
||||
if errors.Is(err, db.ErrArtikalDuplikatSifre) {
|
||||
poruka = "Šifra ili barkod već postoji kod drugog artikla."
|
||||
} else {
|
||||
slog.Error("čuvanje izmene artikla nije uspelo", "artikal_id", id, "error", err)
|
||||
}
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
dobavljaci, _ := h.DobavljaciRepo.Lista(r.Context(), "")
|
||||
katIDStr := ""
|
||||
if artikal.KategorijaID != nil {
|
||||
katIDStr = strconv.FormatInt(*artikal.KategorijaID, 10)
|
||||
}
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "magacin"
|
||||
ps.NaslovStranice = "Izmeni artikal"
|
||||
h.renderujFormuArtikla(w, PodaciFormeArtikla{
|
||||
PodaciStranice: ps,
|
||||
Artikal: artikal,
|
||||
Kategorije: kategorije,
|
||||
KategorijaIDStr: katIDStr,
|
||||
Dobavljaci: dobavljaci,
|
||||
IzabraniDobavljaci: mapaDobavljaca(citajDobavljaceForme(r)),
|
||||
Greska: poruka,
|
||||
Izmena: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -276,14 +276,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
brojNaloga, err := h.ProdajaRepo.SledeciBroj(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("greška pri generisanju broja naloga", "error", err)
|
||||
renderujGresku("Greška pri generisanju broja naloga.")
|
||||
return
|
||||
}
|
||||
|
||||
nalog.BrojNaloga = brojNaloga
|
||||
// broj naloga generiše ProdajaRepo.Kreiraj unutar transakcije upisa
|
||||
nalog.Datum = time.Now()
|
||||
|
||||
var ukupno float64
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -91,7 +92,11 @@ func (h *Handler) SacuvajTrosak(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := h.TroskoviRepo.Kreiraj(r.Context(), &trosak); err != nil {
|
||||
h.renderujFormuTroska(w, r, trosak, false, "Greška pri čuvanju troška. Pokušajte ponovo.")
|
||||
poruka := "Greška pri čuvanju troška. Pokušajte ponovo."
|
||||
if errors.Is(err, db.ErrTrosakDuplikatSifre) {
|
||||
poruka = "Šifra već postoji kod drugog troška."
|
||||
}
|
||||
h.renderujFormuTroska(w, r, trosak, false, poruka)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
||||
@@ -114,7 +119,11 @@ func (h *Handler) SacuvajIzmenuTroska(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := h.TroskoviRepo.Izmeni(r.Context(), &trosak); err != nil {
|
||||
h.renderujFormuTroska(w, r, trosak, true, "Greška pri čuvanju troška. Pokušajte ponovo.")
|
||||
poruka := "Greška pri čuvanju troška. Pokušajte ponovo."
|
||||
if errors.Is(err, db.ErrTrosakDuplikatSifre) {
|
||||
poruka = "Šifra već postoji kod drugog troška."
|
||||
}
|
||||
h.renderujFormuTroska(w, r, trosak, true, poruka)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/troskovi?sacuvano=1", http.StatusSeeOther)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -92,7 +93,11 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := h.UslugeRepo.Kreiraj(r.Context(), &usluga); err != nil {
|
||||
h.renderujFormuUsluge(w, r, usluga, false, "Greška pri čuvanju usluge. Pokušajte ponovo.")
|
||||
poruka := "Greška pri čuvanju usluge. Pokušajte ponovo."
|
||||
if errors.Is(err, db.ErrUslugaDuplikatSifre) {
|
||||
poruka = "Šifra već postoji kod druge usluge."
|
||||
}
|
||||
h.renderujFormuUsluge(w, r, usluga, false, poruka)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
||||
@@ -115,7 +120,11 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := h.UslugeRepo.Izmeni(r.Context(), &usluga); err != nil {
|
||||
h.renderujFormuUsluge(w, r, usluga, true, "Greška pri čuvanju usluge. Pokušajte ponovo.")
|
||||
poruka := "Greška pri čuvanju usluge. Pokušajte ponovo."
|
||||
if errors.Is(err, db.ErrUslugaDuplikatSifre) {
|
||||
poruka = "Šifra već postoji kod druge usluge."
|
||||
}
|
||||
h.renderujFormuUsluge(w, r, usluga, true, poruka)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/usluge?sacuvano=1", http.StatusSeeOther)
|
||||
|
||||
Reference in New Issue
Block a user