Compare commits
4
Commits
fd762a6527
...
3a0f98622b
| 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.Get("/prodaja/nova", h.NovaProdaja)
|
||||
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.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
||||
|
||||
+49
-1
@@ -4,8 +4,10 @@
|
||||
package be
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -14,6 +16,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ntech/internal/db/sqlite"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type Kartica struct {
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
|
||||
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
|
||||
JID string
|
||||
@@ -59,6 +64,7 @@ type Kartica struct {
|
||||
|
||||
func novaKartica(db *sql.DB) *Kartica {
|
||||
k := &Kartica{
|
||||
db: db,
|
||||
JID: env("BE_JID", "TRNMOCK1"),
|
||||
PIN: env("BE_PIN", "1234"),
|
||||
Limit: envFloat("BE_LIMIT", 500000),
|
||||
@@ -91,7 +97,7 @@ func (k *Kartica) ucitajFirmu(db *sql.DB) {
|
||||
"SELECT kljuc, vrednost FROM podesavanja WHERE kljuc IN " +
|
||||
"('naziv_firme','pib','maticni_broj','adresa','telefon'," +
|
||||
"'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 {
|
||||
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() {
|
||||
@@ -220,6 +265,7 @@ func (k *Kartica) cmdResetAudit() map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.unreadAmount = 0
|
||||
k.sacuvajBrojace()
|
||||
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
|
||||
sig := mockPotpis()
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ func (r *FiskalRepo) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*mo
|
||||
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||
FROM fiskalni_racuni WHERE prodaja_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, prodajaID).Scan(
|
||||
&fr.ID, &fr.ProdajaID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||
&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,
|
||||
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||
FROM fiskalni_racuni WHERE servis_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, servisID).Scan(
|
||||
&fr.ID, &pID, &sID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
||||
|
||||
@@ -20,10 +20,13 @@ func NoviProdajaRepo(db *sql.DB) *ProdajaRepo {
|
||||
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) {
|
||||
godina := time.Now().Year()
|
||||
uzorak := fmt.Sprintf("PR-%d-%%", godina)
|
||||
sada := time.Now()
|
||||
// prefiks "PR-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("PR-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
uzorak := prefiks + "%"
|
||||
|
||||
var sledeci int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
@@ -34,7 +37,7 @@ func (r *ProdajaRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
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
|
||||
|
||||
@@ -30,12 +30,12 @@ func NoviServisRepo(db *sql.DB) *ServisRepo {
|
||||
return &ServisRepo{db: db}
|
||||
}
|
||||
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-MMGG-NNN
|
||||
// (MM mesec, GG dvocifrena godina); brojač NNN se resetuje svakog meseca
|
||||
// SledeciBroj generiše sledeći broj naloga u formatu SN-GGMM-NNN
|
||||
// (GG dvocifrena godina, MM mesec); brojač NNN se resetuje svakog meseca
|
||||
func (r *ServisRepo) SledeciBroj(ctx context.Context) (string, error) {
|
||||
sada := time.Now()
|
||||
// prefiks "SN-MMGG-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("SN-%02d%02d-", int(sada.Month()), sada.Year()%100)
|
||||
// prefiks "SN-GGMM-" je dug 8 karaktera, pa brojač počinje od 9. karaktera
|
||||
prefiks := fmt.Sprintf("SN-%02d%02d-", sada.Year()%100, int(sada.Month()))
|
||||
uzorak := prefiks + "%"
|
||||
|
||||
// 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
|
||||
var poslednjeProdaje []model.StavkaProdajePregled
|
||||
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,
|
||||
PoslednjeProdaje: poslednjeProdaje,
|
||||
FlashGreska: flashGreska,
|
||||
PrazninaKnjige: prazninaKnjigovodstva,
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "dashboard", podaci)
|
||||
|
||||
@@ -52,6 +52,7 @@ type fiskalVerifikacijaPodaci struct {
|
||||
Podaci *FiskalVlPodaci
|
||||
Greska string
|
||||
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) {
|
||||
@@ -88,5 +89,13 @@ func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
|
||||
vlPodaci.InvoiceType != "Training" &&
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"ntech/internal/config"
|
||||
@@ -257,3 +258,25 @@ func (h *Handler) fiskalKlijent() *fiskal.Klijent {
|
||||
}
|
||||
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"
|
||||
"time"
|
||||
|
||||
appdb "ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
@@ -161,24 +160,28 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var kreirano, preskoceno int
|
||||
var kreirano, preskoceno, sumnjivo int
|
||||
|
||||
// prodajni nalozi
|
||||
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||
nalozi, prodajaPostoji, prodajaKandidati, err := h.kpoKandidatiProdaje(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prodajaDuplikati := sumnjiviDuplikati(prodajaKandidati)
|
||||
for _, nd := range nalozi {
|
||||
if nd.Stornirano {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
||||
if postoji {
|
||||
if prodajaPostoji[nd.ID] {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
if prodajaDuplikati[nd.ID] {
|
||||
sumnjivo++
|
||||
continue
|
||||
}
|
||||
z := model.KpoZapis{
|
||||
DatumPrometa: nd.Datum,
|
||||
BrojDokumenta: nd.BrojNaloga,
|
||||
@@ -196,18 +199,19 @@ func (h *Handler) KpoBackfill(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// naplaćeni servisni nalozi
|
||||
servisNalozi, err := h.ServisRepo.Lista(ctx, "", "")
|
||||
naplaceni, servisPostoji, servisKandidati, nenaplaceniBroj, err := h.kpoKandidatiServisa(ctx)
|
||||
if err != nil {
|
||||
slog.Error("kpo backfill: greška pri učitavanju servisa", "error", err)
|
||||
} else {
|
||||
for _, sn := range servisNalozi {
|
||||
if sn.Status != model.StatusPreuzeto || sn.Naplaceno == 0 {
|
||||
preskoceno += nenaplaceniBroj
|
||||
servisDuplikati := sumnjiviDuplikati(servisKandidati)
|
||||
for _, sn := range naplaceni {
|
||||
if servisPostoji[sn.ID] {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
postoji, _ := h.KpoRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
|
||||
if postoji {
|
||||
preskoceno++
|
||||
if servisDuplikati[sn.ID] {
|
||||
sumnjivo++
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appdb "ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
@@ -223,23 +222,27 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
sada := time.Now()
|
||||
|
||||
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||
nalozi, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju prodaje", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
duplikati := sumnjiviDuplikati(kandidati)
|
||||
|
||||
var kreirano, preskoceno int
|
||||
var kreirano, preskoceno, sumnjivo int
|
||||
for _, nd := range nalozi {
|
||||
if nd.KlijentID == nil {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "prodaja", nd.ID)
|
||||
if postoji {
|
||||
if postojiMap[nd.ID] {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
if duplikati[nd.ID] {
|
||||
sumnjivo++
|
||||
continue
|
||||
}
|
||||
stavke, err := h.ProdajaRepo.DohvatiStavke(ctx, nd.ID)
|
||||
if err != nil {
|
||||
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++
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ type PodaciProdaje struct {
|
||||
model.PodaciStranice
|
||||
Nalozi []model.ProdajniNalogSaDetaljem
|
||||
Sacuvano bool
|
||||
Obrisan bool
|
||||
Pretraga string
|
||||
Od string
|
||||
Do string
|
||||
@@ -189,7 +188,6 @@ func (h *Handler) Prodaja(w http.ResponseWriter, r *http.Request) {
|
||||
PodaciStranice: ps,
|
||||
Nalozi: nalozi,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
Obrisan: r.URL.Query().Get("obrisan") == "1",
|
||||
Pretraga: pretraga,
|
||||
Od: od,
|
||||
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>`)
|
||||
}
|
||||
|
||||
// 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
|
||||
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) {
|
||||
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
|
||||
// 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.
|
||||
// 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 {
|
||||
// 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.
|
||||
@@ -735,10 +698,7 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
||||
if fk := h.fiskalKlijent(); fk != nil {
|
||||
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil {
|
||||
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil {
|
||||
kasirFisk, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
||||
if kasirFisk == "" {
|
||||
kasirFisk = "NTech"
|
||||
}
|
||||
kasirFisk := h.imeKasira(ctx)
|
||||
var pibFisk, jmbgFisk string
|
||||
if nalogFisk.KlijentID != 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
|
||||
}
|
||||
|
||||
kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
||||
if kasir == "" {
|
||||
kasir = "NTech"
|
||||
}
|
||||
kasir := h.imeKasira(ctx)
|
||||
|
||||
var pib, jmbg string
|
||||
if nalog.KlijentID != nil {
|
||||
|
||||
+10
-21
@@ -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") {
|
||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
if nalog != nil && !nalog.PopravkaOdbijena {
|
||||
if nalog != nil && !nalog.PopravkaOdbijena && nalog.KlijentID != nil {
|
||||
kupacNaziv, kupacPib, kupacMesto := "", "", ""
|
||||
if nalog.KlijentID != nil {
|
||||
if k, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
|
||||
kupacNaziv = k.PunoIme()
|
||||
kupacPib = k.PIB
|
||||
kupacMesto = k.Mesto
|
||||
if k, e := h.KlijentiRepo.DohvatiID(r.Context(), *nalog.KlijentID); e == nil {
|
||||
kupacNaziv = k.PunoIme()
|
||||
kupacPib = k.PIB
|
||||
if k.Tip != "pravno" {
|
||||
kupacPib = k.JMBG
|
||||
}
|
||||
kupacMesto = k.Mesto
|
||||
}
|
||||
radovi, _ := h.ServisniRadoviRepo.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 := ""
|
||||
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"
|
||||
}
|
||||
kasir := h.imeKasira(ctx)
|
||||
|
||||
zahtev := fiskal.InvoiceRequest{
|
||||
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",
|
||||
"prodaja.pregled",
|
||||
"prodaja.dodaj",
|
||||
"prodaja.obrisi",
|
||||
"prodaja.storno",
|
||||
"klijent.pregled",
|
||||
"klijent.dodaj",
|
||||
@@ -43,6 +42,9 @@ var sveAkcije = []string{
|
||||
"pdv.pregled",
|
||||
"pdv.dodaj",
|
||||
"pdv.obrisi",
|
||||
"kpo.pregled",
|
||||
"kpo.dodaj",
|
||||
"kpo.obrisi",
|
||||
}
|
||||
|
||||
// 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":
|
||||
return true
|
||||
// prodaja
|
||||
case "prodaja.pregled", "prodaja.dodaj", "prodaja.obrisi", "prodaja.storno":
|
||||
case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno":
|
||||
return true
|
||||
// klijent
|
||||
case "klijent.pregled", "klijent.dodaj",
|
||||
@@ -103,6 +105,9 @@ func ImaDozvolu(uloga, akcija string) bool {
|
||||
// PDV evidencija (KIR/KPR) — administrativno, radnik nema
|
||||
case "pdv.pregled", "pdv.dodaj", "pdv.obrisi":
|
||||
return true
|
||||
// KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema
|
||||
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestImaDozvoluRadnik(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// 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"}
|
||||
for _, a := range neSme {
|
||||
if ImaDozvolu("radnik", a) {
|
||||
|
||||
@@ -71,4 +71,23 @@ type PodaciDashboarda struct {
|
||||
KriticneZalihe []StavkaZalihe
|
||||
PoslednjeProdaje []StavkaProdajePregled
|
||||
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',
|
||||
primljenoIznos: '',
|
||||
prikaziRacun: true,
|
||||
saljemSe: false,
|
||||
_fiskalniTab: null,
|
||||
artikliOpcije: [],
|
||||
pretragaArtikal: '',
|
||||
@@ -358,6 +359,8 @@ document.addEventListener('alpine:init', () => {
|
||||
// posaljiProdaju uklanja prazne stavke (npr. automatski dodat red na kraju koji korisnik
|
||||
// nije popunio) pre nego što se forma stvarno pošalje na server
|
||||
posaljiProdaju(e) {
|
||||
if (this.saljemSe) return
|
||||
this.saljemSe = true
|
||||
this.stavke = this.stavke.filter(s => s.artikal_id)
|
||||
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>
|
||||
{{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 style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:9px 16px;font-size:13px;color:var(--tekst-glavni);">Storniranje prodaje</td>
|
||||
|
||||
@@ -19,6 +19,25 @@
|
||||
{{ if .FlashGreska }}
|
||||
<div class="poruka-greska animiraj" style="margin-bottom:16px;">{{ .FlashGreska }}</div>
|
||||
{{ 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">
|
||||
<!-- 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">
|
||||
|
||||
@@ -64,9 +64,11 @@
|
||||
/* podnožje */
|
||||
.podnozje { text-align: center; padding: 20px 16px 8px; font-size: 11px; color: #94a3b8; line-height: 1.6; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-traka">⚠ TESTNA STRANICA — lokalna simulacija (Teron mock), NIJE prava provera kod Poreske uprave RS</div>
|
||||
<div class="omotac">
|
||||
|
||||
{{if .Greska}}
|
||||
@@ -80,8 +82,8 @@
|
||||
{{if .JeFiskalni}}
|
||||
<div class="baner ok">
|
||||
<div class="baner-ikona">✓</div>
|
||||
<div class="baner-tekst">Račun je verifikovan</div>
|
||||
<div class="baner-podnaslov">Fiskalni račun je evidentiran u sistemu</div>
|
||||
<div class="baner-tekst">Podaci pročitani sa QR koda</div>
|
||||
<div class="baner-podnaslov">Testni prikaz — nije prava provera kod Poreske uprave RS</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="baner" style="background:#f59e0b;color:#1c1917;">
|
||||
@@ -201,6 +203,12 @@
|
||||
<span style="font-weight:600;">{{dinari .Amount}} din</span>
|
||||
</div>
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
@@ -222,7 +230,7 @@
|
||||
|
||||
<div class="podnozje">
|
||||
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>
|
||||
|
||||
{{end}}{{/* if .Greska */}}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<div style="display: flex; flex-direction: column; gap: 16px">
|
||||
{{if .Sacuvano}}
|
||||
<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}}
|
||||
|
||||
<!-- zaglavlje sa pretragom, filterima i dugmetom -->
|
||||
@@ -26,7 +24,7 @@
|
||||
</div>
|
||||
<div class="prodaja-traka-akcije" style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||
<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
|
||||
</label>
|
||||
<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">
|
||||
<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>
|
||||
{{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>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
|
||||
{{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>
|
||||
{{if .FiskalniRacun.UrlVerifikacija}}
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -315,27 +315,6 @@
|
||||
</div>
|
||||
{{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>
|
||||
|
||||
@@ -332,8 +332,8 @@
|
||||
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>
|
||||
</label>
|
||||
<button type="submit" class="btn-primarno" style="width:100%;margin-top:4px;" :disabled="!naplataValidna()"
|
||||
:style="!naplataValidna() ? 'opacity:0.4;cursor:not-allowed;' : ''" @click="pripremiFiskalniTab()">
|
||||
<button type="submit" class="btn-primarno" style="width:100%;margin-top:4px;" :disabled="!naplataValidna() || saljemSe"
|
||||
:style="(!naplataValidna() || saljemSe) ? 'opacity:0.4;cursor:not-allowed;' : ''" @click="pripremiFiskalniTab()">
|
||||
Naplati
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -987,7 +987,7 @@
|
||||
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.FiskalniRacun.Brojac}}</p>
|
||||
{{if .FiskalniRacun.UrlVerifikacija}}
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user