Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0f98622b
|
||
|
|
fcae2ae441
|
||
|
|
74d90cf2f2
|
||
|
|
16d84fe27e
|
@@ -416,7 +416,6 @@ func main() {
|
|||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja", h.Prodaja)
|
||||||
r.Get("/prodaja/nova", h.NovaProdaja)
|
r.Get("/prodaja/nova", h.NovaProdaja)
|
||||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
||||||
r.With(doz("prodaja.obrisi")).Post("/prodaja/obrisi/{id}", h.ObrisiProdaju)
|
|
||||||
r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje)
|
r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje)
|
||||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje)
|
r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje)
|
||||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
||||||
|
|||||||
+49
-1
@@ -4,8 +4,10 @@
|
|||||||
package be
|
package be
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -14,6 +16,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/db/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača)
|
// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača)
|
||||||
@@ -33,6 +37,7 @@ var tipExt = map[[2]string][2]string{
|
|||||||
// Kartica drži stanje emuliranog bezbednosnog elementa.
|
// Kartica drži stanje emuliranog bezbednosnog elementa.
|
||||||
type Kartica struct {
|
type Kartica struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
db *sql.DB
|
||||||
|
|
||||||
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
|
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
|
||||||
JID string
|
JID string
|
||||||
@@ -59,6 +64,7 @@ type Kartica struct {
|
|||||||
|
|
||||||
func novaKartica(db *sql.DB) *Kartica {
|
func novaKartica(db *sql.DB) *Kartica {
|
||||||
k := &Kartica{
|
k := &Kartica{
|
||||||
|
db: db,
|
||||||
JID: env("BE_JID", "TRNMOCK1"),
|
JID: env("BE_JID", "TRNMOCK1"),
|
||||||
PIN: env("BE_PIN", "1234"),
|
PIN: env("BE_PIN", "1234"),
|
||||||
Limit: envFloat("BE_LIMIT", 500000),
|
Limit: envFloat("BE_LIMIT", 500000),
|
||||||
@@ -91,7 +97,7 @@ func (k *Kartica) ucitajFirmu(db *sql.DB) {
|
|||||||
"SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " +
|
"SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " +
|
||||||
"('naziv_firme','pib','maticni_broj','adresa','telefon'," +
|
"('naziv_firme','pib','maticni_broj','adresa','telefon'," +
|
||||||
"'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," +
|
"'poslovna_jedinica_naziv','poslovna_jedinica_oznaka','opstina','grad'," +
|
||||||
"'be_pin','be_limit')",
|
"'be_pin','be_limit','be_total_counter','be_counters_json','be_unread_amount')",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("be: ne mogu da čitam podesavanja", "error", err)
|
slog.Warn("be: ne mogu da čitam podesavanja", "error", err)
|
||||||
@@ -154,6 +160,45 @@ func (k *Kartica) ucitajFirmu(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brojači se pamte preko restarta — fiskalni brojač NIKAD ne sme da krene
|
||||||
|
// ispočetka, to bi proizvelo duplirane PFR brojeve za stvarne (ili mock) račune
|
||||||
|
if tc := p["be_total_counter"]; tc != "" {
|
||||||
|
if n, err := strconv.Atoi(tc); err == nil {
|
||||||
|
k.totalCounter = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cj := p["be_counters_json"]; cj != "" {
|
||||||
|
var sacuvani map[string]int
|
||||||
|
if err := json.Unmarshal([]byte(cj), &sacuvani); err == nil {
|
||||||
|
for key, val := range sacuvani {
|
||||||
|
k.counters[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ua := p["be_unread_amount"]; ua != "" {
|
||||||
|
if f, err := strconv.ParseFloat(ua, 64); err == nil {
|
||||||
|
k.unreadAmount = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sacuvajBrojace upisuje trenutno stanje brojača u podesavanja, da posle restarta
|
||||||
|
// emulatora fiskalni brojač nastavi odakle je stao (poziva se pod k.mu iz cmdSign/
|
||||||
|
// cmdResetAudit).
|
||||||
|
func (k *Kartica) sacuvajBrojace() {
|
||||||
|
if k.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := sqlite.SacuvajPodesavanje(ctx, k.db, "be_total_counter", strconv.Itoa(k.totalCounter)); err != nil {
|
||||||
|
slog.Warn("be: ne mogu da sačuvam brojač", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cj, err := json.Marshal(k.counters); err == nil {
|
||||||
|
_ = sqlite.SacuvajPodesavanje(ctx, k.db, "be_counters_json", string(cj))
|
||||||
|
}
|
||||||
|
_ = sqlite.SacuvajPodesavanje(ctx, k.db, "be_unread_amount", strconv.FormatFloat(k.unreadAmount, 'f', -1, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *Kartica) postaviTestPodatke() {
|
func (k *Kartica) postaviTestPodatke() {
|
||||||
@@ -220,6 +265,7 @@ func (k *Kartica) cmdResetAudit() map[string]any {
|
|||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
k.unreadAmount = 0
|
k.unreadAmount = 0
|
||||||
|
k.sacuvajBrojace()
|
||||||
return map[string]any{"status": "ok", "unread_amount": 0}
|
return map[string]any{"status": "ok", "unread_amount": 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +306,8 @@ func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
k.sacuvajBrojace()
|
||||||
|
|
||||||
// lažni potpis — random base64 dovoljno dug da izgleda realistično
|
// lažni potpis — random base64 dovoljno dug da izgleda realistično
|
||||||
sig := mockPotpis()
|
sig := mockPotpis()
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (r *FiskalRepo) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*mo
|
|||||||
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||||
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||||
FROM fiskalni_racuni WHERE prodaja_id = ?
|
FROM fiskalni_racuni WHERE prodaja_id = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
`, prodajaID).Scan(
|
`, prodajaID).Scan(
|
||||||
&fr.ID, &fr.ProdajaID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
&fr.ID, &fr.ProdajaID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||||
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
||||||
@@ -93,6 +94,7 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod
|
|||||||
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||||
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||||
FROM fiskalni_racuni WHERE servis_id = ?
|
FROM fiskalni_racuni WHERE servis_id = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
`, servisID).Scan(
|
`, servisID).Scan(
|
||||||
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||||
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
|
|||||||
return &ProdajaRepo{db: db}
|
return &ProdajaRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGGG-NNNN
|
// SledeciBroj generiše sledeći broj naloga u formatu PR-GGMM-NNNN
|
||||||
|
// (GG dvocifrena godina, MM mesec); brojač NNNN se resetuje svakog meseca
|
||||||
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||||
godina := time.Now().Year()
|
sada := time.Now()
|
||||||
uzorak := fmt.Sprintf("PR-%d-%%", godina)
|
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||||
|
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||||
|
uzorak := prefiks + "%"
|
||||||
|
|
||||||
var sledeci int
|
var sledeci int
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
@@ -34,7 +37,7 @@ func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
|||||||
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
|
return "", fmt.Errorf("ntech: ProdajaRepo.SledeciBroj: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("PR-%d-%04d", godina, sledeci), nil
|
return fmt.Sprintf("%s%04d", prefiks, sledeci), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lista vraća listu prodajnih naloga sa imenom klijenta, po zadatom filteru
|
// Lista vraća listu prodajnih naloga sa imenom klijenta, po zadatom filteru
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ func NoviServisRepo(db *sql.DB) *ServisRepo {
|
|||||||
return &ServisRepo{db: db}
|
return &ServisRepo{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-MMGG-NNN
|
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
||||||
// (MM mesec, GG dvocifrena godina); brojač NNN se resetuje svakog meseca
|
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca
|
||||||
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||||
sada := time.Now()
|
sada := time.Now()
|
||||||
// prefiks "SN-MMGG-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||||
prefiks := fmt.Sprintf("SN-%02d%02d-", int(sada.Month()), sada.Year()%100)
|
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||||
uzorak := prefiks + "%"
|
uzorak := prefiks + "%"
|
||||||
|
|
||||||
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
// COALESCE(MAX, 0)+1 → prvi nalog u mesecu dobija 001
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var prazninaKnjigovodstva model.PrazninaKnjigovodstva
|
||||||
|
if p, err := h.PraznineKnjigovodstva(ctx); err != nil {
|
||||||
|
slog.Error("dashboard: praznine knjigovodstva", "error", err)
|
||||||
|
} else {
|
||||||
|
prazninaKnjigovodstva = p
|
||||||
|
}
|
||||||
|
|
||||||
// poslednjih 5 prodajnih naloga
|
// poslednjih 5 prodajnih naloga
|
||||||
var poslednjeProdaje []model.StavkaProdajePregled
|
var poslednjeProdaje []model.StavkaProdajePregled
|
||||||
if redovi, err := h.IzvestajRepo.PoslednjeProdaje(ctx, 5); err != nil {
|
if redovi, err := h.IzvestajRepo.PoslednjeProdaje(ctx, 5); err != nil {
|
||||||
@@ -130,6 +137,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
KriticneZalihe: kriticneZalihe,
|
KriticneZalihe: kriticneZalihe,
|
||||||
PoslednjeProdaje: poslednjeProdaje,
|
PoslednjeProdaje: poslednjeProdaje,
|
||||||
FlashGreska: flashGreska,
|
FlashGreska: flashGreska,
|
||||||
|
PrazninaKnjige: prazninaKnjigovodstva,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderujTemplate(w, "dashboard", podaci)
|
h.renderujTemplate(w, "dashboard", podaci)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type fiskalVerifikacijaPodaci struct {
|
|||||||
Podaci *FiskalVlPodaci
|
Podaci *FiskalVlPodaci
|
||||||
Greska string
|
Greska string
|
||||||
JeFiskalni bool
|
JeFiskalni bool
|
||||||
|
Kusur float64 // zbir plaćanja umanjen za ukupan iznos, prikazuje se samo ako je > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -88,5 +89,13 @@ func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
|
|||||||
vlPodaci.InvoiceType != "Training" &&
|
vlPodaci.InvoiceType != "Training" &&
|
||||||
vlPodaci.InvoiceType != "Proforma"
|
vlPodaci.InvoiceType != "Proforma"
|
||||||
|
|
||||||
|
var placeno float64
|
||||||
|
for _, p := range vlPodaci.Payments {
|
||||||
|
placeno += p.Amount
|
||||||
|
}
|
||||||
|
if kusur := placeno - vlPodaci.TotalAmount; kusur > 0.005 {
|
||||||
|
podaci.Kusur = kusur
|
||||||
|
}
|
||||||
|
|
||||||
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"ntech/internal/config"
|
"ntech/internal/config"
|
||||||
@@ -257,3 +258,25 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent {
|
|||||||
}
|
}
|
||||||
return fiskal.NoviKlijent(url, key)
|
return fiskal.NoviKlijent(url, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// imeKasira vraća ime i prezime prijavljenog korisnika za polje "kasir" na fiskalnom
|
||||||
|
// računu (korisničko ime kao rezerva ako ime/prezime nisu uneti). Pada nazad na
|
||||||
|
// podešavanje pfr_kasir (npr. za pozadinske pozive bez korisnika u kontekstu), pa na
|
||||||
|
// "NTech" ako ni to nije uneto.
|
||||||
|
func (h *Handler) imeKasira(ctx context.Context) string {
|
||||||
|
kasir := ""
|
||||||
|
if k := middleware.KorisnikIzKonteksta(ctx); k != nil {
|
||||||
|
if k.Ime != "" || k.Prezime != "" {
|
||||||
|
kasir = strings.TrimSpace(k.Ime + " " + k.Prezime)
|
||||||
|
} else {
|
||||||
|
kasir = k.KorisnickoIme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kasir == "" {
|
||||||
|
kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
||||||
|
}
|
||||||
|
if kasir == "" {
|
||||||
|
kasir = "NTech"
|
||||||
|
}
|
||||||
|
return kasir
|
||||||
|
}
|
||||||
|
|||||||
+20
-12
@@ -7,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
appdb "ntech/internal/db"
|
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
@@ -161,24 +160,28 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var kreirano, preskoceno int
|
var kreirano, preskoceno, sumnjivo int
|
||||||
|
|
||||||
// prodajni nalozi
|
// prodajni nalozi
|
||||||
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
nalozi, prodajaPostoji, prodajaKandidati, err := h.kpoKandidatiProdaje(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
prodajaDuplikati := sumnjiviDuplikati(prodajaKandidati)
|
||||||
for _, nd := range nalozi {
|
for _, nd := range nalozi {
|
||||||
if nd.Stornirano {
|
if nd.Stornirano {
|
||||||
preskoceno++
|
preskoceno++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
if prodajaPostoji[nd.ID] {
|
||||||
if postoji {
|
|
||||||
preskoceno++
|
preskoceno++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if prodajaDuplikati[nd.ID] {
|
||||||
|
sumnjivo++
|
||||||
|
continue
|
||||||
|
}
|
||||||
z := model.KpoZapis{
|
z := model.KpoZapis{
|
||||||
DatumPrometa: nd.Datum,
|
DatumPrometa: nd.Datum,
|
||||||
BrojDokumenta: nd.BrojNaloga,
|
BrojDokumenta: nd.BrojNaloga,
|
||||||
@@ -196,18 +199,19 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// naplaćeni servisni nalozi
|
// naplaćeni servisni nalozi
|
||||||
servisNalozi, err := h.ServisRepo.Lista(ctx, "", "")
|
naplaceni, servisPostoji, servisKandidati, nenaplaceniBroj, err := h.kpoKandidatiServisa(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err)
|
slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err)
|
||||||
} else {
|
} else {
|
||||||
for _, sn := range servisNalozi {
|
preskoceno += nenaplaceniBroj
|
||||||
if sn.Status != model.StatusPreuzeto || sn.Naplaceno == 0 {
|
servisDuplikati := sumnjiviDuplikati(servisKandidati)
|
||||||
|
for _, sn := range naplaceni {
|
||||||
|
if servisPostoji[sn.ID] {
|
||||||
preskoceno++
|
preskoceno++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
|
if servisDuplikati[sn.ID] {
|
||||||
if postoji {
|
sumnjivo++
|
||||||
preskoceno++
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
datum := sn.DatumPrijema
|
datum := sn.DatumPrijema
|
||||||
@@ -231,6 +235,10 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno))
|
poruka := fmt.Sprintf("Backfill završen: %d novih KPO unosa, %d preskočeno.", kreirano, preskoceno)
|
||||||
|
if sumnjivo > 0 {
|
||||||
|
poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo)
|
||||||
|
}
|
||||||
|
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
|
||||||
http.Redirect(w, r, "/kpo", http.StatusSeeOther)
|
http.Redirect(w, r, "/kpo", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
appdb "ntech/internal/db"
|
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
@@ -223,23 +222,27 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
sada := time.Now()
|
sada := time.Now()
|
||||||
|
|
||||||
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
nalozi, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
duplikati := sumnjiviDuplikati(kandidati)
|
||||||
|
|
||||||
var kreirano, preskoceno int
|
var kreirano, preskoceno, sumnjivo int
|
||||||
for _, nd := range nalozi {
|
for _, nd := range nalozi {
|
||||||
if nd.KlijentID == nil {
|
if nd.KlijentID == nil {
|
||||||
preskoceno++
|
preskoceno++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
if postojiMap[nd.ID] {
|
||||||
if postoji {
|
|
||||||
preskoceno++
|
preskoceno++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if duplikati[nd.ID] {
|
||||||
|
sumnjivo++
|
||||||
|
continue
|
||||||
|
}
|
||||||
stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, nd.ID)
|
stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, nd.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("backfill KIR: greška pri čitanju stavki", "prodaja_id", nd.ID, "error", err)
|
slog.Error("backfill KIR: greška pri čitanju stavki", "prodaja_id", nd.ID, "error", err)
|
||||||
@@ -267,7 +270,11 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
kreirano++
|
kreirano++
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware.SetFlash(w, r, h.DB, "uspeh", fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno))
|
poruka := fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno)
|
||||||
|
if sumnjivo > 0 {
|
||||||
|
poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo)
|
||||||
|
}
|
||||||
|
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
|
||||||
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ type PodaciProdaje struct {
|
|||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
Nalozi []model.ProdajniNalogSaDetaljem
|
Nalozi []model.ProdajniNalogSaDetaljem
|
||||||
Sacuvano bool
|
Sacuvano bool
|
||||||
Obrisan bool
|
|
||||||
Pretraga string
|
Pretraga string
|
||||||
Od string
|
Od string
|
||||||
Do string
|
Do string
|
||||||
@@ -189,7 +188,6 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) {
|
|||||||
PodaciStranice: ps,
|
PodaciStranice: ps,
|
||||||
Nalozi: nalozi,
|
Nalozi: nalozi,
|
||||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
|
||||||
Pretraga: pretraga,
|
Pretraga: pretraga,
|
||||||
Od: od,
|
Od: od,
|
||||||
Do: do,
|
Do: do,
|
||||||
@@ -533,39 +531,6 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis
|
|||||||
fmt.Fprint(w, `<p style="margin-top:16px;"><button onclick="window.print()" style="padding:8px 16px;">Štampaj</button></p></body></html>`)
|
fmt.Fprint(w, `<p style="margin-top:16px;"><button onclick="window.print()" style="padding:8px 16px;">Štampaj</button></p></body></html>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObrisiProdaju prima POST zahtev i stornira nalog umesto fizičkog brisanja.
|
|
||||||
// Jednom izdat račun ne sme da nestane — umesto brisanja koristi se storno.
|
|
||||||
func (h *Handler) ObrisiProdaju(w http.ResponseWriter, r *http.Request) {
|
|
||||||
k, ok := h.zahtevajDozvolu(w, r, "prodaja.obrisi")
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := parseID(chi.URLParam(r, "id"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
|
|
||||||
if err := h.stornirajProdaju(r.Context(), id, "administrativno uklanjanje", &k.ID, poreskiBrojKupca); err != nil {
|
|
||||||
poruka := "Greška pri uklanjanju. Možda je nalog već storniran."
|
|
||||||
if errors.Is(err, errPotrebnaIdentKupca) {
|
|
||||||
poruka = errPotrebnaIdentKupca.Error()
|
|
||||||
} else {
|
|
||||||
slog.Error("greška pri uklanjanju naloga", "error", err)
|
|
||||||
}
|
|
||||||
middleware.SetFlash(w, r, h.DB, "greska", poruka)
|
|
||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/prodaja?obrisan=1", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
|
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
|
||||||
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) {
|
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) {
|
||||||
var nalog model.ProdajniNalog
|
var nalog model.ProdajniNalog
|
||||||
@@ -701,8 +666,6 @@ func klasifikujPoreskiBroj(broj string) (pib, jmbg string) {
|
|||||||
// stornirajProdaju sprovodi kompletan storno prodajnog naloga: menja status naloga i
|
// stornirajProdaju sprovodi kompletan storno prodajnog naloga: menja status naloga i
|
||||||
// vraća artikle na stanje, uklanja vezani auto-KIR zapis, šalje fiskalni refund
|
// vraća artikle na stanje, uklanja vezani auto-KIR zapis, šalje fiskalni refund
|
||||||
// (best-effort — storno ostaje validan i bez uspešnog refunda) i briše KPO zapis.
|
// (best-effort — storno ostaje validan i bez uspešnog refunda) i briše KPO zapis.
|
||||||
// Zajednička je za StornoProdaje i ObrisiProdaju kako obe akcije imaju isti,
|
|
||||||
// potpuni efekat prema PDV/fiskalnoj i KPO evidenciji.
|
|
||||||
func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, korisnikID *int64, poreskiBrojKupca string) error {
|
func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string, korisnikID *int64, poreskiBrojKupca string) error {
|
||||||
// Refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG) — ako je nalog
|
// Refundacija po propisu zahteva identifikaciju kupca (PIB/JMBG) — ako je nalog
|
||||||
// bez klijenta (ili klijent nema upisan PIB/JMBG), mora se ručno uneti pre storna.
|
// bez klijenta (ili klijent nema upisan PIB/JMBG), mora se ručno uneti pre storna.
|
||||||
@@ -735,10 +698,7 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
|||||||
if fk := h.fiskalKlijent(); fk != nil {
|
if fk := h.fiskalKlijent(); fk != nil {
|
||||||
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil {
|
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil {
|
||||||
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil {
|
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil {
|
||||||
kasirFisk, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
kasirFisk := h.imeKasira(ctx)
|
||||||
if kasirFisk == "" {
|
|
||||||
kasirFisk = "NTech"
|
|
||||||
}
|
|
||||||
var pibFisk, jmbgFisk string
|
var pibFisk, jmbgFisk string
|
||||||
if nalogFisk.KlijentID != nil {
|
if nalogFisk.KlijentID != nil {
|
||||||
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil {
|
if kk, e3 := h.KlijentiRepo.DohvatiID(ctx, *nalogFisk.KlijentID); e3 == nil {
|
||||||
@@ -810,10 +770,7 @@ func (h *Handler) fiskalizujProdaju(ctx context.Context, prodajaID int64, klijen
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
kasir := h.imeKasira(ctx)
|
||||||
if kasir == "" {
|
|
||||||
kasir = "NTech"
|
|
||||||
}
|
|
||||||
|
|
||||||
var pib, jmbg string
|
var pib, jmbg string
|
||||||
if nalog.KlijentID != nil {
|
if nalog.KlijentID != nil {
|
||||||
|
|||||||
@@ -2212,17 +2212,19 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// automatski upis u KIR ako je firma PDV obveznik
|
// automatski upis u KIR ako je firma PDV obveznik i servis je na klijenta (B2B/identifikovan
|
||||||
|
// kupac) — maloprodaja bez klijenta ide samo preko fiskalizacije, isto kao u Prodaji.
|
||||||
if h.modulUkljucen(r.Context(), "pdv") {
|
if h.modulUkljucen(r.Context(), "pdv") {
|
||||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||||
if nalog != nil && !nalog.PopravkaOdbijena {
|
if nalog != nil && !nalog.PopravkaOdbijena && nalog.KlijentID != nil {
|
||||||
kupacNaziv, kupacPib, kupacMesto := "", "", ""
|
kupacNaziv, kupacPib, kupacMesto := "", "", ""
|
||||||
if nalog.KlijentID != nil {
|
|
||||||
if k, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
|
if k, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
|
||||||
kupacNaziv = k.PunoIme()
|
kupacNaziv = k.PunoIme()
|
||||||
kupacPib = k.PIB
|
kupacPib = k.PIB
|
||||||
kupacMesto = k.Mesto
|
if k.Tip != "pravno" {
|
||||||
|
kupacPib = k.JMBG
|
||||||
}
|
}
|
||||||
|
kupacMesto = k.Mesto
|
||||||
}
|
}
|
||||||
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
radovi, _ := h.ServisniRadoviRepo.DohvatiZaNalog(r.Context(), id)
|
||||||
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
delovi, _ := h.ServisniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||||
@@ -2338,20 +2340,7 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kasir := ""
|
kasir := h.imeKasira(ctx)
|
||||||
if kor := middleware.KorisnikIzKonteksta(ctx); kor != nil {
|
|
||||||
if kor.Ime != "" || kor.Prezime != "" {
|
|
||||||
kasir = strings.TrimSpace(kor.Ime + " " + kor.Prezime)
|
|
||||||
} else {
|
|
||||||
kasir = kor.KorisnickoIme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if kasir == "" {
|
|
||||||
kasir, _ = sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
|
||||||
}
|
|
||||||
if kasir == "" {
|
|
||||||
kasir = "NTech"
|
|
||||||
}
|
|
||||||
|
|
||||||
zahtev := fiskal.InvoiceRequest{
|
zahtev := fiskal.InvoiceRequest{
|
||||||
InvoiceRequest: fiskal.InvoiceRequestBody{
|
InvoiceRequest: fiskal.InvoiceRequestBody{
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
appdb "ntech/internal/db"
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kandidatUskladjivanja opisuje jedan nalog koji se razmatra za automatski backfill
|
||||||
|
// KIR/KPO upisa — koristi se samo za detekciju sumnjivih duplikata (double-submit),
|
||||||
|
// ne za sam upis.
|
||||||
|
type kandidatUskladjivanja struct {
|
||||||
|
ID int64
|
||||||
|
KlijentID *int64
|
||||||
|
Iznos float64
|
||||||
|
Datum time.Time
|
||||||
|
ImaUpis bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumnjiviDuplikati vraća ID-eve naloga BEZ upisa koje automatski backfill treba da
|
||||||
|
// PRESKOČI — jer postoji drugi (bliski) nalog istog klijenta i iznosa u roku od minuta
|
||||||
|
// koji već ima upis, ili je stariji i takođe čeka na upis. Ovo je znak dvostrukog
|
||||||
|
// slanja forme (double-submit bag na naplati), ne dva odvojena prometa — automatska
|
||||||
|
// dopuna bi inače duplirala prihod. Prosleđuje se CEO skup naloga (i sa i bez upisa)
|
||||||
|
// radi poređenja.
|
||||||
|
func sumnjiviDuplikati(svi []kandidatUskladjivanja) map[int64]bool {
|
||||||
|
const prozor = 60 * time.Second
|
||||||
|
sumnjivi := make(map[int64]bool)
|
||||||
|
for _, a := range svi {
|
||||||
|
if a.ImaUpis {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, b := range svi {
|
||||||
|
if a.ID == b.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !istKlijent(a.KlijentID, b.KlijentID) || a.Iznos != b.Iznos {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
razlika := a.Datum.Sub(b.Datum)
|
||||||
|
if razlika < 0 {
|
||||||
|
razlika = -razlika
|
||||||
|
}
|
||||||
|
if razlika > prozor {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.ImaUpis || b.ID < a.ID {
|
||||||
|
sumnjivi[a.ID] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sumnjivi
|
||||||
|
}
|
||||||
|
|
||||||
|
// kirKandidatiProdaje vraća sve B2B prodajne naloge (nezavisno od toga da li već imaju
|
||||||
|
// KIR upis) sa mapom postojećih upisa i kandidatima za detekciju duplikata. Koristi se
|
||||||
|
// i za backfill (KirBackfillProdaje) i za brojanje praznina na dashboard-u.
|
||||||
|
func (h *Handler) kirKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) {
|
||||||
|
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
postojiMap := make(map[int64]bool, len(nalozi))
|
||||||
|
var kandidati []kandidatUskladjivanja
|
||||||
|
for _, nd := range nalozi {
|
||||||
|
if nd.KlijentID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
||||||
|
postojiMap[nd.ID] = postoji
|
||||||
|
kandidati = append(kandidati, kandidatUskladjivanja{
|
||||||
|
ID: nd.ID, KlijentID: nd.KlijentID, Iznos: nd.Ukupno, Datum: nd.Datum, ImaUpis: postoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nalozi, postojiMap, kandidati, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kpoKandidatiProdaje vraća sve nestornirane prodajne naloge sa mapom postojećih KPO
|
||||||
|
// upisa i kandidatima za detekciju duplikata.
|
||||||
|
func (h *Handler) kpoKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) {
|
||||||
|
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
postojiMap := make(map[int64]bool, len(nalozi))
|
||||||
|
var kandidati []kandidatUskladjivanja
|
||||||
|
for _, nd := range nalozi {
|
||||||
|
if nd.Stornirano {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
||||||
|
postojiMap[nd.ID] = postoji
|
||||||
|
kandidati = append(kandidati, kandidatUskladjivanja{
|
||||||
|
ID: nd.ID, KlijentID: nd.KlijentID, Iznos: nd.Ukupno, Datum: nd.Datum, ImaUpis: postoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nalozi, postojiMap, kandidati, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kpoKandidatiServisa vraća sve naplaćene i preuzete servisne naloge sa mapom
|
||||||
|
// postojećih KPO upisa, kandidatima za detekciju duplikata, i brojem naloga koji su
|
||||||
|
// izostavljeni jer nisu (još) naplaćeni/preuzeti.
|
||||||
|
func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.ServisniNalogSaKlijentom, postojiMap map[int64]bool, kandidati []kandidatUskladjivanja, nenaplaceniBroj int, err error) {
|
||||||
|
servisNalozi, err := h.ServisRepo.Lista(ctx, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, 0, err
|
||||||
|
}
|
||||||
|
naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi))
|
||||||
|
for _, sn := range servisNalozi {
|
||||||
|
if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 {
|
||||||
|
naplaceni = append(naplaceni, sn)
|
||||||
|
} else {
|
||||||
|
nenaplaceniBroj++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postojiMap = make(map[int64]bool, len(naplaceni))
|
||||||
|
for _, sn := range naplaceni {
|
||||||
|
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
|
||||||
|
postojiMap[sn.ID] = postoji
|
||||||
|
datum := sn.DatumPrijema
|
||||||
|
if sn.DatumZavrsetka != nil {
|
||||||
|
datum = *sn.DatumZavrsetka
|
||||||
|
}
|
||||||
|
kandidati = append(kandidati, kandidatUskladjivanja{
|
||||||
|
ID: sn.ID, KlijentID: sn.KlijentID, Iznos: sn.Naplaceno, Datum: datum, ImaUpis: postoji,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return naplaceni, postojiMap, kandidati, nenaplaceniBroj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PraznineKnjigovodstva broji naloge (prodaja + servis) koji čekaju KIR/KPO/fiskalni
|
||||||
|
// upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati
|
||||||
|
// (double-submit) pa ih automatski backfill preskače.
|
||||||
|
func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnjigovodstva, error) {
|
||||||
|
var p model.PrazninaKnjigovodstva
|
||||||
|
|
||||||
|
if h.modulUkljucen(ctx, "pdv") {
|
||||||
|
_, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
duplikati := sumnjiviDuplikati(kandidati)
|
||||||
|
for id, postoji := range postojiMap {
|
||||||
|
if postoji {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duplikati[id] {
|
||||||
|
p.SumnjiviDupli++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.BezKir++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.modulUkljucen(ctx, "kpo") {
|
||||||
|
_, postojiMapP, kandidatiP, err := h.kpoKandidatiProdaje(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
_, postojiMapS, kandidatiS, _, err := h.kpoKandidatiServisa(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
duplikatiP := sumnjiviDuplikati(kandidatiP)
|
||||||
|
duplikatiS := sumnjiviDuplikati(kandidatiS)
|
||||||
|
for id, postoji := range postojiMapP {
|
||||||
|
if postoji {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duplikatiP[id] {
|
||||||
|
p.SumnjiviDupli++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.BezKpo++
|
||||||
|
}
|
||||||
|
for id, postoji := range postojiMapS {
|
||||||
|
if postoji {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duplikatiS[id] {
|
||||||
|
p.SumnjiviDupli++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.BezKpo++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.modulUkljucen(ctx, "fiskalizacija") {
|
||||||
|
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
|
||||||
|
p.BezFiskalnog += len(m)
|
||||||
|
}
|
||||||
|
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
|
||||||
|
p.BezFiskalnog += len(m)
|
||||||
|
}
|
||||||
|
if n, err := h.stornoBezRefunda(ctx); err == nil {
|
||||||
|
p.BezRefunda = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni
|
||||||
|
// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali
|
||||||
|
// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao).
|
||||||
|
// Servis nema storno funkciju — obuhvaćena je samo prodaja.
|
||||||
|
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := h.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM prodajni_nalozi pn
|
||||||
|
JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id
|
||||||
|
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
|
||||||
|
WHERE pn.stornirano = 1
|
||||||
|
`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func istKlijent(a, b *int64) bool {
|
||||||
|
if a == nil && b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ var sveAkcije = []string{
|
|||||||
"servis.obrisi",
|
"servis.obrisi",
|
||||||
"prodaja.pregled",
|
"prodaja.pregled",
|
||||||
"prodaja.dodaj",
|
"prodaja.dodaj",
|
||||||
"prodaja.obrisi",
|
|
||||||
"prodaja.storno",
|
"prodaja.storno",
|
||||||
"klijent.pregled",
|
"klijent.pregled",
|
||||||
"klijent.dodaj",
|
"klijent.dodaj",
|
||||||
@@ -43,6 +42,9 @@ var sveAkcije = []string{
|
|||||||
"pdv.pregled",
|
"pdv.pregled",
|
||||||
"pdv.dodaj",
|
"pdv.dodaj",
|
||||||
"pdv.obrisi",
|
"pdv.obrisi",
|
||||||
|
"kpo.pregled",
|
||||||
|
"kpo.dodaj",
|
||||||
|
"kpo.obrisi",
|
||||||
}
|
}
|
||||||
|
|
||||||
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
|
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
|
||||||
@@ -78,7 +80,7 @@ func ImaDozvolu(uloga, akcija string) bool {
|
|||||||
"servis.izmeni", "servis.obrisi":
|
"servis.izmeni", "servis.obrisi":
|
||||||
return true
|
return true
|
||||||
// prodaja
|
// prodaja
|
||||||
case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi", "prodaja.storno":
|
case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno":
|
||||||
return true
|
return true
|
||||||
// klijent
|
// klijent
|
||||||
case "klijent.pregled", "klijent.dodaj",
|
case "klijent.pregled", "klijent.dodaj",
|
||||||
@@ -103,6 +105,9 @@ func ImaDozvolu(uloga, akcija string) bool {
|
|||||||
// PDV evidencija (KIR/KPR) — administrativno, radnik nema
|
// PDV evidencija (KIR/KPR) — administrativno, radnik nema
|
||||||
case "pdv.pregled", "pdv.dodaj", "pdv.obrisi":
|
case "pdv.pregled", "pdv.dodaj", "pdv.obrisi":
|
||||||
return true
|
return true
|
||||||
|
// KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema
|
||||||
|
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestImaDozvoluRadnik(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// radnik NE sme: brisanje, storno, izveštaje, podešavanja, backup
|
// radnik NE sme: brisanje, storno, izveštaje, podešavanja, backup
|
||||||
neSme := []string{"prodaja.obrisi", "prodaja.storno", "servis.obrisi", "klijent.obrisi",
|
neSme := []string{"prodaja.storno", "servis.obrisi", "klijent.obrisi",
|
||||||
"dobavljac.obrisi", "izvestaj.pregled", "podesavanja.izmeni", "backup.pokreni", "dashboard.prihod"}
|
"dobavljac.obrisi", "izvestaj.pregled", "podesavanja.izmeni", "backup.pokreni", "dashboard.prihod"}
|
||||||
for _, a := range neSme {
|
for _, a := range neSme {
|
||||||
if ImaDozvolu("radnik", a) {
|
if ImaDozvolu("radnik", a) {
|
||||||
|
|||||||
@@ -71,4 +71,23 @@ type PodaciDashboarda struct {
|
|||||||
KriticneZalihe []StavkaZalihe
|
KriticneZalihe []StavkaZalihe
|
||||||
PoslednjeProdaje []StavkaProdajePregled
|
PoslednjeProdaje []StavkaProdajePregled
|
||||||
FlashGreska string
|
FlashGreska string
|
||||||
|
PrazninaKnjige PrazninaKnjigovodstva
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrazninaKnjigovodstva sažima broj naloga bez KIR/KPO/fiskalnog upisa i broj
|
||||||
|
// sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje
|
||||||
|
// se kao kartica na dashboard-u.
|
||||||
|
type PrazninaKnjigovodstva struct {
|
||||||
|
BezKir int
|
||||||
|
BezKpo int
|
||||||
|
BezFiskalnog int
|
||||||
|
BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao)
|
||||||
|
SumnjiviDupli int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ukupno vraća zbir svih praznina UKLJUČUJUĆI sumnjive duplikate — koristi se da se
|
||||||
|
// odluči da li se kartica uopšte prikazuje na dashboard-u (i kad ostanu samo
|
||||||
|
// duplikati za ručnu proveru, kartica mora da ostane vidljiva).
|
||||||
|
func (p PrazninaKnjigovodstva) Ukupno() int {
|
||||||
|
return p.BezKir + p.BezKpo + p.BezFiskalnog + p.BezRefunda + p.SumnjiviDupli
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Prodaja: broj naloga menja format iz PR-GGGG-NNNN (puna godina, brojač po godini)
|
||||||
|
-- u PR-GGMM-NNNN (dvocifrena godina + mesec, brojač po mesecu) — usklađeno sa
|
||||||
|
-- servisnim nalozima koji već koriste mesečni brojač.
|
||||||
|
--
|
||||||
|
-- Idempotentno: pogađa samo redove čiji četvorocifreni blok NIJE validan mesec na
|
||||||
|
-- poslednje dve cifre (stari format: puna godina npr. "2026" → poslednje dve cifre
|
||||||
|
-- "26" nisu 01-12; novi format: "2601" → "01" jeste validan mesec — pa migracija na
|
||||||
|
-- ponovnom pokretanju ništa ne dira).
|
||||||
|
|
||||||
|
WITH stari AS (
|
||||||
|
SELECT id, datum FROM prodajni_nalozi
|
||||||
|
WHERE CAST(substr(broj_naloga, 6, 2) AS INTEGER) NOT BETWEEN 1 AND 12
|
||||||
|
),
|
||||||
|
raspored AS (
|
||||||
|
SELECT id,
|
||||||
|
'PR-' || substr(datum, 3, 2) || substr(datum, 6, 2) || '-' ||
|
||||||
|
printf('%04d', ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY substr(datum, 1, 7)
|
||||||
|
ORDER BY datum, id
|
||||||
|
)) AS novi_broj
|
||||||
|
FROM stari
|
||||||
|
)
|
||||||
|
UPDATE prodajni_nalozi
|
||||||
|
SET broj_naloga = (SELECT novi_broj FROM raspored WHERE raspored.id = prodajni_nalozi.id)
|
||||||
|
WHERE id IN (SELECT id FROM raspored);
|
||||||
|
|
||||||
|
-- uskladi denormalizovane kopije broja dokumenta u KIR/KPO sa novim brojem naloga
|
||||||
|
UPDATE pdv_kir
|
||||||
|
SET broj_dokumenta = (SELECT broj_naloga FROM prodajni_nalozi WHERE id = pdv_kir.izvor_id)
|
||||||
|
WHERE izvor = 'prodaja' AND izvor_id IN (SELECT id FROM prodajni_nalozi);
|
||||||
|
|
||||||
|
UPDATE kpo_unosi
|
||||||
|
SET broj_dokumenta = (SELECT broj_naloga FROM prodajni_nalozi WHERE id = kpo_unosi.izvor_id)
|
||||||
|
WHERE izvor = 'prodaja' AND izvor_id IN (SELECT id FROM prodajni_nalozi);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Servis: broj naloga menja redosled iz SN-MMGG-NNN (mesec-godina) u SN-GGMM-NNN
|
||||||
|
-- (godina-mesec) — usklađeno sa prodajnim nalozima (PR-GGMM-NNNN, migracija 098).
|
||||||
|
--
|
||||||
|
-- Idempotentno: broj se za svaki nalog PONOVO izračunava iz datum_prijema (isti
|
||||||
|
-- hronološki raspored po mesecu daje isti rezultat svaki put), pa se UPDATE
|
||||||
|
-- primenjuje samo na redove čija se trenutna vrednost razlikuje od izračunate.
|
||||||
|
|
||||||
|
WITH raspored AS (
|
||||||
|
SELECT id,
|
||||||
|
'SN-' || substr(datum_prijema, 3, 2) || substr(datum_prijema, 6, 2) || '-' ||
|
||||||
|
printf('%03d', ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY substr(datum_prijema, 1, 7)
|
||||||
|
ORDER BY datum_prijema, id
|
||||||
|
)) AS novi_broj
|
||||||
|
FROM servisni_nalozi
|
||||||
|
)
|
||||||
|
UPDATE servisni_nalozi
|
||||||
|
SET broj_naloga = (SELECT novi_broj FROM raspored WHERE raspored.id = servisni_nalozi.id)
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT raspored.id FROM raspored
|
||||||
|
JOIN servisni_nalozi sn ON sn.id = raspored.id
|
||||||
|
WHERE raspored.novi_broj != sn.broj_naloga
|
||||||
|
);
|
||||||
|
|
||||||
|
-- uskladi denormalizovane kopije broja dokumenta u KIR/KPO sa novim brojem naloga
|
||||||
|
UPDATE pdv_kir
|
||||||
|
SET broj_dokumenta = (SELECT broj_naloga FROM servisni_nalozi WHERE id = pdv_kir.izvor_id)
|
||||||
|
WHERE izvor = 'servis' AND izvor_id IN (SELECT id FROM servisni_nalozi);
|
||||||
|
|
||||||
|
UPDATE kpo_unosi
|
||||||
|
SET broj_dokumenta = (SELECT broj_naloga FROM servisni_nalozi WHERE id = kpo_unosi.izvor_id)
|
||||||
|
WHERE izvor = 'servis' AND izvor_id IN (SELECT id FROM servisni_nalozi);
|
||||||
@@ -141,6 +141,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
nacinPlacanja: 'gotovina',
|
nacinPlacanja: 'gotovina',
|
||||||
primljenoIznos: '',
|
primljenoIznos: '',
|
||||||
prikaziRacun: true,
|
prikaziRacun: true,
|
||||||
|
saljemSe: false,
|
||||||
_fiskalniTab: null,
|
_fiskalniTab: null,
|
||||||
artikliOpcije: [],
|
artikliOpcije: [],
|
||||||
pretragaArtikal: '',
|
pretragaArtikal: '',
|
||||||
@@ -358,6 +359,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// posaljiProdaju uklanja prazne stavke (npr. automatski dodat red na kraju koji korisnik
|
// posaljiProdaju uklanja prazne stavke (npr. automatski dodat red na kraju koji korisnik
|
||||||
// nije popunio) pre nego što se forma stvarno pošalje na server
|
// nije popunio) pre nego što se forma stvarno pošalje na server
|
||||||
posaljiProdaju(e) {
|
posaljiProdaju(e) {
|
||||||
|
if (this.saljemSe) return
|
||||||
|
this.saljemSe = true
|
||||||
this.stavke = this.stavke.filter(s => s.artikal_id)
|
this.stavke = this.stavke.filter(s => s.artikal_id)
|
||||||
this.$nextTick(() => e.target.submit())
|
this.$nextTick(() => e.target.submit())
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -195,12 +195,6 @@
|
|||||||
<td class="matrica-checkbox"><input type="checkbox" name="radnik__prodaja.dodaj" {{if index .DozvoleRadnik "prodaja.dodaj"}}checked{{end}}></td>
|
<td class="matrica-checkbox"><input type="checkbox" name="radnik__prodaja.dodaj" {{if index .DozvoleRadnik "prodaja.dodaj"}}checked{{end}}></td>
|
||||||
{{if eq .KorisnikUloga "superadmin"}}<td class="matrica-checkbox"><input type="checkbox" name="admin__prodaja.dodaj" {{if index .DozvoleAdmin "prodaja.dodaj"}}checked{{end}}></td>{{end}}
|
{{if eq .KorisnikUloga "superadmin"}}<td class="matrica-checkbox"><input type="checkbox" name="admin__prodaja.dodaj" {{if index .DozvoleAdmin "prodaja.dodaj"}}checked{{end}}></td>{{end}}
|
||||||
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
|
||||||
<td style="padding:9px 16px;font-size:13px;color:var(--tekst-glavni);">Brisanje prodaje</td>
|
|
||||||
<td class="matrica-checkbox"><input type="checkbox" name="radnik__prodaja.obrisi" {{if index .DozvoleRadnik "prodaja.obrisi"}}checked{{end}}></td>
|
|
||||||
{{if eq .KorisnikUloga "superadmin"}}<td class="matrica-checkbox"><input type="checkbox" name="admin__prodaja.obrisi" {{if index .DozvoleAdmin "prodaja.obrisi"}}checked{{end}}></td>{{end}}
|
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||||
<td style="padding:9px 16px;font-size:13px;color:var(--tekst-glavni);">Storniranje prodaje</td>
|
<td style="padding:9px 16px;font-size:13px;color:var(--tekst-glavni);">Storniranje prodaje</td>
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
{{ if .FlashGreska }}
|
{{ if .FlashGreska }}
|
||||||
<div class="poruka-greska animiraj" style="margin-bottom:16px;">{{ .FlashGreska }}</div>
|
<div class="poruka-greska animiraj" style="margin-bottom:16px;">{{ .FlashGreska }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if gt .PrazninaKnjige.Ukupno 0 }}
|
||||||
|
<div class="kartica animiraj" style="margin-bottom:16px;background:#fef3c7;border:1px solid #fde68a;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;color:#92400e;">Knjigovodstvo — praznine u evidenciji</div>
|
||||||
|
<div style="font-size:13px;color:#92400e;margin-top:2px;">
|
||||||
|
{{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}{{ .PrazninaKnjige.BezFiskalnog }} bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }}
|
||||||
|
{{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR</a>{{ end }}
|
||||||
|
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO</a>{{ end }}
|
||||||
|
{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Fiskalizacija</a>{{ end }}
|
||||||
|
{{ if gt .PrazninaKnjige.BezRefunda 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?stornirano=1&prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Storno bez refunda</a>{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||||
<!-- stat kartice -->
|
<!-- stat kartice -->
|
||||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin" class="kartica dash-stat animiraj kartica-link">
|
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/magacin" class="kartica dash-stat animiraj kartica-link">
|
||||||
|
|||||||
@@ -64,9 +64,11 @@
|
|||||||
/* podnožje */
|
/* podnožje */
|
||||||
.podnozje { text-align: center; padding: 20px 16px 8px; font-size: 11px; color: #94a3b8; line-height: 1.6; }
|
.podnozje { text-align: center; padding: 20px 16px 8px; font-size: 11px; color: #94a3b8; line-height: 1.6; }
|
||||||
.podnozje a { color: #64748b; text-decoration: none; }
|
.podnozje a { color: #64748b; text-decoration: none; }
|
||||||
|
.test-traka { background: #7c2d12; color: #fed7aa; text-align: center; padding: 8px 16px; font-size: 12px; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="test-traka">⚠ TESTNA STRANICA — lokalna simulacija (Teron mock), NIJE prava provera kod Poreske uprave RS</div>
|
||||||
<div class="omotac">
|
<div class="omotac">
|
||||||
|
|
||||||
{{if .Greska}}
|
{{if .Greska}}
|
||||||
@@ -80,8 +82,8 @@
|
|||||||
{{if .JeFiskalni}}
|
{{if .JeFiskalni}}
|
||||||
<div class="baner ok">
|
<div class="baner ok">
|
||||||
<div class="baner-ikona">✓</div>
|
<div class="baner-ikona">✓</div>
|
||||||
<div class="baner-tekst">Račun je verifikovan</div>
|
<div class="baner-tekst">Podaci pročitani sa QR koda</div>
|
||||||
<div class="baner-podnaslov">Fiskalni račun je evidentiran u sistemu</div>
|
<div class="baner-podnaslov">Testni prikaz — nije prava provera kod Poreske uprave RS</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="baner" style="background:#f59e0b;color:#1c1917;">
|
<div class="baner" style="background:#f59e0b;color:#1c1917;">
|
||||||
@@ -201,6 +203,12 @@
|
|||||||
<span style="font-weight:600;">{{dinari .Amount}} din</span>
|
<span style="font-weight:600;">{{dinari .Amount}} din</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if $.Kusur}}
|
||||||
|
<div class="placanje-red">
|
||||||
|
<span style="color:#374151;">Kusur</span>
|
||||||
|
<span style="font-weight:600;">{{dinari $.Kusur}} din</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@@ -222,7 +230,7 @@
|
|||||||
|
|
||||||
<div class="podnozje">
|
<div class="podnozje">
|
||||||
Fiskalizacija — NTech sistem<br>
|
Fiskalizacija — NTech sistem<br>
|
||||||
<span style="font-size:10px;color:#cbd5e1;">Poreski identifikacioni broj se proverava kod Poreske uprave RS</span>
|
<span style="font-size:10px;color:#cbd5e1;">Podaci su dekodirani direktno iz QR koda na ovom serveru — ovo NIJE zvanična provera kod Poreske uprave Republike Srbije.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{end}}{{/* if .Greska */}}
|
{{end}}{{/* if .Greska */}}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<div style="display: flex; flex-direction: column; gap: 16px">
|
<div style="display: flex; flex-direction: column; gap: 16px">
|
||||||
{{if .Sacuvano}}
|
{{if .Sacuvano}}
|
||||||
<div class="poruka-uspeh poruka-animacija">Prodaja je uspešno sačuvana.</div>
|
<div class="poruka-uspeh poruka-animacija">Prodaja je uspešno sačuvana.</div>
|
||||||
{{end}} {{if .Obrisan}}
|
|
||||||
<div class="poruka-uspeh poruka-animacija">Prodajni nalog je uspešno obrisan.</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- zaglavlje sa pretragom, filterima i dugmetom -->
|
<!-- zaglavlje sa pretragom, filterima i dugmetom -->
|
||||||
@@ -26,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="prodaja-traka-akcije" style="display: flex; gap: 10px; flex-wrap: wrap">
|
<div class="prodaja-traka-akcije" style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||||
<label class="dugme-prekidac">
|
<label class="dugme-prekidac">
|
||||||
<input type="checkbox" name="stornirano" value="1" {{if .SamoStornirano}}checked{{end}} />
|
<input type="checkbox" name="stornirano" value="1" {{if .SamoStornirano}}checked{{end}} onchange="this.form.submit()" />
|
||||||
Samo stornirano
|
Samo stornirano
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" class="btn-sekundarno" style="white-space: nowrap">Prikaži</button>
|
<button type="submit" class="btn-sekundarno" style="white-space: nowrap">Prikaži</button>
|
||||||
@@ -65,12 +63,6 @@
|
|||||||
<td style="padding: 12px 16px; text-align: center">
|
<td style="padding: 12px 16px; text-align: center">
|
||||||
<div style="display: flex; align-items: center; justify-content: center; gap: 8px">
|
<div style="display: flex; align-items: center; justify-content: center; gap: 8px">
|
||||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{.ID}}" class="btn-primarno-malo">Detalji</a>
|
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{.ID}}" class="btn-primarno-malo">Detalji</a>
|
||||||
{{if and (index $.Dozvole "prodaja.storno") (not .Stornirano)}}
|
|
||||||
<form method="POST" action="/prodaja/storno/{{.ID}}" style="margin: 0; padding: 0">
|
|
||||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}" />
|
|
||||||
<button type="submit" class="btn-obrisi-malo" data-potvrda="Da li ste sigurni da želite da stornirate ovaj nalog? Artikli će biti vraćeni na stanje.">Storno</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- storno i brisanje -->
|
<!-- storno i brisanje -->
|
||||||
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
|
<div id="storno" class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
|
||||||
<div class="kolona" style="gap:16px;">
|
<div class="kolona" style="gap:16px;">
|
||||||
|
|
||||||
{{if and (index $.Dozvole "prodaja.storno") (not .Nalog.Stornirano) }}
|
{{if and (index $.Dozvole "prodaja.storno") (not .Nalog.Stornirano) }}
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
<p style="margin:0 0 4px 0;color:var(--tekst-sekundarno);"><strong>Brojač:</strong> {{.FiskalniRacun.Brojac}}</p>
|
<p style="margin:0 0 4px 0;color:var(--tekst-sekundarno);"><strong>Brojač:</strong> {{.FiskalniRacun.Brojac}}</p>
|
||||||
{{if .FiskalniRacun.UrlVerifikacija}}
|
{{if .FiskalniRacun.UrlVerifikacija}}
|
||||||
<p style="margin:4px 0 0 0;font-size:13px;">
|
<p style="margin:4px 0 0 0;font-size:13px;">
|
||||||
<a href="{{.FiskalniRacun.UrlVerifikacija}}" target="_blank" rel="noopener noreferrer" style="color: var(--sb-akcent); font-weight: 500">Provera na PURS-u →</a>
|
<a href="{{.FiskalniRacun.UrlVerifikacija}}" target="_blank" rel="noopener noreferrer" style="color: var(--sb-akcent); font-weight: 500">Otvori QR verifikaciju (test) →</a>
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -315,27 +315,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if index $.Dozvole "prodaja.obrisi"}}
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-top:14px;border-top:0.5px solid var(--ivica);">
|
|
||||||
<div style="flex:1;min-width:200px;">
|
|
||||||
<div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Uklanjanje naloga</div>
|
|
||||||
<div class="pomocni-tekst">
|
|
||||||
Nalog će biti storniran (vraća artikle na stanje) i označen kao uklonjen. Ne briše se fizički — ostaje u evidenciji.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/prodaja/obrisi/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
|
||||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
|
||||||
{{if .PotrebnaIdentKupca}}
|
|
||||||
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
|
|
||||||
{{end}}
|
|
||||||
<button type="submit" class="btn-upozorenje"
|
|
||||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}? Količine artikala biće vraćene na stanje.">
|
|
||||||
Ukloni nalog
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -332,8 +332,8 @@
|
|||||||
style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;">
|
style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;">
|
||||||
<span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span>
|
<span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" class="btn-primarno" style="width:100%;margin-top:4px;" :disabled="!naplataValidna()"
|
<button type="submit" class="btn-primarno" style="width:100%;margin-top:4px;" :disabled="!naplataValidna() || saljemSe"
|
||||||
:style="!naplataValidna() ? 'opacity:0.4;cursor:not-allowed;' : ''" @click="pripremiFiskalniTab()">
|
:style="(!naplataValidna() || saljemSe) ? 'opacity:0.4;cursor:not-allowed;' : ''" @click="pripremiFiskalniTab()">
|
||||||
Naplati
|
Naplati
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -987,7 +987,7 @@
|
|||||||
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.FiskalniRacun.Brojac}}</p>
|
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.FiskalniRacun.Brojac}}</p>
|
||||||
{{if .FiskalniRacun.UrlVerifikacija}}
|
{{if .FiskalniRacun.UrlVerifikacija}}
|
||||||
<p style="margin:4px 0 0 0;font-size:13px;">
|
<p style="margin:4px 0 0 0;font-size:13px;">
|
||||||
<a href="{{.FiskalniRacun.UrlVerifikacija}}" target="_blank" rel="noopener noreferrer" style="color:var(--sb-akcent);">Provera na PURS-u →</a>
|
<a href="{{.FiskalniRacun.UrlVerifikacija}}" target="_blank" rel="noopener noreferrer" style="color:var(--sb-akcent);">Otvori QR verifikaciju (test) →</a>
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user