Popravka sidebara: kolaps, podmeni i HTMX navigacija

This commit is contained in:
2026-06-08 19:29:17 +02:00
parent f53618ce5e
commit a99920d102
32 changed files with 1385 additions and 400 deletions
+14 -1
View File
@@ -71,11 +71,24 @@ type ProdajaRepository interface {
Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error)
DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error)
DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error)
Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error)
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
Obrisi(ctx context.Context, id int64) error
SledeciBroj(ctx context.Context) (string, error)
}
// ServisniDeloviRepository definiše operacije nad ugrađenim delovima u servisu
type ServisniDeloviRepository interface {
DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniDeoSaArtiklom, error)
Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error)
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
}
// MagacinskePromeneRepository definiše operacije nad revizijskim tragom magacina
type MagacinskePromeneRepository interface {
Lista(ctx context.Context, artikalID *int64, limit int) ([]model.MagacinskaPromenaSaDetaljem, error)
}
// KorisniciRepository definiše operacije nad korisnicima
type KorisniciRepository interface {
Kreiraj(ctx context.Context, korisnickoIme, lozinkaHash, uloga string) (*model.Korisnik, error)
+9 -9
View File
@@ -25,7 +25,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
SELECT
a.id, a.kategorija_id, a.naziv, a.opis,
a.kolicina, a.kolicina_min, a.lokacija,
a.prodajna_cena, a.napomena, a.datum_unosa,
a.nabavna_cena, a.prodajna_cena, a.pdv_stopa, a.napomena, a.datum_unosa,
COALESCE(k.naziv, '') as kategorija_naziv
FROM artikli a
LEFT JOIN kategorije k ON a.kategorija_id = k.id
@@ -63,7 +63,7 @@ func (r *ArtikalRepo) Lista(ctx context.Context, filter db.ArtikalFilter) ([]mod
err := redovi.Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.ProdajnaCena, &a.Napomena, &a.DatumUnosa,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa,
&a.KategorijaNaziv,
)
if err != nil {
@@ -89,11 +89,11 @@ func (r *ArtikalRepo) DohvatiID(ctx context.Context, id int64) (*model.Artikal,
err := r.db.QueryRowContext(ctx, `
SELECT id, kategorija_id, naziv, opis, kolicina, kolicina_min,
lokacija, prodajna_cena, napomena, datum_unosa
lokacija, nabavna_cena, prodajna_cena, pdv_stopa, napomena, datum_unosa
FROM artikli WHERE id = ?`, id).Scan(
&a.ID, &kategorijaID, &a.Naziv, &a.Opis,
&a.Kolicina, &a.KolicinMin, &a.Lokacija,
&a.ProdajnaCena, &a.Napomena, &a.DatumUnosa,
&a.NabavnaCena, &a.ProdajnaCena, &a.PdvStopa, &a.Napomena, &a.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: ArtikalRepo.DohvatiID: %w", err)
@@ -111,10 +111,10 @@ func (r *ArtikalRepo) Kreiraj(ctx context.Context, a *model.Artikal) (int64, err
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO artikli
(kategorija_id, naziv, opis, kolicina, kolicina_min, lokacija,
prodajna_cena, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nabavna_cena, prodajna_cena, pdv_stopa, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina, a.KolicinMin,
a.Lokacija, a.ProdajnaCena, a.Napomena,
a.Lokacija, a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena,
)
if err != nil {
return 0, fmt.Errorf("ntech: ArtikalRepo.Kreiraj: %w", err)
@@ -134,11 +134,11 @@ func (r *ArtikalRepo) Izmeni(ctx context.Context, a *model.Artikal) error {
UPDATE artikli SET
kategorija_id = ?, naziv = ?, opis = ?, kolicina = ?,
kolicina_min = ?, lokacija = ?,
prodajna_cena = ?, napomena = ?
nabavna_cena = ?, prodajna_cena = ?, pdv_stopa = ?, napomena = ?
WHERE id = ?`,
a.KategorijaID, a.Naziv, a.Opis, a.Kolicina,
a.KolicinMin, a.Lokacija,
a.ProdajnaCena, a.Napomena, a.ID,
a.NabavnaCena, a.ProdajnaCena, a.PdvStopa, a.Napomena, a.ID,
)
if err != nil {
return fmt.Errorf("ntech: ArtikalRepo.Izmeni: %w", err)
+24 -14
View File
@@ -21,7 +21,7 @@ func NoviKlijentRepo(db *sql.DB) *KlijentRepo {
// Lista vraća listu klijenata sa opcionom pretragom po imenu, prezimenu ili nazivu firme
func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klijent, error) {
upit := `
SELECT id, ime, prezime, naziv_firme, pib, telefon, email, napomena, datum_unosa
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa
FROM klijenti
WHERE 1=1`
@@ -44,15 +44,16 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
var rezultat []model.Klijent
for redovi.Next() {
var k model.Klijent
var ime, prezime, nazivFirme, pib, telefon, email, napomena sql.NullString
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString
err := redovi.Scan(
&k.ID, &ime, &prezime, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err)
}
k.Ime = ime.String
k.Prezime = prezime.String
k.JMBG = jmbg.String
k.NazivFirme = nazivFirme.String
k.PIB = pib.String
k.Telefon = telefon.String
@@ -67,12 +68,12 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije
// DohvatiID vraća jednog klijenta po ID-u
func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, error) {
var k model.Klijent
var ime, prezime, nazivFirme, pib, telefon, email, napomena sql.NullString
var ime, prezime, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, ime, prezime, naziv_firme, pib, telefon, email, napomena, datum_unosa
SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa
FROM klijenti WHERE id = ?`, id).Scan(
&k.ID, &ime, &prezime, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
&k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa,
)
if err != nil {
return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err)
@@ -80,6 +81,7 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
k.Ime = ime.String
k.Prezime = prezime.String
k.JMBG = jmbg.String
k.NazivFirme = nazivFirme.String
k.PIB = pib.String
k.Telefon = telefon.String
@@ -91,11 +93,15 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent,
// Kreiraj dodaje novog klijenta u bazu
func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, error) {
if k.Tip == "" {
k.Tip = "fizicko"
}
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO klijenti (ime, prezime, naziv_firme, pib, telefon, email, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme),
nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena),
INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
nullString(k.Email), nullString(k.Napomena),
)
if err != nil {
return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err)
@@ -111,13 +117,17 @@ func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, err
// Izmeni ažurira postojećeg klijenta
func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error {
if k.Tip == "" {
k.Tip = "fizicko"
}
_, err := r.db.ExecContext(ctx, `
UPDATE klijenti SET
ime = ?, prezime = ?, naziv_firme = ?, pib = ?, telefon = ?, email = ?, napomena = ?
tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?,
pib = ?, telefon = ?, email = ?, napomena = ?
WHERE id = ?`,
nullString(k.Ime), nullString(k.Prezime), nullString(k.NazivFirme),
nullString(k.PIB), nullString(k.Telefon), nullString(k.Email), nullString(k.Napomena),
k.ID,
k.Tip, nullString(k.Ime), nullString(k.Prezime), nullString(k.JMBG),
nullString(k.NazivFirme), nullString(k.PIB), nullString(k.Telefon),
nullString(k.Email), nullString(k.Napomena), k.ID,
)
if err != nil {
return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err)
+102
View File
@@ -0,0 +1,102 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/model"
)
// MagacinskePromeneRepo je SQLite implementacija MagacinskePromeneRepository interfejsa
type MagacinskePromeneRepo struct {
db *sql.DB
}
// NoviMagacinskePromeneRepo kreira novi MagacinskePromeneRepo
func NoviMagacinskePromeneRepo(db *sql.DB) *MagacinskePromeneRepo {
return &MagacinskePromeneRepo{db: db}
}
// Lista vraća listu magacinskih promena, opcionalno filtrirano po artiklu
func (r *MagacinskePromeneRepo) Lista(ctx context.Context, artikalID *int64, limit int) ([]model.MagacinskaPromenaSaDetaljem, error) {
if limit <= 0 {
limit = 100
}
upit := `
SELECT mp.id, mp.artikal_id, a.naziv, mp.tip_promene, mp.referentni_id,
mp.promena_kolicine, mp.stanje_pre, mp.stanje_posle,
mp.korisnik_id, mp.napomena, mp.datum
FROM magacinske_promene mp
JOIN artikli a ON a.id = mp.artikal_id
WHERE 1=1`
args := []any{}
if artikalID != nil {
upit += " AND mp.artikal_id = ?"
args = append(args, *artikalID)
}
upit += " ORDER BY mp.datum DESC LIMIT ?"
args = append(args, limit)
redovi, err := r.db.QueryContext(ctx, upit, args...)
if err != nil {
return nil, fmt.Errorf("ntech: MagacinskePromeneRepo.Lista: %w", err)
}
defer redovi.Close()
var rezultat []model.MagacinskaPromenaSaDetaljem
for redovi.Next() {
var p model.MagacinskaPromenaSaDetaljem
var korisnikID sql.NullInt64
var napomena sql.NullString
err := redovi.Scan(
&p.ID, &p.ArtikalID, &p.ArtikalNaziv, &p.TipPromene, &p.ReferentniID,
&p.PromenaKolicine, &p.StanjePre, &p.StanjePosle,
&korisnikID, &napomena, &p.Datum,
)
if err != nil {
return nil, fmt.Errorf("ntech: MagacinskePromeneRepo.Lista: scan: %w", err)
}
if korisnikID.Valid {
v := korisnikID.Int64
p.KorisnikID = &v
}
p.Napomena = napomena.String
rezultat = append(rezultat, p)
}
return rezultat, nil
}
// zabeleziMagacinPromenu je interni helper koji upisuje jednu promenu stanja artikla.
// Poziva se iz Kreiraj/Storno unutar postojeće transakcije.
func zabeleziMagacinPromenu(
ctx context.Context,
tx *sql.Tx,
artikalID int64,
tipPromene string,
promenaKolicine, stanjePre, stanjePosle int,
referentniID int64,
korisnikID *int64,
napomena string,
) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO magacinske_promene
(artikal_id, tip_promene, promena_kolicine, stanje_pre, stanje_posle,
referentni_id, korisnik_id, napomena)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
artikalID, tipPromene, promenaKolicine, stanjePre, stanjePosle,
referentniID, nullInt64(korisnikID), nullString(napomena),
)
if err != nil {
return fmt.Errorf("ntech: zabeleziMagacinPromenu: %w", err)
}
return nil
}
+9 -1
View File
@@ -4,8 +4,10 @@ import (
"database/sql"
"fmt"
"io/fs"
"log"
"path"
"sort"
"strings"
_ "modernc.org/sqlite"
)
@@ -82,7 +84,13 @@ func PokreniMigracije(db *sql.DB, fsys fs.FS) error {
// izvršavamo SQL
if _, err := db.Exec(string(sadrzaj)); err != nil {
return fmt.Errorf("ntech: PokreniMigracije: izvršavanje %s: %w", naziv, err)
// "duplicate column name" znači da kolona već postoji — željeno stanje je ispunjeno,
// pa nastavljamo i beležimo migraciju kao izvršenu
if strings.Contains(err.Error(), "duplicate column name") {
log.Printf("Upozorenje: migration %s: kolona već postoji, preskačemo", naziv)
} else {
return fmt.Errorf("ntech: PokreniMigracije: izvršavanje %s: %w", naziv, err)
}
}
// upisujemo u tabelu migracija da je izvršena
+178 -64
View File
@@ -41,7 +41,8 @@ func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.ProdajniNalogSaDetaljem, error) {
upit := `
SELECT
pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno, pn.datum,
pn.id, pn.klijent_id, pn.broj_naloga, pn.napomena, pn.ukupno,
pn.nacin_placanja, pn.stornirano, pn.datum,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv
FROM prodajni_nalozi pn
LEFT JOIN klijenti k ON k.id = pn.klijent_id
@@ -69,7 +70,8 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda
var napomena sql.NullString
err := redovi.Scan(
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum,
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno,
&n.NacinPlacanja, &n.Stornirano, &n.Datum,
&n.KlijentNaziv,
)
if err != nil {
@@ -91,14 +93,18 @@ func (r *ProdajaRepo) Lista(ctx context.Context, pretraga string) ([]model.Proda
// DohvatiID vraća jedan prodajni nalog po ID-u
func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniNalog, error) {
red := r.db.QueryRowContext(ctx, `
SELECT id, klijent_id, broj_naloga, napomena, ukupno, datum
SELECT id, klijent_id, broj_naloga, napomena, ukupno,
nacin_placanja, stornirano, razlog_storniranja, datum
FROM prodajni_nalozi WHERE id = ?`, id)
var n model.ProdajniNalog
var klijentID sql.NullInt64
var napomena sql.NullString
var napomena, razlogStorniranja sql.NullString
err := red.Scan(&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno, &n.Datum)
err := red.Scan(
&n.ID, &klijentID, &n.BrojNaloga, &napomena, &n.Ukupno,
&n.NacinPlacanja, &n.Stornirano, &razlogStorniranja, &n.Datum,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiID: %w", err)
}
@@ -108,15 +114,16 @@ func (r *ProdajaRepo) DohvatiID(ctx context.Context, id int64) (*model.ProdajniN
n.KlijentID = &v
}
n.Napomena = napomena.String
n.RazlogStorniranja = razlogStorniranja.String
return &n, nil
}
// DohvatiStavke vraća stavke prodaje sa nazivima artikala za dati nalog
// DohvatiStavke vraća stavke prodaje sa nazivima artikala i PDV podacima za dati nalog
func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model.StavkaProdajeSaArtiklom, error) {
redovi, err := r.db.QueryContext(ctx, `
SELECT sp.id, sp.nalog_id, sp.artikal_id, sp.kolicina, sp.cena_po_komadu, sp.ukupno,
a.naziv
sp.pdv_stopa, sp.pdv_iznos, sp.cena_bez_pdv, a.naziv
FROM stavke_prodaje sp
JOIN artikli a ON a.id = sp.artikal_id
WHERE sp.nalog_id = ?
@@ -131,7 +138,9 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
var s model.StavkaProdajeSaArtiklom
err := redovi.Scan(
&s.ID, &s.NalogID, &s.ArtikalID, &s.Kolicina,
&s.CenaPoKomadu, &s.Ukupno, &s.ArtikalNaziv,
&s.CenaPoKomadu, &s.Ukupno,
&s.PdvStopa, &s.PdvIznos, &s.CenaBezPdv,
&s.ArtikalNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ProdajaRepo.DohvatiStavke: scan: %w", err)
@@ -143,44 +152,24 @@ func (r *ProdajaRepo) DohvatiStavke(ctx context.Context, nalogID int64) ([]model
}
// Kreiraj upisuje novi prodajni nalog u bazu u okviru jedne transakcije.
// Za svaku stavku proverava stanje u magacinu i smanjuje ga.
// Za svaku stavku proverava stanje u magacinu, smanjuje ga i beleži promenu.
// Ako bilo koji artikal nema dovoljno stanja, vraća ErrNedovoljnoKolicine.
func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje) (int64, error) {
func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavke []model.StavkaProdaje, korisnikID *int64) (int64, error) {
if n.NacinPlacanja == "" {
n.NacinPlacanja = "gotovina"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: begin tx: %w", err)
}
defer tx.Rollback()
// provera i smanjenje stanja za svaku stavku
for _, s := range stavke {
var naziv string
var kolicinaNaStanju int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &kolicinaNaStanju)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if kolicinaNaStanju < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina - ? WHERE id = ?",
s.Kolicina, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
}
// insert zaglavlja naloga
// 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, datum)
VALUES (?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.Datum,
INSERT INTO prodajni_nalozi (klijent_id, broj_naloga, napomena, ukupno, nacin_placanja, datum)
VALUES (?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, nullString(n.Napomena), n.Ukupno, n.NacinPlacanja, n.Datum,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert nalog: %w", err)
@@ -191,17 +180,54 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: last insert id: %w", err)
}
// insert stavki
// provera stanja, smanjenje i insert stavki
for _, s := range stavke {
var naziv string
var stanjePre int
err := tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", s.ArtikalID,
).Scan(&naziv, &stanjePre)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
}
if stanjePre < s.Kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle := stanjePre - s.Kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, s.ArtikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: update stanje: %w", err)
}
// PDV računamo iz cene ako nije eksplicitno postavljeno
cenaBezPdv := s.CenaBezPdv
pdvIznos := s.PdvIznos
if cenaBezPdv == 0 && s.PdvStopa > 0 {
cenaBezPdv = s.CenaPoKomadu / (1 + s.PdvStopa/100)
pdvIznos = s.CenaPoKomadu - cenaBezPdv
}
ukupnoStavke := float64(s.Kolicina) * s.CenaPoKomadu
_, err := tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje (nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno)
VALUES (?, ?, ?, ?, ?)`,
_, err = tx.ExecContext(ctx, `
INSERT INTO stavke_prodaje
(nalog_id, artikal_id, kolicina, cena_po_komadu, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, ukupnoStavke,
s.PdvStopa, pdvIznos, cenaBezPdv,
)
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, s.ArtikalID, model.PromenaIzlazProdaja,
-s.Kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: magacin: %w", err)
}
}
if err := tx.Commit(); err != nil {
@@ -211,6 +237,83 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
return nalogID, nil
}
// Storno označava prodajni nalog kao storniran i vraća sve artikle na stanje
func (r *ProdajaRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: begin tx: %w", err)
}
defer tx.Rollback()
// proverava da li je već storniran
var stornirano bool
err = tx.QueryRowContext(ctx, "SELECT stornirano FROM prodajni_nalozi WHERE id = ?", id).Scan(&stornirano)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: provera: %w", err)
}
if stornirano {
return fmt.Errorf("ntech: ProdajaRepo.Storno: nalog je već storniran")
}
// vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stavke: %w", err)
}
type stavkaPovrat struct {
artikalID int64
kolicina int
}
var stavke []stavkaPovrat
for redovi.Next() {
var p stavkaPovrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ProdajaRepo.Storno: scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
for _, p := range stavke {
var stanjePre int
err := tx.QueryRowContext(ctx, "SELECT kolicina FROM artikli WHERE id = ?", p.artikalID).Scan(&stanjePre)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: dohvati stanje: %w", err)
}
stanjePosle := stanjePre + p.kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: vrati stanje: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
p.kolicina, stanjePre, stanjePosle, id, korisnikID, razlog)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: magacin: %w", err)
}
}
_, err = tx.ExecContext(ctx,
"UPDATE prodajni_nalozi SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
nullString(razlog), id,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: update nalog: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Storno: commit: %w", err)
}
return nil
}
// Obrisi briše prodajni nalog i vraća količine artikala na stanje (u transakciji)
func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
tx, err := r.db.BeginTx(ctx, nil)
@@ -219,32 +322,43 @@ func (r *ProdajaRepo) Obrisi(ctx context.Context, id int64) error {
}
defer tx.Rollback()
// vraćanje stanja u magacin
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
// vraćanje stanja u magacin samo ako nalog nije storniran (storno je već vratio)
var stornirano bool
err = tx.QueryRowContext(ctx, "SELECT stornirano FROM prodajni_nalozi WHERE id = ?", id).Scan(&stornirano)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err)
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: provera: %w", err)
}
type povrat struct{ artikalID int64; kolicina int }
var stavke []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
for _, p := range stavke {
_, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
p.kolicina, p.artikalID,
)
if !stornirano {
redovi, err := tx.QueryContext(ctx,
"SELECT artikal_id, kolicina FROM stavke_prodaje WHERE nalog_id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: dohvati stavke: %w", err)
}
type povrat struct {
artikalID int64
kolicina int
}
var stavke []povrat
for redovi.Next() {
var p povrat
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
redovi.Close()
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: scan stavke: %w", err)
}
stavke = append(stavke, p)
}
redovi.Close()
for _, p := range stavke {
_, err := tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = kolicina + ? WHERE id = ?",
p.kolicina, p.artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ProdajaRepo.Obrisi: vrati stanje: %w", err)
}
}
}
+28 -19
View File
@@ -40,9 +40,9 @@ func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]model.ServisniNalogSaKlijentom, error) {
upit := `
SELECT
sn.id, sn.klijent_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
sn.opis_kvara, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
sn.avans, sn.napomena, sn.datum_prijema, sn.datum_zavrsetka,
sn.avans, sn.napomena, sn.garancija_do, sn.datum_prijema, sn.datum_zavrsetka,
COALESCE(NULLIF(k.naziv_firme, ''), TRIM(COALESCE(k.ime, '') || ' ' || COALESCE(k.prezime, '')), '') AS klijent_naziv
FROM servisni_nalozi sn
LEFT JOIN klijenti k ON k.id = sn.klijent_id
@@ -86,9 +86,9 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNalog, error) {
red := r.db.QueryRowContext(ctx, `
SELECT
id, klijent_id, broj_naloga, uredjaj, serijski_broj,
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
opis_kvara, status, cena_od, cena_do, cena_konacna,
avans, napomena, datum_prijema, datum_zavrsetka
avans, napomena, garancija_do, datum_prijema, datum_zavrsetka
FROM servisni_nalozi WHERE id = ?`, id)
var n model.ServisniNalog
@@ -104,13 +104,14 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64, error) {
rezultat, err := r.db.ExecContext(ctx, `
INSERT INTO servisni_nalozi
(klijent_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, datum_zavrsetka)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), n.BrojNaloga, n.Uredjaj, nullString(n.SerijskiBroj),
n.OpisKvara, n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo),
nullFloat64(n.CenaKonacna), nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.DatumZavrsetka),
(klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj, opis_kvara,
status, cena_od, cena_do, cena_konacna, avans, napomena, garancija_do, datum_zavrsetka)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.BrojNaloga, n.Uredjaj,
nullString(n.SerijskiBroj), n.OpisKvara, n.Status,
nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena),
nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisRepo.Kreiraj: %w", err)
@@ -128,13 +129,13 @@ func (r *ServisRepo) Kreiraj(ctx context.Context, n *model.ServisniNalog) (int64
func (r *ServisRepo) Izmeni(ctx context.Context, n *model.ServisniNalog) error {
_, err := r.db.ExecContext(ctx, `
UPDATE servisni_nalozi SET
klijent_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?,
klijent_id = ?, tehnicar_id = ?, uredjaj = ?, serijski_broj = ?, opis_kvara = ?,
status = ?, cena_od = ?, cena_do = ?, cena_konacna = ?,
avans = ?, napomena = ?, datum_zavrsetka = ?
avans = ?, napomena = ?, garancija_do = ?, datum_zavrsetka = ?
WHERE id = ?`,
nullInt64(n.KlijentID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara,
nullInt64(n.KlijentID), nullInt64(n.TehnicarID), n.Uredjaj, nullString(n.SerijskiBroj), n.OpisKvara,
n.Status, nullFloat64(n.CenaOd), nullFloat64(n.CenaDo), nullFloat64(n.CenaKonacna),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.DatumZavrsetka),
nullFloat64(n.Avans), nullString(n.Napomena), nullTime(n.GarancijaDo), nullTime(n.DatumZavrsetka),
n.ID,
)
if err != nil {
@@ -157,15 +158,15 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64) error {
// scanNalog čita redove iz upita u ServisniNalog struct —
// klijentNaziv je opcioni pokazivač, nil kada se čita bez JOIN-a
func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *string) error {
var klijentID sql.NullInt64
var klijentID, tehnicarID sql.NullInt64
var serijskiBroj, napomena sql.NullString
var cenaOd, cenaDo, cenaKonacna, avans sql.NullFloat64
var datumZavrsetka sql.NullTime
var garancijaDo, datumZavrsetka sql.NullTime
args := []any{
&n.ID, &klijentID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
&n.OpisKvara, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
&avans, &napomena, &n.DatumPrijema, &datumZavrsetka,
&avans, &napomena, &garancijaDo, &n.DatumPrijema, &datumZavrsetka,
}
if klijentNaziv != nil {
@@ -180,6 +181,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := klijentID.Int64
n.KlijentID = &v
}
if tehnicarID.Valid {
v := tehnicarID.Int64
n.TehnicarID = &v
}
n.SerijskiBroj = serijskiBroj.String
n.Napomena = napomena.String
if cenaOd.Valid {
@@ -198,6 +203,10 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
v := avans.Float64
n.Avans = &v
}
if garancijaDo.Valid {
v := garancijaDo.Time
n.GarancijaDo = &v
}
if datumZavrsetka.Valid {
v := datumZavrsetka.Time
n.DatumZavrsetka = &v
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"ntech/internal/db"
"ntech/internal/model"
)
// ServisniDeloviRepo je SQLite implementacija ServisniDeloviRepository interfejsa
type ServisniDeloviRepo struct {
db *sql.DB
}
// NoviServisniDeloviRepo kreira novi ServisniDeloviRepo
func NoviServisniDeloviRepo(db *sql.DB) *ServisniDeloviRepo {
return &ServisniDeloviRepo{db: db}
}
// DohvatiZaNalog vraća sve ugrađene delove za dati servisni nalog
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,
a.naziv
FROM servisni_delovi sd
JOIN artikli a ON a.id = sd.artikal_id
WHERE sd.nalog_id = ?
ORDER BY sd.datum`, nalogID)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: %w", err)
}
defer redovi.Close()
var rezultat []model.ServisniDeoSaArtiklom
for redovi.Next() {
var d model.ServisniDeoSaArtiklom
err := redovi.Scan(
&d.ID, &d.NalogID, &d.ArtikalID, &d.Kolicina, &d.CenaKomada, &d.Datum,
&d.ArtikalNaziv,
)
if err != nil {
return nil, fmt.Errorf("ntech: ServisniDeloviRepo.DohvatiZaNalog: scan: %w", err)
}
rezultat = append(rezultat, d)
}
return rezultat, nil
}
// Dodaj dodaje jedan artikal u servisni nalog, smanjuje stanje u magacinu i beleži promenu
func (r *ServisniDeloviRepo) Dodaj(ctx context.Context, nalogID, artikalID int64, kolicina int, cenaKomada float64, korisnikID *int64) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: begin tx: %w", err)
}
defer tx.Rollback()
var naziv string
var stanjePre int
err = tx.QueryRowContext(ctx,
"SELECT naziv, kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&naziv, &stanjePre)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: dohvati artikal: %w", err)
}
if stanjePre < kolicina {
return 0, &db.ErrNedovoljnoKolicine{ArtikalNaziv: naziv}
}
stanjePosle := stanjePre - kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: update stanje: %w", err)
}
rezultat, err := tx.ExecContext(ctx, `
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
VALUES (?, ?, ?, ?)`,
nalogID, artikalID, kolicina, cenaKomada,
)
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: insert: %w", err)
}
deoID, err := rezultat.LastInsertId()
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: last insert id: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaIzlazServis,
-kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "")
if err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: magacin: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("ntech: ServisniDeloviRepo.Dodaj: commit: %w", err)
}
return deoID, nil
}
// Obrisi uklanja servisni deo i vraća količinu na stanje u magacinu
func (r *ServisniDeloviRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: begin tx: %w", err)
}
defer tx.Rollback()
var artikalID int64
var nalogID int64
var kolicina int
err = tx.QueryRowContext(ctx,
"SELECT artikal_id, nalog_id, kolicina FROM servisni_delovi WHERE id = ?", id,
).Scan(&artikalID, &nalogID, &kolicina)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: dohvati deo: %w", err)
}
var stanjePre int
err = tx.QueryRowContext(ctx,
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
).Scan(&stanjePre)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: dohvati stanje: %w", err)
}
stanjePosle := stanjePre + kolicina
_, err = tx.ExecContext(ctx,
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, artikalID,
)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: vrati stanje: %w", err)
}
_, err = tx.ExecContext(ctx, "DELETE FROM servisni_delovi WHERE id = ?", id)
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: delete: %w", err)
}
err = zabeleziMagacinPromenu(ctx, tx, artikalID, model.PromenaPovracaj,
kolicina, stanjePre, stanjePosle, nalogID, korisnikID, "uklonjen servisni deo")
if err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: magacin: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("ntech: ServisniDeloviRepo.Obrisi: commit: %w", err)
}
return nil
}
+9 -3
View File
@@ -21,7 +21,9 @@ type Handler struct {
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
@@ -43,8 +45,10 @@ func Novi(baza *sql.DB) *Handler {
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
@@ -63,6 +67,8 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.NabavkeRepo = sqlite.NoviNabavkaRepo(novaDB)
h.KlijentiRepo = sqlite.NoviKlijentRepo(novaDB)
h.ServisRepo = sqlite.NoviServisRepo(novaDB)
h.ServisniDeloviRepo = sqlite.NoviServisniDeloviRepo(novaDB)
h.MagacinskePromeneRepo = sqlite.NoviMagacinskePromeneRepo(novaDB)
h.ProdajaRepo = sqlite.NoviProdajaRepo(novaDB)
h.KorisniciRepo = sqlite.NoviKorisniciRepo(novaDB)
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
+13 -3
View File
@@ -227,21 +227,31 @@ func (h *Handler) ObrisiKlijenta(w http.ResponseWriter, r *http.Request) {
// parseFormuKlijenta čita polja iz HTTP forme, validira ih i vraća model i eventualnu grešku
func parseFormuKlijenta(r *http.Request) (model.Klijent, string) {
tip := r.FormValue("tip")
if tip != "fizicko" && tip != "pravno" {
tip = "fizicko"
}
ime := strings.TrimSpace(r.FormValue("ime"))
nazivFirme := strings.TrimSpace(r.FormValue("naziv_firme"))
if ime == "" && nazivFirme == "" {
return model.Klijent{}, "Mora biti uneto ime i prezime ili naziv firme."
if tip == "fizicko" && ime == "" {
return model.Klijent{Tip: tip}, "Za fizičko lice obavezno je uneti ime."
}
if tip == "pravno" && nazivFirme == "" {
return model.Klijent{Tip: tip}, "Za pravno lice obavezan je naziv firme."
}
email := strings.TrimSpace(r.FormValue("email"))
if email != "" && !strings.Contains(email, "@") {
return model.Klijent{}, "Adresa e-pošte nije ispravna."
return model.Klijent{Tip: tip}, "Adresa e-pošte nije ispravna."
}
return model.Klijent{
Tip: tip,
Ime: ime,
Prezime: strings.TrimSpace(r.FormValue("prezime")),
JMBG: strings.TrimSpace(r.FormValue("jmbg")),
NazivFirme: nazivFirme,
PIB: strings.TrimSpace(r.FormValue("pib")),
Telefon: strings.TrimSpace(r.FormValue("telefon")),
+3
View File
@@ -606,6 +606,7 @@ func (h *Handler) PodesavanjaOpste(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
podaci.Stranica = "podesavanja-opste"
h.renderujTemplate(w, "podesavanja_opste", podaci)
}
@@ -621,6 +622,7 @@ func (h *Handler) PodesavanjaIzgled(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
podaci.Stranica = "podesavanja-izgled"
h.renderujTemplate(w, "podesavanja_izgled", podaci)
}
@@ -636,6 +638,7 @@ func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
return
}
podaci.Stranica = "podesavanja-sistem"
h.renderujTemplate(w, "podesavanja_sistem", podaci)
}
+26 -7
View File
@@ -57,17 +57,18 @@ type PodaciStampeProdaje struct {
PIB string
}
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom i stanjem
// artikalUJSONSaCenom pretvara listu artikala u template.JS vrednost sa prodajnom cenom, PDV stopom i stanjem
func artikalUJSONSaCenom(artikli []model.ArtikalSaKategorijom) template.JS {
type stavka struct {
ID int64 `json:"id"`
Naziv string `json:"naziv"`
Cena float64 `json:"cena"`
PdvStopa float64 `json:"pdv_stopa"`
Kolicina int `json:"kolicina"`
}
lista := make([]stavka, 0, len(artikli))
for _, a := range artikli {
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, Kolicina: a.Kolicina})
lista = append(lista, stavka{ID: a.ID, Naziv: a.Naziv, Cena: a.ProdajnaCena, PdvStopa: a.PdvStopa, Kolicina: a.Kolicina})
}
b, _ := json.Marshal(lista)
return template.JS(b)
@@ -185,7 +186,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
}
nalog.Ukupno = ukupno
id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke)
id, err := h.ProdajaRepo.Kreiraj(r.Context(), &nalog, stavke, &k.ID)
if err != nil {
var errStanje *appdb.ErrNedovoljnoKolicine
if errors.As(err, &errStanje) {
@@ -336,10 +337,15 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
}
}
nalog.Napomena = strings.TrimSpace(r.FormValue("napomena"))
nalog.NacinPlacanja = r.FormValue("nacin_placanja")
if nalog.NacinPlacanja != "gotovina" && nalog.NacinPlacanja != "kartica" && nalog.NacinPlacanja != "prenos" {
nalog.NacinPlacanja = "gotovina"
}
artikalIDovi := r.Form["artikal_id[]"]
kolicine := r.Form["kolicina[]"]
cene := r.Form["cena_po_komadu[]"]
pdvStope := r.Form["pdv_stopa[]"]
if len(artikalIDovi) == 0 {
return nalog, nil, "Prodaja mora imati najmanje jednu stavku."
@@ -366,17 +372,23 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
return nalog, nil, "Cena mora biti pozitivan broj."
}
var pdvStopa float64
if i < len(pdvStope) {
pdvStopa, _ = strconv.ParseFloat(strings.TrimSpace(pdvStope[i]), 64)
}
stavke = append(stavke, model.StavkaProdaje{
ArtikalID: artikalID,
Kolicina: kolicina,
CenaPoKomadu: cena,
PdvStopa: pdvStopa,
})
}
return nalog, stavke, ""
}
// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i briše nalog
// StornoProdaje stornira prodajni nalog: vraća artikle na stanje i označava nalog kao storniran
func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "prodaja.storno") {
@@ -388,12 +400,19 @@ func (h *Handler) StornoProdaje(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
if err := h.ProdajaRepo.Obrisi(r.Context(), id); err != nil {
http.Error(w, "Greška pri storniranju naloga", http.StatusInternalServerError)
if err := r.ParseForm(); err != nil {
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
return
}
razlog := strings.TrimSpace(r.FormValue("razlog"))
if err := h.ProdajaRepo.Storno(r.Context(), id, razlog, &k.ID); err != nil {
log.Printf("greška pri storniranju naloga: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri storniranju. Možda je nalog već storniran.")
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Prodajni nalog je storniran.")
http.Redirect(w, r, "/prodaja", http.StatusSeeOther)
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// renderujFormuProdaje renderuje HTML šablon forme za unos nove prodaje
+149 -9
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
appdbPkg "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/middleware"
"ntech/internal/model"
@@ -30,6 +31,7 @@ type PodaciFormeNaloga struct {
model.PodaciStranice
Nalog model.ServisniNalog
Klijenti []model.Klijent
Tehnicari []model.Korisnik
SviStatusi []string
Greska string
Izmena bool
@@ -38,9 +40,12 @@ type PodaciFormeNaloga struct {
// PodaciDetaljiNaloga su podaci za pregled jednog servisnog naloga
type PodaciDetaljiNaloga struct {
model.PodaciStranice
Nalog model.ServisniNalog
KlijentNaziv string
Sacuvano bool
Nalog model.ServisniNalog
KlijentNaziv string
TehnicarNaziv string
ServisniDelovi []model.ServisniDeoSaArtiklom
Artikli []model.ArtikalSaKategorijom
Sacuvano bool
}
// Servis renderuje listu servisnih naloga sa opcionom pretragom i filterom statusa
@@ -96,6 +101,12 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
return
}
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog"
@@ -103,6 +114,7 @@ func (h *Handler) NoviNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: model.ServisniNalog{BrojNaloga: brojNaloga, Status: model.StatusPrimljeno},
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Izmena: false,
})
@@ -124,6 +136,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog"
@@ -131,6 +144,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: greska,
Izmena: false,
@@ -143,6 +157,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Novi nalog"
@@ -150,6 +165,7 @@ func (h *Handler) SacuvajNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: false,
@@ -186,6 +202,12 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
return
}
tehnicari, err := h.KorisniciRepo.Lista(r.Context())
if err != nil {
http.Error(w, "Greška pri učitavanju tehničara", http.StatusInternalServerError)
return
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Izmeni nalog"
@@ -193,6 +215,7 @@ func (h *Handler) IzmeniNalog(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: *nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Izmena: true,
})
@@ -220,6 +243,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
if greska != "" {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
nalog.ID = id
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
@@ -228,6 +252,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: greska,
Izmena: true,
@@ -240,6 +265,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
log.Printf("greška pri čuvanju izmene naloga: %v", err)
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
klijenti, _ := h.KlijentiRepo.Lista(r.Context(), "")
tehnicari, _ := h.KorisniciRepo.Lista(r.Context())
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Izmeni nalog"
@@ -247,6 +273,7 @@ func (h *Handler) SacuvajIzmenaNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: nalog,
Klijenti: klijenti,
Tehnicari: tehnicari,
SviStatusi: model.SviStatusi,
Greska: "Došlo je do greške pri čuvanju. Pokušajte ponovo.",
Izmena: true,
@@ -278,7 +305,7 @@ func (h *Handler) ObrisiNalog(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/servis?obrisan=1", http.StatusSeeOther)
}
// DetaljiNaloga prikazuje sve podatke jednog servisnog naloga
// DetaljiNaloga prikazuje sve podatke jednog servisnog naloga sa ugrađenim delovima
func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
id, err := parseID(chi.URLParam(r, "id"))
if err != nil {
@@ -302,14 +329,29 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
if nalog.KlijentID != nil {
klijent, err := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID)
if err == nil {
if klijent.NazivFirme != "" {
klijentNaziv = klijent.NazivFirme
} else {
klijentNaziv = strings.TrimSpace(klijent.Ime + " " + klijent.Prezime)
}
klijentNaziv = klijent.PunoIme()
}
}
tehnicarNaziv := ""
if nalog.TehnicarID != nil {
tehnicar, err := h.KorisniciRepo.DohvatiPoID(r.Context(), *nalog.TehnicarID)
if err == nil {
tehnicarNaziv = tehnicar.KorisnickoIme
}
}
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
if err != nil {
log.Printf("greška pri učitavanju delova: %v", err)
}
appdb := appdbPkg.ArtikalFilter{}
artikli, err := h.Artikli.Lista(r.Context(), appdb)
if err != nil {
log.Printf("greška pri učitavanju artikala: %v", err)
}
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "servis"
ps.NaslovStranice = "Detalji naloga"
@@ -317,12 +359,96 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
PodaciStranice: ps,
Nalog: *nalog,
KlijentNaziv: klijentNaziv,
TehnicarNaziv: tehnicarNaziv,
ServisniDelovi: delovi,
Artikli: artikli,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
h.renderujTemplate(w, "servis_detalji", podaci)
}
// DodajDeloNalogu prima POST formu i dodaje artikal kao deo servisnog naloga
func (h *Handler) DodajDeloNalogu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
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
}
artikalID, err := strconv.ParseInt(r.FormValue("artikal_id"), 10, 64)
if err != nil || artikalID <= 0 {
middleware.SetFlash(w, r, h.DB, "greska", "Neispravan artikal.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
kolicina, err := strconv.Atoi(r.FormValue("kolicina"))
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.ServisniDeloviRepo.Dodaj(r.Context(), nalogID, artikalID, kolicina, cena, &k.ID); err != nil {
log.Printf("greška pri dodavanju dela: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri dodavanju dela. Proverite stanje na magacinu.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je dodat.")
http.Redirect(w, r, "/servis/"+strconv.FormatInt(nalogID, 10), http.StatusSeeOther)
}
// ObrisiDeloNaloga prima POST zahtev i uklanja deo iz servisnog naloga
func (h *Handler) ObrisiDeloNaloga(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "servis.izmeni") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
nalogID, err := parseID(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
return
}
deoID, err := parseID(chi.URLParam(r, "deo_id"))
if err != nil {
http.Error(w, "Neispravan ID dela", http.StatusBadRequest)
return
}
if err := h.ServisniDeloviRepo.Obrisi(r.Context(), deoID, &k.ID); err != nil {
log.Printf("greška pri brisanju dela: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju dela.")
} else {
middleware.SetFlash(w, r, h.DB, "uspeh", "Deo je uklonjen.")
}
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"))
@@ -355,6 +481,13 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
}
}
// opcioni tehničar
if tidStr := r.FormValue("tehnicar_id"); tidStr != "" {
if tid, err := strconv.ParseInt(tidStr, 10, 64); err == nil {
nalog.TehnicarID = &tid
}
}
// opcione cene — prazno polje ostaje nil
nalog.CenaOd = parseOpcionuCenu(r.FormValue("cena_od"))
nalog.CenaDo = parseOpcionuCenu(r.FormValue("cena_do"))
@@ -368,6 +501,13 @@ func parseFormuNaloga(r *http.Request) (model.ServisniNalog, string) {
}
}
// opcioni datum garancije
if gd := strings.TrimSpace(r.FormValue("garancija_do")); gd != "" {
if t, err := time.Parse("2006-01-02", gd); err == nil {
nalog.GarancijaDo = &t
}
}
return nalog, ""
}
+12
View File
@@ -11,11 +11,23 @@ type Artikal struct {
Kolicina int
KolicinMin int
Lokacija string
NabavnaCena float64
ProdajnaCena float64
PdvStopa float64
Napomena string
DatumUnosa time.Time
}
// CenaBezPdv izračunava prodajnu cenu bez PDV-a
func (a Artikal) CenaBezPdv() float64 {
return a.ProdajnaCena / (1 + a.PdvStopa/100)
}
// PdvIznos izračunava iznos PDV-a za jednu jedinicu
func (a Artikal) PdvIznos() float64 {
return a.ProdajnaCena - a.CenaBezPdv()
}
// Kategorija predstavlja kategoriju artikala
type Kategorija struct {
ID int64
+14 -1
View File
@@ -1,12 +1,17 @@
package model
import "time"
import (
"strings"
"time"
)
// Klijent predstavlja jednog klijenta — fizičko lice ili firmu
type Klijent struct {
ID int64
Tip string
Ime string
Prezime string
JMBG string
NazivFirme string
PIB string
Telefon string
@@ -14,3 +19,11 @@ type Klijent struct {
Napomena string
DatumUnosa time.Time
}
// PunoIme vraća ime i prezime za fizičko lice, ili naziv firme za pravno
func (k Klijent) PunoIme() string {
if k.Tip == "pravno" {
return k.NazivFirme
}
return strings.TrimSpace(k.Ime + " " + k.Prezime)
}
+27
View File
@@ -0,0 +1,27 @@
package model
import "time"
// Tipovi magacinskih promena
const (
PromenaUlazNabavka = "ulaz_nabavka"
PromenaIzlazProdaja = "izlaz_prodaja"
PromenaIzlazServis = "izlaz_servis"
PromenaPovracaj = "povracaj"
PromenaKorekcija = "korekcija"
)
// MagacinskaPromenaSaDetaljem je promena stanja artikla sa nazivom artikla
type MagacinskaPromenaSaDetaljem struct {
ID int64
ArtikalID int64
ArtikalNaziv string
TipPromene string
ReferentniID int64
PromenaKolicine int
StanjePre int
StanjePosle int
KorisnikID *int64
Napomena string
Datum time.Time
}
+12 -6
View File
@@ -4,12 +4,15 @@ import "time"
// ProdajniNalog predstavlja zaglavlje jedne prodaje
type ProdajniNalog struct {
ID int64
KlijentID *int64
BrojNaloga string
Napomena string
Ukupno float64
Datum time.Time
ID int64
KlijentID *int64
BrojNaloga string
Napomena string
Ukupno float64
NacinPlacanja string
Stornirano bool
RazlogStorniranja string
Datum time.Time
}
// StavkaProdaje predstavlja jednu liniju (artikal) unutar prodaje
@@ -20,6 +23,9 @@ type StavkaProdaje struct {
Kolicina int
CenaPoKomadu float64
Ukupno float64
PdvStopa float64
PdvIznos float64
CenaBezPdv float64
}
// ProdajniNalogSaDetaljem je nalog sa nazivom klijenta — za prikaz u listi
+31
View File
@@ -29,6 +29,7 @@ var SviStatusi = []string{
type ServisniNalog struct {
ID int64
KlijentID *int64
TehnicarID *int64
BrojNaloga string
Uredjaj string
SerijskiBroj string
@@ -39,10 +40,32 @@ type ServisniNalog struct {
CenaKonacna *float64
Avans *float64
Napomena string
GarancijaDo *time.Time
DatumPrijema time.Time
DatumZavrsetka *time.Time
}
// ServisniDeo predstavlja jedan artikal ugrađen u servisni nalog
type ServisniDeo struct {
ID int64
NalogID int64
ArtikalID int64
Kolicina int
CenaKomada float64
Datum time.Time
}
// Ukupno vraća ukupnu vrednost dela (kolicina × cena)
func (d ServisniDeo) Ukupno() float64 {
return float64(d.Kolicina) * d.CenaKomada
}
// ServisniDeoSaArtiklom je servisni deo sa nazivom artikla — za prikaz
type ServisniDeoSaArtiklom struct {
ServisniDeo
ArtikalNaziv string
}
// ServisniNalogSaKlijentom proširuje ServisniNalog sa nazivom klijenta za prikaz u listi
type ServisniNalogSaKlijentom struct {
ServisniNalog
@@ -57,6 +80,14 @@ func (n ServisniNalog) KlijentIDVrednost() int64 {
return *n.KlijentID
}
// TehnicarIDVrednost vraća vrednost TehnicarID pointera, ili 0 ako je nil
func (n ServisniNalog) TehnicarIDVrednost() int64 {
if n.TehnicarID == nil {
return 0
}
return *n.TehnicarID
}
// CenaOdStr vraća formatiranu procenu od, ili prazan string ako nije uneta
func (n ServisniNalog) CenaOdStr() string {
if n.CenaOd == nil {