diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index bcbdf77..d81ed78 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -8,9 +8,9 @@ import ( "fmt" "io/fs" "log/slog" + "mime" "net/http" "os" - "mime" "path/filepath" "sort" "strconv" @@ -87,12 +87,14 @@ func main() { db, err := sqlite.OtvoriDB(putanjaBaze) if err != nil { - slog.Error("Greška pri otvaranju baze", "error", err); os.Exit(1) + slog.Error("Greška pri otvaranju baze", "error", err) + os.Exit(1) } defer db.Close() if err := sqlite.PokreniMigracije(db, migrFS); err != nil { - slog.Error("Greška pri migracijama", "error", err); os.Exit(1) + slog.Error("Greška pri migracijama", "error", err) + os.Exit(1) } slog.Info("migracije uspešno izvršene") @@ -111,7 +113,8 @@ func main() { // ključ za šifrovanje TOTP tajni u mirovanju (AES-256-GCM) totpKljuc, err := ucitajTotpKljuc() if err != nil { - slog.Error("Greška pri učitavanju ključa za TOTP", "error", err); os.Exit(1) + slog.Error("Greška pri učitavanju ključa za TOTP", "error", err) + os.Exit(1) } // jednokratno šifruj eventualne stare TOTP tajne koje su ostale kao čist tekst @@ -137,7 +140,8 @@ func main() { if os.Getenv("NTECH_ENV") == "production" { kes, err := handler.KreirajKes(templFS) if err != nil { - slog.Error("Greška pri kreiranju keša šablona", "error", err); os.Exit(1) + slog.Error("Greška pri kreiranju keša šablona", "error", err) + os.Exit(1) } h.Templates = kes slog.Info("keš šablona kreiran", "broj", len(kes)) @@ -221,6 +225,20 @@ func main() { return ntechmw.RequireDozvolaMut(h.DozvoleRepo.ImaDozvolu, akcija) } + // modul vraća middleware koji propušta zahtev samo ako je zakonski modul + // uključen za firmu (profil firme). Sloj IZNAD RBAC-a — zahtev mora proći + // i „modul uključen" (ovo) i „korisnik sme" (doz/zahtevajDozvolu). + proveriModul := func(ctx context.Context, m string) bool { + pod, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) + if err != nil { + return false + } + return config.ModulUkljucen(pod, m) + } + modul := func(m string) func(http.Handler) http.Handler { + return ntechmw.RequireModul(proveriModul, m) + } + r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/dashboard", http.StatusFound) }) @@ -229,6 +247,10 @@ func main() { r.Get("/admin/podesavanja/opste", h.PodesavanjaOpste) r.Get("/admin/podesavanja/izgled", h.PodesavanjaIzgled) r.Get("/admin/podesavanja/sistem", h.PodesavanjaSistem) + r.Get("/admin/podesavanja/pdv-stope", h.PdvStope) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu) + r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/aktivnost", h.PromeniAktivnostPdvStope) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/sacuvaj", h.SacuvajPodesavanja) r.With(doz("podesavanja.izmeni")).Post("/podesavanja/logo", h.OtpremiLogo) r.With(doz("podesavanja.login_pozadina")).Post("/podesavanja/login-pozadina", h.OtpremiLoginPozadinu) @@ -237,6 +259,18 @@ func main() { r.Get("/podesavanja/backup", h.BackupBaze) r.With(doz("backup.pokreni")).Post("/podesavanja/backup/vrati", h.VratiBackup) + + // PDV evidencija — KIR (knjiga izdatih računa). Dostupno samo kada je modul + // „pdv" uključen za firmu (RequireModul), uz RBAC dozvolu pdv.*. + r.With(modul("pdv")).Get("/pdv/kir", h.PdvKir) + r.With(modul("pdv")).Get("/pdv/kir/nova", h.NoviPdvKir) + r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir) + r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir) + r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr) + r.With(modul("pdv")).Get("/pdv/kpr/nova", h.NoviPdvKpr) + r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kpr/nova", h.SacuvajPdvKpr) + r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kpr/obrisi/{id}", h.ObrisiPdvKpr) + r.With(modul("pdv")).Get("/pdv/obracun", h.PdvObracunStranica) r.Get("/magacin", h.Magacin) r.Get("/magacin/novi", h.NoviArtikal) r.With(doz("artikal.dodaj")).Post("/magacin/novi", h.SacuvajArtikal) @@ -327,7 +361,8 @@ func main() { slog.Info("NTech pokrenut", "port", port) err = http.ListenAndServe(":"+port, r) if err != nil { - slog.Error("port je zauzet ili nije dostupan", "port", port); os.Exit(1) + slog.Error("port je zauzet ili nije dostupan", "port", port) + os.Exit(1) } } @@ -419,4 +454,3 @@ func ocistiStareBackupe(folder string, max int) { _ = os.Remove(f) } } - diff --git a/internal/config/moduli.go b/internal/config/moduli.go new file mode 100644 index 0000000..db15eca --- /dev/null +++ b/internal/config/moduli.go @@ -0,0 +1,67 @@ +package config + +// Ovaj fajl izvodi iz profila firme (vidi Project.md §2, §4) koji su zakonski +// moduli uključeni za datu firmu. Profil se čuva kao key-value u tabeli +// `podesavanja`, pa helper prima već učitanu mapu (čista funkcija — lako se +// testira bez baze). Ovo je sloj IZNAD RBAC-a: „da li firma uopšte koristi +// modul", nezavisno od „da li korisnik sme". + +// Ključevi profila firme u tabeli `podesavanja`. +const ( + KljucPravniOblik = "firma_pravni_oblik" + KljucPdvObveznik = "firma_pdv_obveznik" + KljucFiskalizacija = "firma_fiskalizacija" + KljucRezim = "firma_rezim" +) + +// Nazivi modula koje program pali/gasi prema profilu firme. +const ( + ModulPdv = "pdv" + ModulFiskalizacija = "fiskalizacija" + ModulKpo = "kpo" + ModulDvojno = "dvojno" +) + +// ModulUkljucen vraća da li je dati zakonski modul aktivan za firmu, na osnovu +// profila firme. Režim „samo evidencija" gasi ceo zakonski sloj — tada nijedan +// modul nije uključen, bez obzira na ostale prekidače. +func ModulUkljucen(podesavanja map[string]string, modul string) bool { + // podrazumevano (ključ još ne postoji) je „samo evidencija" — najbezbednije + // stanje: stara instalacija ne počinje da se ponaša kao poreski obveznik. + rezim := podesavanja[KljucRezim] + if rezim == "" { + rezim = "samo_evidencija" + } + if rezim != "pun" { + return false + } + + switch modul { + case ModulFiskalizacija: + // fiskalizacija je nezavisna od pravnog oblika — zaseban prekidač + // „izdaje li račune građanima" (Project.md §3, napomena *). + return podesavanja[KljucFiskalizacija] == "da" + case ModulPdv: + // PDV evidencija se vodi kad je firma u sistemu PDV-a. + return podesavanja[KljucPdvObveznik] == "da" + case ModulKpo: + // KPO (knjiga o ostvarenom prometu) je samo za paušalce. + return podesavanja[KljucPravniOblik] == "pausalac" + case ModulDvojno: + // dvojno knjigovodstvo je za DOO (za preduzetnike je buduća podela, Project.md §8). + return podesavanja[KljucPravniOblik] == "doo" + default: + return false + } +} + +// SviModuli vraća mapu svih poznatih modula → da li su uključeni za dati profil firme. +// Koristi se da šabloni uslovno prikazuju stavke menija (analogno mapi Dozvole). +func SviModuli(podesavanja map[string]string) map[string]bool { + moduli := []string{ModulPdv, ModulFiskalizacija, ModulKpo, ModulDvojno} + m := make(map[string]bool, len(moduli)) + for _, modul := range moduli { + m[modul] = ModulUkljucen(podesavanja, modul) + } + return m +} diff --git a/internal/config/moduli_test.go b/internal/config/moduli_test.go new file mode 100644 index 0000000..4f89f6c --- /dev/null +++ b/internal/config/moduli_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "maps" + "testing" +) + +func TestModulUkljucen(t *testing.T) { + // pun je profil punog režima sa svim prekidačima upaljenim i pravnim oblikom doo; + // pojedinačni testovi ga prepravljaju po potrebi preko pomoćne funkcije izmeni. + pun := map[string]string{ + KljucRezim: "pun", + KljucPdvObveznik: "da", + KljucFiskalizacija: "da", + KljucPravniOblik: "doo", + } + // izmeni vraća kopiju mape sa promenjenim/dodatim ključem (da testovi ne dele stanje) + izmeni := func(osnova map[string]string, kljuc, vrednost string) map[string]string { + kopija := make(map[string]string, len(osnova)+1) + maps.Copy(kopija, osnova) + kopija[kljuc] = vrednost + return kopija + } + + testovi := []struct { + naziv string + podesavanja map[string]string + modul string + ocekivano bool + }{ + // master prekidač: režim „samo evidencija" gasi sve + {"prazna mapa — pdv ugašen (default samo_evidencija)", map[string]string{}, ModulPdv, false}, + {"prazna mapa — fiskalizacija ugašena", map[string]string{}, ModulFiskalizacija, false}, + {"prazna mapa — kpo ugašen", map[string]string{}, ModulKpo, false}, + {"prazna mapa — dvojno ugašeno", map[string]string{}, ModulDvojno, false}, + {"samo evidencija gasi pdv iako je obveznik", izmeni(pun, KljucRezim, "samo_evidencija"), ModulPdv, false}, + {"samo evidencija gasi fiskalizaciju iako je upaljena", izmeni(pun, KljucRezim, "samo_evidencija"), ModulFiskalizacija, false}, + + // fiskalizacija — zaseban prekidač, nezavisan od oblika + {"pun + fiskalizacija da", pun, ModulFiskalizacija, true}, + {"pun + fiskalizacija ne", izmeni(pun, KljucFiskalizacija, "ne"), ModulFiskalizacija, false}, + + // pdv — vezan isključivo za prekidač obveznika + {"pun + obveznik da", pun, ModulPdv, true}, + {"pun + obveznik ne (čak i doo)", izmeni(pun, KljucPdvObveznik, "ne"), ModulPdv, false}, + + // kpo — samo paušalac + {"pun + paušalac → kpo", izmeni(pun, KljucPravniOblik, "pausalac"), ModulKpo, true}, + {"pun + doo → nema kpo", pun, ModulKpo, false}, + {"pun + preduzetnik → nema kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, false}, + + // dvojno — samo doo + {"pun + doo → dvojno", pun, ModulDvojno, true}, + {"pun + paušalac → nema dvojno", izmeni(pun, KljucPravniOblik, "pausalac"), ModulDvojno, false}, + {"pun + preduzetnik → nema dvojno (buduća podela)", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulDvojno, false}, + + // nepoznat modul + {"nepoznat modul → false", pun, "nepostojeci", false}, + } + + for _, tt := range testovi { + t.Run(tt.naziv, func(t *testing.T) { + if got := ModulUkljucen(tt.podesavanja, tt.modul); got != tt.ocekivano { + t.Errorf("ModulUkljucen(%q) = %v, očekivano %v", tt.modul, got, tt.ocekivano) + } + }) + } +} + +func TestSviModuli(t *testing.T) { + // pun režim, doo, PDV obveznik, fiskalizacija → pdv, fiskalizacija, dvojno uključeni; kpo ne + pod := map[string]string{ + KljucRezim: "pun", + KljucPdvObveznik: "da", + KljucFiskalizacija: "da", + KljucPravniOblik: "doo", + } + m := SviModuli(pod) + if len(m) != 4 { + t.Fatalf("SviModuli vraća %d modula, očekivano 4", len(m)) + } + ocek := map[string]bool{ModulPdv: true, ModulFiskalizacija: true, ModulKpo: false, ModulDvojno: true} + for modul, want := range ocek { + if m[modul] != want { + t.Errorf("SviModuli[%q] = %v, očekivano %v", modul, m[modul], want) + } + } + + // samo evidencija → svi ugašeni + prazna := SviModuli(map[string]string{KljucRezim: "samo_evidencija"}) + for modul, ukljucen := range prazna { + if ukljucen { + t.Errorf("u režimu samo_evidencija modul %q ne sme biti uključen", modul) + } + } +} diff --git a/internal/db/repository.go b/internal/db/repository.go index ab4e689..aa82b94 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -23,6 +23,35 @@ type KategorijaRepository interface { Kreiraj(ctx context.Context, k *model.Kategorija) (int64, error) } +// PdvStopaRepository definiše operacije nad šifarnikom PDV stopa +type PdvStopaRepository interface { + Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) + Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) + Izmeni(ctx context.Context, s *model.PdvStopa) error + PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error +} + +// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR) +type PdvKirRepository interface { + Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) + Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) + Obrisi(ctx context.Context, id int64) error + // ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje) + ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error +} + +// PdvKprRepository definiše operacije nad knjigom primljenih računa (KPR) +type PdvKprRepository interface { + Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) + DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) + Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) + Obrisi(ctx context.Context, id int64) error + // ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke) + ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error +} + // ArtikalFilter definiše parametre za filtriranje liste artikala type ArtikalFilter struct { Pretraga string @@ -117,8 +146,8 @@ type SesijeRepository interface { // PodsetnikFilter definiše parametre za filtriranje liste podsetnika type PodsetnikFilter struct { - SamoAktivni bool // true = samo nezavršeni; false = svi - KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika + SamoAktivni bool // true = samo nezavršeni; false = svi + KorisnikID *int64 // ako nije nil — samo podsetnici tog korisnika } // PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave diff --git a/internal/db/sqlite/dobavljac.go b/internal/db/sqlite/dobavljac.go index b3f96d4..48cebfa 100644 --- a/internal/db/sqlite/dobavljac.go +++ b/internal/db/sqlite/dobavljac.go @@ -21,7 +21,7 @@ func NoviDobavljacRepo(db *sql.DB) *DobavljacRepo { // Lista vraća listu dobavljača sa opcionom pretragom po nazivu func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dobavljac, error) { upit := ` - SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa + SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa FROM dobavljaci WHERE 1=1` @@ -43,9 +43,9 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob var rezultat []model.Dobavljac for redovi.Next() { var d model.Dobavljac - var kontaktOsoba, telefon, email, napomena sql.NullString + var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString err := redovi.Scan( - &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, + &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: DobavljacRepo.Lista: scan: %w", err) @@ -53,6 +53,8 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob d.KontaktOsoba = kontaktOsoba.String d.Telefon = telefon.String d.Email = email.String + d.PIB = pib.String + d.Mesto = mesto.String d.Napomena = napomena.String rezultat = append(rezultat, d) } @@ -63,12 +65,12 @@ func (r *DobavljacRepo) Lista(ctx context.Context, pretraga string) ([]model.Dob // DohvatiID vraća jednog dobavljača po ID-u func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavljac, error) { var d model.Dobavljac - var kontaktOsoba, telefon, email, napomena sql.NullString + var kontaktOsoba, telefon, email, pib, mesto, napomena sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, naziv, kontakt_osoba, telefon, email, napomena, datum_unosa + SELECT id, naziv, kontakt_osoba, telefon, email, pib, mesto, napomena, datum_unosa FROM dobavljaci WHERE id = ?`, id).Scan( - &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &napomena, &d.DatumUnosa, + &d.ID, &d.Naziv, &kontaktOsoba, &telefon, &email, &pib, &mesto, &napomena, &d.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: DobavljacRepo.DohvatiID: %w", err) @@ -77,6 +79,8 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj d.KontaktOsoba = kontaktOsoba.String d.Telefon = telefon.String d.Email = email.String + d.PIB = pib.String + d.Mesto = mesto.String d.Napomena = napomena.String return &d, nil @@ -85,10 +89,10 @@ func (r *DobavljacRepo) DohvatiID(ctx context.Context, id int64) (*model.Dobavlj // Kreiraj dodaje novog dobavljača u bazu func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, error) { rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, napomena) - VALUES (?, ?, ?, ?, ?)`, + INSERT INTO dobavljaci (naziv, kontakt_osoba, telefon, email, pib, mesto, napomena) + VALUES (?, ?, ?, ?, ?, ?, ?)`, d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), - nullString(d.Email), nullString(d.Napomena), + nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena), ) if err != nil { return 0, fmt.Errorf("ntech: DobavljacRepo.Kreiraj: %w", err) @@ -106,10 +110,10 @@ func (r *DobavljacRepo) Kreiraj(ctx context.Context, d *model.Dobavljac) (int64, func (r *DobavljacRepo) Izmeni(ctx context.Context, d *model.Dobavljac) error { _, err := r.db.ExecContext(ctx, ` UPDATE dobavljaci SET - naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, napomena = ? + naziv = ?, kontakt_osoba = ?, telefon = ?, email = ?, pib = ?, mesto = ?, napomena = ? WHERE id = ?`, d.Naziv, nullString(d.KontaktOsoba), nullString(d.Telefon), - nullString(d.Email), nullString(d.Napomena), d.ID, + nullString(d.Email), nullString(d.PIB), nullString(d.Mesto), nullString(d.Napomena), d.ID, ) if err != nil { return fmt.Errorf("ntech: DobavljacRepo.Izmeni: %w", err) @@ -127,4 +131,3 @@ func (r *DobavljacRepo) Obrisi(ctx context.Context, id int64) error { return nil } - diff --git a/internal/db/sqlite/klijent.go b/internal/db/sqlite/klijent.go index 3dc8c2d..88e8b61 100644 --- a/internal/db/sqlite/klijent.go +++ b/internal/db/sqlite/klijent.go @@ -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, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa + SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa FROM klijenti WHERE 1=1` @@ -44,9 +44,9 @@ 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, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString + var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString err := redovi.Scan( - &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, + &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: KlijentRepo.Lista: scan: %w", err) @@ -58,6 +58,7 @@ func (r *KlijentRepo) Lista(ctx context.Context, pretraga string) ([]model.Klije k.PIB = pib.String k.Telefon = telefon.String k.Email = email.String + k.Mesto = mesto.String k.Napomena = napomena.String rezultat = append(rezultat, k) } @@ -68,12 +69,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, jmbg, nazivFirme, pib, telefon, email, napomena sql.NullString + var ime, prezime, jmbg, nazivFirme, pib, telefon, email, mesto, napomena sql.NullString err := r.db.QueryRowContext(ctx, ` - SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena, datum_unosa + SELECT id, tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, napomena, datum_unosa FROM klijenti WHERE id = ?`, id).Scan( - &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &napomena, &k.DatumUnosa, + &k.ID, &k.Tip, &ime, &prezime, &jmbg, &nazivFirme, &pib, &telefon, &email, &mesto, &napomena, &k.DatumUnosa, ) if err != nil { return nil, fmt.Errorf("ntech: KlijentRepo.DohvatiID: %w", err) @@ -86,6 +87,7 @@ func (r *KlijentRepo) DohvatiID(ctx context.Context, id int64) (*model.Klijent, k.PIB = pib.String k.Telefon = telefon.String k.Email = email.String + k.Mesto = mesto.String k.Napomena = napomena.String return &k, nil @@ -97,11 +99,11 @@ func (r *KlijentRepo) Kreiraj(ctx context.Context, k *model.Klijent) (int64, err k.Tip = "fizicko" } rezultat, err := r.db.ExecContext(ctx, ` - INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, napomena) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO klijenti (tip, ime, prezime, jmbg, naziv_firme, pib, telefon, email, mesto, 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), + nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena), ) if err != nil { return 0, fmt.Errorf("ntech: KlijentRepo.Kreiraj: %w", err) @@ -123,11 +125,11 @@ func (r *KlijentRepo) Izmeni(ctx context.Context, k *model.Klijent) error { _, err := r.db.ExecContext(ctx, ` UPDATE klijenti SET tip = ?, ime = ?, prezime = ?, jmbg = ?, naziv_firme = ?, - pib = ?, telefon = ?, email = ?, napomena = ? + pib = ?, telefon = ?, email = ?, mesto = ?, napomena = ? WHERE 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, + nullString(k.Email), nullString(k.Mesto), nullString(k.Napomena), k.ID, ) if err != nil { return fmt.Errorf("ntech: KlijentRepo.Izmeni: %w", err) diff --git a/internal/db/sqlite/pdv_evidencija.go b/internal/db/sqlite/pdv_evidencija.go new file mode 100644 index 0000000..8f620c0 --- /dev/null +++ b/internal/db/sqlite/pdv_evidencija.go @@ -0,0 +1,288 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/model" +) + +// --- KIR: knjiga izdatih računa --- + +// PdvKirRepo je SQLite implementacija PdvKirRepository interfejsa +type PdvKirRepo struct { + db *sql.DB +} + +// NoviPdvKirRepo kreira novi PdvKirRepo +func NoviPdvKirRepo(db *sql.DB) *PdvKirRepo { + return &PdvKirRepo{db: db} +} + +// Lista vraća zapise KIR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice +func (r *PdvKirRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKir, error) { + upit := ` + SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kir WHERE 1=1` + args := []any{} + if !od.IsZero() { + upit += " AND datum_prometa >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND datum_prometa <= ?" + args = append(args, do) + } + upit += " ORDER BY datum_prometa ASC, id ASC" + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvKir + for redovi.Next() { + k, err := skenirajKir(redovi.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, k) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jedan zapis KIR-a po identifikatoru +func (r *PdvKirRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error) { + red := r.db.QueryRowContext(ctx, ` + SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kir WHERE id = ?`, id) + k, err := skenirajKir(red.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiID: %w", err) + } + return &k, nil +} + +// skenirajKir čita jedan red KIR-a; izvor_id je nullable pa ide preko sql.NullInt64 +func skenirajKir(scan func(...any) error) (model.PdvKir, error) { + var k model.PdvKir + var izvorID sql.NullInt64 + if err := scan( + &k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &k.BrojDokumenta, + &k.KupacNaziv, &k.KupacPib, &k.KupacMesto, + &k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna, + &k.OslobodenSaPravom, &k.OslobodenBezPrava, &k.Ukupno, + &k.Napomena, &k.Izvor, &izvorID, &k.DatumUnosa, + ); err != nil { + return model.PdvKir{}, err + } + if izvorID.Valid { + k.IzvorID = &izvorID.Int64 + } + return k, nil +} + +// Kreiraj dodaje novi zapis u KIR i vraća njegov id +func (r *PdvKirRepo) Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error) { + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_kir ( + datum_prometa, datum_knjizenja, broj_dokumenta, + kupac_naziv, kupac_pib, kupac_mesto, + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + osloboden_sa_pravom, osloboden_bez_prava, ukupno, napomena, izvor, izvor_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.DatumPrometa, k.DatumKnjizenja, k.BrojDokumenta, + k.KupacNaziv, k.KupacPib, k.KupacMesto, + k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, + k.OslobodenSaPravom, k.OslobodenBezPrava, k.Ukupno, k.Napomena, + izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID)) + if err != nil { + return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvKirRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Obrisi briše zapis KIR-a +func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: PdvKirRepo.Obrisi: %w", err) + } + return nil +} + +// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje) +func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { + return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err) + } + return nil +} + +// izvorIliRucno vraća izvor ili podrazumevano „rucno" (izvor kolona je NOT NULL) +func izvorIliRucno(izvor string) string { + if izvor == "" { + return "rucno" + } + return izvor +} + +// izvorIDArg pretvara *int64 u argument za upit (nil → NULL) +func izvorIDArg(id *int64) any { + if id == nil { + return nil + } + return *id +} + +// --- KPR: knjiga primljenih računa --- + +// PdvKprRepo je SQLite implementacija PdvKprRepository interfejsa +type PdvKprRepo struct { + db *sql.DB +} + +// NoviPdvKprRepo kreira novi PdvKprRepo +func NoviPdvKprRepo(db *sql.DB) *PdvKprRepo { + return &PdvKprRepo{db: db} +} + +// Lista vraća zapise KPR-a u zadatom periodu (po datumu prometa); nulti datum znači bez granice +func (r *PdvKprRepo) Lista(ctx context.Context, od, do time.Time) ([]model.PdvKpr, error) { + upit := ` + SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kpr WHERE 1=1` + args := []any{} + if !od.IsZero() { + upit += " AND datum_prometa >= ?" + args = append(args, od) + } + if !do.IsZero() { + upit += " AND datum_prometa <= ?" + args = append(args, do) + } + upit += " ORDER BY datum_prometa ASC, id ASC" + + redovi, err := r.db.QueryContext(ctx, upit, args...) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvKpr + for redovi.Next() { + k, err := skenirajKpr(redovi.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, k) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jedan zapis KPR-a po identifikatoru +func (r *PdvKprRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error) { + red := r.db.QueryRowContext(ctx, ` + SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''), + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, + COALESCE(napomena, ''), izvor, izvor_id, datum_unosa + FROM pdv_kpr WHERE id = ?`, id) + k, err := skenirajKpr(red.Scan) + if err != nil { + return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiID: %w", err) + } + return &k, nil +} + +// skenirajKpr čita jedan red KPR-a; datum_placanja je nullable pa ide preko sql.NullTime +func skenirajKpr(scan func(...any) error) (model.PdvKpr, error) { + var k model.PdvKpr + var datumPlacanja sql.NullTime + var izvorID sql.NullInt64 + if err := scan( + &k.ID, &k.DatumPrometa, &k.DatumKnjizenja, &datumPlacanja, &k.BrojDokumenta, + &k.DobavljacNaziv, &k.DobavljacPib, &k.DobavljacMesto, + &k.OsnovicaOpsta, &k.PdvOpsta, &k.OsnovicaPosebna, &k.PdvPosebna, + &k.PdvBezOdbitka, &k.OslobodenNabavka, &k.Ukupno, + &k.Napomena, &k.Izvor, &izvorID, &k.DatumUnosa, + ); err != nil { + return model.PdvKpr{}, err + } + if datumPlacanja.Valid { + k.DatumPlacanja = &datumPlacanja.Time + } + if izvorID.Valid { + k.IzvorID = &izvorID.Int64 + } + return k, nil +} + +// Kreiraj dodaje novi zapis u KPR i vraća njegov id +func (r *PdvKprRepo) Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error) { + var datumPlacanja any + if k.DatumPlacanja != nil { + datumPlacanja = *k.DatumPlacanja + } + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_kpr ( + datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta, + dobavljac_naziv, dobavljac_pib, dobavljac_mesto, + osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna, + pdv_bez_odbitka, osloboden_nabavka, ukupno, napomena, izvor, izvor_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.DatumPrometa, k.DatumKnjizenja, datumPlacanja, k.BrojDokumenta, + k.DobavljacNaziv, k.DobavljacPib, k.DobavljacMesto, + k.OsnovicaOpsta, k.PdvOpsta, k.OsnovicaPosebna, k.PdvPosebna, + k.PdvBezOdbitka, k.OslobodenNabavka, k.Ukupno, k.Napomena, + izvorIliRucno(k.Izvor), izvorIDArg(k.IzvorID)) + if err != nil { + return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvKprRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Obrisi briše zapis KPR-a +func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE id = ?", id); err != nil { + return fmt.Errorf("ntech: PdvKprRepo.Obrisi: %w", err) + } + return nil +} + +// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke) +func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error { + if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil { + return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/pdv_evidencija_test.go b/internal/db/sqlite/pdv_evidencija_test.go new file mode 100644 index 0000000..fff0c75 --- /dev/null +++ b/internal/db/sqlite/pdv_evidencija_test.go @@ -0,0 +1,173 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "ntech/internal/model" +) + +// istiDan poredi datume po godini/mesecu/danu (izbegava razlike u vremenskoj zoni/času) +func istiDan(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +func TestPdvKirRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvKirRepo(db) + ctx := context.Background() + + dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + dk := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) + + id, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dk, BrojDokumenta: "R-1", + KupacNaziv: "Kupac doo", KupacPib: "123456789", KupacMesto: "Niš", + OsnovicaOpsta: 100, PdvOpsta: 20, Ukupno: 120, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + + t.Run("datum i iznosi round-trip", func(t *testing.T) { + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if !istiDan(k.DatumPrometa, dp) { + t.Errorf("datum_prometa = %v, očekivano %v", k.DatumPrometa, dp) + } + if !istiDan(k.DatumKnjizenja, dk) { + t.Errorf("datum_knjizenja = %v, očekivano %v", k.DatumKnjizenja, dk) + } + if k.KupacNaziv != "Kupac doo" || k.KupacPib != "123456789" || k.KupacMesto != "Niš" { + t.Errorf("kupac podaci ne odgovaraju: %+v", k) + } + if k.OsnovicaOpsta != 100 || k.PdvOpsta != 20 || k.Ukupno != 120 { + t.Errorf("iznosi ne odgovaraju: %+v", k) + } + }) + + t.Run("filter perioda", func(t *testing.T) { + uPeriodu, err := repo.Lista(ctx, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Lista u periodu: %v", err) + } + if len(uPeriodu) != 1 { + t.Errorf("u junu očekivano 1 zapis, dobijeno %d", len(uPeriodu)) + } + vanPerioda, err := repo.Lista(ctx, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 31, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Lista van perioda: %v", err) + } + if len(vanPerioda) != 0 { + t.Errorf("u julu očekivano 0 zapisa, dobijeno %d", len(vanPerioda)) + } + }) + + t.Run("brisanje", func(t *testing.T) { + if err := repo.Obrisi(ctx, id); err != nil { + t.Fatalf("Obrisi: %v", err) + } + sve, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(sve) != 0 { + t.Errorf("posle brisanja očekivano 0, dobijeno %d", len(sve)) + } + }) +} + +func TestPdvKprRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvKprRepo(db) + ctx := context.Background() + + dp := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + dpl := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + + t.Run("sa datumom plaćanja", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvKpr{ + DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: &dpl, BrojDokumenta: "U-1", + DobavljacNaziv: "Dobavljač doo", OsnovicaOpsta: 200, PdvOpsta: 40, Ukupno: 240, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if k.DatumPlacanja == nil { + t.Fatalf("datum_placanja je nil, očekivan %v", dpl) + } + if !istiDan(*k.DatumPlacanja, dpl) { + t.Errorf("datum_placanja = %v, očekivano %v", *k.DatumPlacanja, dpl) + } + if k.OsnovicaOpsta != 200 || k.PdvOpsta != 40 { + t.Errorf("iznosi ne odgovaraju: %+v", k) + } + }) + + t.Run("bez datuma plaćanja (NULL)", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvKpr{ + DatumPrometa: dp, DatumKnjizenja: dp, DatumPlacanja: nil, BrojDokumenta: "U-2", + DobavljacNaziv: "Dobavljač 2", OsnovicaPosebna: 50, PdvPosebna: 5, Ukupno: 55, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + k, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if k.DatumPlacanja != nil { + t.Errorf("datum_placanja = %v, očekivan nil", *k.DatumPlacanja) + } + }) +} + +func TestPdvKirIzvor(t *testing.T) { + db := testDB(t) + repo := NoviPdvKirRepo(db) + ctx := context.Background() + dp := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + izvorID := int64(7) + + // ručni zapis (bez izvora) → default „rucno", izvor_id NULL + if _, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "R-1", KupacNaziv: "Ručni", + }); err != nil { + t.Fatalf("Kreiraj ručni: %v", err) + } + // auto zapis iz prodaje + if _, err := repo.Kreiraj(ctx, &model.PdvKir{ + DatumPrometa: dp, DatumKnjizenja: dp, BrojDokumenta: "P-1", KupacNaziv: "Iz prodaje", + Izvor: "prodaja", IzvorID: &izvorID, + }); err != nil { + t.Fatalf("Kreiraj auto: %v", err) + } + + sve, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(sve) != 2 { + t.Fatalf("očekivano 2 zapisa, dobijeno %d", len(sve)) + } + for _, z := range sve { + if z.BrojDokumenta == "R-1" && (z.Izvor != "rucno" || z.IzvorID != nil) { + t.Errorf("ručni zapis: izvor=%q izvor_id=%v, očekivano rucno/nil", z.Izvor, z.IzvorID) + } + if z.BrojDokumenta == "P-1" && (z.Izvor != "prodaja" || z.IzvorID == nil || *z.IzvorID != 7) { + t.Errorf("auto zapis: izvor=%q izvor_id=%v, očekivano prodaja/7", z.Izvor, z.IzvorID) + } + } + + // ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje + if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil { + t.Fatalf("ObrisiPoIzvoru: %v", err) + } + preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{}) + if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" { + t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali)) + } +} diff --git a/internal/db/sqlite/pdv_stopa.go b/internal/db/sqlite/pdv_stopa.go new file mode 100644 index 0000000..d2cfe38 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa.go @@ -0,0 +1,101 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "ntech/internal/model" +) + +// PdvStopaRepo je SQLite implementacija PdvStopaRepository interfejsa +type PdvStopaRepo struct { + db *sql.DB +} + +// NoviPdvStopaRepo kreira novi PdvStopaRepo +func NoviPdvStopaRepo(db *sql.DB) *PdvStopaRepo { + return &PdvStopaRepo{db: db} +} + +// Lista vraća stope iz šifarnika; ako je samoAktivne true, izostavlja arhivirane +func (r *PdvStopaRepo) Lista(ctx context.Context, samoAktivne bool) ([]model.PdvStopa, error) { + upit := ` + SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa + FROM pdv_stope + WHERE 1=1` + if samoAktivne { + upit += " AND aktivna = 1" + } + upit += " ORDER BY redosled ASC, stopa DESC" + + redovi, err := r.db.QueryContext(ctx, upit) + if err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err) + } + defer redovi.Close() + + var rezultat []model.PdvStopa + for redovi.Next() { + var s model.PdvStopa + if err := redovi.Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa); err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: scan: %w", err) + } + rezultat = append(rezultat, s) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.Lista: %w", err) + } + return rezultat, nil +} + +// DohvatiID vraća jednu stopu po identifikatoru +func (r *PdvStopaRepo) DohvatiID(ctx context.Context, id int64) (*model.PdvStopa, error) { + var s model.PdvStopa + err := r.db.QueryRowContext(ctx, ` + SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa + FROM pdv_stope WHERE id = ?`, id). + Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa) + if err != nil { + return nil, fmt.Errorf("ntech: PdvStopaRepo.DohvatiID: %w", err) + } + return &s, nil +} + +// Kreiraj dodaje novu stopu i vraća njen id +func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error) { + rez, err := r.db.ExecContext(ctx, ` + INSERT INTO pdv_stope (naziv, stopa, oznaka, aktivna, redosled) + VALUES (?, ?, ?, ?, ?)`, + s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled) + if err != nil { + return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: %w", err) + } + id, err := rez.LastInsertId() + if err != nil { + return 0, fmt.Errorf("ntech: PdvStopaRepo.Kreiraj: id: %w", err) + } + return id, nil +} + +// Izmeni menja podatke postojeće stope (osim datuma unosa) +func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE pdv_stope + SET naziv = ?, stopa = ?, oznaka = ?, aktivna = ?, redosled = ? + WHERE id = ?`, + s.Naziv, s.Stopa, s.Oznaka, s.Aktivna, s.Redosled, s.ID) + if err != nil { + return fmt.Errorf("ntech: PdvStopaRepo.Izmeni: %w", err) + } + return nil +} + +// PostaviAktivnu arhivira (false) ili vraća u upotrebu (true) stopu, bez brisanja +func (r *PdvStopaRepo) PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error { + _, err := r.db.ExecContext(ctx, "UPDATE pdv_stope SET aktivna = ? WHERE id = ?", aktivna, id) + if err != nil { + return fmt.Errorf("ntech: PdvStopaRepo.PostaviAktivnu: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/pdv_stopa_test.go b/internal/db/sqlite/pdv_stopa_test.go new file mode 100644 index 0000000..22ea8c4 --- /dev/null +++ b/internal/db/sqlite/pdv_stopa_test.go @@ -0,0 +1,97 @@ +package sqlite + +import ( + "context" + "testing" + + "ntech/internal/model" +) + +func TestPdvStopaRepo(t *testing.T) { + db := testDB(t) + repo := NoviPdvStopaRepo(db) + ctx := context.Background() + + // migracija 040 seeduje 3 stope; redosled ASC → opšta(20), posebna(10), oslobođeno(0) + t.Run("seed iz migracije", func(t *testing.T) { + stope, err := repo.Lista(ctx, true) + if err != nil { + t.Fatalf("Lista: %v", err) + } + if len(stope) != 3 { + t.Fatalf("očekivano 3 seed stope, dobijeno %d", len(stope)) + } + if stope[0].Stopa != 20 || stope[0].Oznaka != "opsta" { + t.Errorf("prva stopa = %v %q, očekivano 20 opsta", stope[0].Stopa, stope[0].Oznaka) + } + if stope[2].Stopa != 0 || stope[2].Oznaka != "oslobodjeno" { + t.Errorf("treća stopa = %v %q, očekivano 0 oslobodjeno", stope[2].Stopa, stope[2].Oznaka) + } + }) + + var novaID int64 + + t.Run("kreiraj i dohvati", func(t *testing.T) { + id, err := repo.Kreiraj(ctx, &model.PdvStopa{ + Naziv: "Test stopa", Stopa: 11.5, Oznaka: "posebna", Aktivna: true, Redosled: 9, + }) + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + novaID = id + s, err := repo.DohvatiID(ctx, id) + if err != nil { + t.Fatalf("DohvatiID: %v", err) + } + if s.Naziv != "Test stopa" || s.Stopa != 11.5 || s.Oznaka != "posebna" || !s.Aktivna || s.Redosled != 9 { + t.Errorf("dohvaćeno %+v, ne poklapa se sa unetim", s) + } + }) + + t.Run("izmeni", func(t *testing.T) { + err := repo.Izmeni(ctx, &model.PdvStopa{ + ID: novaID, Naziv: "Izmenjena", Stopa: 12, Oznaka: "opsta", Aktivna: true, Redosled: 5, + }) + if err != nil { + t.Fatalf("Izmeni: %v", err) + } + s, err := repo.DohvatiID(ctx, novaID) + if err != nil { + t.Fatalf("DohvatiID posle izmene: %v", err) + } + if s.Naziv != "Izmenjena" || s.Stopa != 12 || s.Oznaka != "opsta" || s.Redosled != 5 { + t.Errorf("posle izmene %+v, ne poklapa se sa izmenom", s) + } + }) + + t.Run("arhiviranje izostavlja iz aktivnih ali zadržava u punoj listi", func(t *testing.T) { + if err := repo.PostaviAktivnu(ctx, novaID, false); err != nil { + t.Fatalf("PostaviAktivnu: %v", err) + } + aktivne, err := repo.Lista(ctx, true) + if err != nil { + t.Fatalf("Lista aktivne: %v", err) + } + for _, s := range aktivne { + if s.ID == novaID { + t.Errorf("arhivirana stopa %d ne sme biti među aktivnima", novaID) + } + } + sve, err := repo.Lista(ctx, false) + if err != nil { + t.Fatalf("Lista sve: %v", err) + } + nadjena := false + for _, s := range sve { + if s.ID == novaID { + nadjena = true + if s.Aktivna { + t.Errorf("stopa %d treba da bude aktivna=false posle arhiviranja", novaID) + } + } + } + if !nadjena { + t.Errorf("arhivirana stopa %d mora ostati u punoj listi (ne briše se)", novaID) + } + }) +} diff --git a/internal/handler/dobavljac.go b/internal/handler/dobavljac.go index 36ba23f..9d036d7 100644 --- a/internal/handler/dobavljac.go +++ b/internal/handler/dobavljac.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "strings" @@ -85,7 +86,15 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { } dobavljac, greska := parseFormuDobavljaca(r) + jeAjax := r.Header.Get("X-Requested-With") == "fetch" if greska != "" { + // fetch zahtev (iz modala u nabavci) dobija JSON grešku + if jeAjax { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"greska":%q}`, greska) + return + } podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) ps := h.popuniPodaciStranice(r, podesavanja) ps.Stranica = "dobavljaci" @@ -99,11 +108,19 @@ func (h *Handler) SacuvajDobavljaca(w http.ResponseWriter, r *http.Request) { return } - if _, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac); err != nil { + id, err := h.DobavljaciRepo.Kreiraj(r.Context(), &dobavljac) + if err != nil { http.Error(w, "Greška pri čuvanju dobavljača", http.StatusInternalServerError) return } + // fetch zahtev (iz modala) dobija JSON sa ID-em i nazivom novog dobavljača + if jeAjax { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%d,"naziv":%q}`, id, dobavljac.Naziv) + return + } + http.Redirect(w, r, "/dobavljaci?sacuvano=1", http.StatusSeeOther) } @@ -214,6 +231,8 @@ func parseFormuDobavljaca(r *http.Request) (model.Dobavljac, string) { KontaktOsoba: strings.TrimSpace(r.FormValue("kontakt_osoba")), Telefon: strings.TrimSpace(r.FormValue("telefon")), Email: email, + PIB: strings.TrimSpace(r.FormValue("pib")), + Mesto: strings.TrimSpace(r.FormValue("mesto")), Napomena: strings.TrimSpace(r.FormValue("napomena")), }, "" } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fa7dd79..d45149b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,12 +1,14 @@ package handler import ( + "context" "database/sql" "html/template" "io/fs" "net/http" "sync" + "ntech/internal/config" "ntech/internal/db" "ntech/internal/db/sqlite" "ntech/internal/middleware" @@ -34,6 +36,9 @@ type Handler struct { PokusajiRepo db.PokusajiPrijaveRepository LoginIstorijsaRepo db.LoginIstorijsaRepository DozvoleRepo db.DozvoleRepository + PdvStopeRepo db.PdvStopaRepository + PdvKirRepo db.PdvKirRepository + PdvKprRepo db.PdvKprRepository Verzija string AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Templates map[string]*template.Template @@ -93,6 +98,9 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler { 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), } } @@ -118,6 +126,19 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) { h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) + h.PdvStopeRepo = sqlite.NoviPdvStopaRepo(novaDB) + h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB) + h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB) +} + +// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu. +// Koristi se pri automatskom punjenju KIR/KPR iz prodaje/nabavke. +func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool { + podesavanja, err := sqlite.DohvatiSvaPodesavanja(ctx, h.DB) + if err != nil { + return false + } + return config.ModulUkljucen(podesavanja, modul) } // zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju. @@ -159,6 +180,8 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s ps.CsrfToken = middleware.CsrfToken(r.Context()) ps.AssetV = h.AssetV ps.Flash = middleware.GetFlash(r, h.DB) + // uključeni zakonski moduli prema profilu firme — šabloni ih koriste za uslovni meni + ps.Moduli = config.SviModuli(podesavanja) // logika pozadine: // - lična pozadina → uvek se prikazuje i forsira tamnu temu, bez obzira na KoristiLokalnuTemu diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 739ab33..9489633 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -25,6 +25,10 @@ var saSidebar = []string{ "magacin", "magacin_forma", "nabavke", "nabavka_forma", "nabavka_detalji", "podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", + "pdv_stope", + "pdv_kir", "pdv_kir_forma", + "pdv_kpr", "pdv_kpr_forma", + "pdv_obracun", "podsetnici", "podsetnik_forma", "profil_tema", "prodaja", "prodaja_detalji", "prodaja_forma", diff --git a/internal/handler/klijent.go b/internal/handler/klijent.go index 46d71f4..8651851 100644 --- a/internal/handler/klijent.go +++ b/internal/handler/klijent.go @@ -249,6 +249,7 @@ func parseFormuKlijenta(r *http.Request) (model.Klijent, string) { PIB: strings.TrimSpace(r.FormValue("pib")), Telefon: strings.TrimSpace(r.FormValue("telefon")), Email: email, + Mesto: strings.TrimSpace(r.FormValue("mesto")), Napomena: strings.TrimSpace(r.FormValue("napomena")), }, "" } diff --git a/internal/handler/nabavka.go b/internal/handler/nabavka.go index be106f0..ecc0101 100644 --- a/internal/handler/nabavka.go +++ b/internal/handler/nabavka.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "html/template" + "log/slog" "net/http" "strconv" "strings" @@ -26,7 +27,7 @@ type PodaciNabavki struct { type PodaciFormeNabavke struct { model.PodaciStranice Artikli []model.ArtikalSaKategorijom - ArtikliJSON template.JS // JSON niz artikala za Alpine.js — bezbedan za umetanje u +{{end}} diff --git a/web/templates/stranice/pdv_kpr.html b/web/templates/stranice/pdv_kpr.html new file mode 100644 index 0000000..09725b7 --- /dev/null +++ b/web/templates/stranice/pdv_kpr.html @@ -0,0 +1,93 @@ +{{template "base" .}} + +{{define "naslov"}}KPR — knjiga primljenih računa — NTech{{end}} + +{{define "sadrzaj"}} +
+ + +
+
+
+
+ + +
+
+ + +
+ + {{if or .Od .Do}}Poništi filter{{end}} +
+ + Nov ulazni račun +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + {{range .Zapisi}} + + + + + + + + + + + + + + {{else}} + + {{end}} + + {{if .Zapisi}} + + + + + + + + + + + + + + {{end}} +
Datum prometaBroj dok.DobavljačOsn. 20%PDV 20%Osn. 10%PDV 10%PDV bez odb.Oslob. nab.Ukupno
{{.DatumPrometa.Format "02.01.2006."}}{{.BrojDokumenta}}{{.DobavljacNaziv}}{{if .DobavljacPib}}
{{.OznakaPoreskogBroja}}: {{.DobavljacPib}}
{{end}}
{{printf "%.2f" .OsnovicaOpsta}}{{printf "%.2f" .PdvOpsta}}{{printf "%.2f" .OsnovicaPosebna}}{{printf "%.2f" .PdvPosebna}}{{printf "%.2f" .PdvBezOdbitka}}{{printf "%.2f" .OslobodenNabavka}}{{printf "%.2f" .Ukupno}} + {{if eq .Izvor "rucno"}} +
+ +
+ {{else}} + iz nabavke + {{end}} +
Nema zapisa u izabranom periodu. Dodaj prvi.
UKUPNO ({{len .Zapisi}}){{printf "%.2f" .Sume.OsnovicaOpsta}}{{printf "%.2f" .Sume.PdvOpsta}}{{printf "%.2f" .Sume.OsnovicaPosebna}}{{printf "%.2f" .Sume.PdvPosebna}}{{printf "%.2f" .Sume.PdvBezOdbitka}}{{printf "%.2f" .Sume.OslobodenNabavka}}{{printf "%.2f" .Sume.Ukupno}}
+
+
+ +
+{{end}} diff --git a/web/templates/stranice/pdv_kpr_forma.html b/web/templates/stranice/pdv_kpr_forma.html new file mode 100644 index 0000000..1bbdcca --- /dev/null +++ b/web/templates/stranice/pdv_kpr_forma.html @@ -0,0 +1,126 @@ +{{template "base" .}} + +{{define "naslov"}}Novi ulazni račun (KPR) — NTech{{end}} + +{{define "sadrzaj"}} +
+ + + + Nazad na KPR + + +
+ +
+
Dokument
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Dobavljač
+
+ {{if .Dobavljaci}} +
+ + +
Izbor popunjava naziv, PIB i mesto; možeš ih i ručno izmeniti.
+
+ {{end}} +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
Iznosi po stopama
+
PDV (opšta/posebna) je iznos koji se može odbiti. „Ukupno" se računa automatski.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ Odustani + +
+
+
+ +
+ + +{{end}} diff --git a/web/templates/stranice/pdv_obracun.html b/web/templates/stranice/pdv_obracun.html new file mode 100644 index 0000000..49da5de --- /dev/null +++ b/web/templates/stranice/pdv_obracun.html @@ -0,0 +1,200 @@ +{{template "base" .}} + +{{define "naslov"}}PDV obračun — NTech{{end}} + +{{define "sadrzaj"}} +
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OsnovicaPDV
Izlazni (dugovani) PDV — KIR
Opšta stopa (20%){{printf "%.2f" .KirSume.OsnovicaOpsta}}{{printf "%.2f" .Obracun.IzlazniPdvOpsta}}
Posebna stopa (10%){{printf "%.2f" .KirSume.OsnovicaPosebna}}{{printf "%.2f" .Obracun.IzlazniPdvPosebna}}
Oslobođen promet{{printf "%.2f" .KirSume.OslobodenUkupno}}
Ukupno izlazni PDV{{printf "%.2f" .Obracun.IzlazniPdvUkupno}}
Odbitni (prethodni) PDV — KPR
Opšta stopa (20%){{printf "%.2f" .KprSume.OsnovicaOpsta}}{{printf "%.2f" .Obracun.OdbitniPdvOpsta}}
Posebna stopa (10%){{printf "%.2f" .KprSume.OsnovicaPosebna}}{{printf "%.2f" .Obracun.OdbitniPdvPosebna}}
PDV bez prava na odbitak{{printf "%.2f" .KprSume.PdvBezOdbitka}}
Ukupno odbitni PDV{{printf "%.2f" .Obracun.OdbitniPdvUkupno}}
+
+
+ + +
+
+
+ {{if .Obracun.ZaUplatu}}PDV obaveza za uplatu{{else}}PDV za povraćaj / prenos u naredni period{{end}} +
+
izlazni − odbitni PDV
+
+
+ {{printf "%.2f" .Obracun.ObavezaApsolutna}} RSD +
+
+ + +
+
+
PPPDV — poreska prijava PDV
+
+ Prikaz polja obrasca za izabrani period, u celim dinarima. + ⚠ Uvoz (006/106) i PDV nadoknada poljoprivredniku (007/107) se ne vode zasebno u programu. +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PoljeOpisNaknada bez PDVPDV
I. Promet dobara i usluga
001Oslobođen sa pravom na odbitak{{.PPPDV.Polje001}}
002Oslobođen bez prava na odbitak{{.PPPDV.Polje002}}
003
103
Promet po opštoj stopi (20%){{.PPPDV.Polje003}}{{.PPPDV.Polje103}}
004
104
Promet po posebnoj stopi (10%){{.PPPDV.Polje004}}{{.PPPDV.Polje104}}
005
105
ZBIR (1+2+3+4){{.PPPDV.Polje005}}{{.PPPDV.Polje105}}
II. Prethodni porez
006
106
Prethodni porez pri uvozu{{.PPPDV.Polje006}}{{.PPPDV.Polje106}}
007
107
PDV nadoknada poljoprivredniku{{.PPPDV.Polje007}}{{.PPPDV.Polje107}}
008
108
Ostali prethodni porez (domaće nabavke){{.PPPDV.Polje008}}{{.PPPDV.Polje108}}
009
109
ZBIR (6+7+8){{.PPPDV.Polje009}}{{.PPPDV.Polje109}}
110 + Iznos PDV u periodu (105 − 109) + {{if .PPPDV.Povracaj}} — za povraćaj{{else}} — za uplatu{{end}} + {{.PPPDV.Polje110Apsolutno}}
11Povraćaj: {{if .PPPDV.Povracaj}}DA{{else}}NE{{end}}
+
+
+ +
+{{end}} diff --git a/web/templates/stranice/pdv_stope.html b/web/templates/stranice/pdv_stope.html new file mode 100644 index 0000000..bca05e6 --- /dev/null +++ b/web/templates/stranice/pdv_stope.html @@ -0,0 +1,90 @@ +{{template "base" .}} + +{{define "naslov"}}Podešavanja — PDV stope — NTech{{end}} + +{{define "sadrzaj"}} +
+ + +
+
+ + Šifarnik PDV stopa +
+ +
+ Stope se koriste u kalkulaciji cena i PDV evidenciji. Stopa koja je negde upotrebljena se ne briše — arhivira se (nestaje iz padajućih lista, a postojeći zapisi ostaju ispravni). +
+ + {{if not .Stope}} +
Nema unetih PDV stopa.
+ {{end}} + + {{range .Stope}} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ {{end}} +
+ + +
+
+
+ + Nova stopa +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+{{end}} diff --git a/web/templates/stranice/podesavanja_opste.html b/web/templates/stranice/podesavanja_opste.html index 96480c8..adffe2c 100644 --- a/web/templates/stranice/podesavanja_opste.html +++ b/web/templates/stranice/podesavanja_opste.html @@ -117,6 +117,94 @@ + +
+ +
+
+ + Pravni i poreski status +
+ +
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+
„Samo evidencija" gasi ceo zakonski sloj — program vodi servis i zalihe, ali ne izdaje fiskalne ni poreske dokumente.
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+
+ {{if .LogoGreska}} @@ -135,4 +223,37 @@ })(); {{end}} + + {{end}}