Compare commits

...
4 Commits
Author SHA1 Message Date
Dasko 3a0f98622b Knjigovodstvo i fiskalizacija: dashboard upozorenje za praznine, KPO dozvole, pojednostavljen storno, ispravke verifikacije i kasira
- Dashboard kartica prikazuje naloge bez KIR/KPO/fiskalnog upisa i sumnjive duplikate (double-submit), sa zaštitom od automatskog dupliranja prihoda pri bulk dopuni
- Registrovane nedostajuće KPO dozvole (kpo.pregled/dodaj/obrisi) — modul je bio potpuno nedostupan
- Uklonjen suvišan/nefunkcionalan 'Ukloni nalog' (duplirao Storno bez stvarne razlike); Storno u listi prodaje sad vodi direktno na formu
- Kasir na fiskalnom računu koristi ime prijavljenog korisnika umesto fiksnog podešavanja
- Fiskalni računi za nalog uvek prikazuju najnoviji zapis (original vs. refund posle storna)
- Verifikaciona stranica (/v/) jasno označena kao lokalna test simulacija, ne prava provera kod PURS-a; dodat prikaz kusura
- Mock brojač fiskalnih računa (kartica emulator) sad preživljava restart servera
2026-07-01 23:01:26 +02:00
Dasko fcae2ae441 Prodaja/Servis: broj naloga u formatu GGMM (godina-mesec), mesečni brojač 2026-07-01 23:01:10 +02:00
Dasko 74d90cf2f2 Prodaja: sprečen dupli submit forme naplate (double-submit bag) 2026-07-01 23:01:06 +02:00
Dasko 16d84fe27e Servis: KIR upis usklađen sa Prodajom (preskoči bez klijenta, JMBG fallback za fizička lica) 2026-07-01 21:14:14 +02:00
26 changed files with 508 additions and 141 deletions
-1
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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,
+7 -4
View File
@@ -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
+4 -4
View File
@@ -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
+8
View File
@@ -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)
+9
View File
@@ -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)
}
+23
View File
@@ -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
View File
@@ -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)
}
+13 -6
View File
@@ -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)
}
+2 -45
View File
@@ -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
View File
@@ -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{
+229
View File
@@ -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
}
+7 -2
View File
@@ -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
+1 -1
View File
@@ -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) {
+19
View File
@@ -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);
+3
View File
@@ -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
View File
@@ -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 */}}
+1 -9
View File
@@ -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>
+2 -23
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>