Kartica emulator (BE) + fiskalna verifikacija + ime/prezime korisnika
- internal/be: emulator kartice (TCP listener za L-PFR mock)
- /v/: javna stranica za verifikaciju fiskalnog računa (QR kod)
- /servis/{id}/fiskalni-racun: štampa fiskalnog računa
- Podešavanja fiskalizacije: BE status/reset, proširen UI
- Korisnik: polja ime i prezime (model, DB, handler, admin profil)
- Fisk server: ažuriran receipt.py i server.py
- Koncept.md, Magacin.md: dokumentacija
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// Package be implementira emulator bezbednosnog elementa (pametne kartice).
|
||||
// Sluša na TCP portu i obrađuje 4 JSON komande: status, certificate, verify_pin, sign.
|
||||
// Podatke o firmi čita iz NTech SQLite baze pri pokretanju.
|
||||
package be
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tipExt mapira (invoiceType, transactionType) → (ćirilični sufiks, ključ brojača)
|
||||
var tipExt = map[[2]string][2]string{
|
||||
{"Normal", "Sale"}: {"ПП", "pp"},
|
||||
{"Normal", "Refund"}: {"ПР", "pr"},
|
||||
{"Advance", "Sale"}: {"АП", "ap"},
|
||||
{"Advance", "Refund"}: {"АР", "ar"},
|
||||
{"Copy", "Sale"}: {"КП", "kp"},
|
||||
{"Copy", "Refund"}: {"КР", "kr"},
|
||||
{"Training", "Sale"}: {"ОП", "op"},
|
||||
{"Training", "Refund"}: {"ОР", "or"},
|
||||
{"Proforma", "Sale"}: {"РП", "rp"},
|
||||
{"Proforma", "Refund"}: {"РР", "rr"},
|
||||
}
|
||||
|
||||
// Kartica drži stanje emuliranog bezbednosnog elementa.
|
||||
type Kartica struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// identitet (zamrznuto pri "personalizaciji" — čitamo iz DB pri pokretanju)
|
||||
JID string
|
||||
PIN string
|
||||
TIN string // RS+PIB
|
||||
TINPlain string // samo PIB
|
||||
Name string
|
||||
Address string
|
||||
City string
|
||||
District string
|
||||
BusinessUnitID string
|
||||
LocationName string
|
||||
ValidFrom string
|
||||
ValidTo string
|
||||
Issuer string
|
||||
|
||||
// stanje u toku rada
|
||||
pinUnesen bool
|
||||
totalCounter int
|
||||
counters map[string]int
|
||||
Limit float64
|
||||
unreadAmount float64
|
||||
}
|
||||
|
||||
func novaKartica(db *sql.DB) *Kartica {
|
||||
k := &Kartica{
|
||||
JID: env("BE_JID", "TRNMOCK1"),
|
||||
PIN: env("BE_PIN", "1234"),
|
||||
Limit: envFloat("BE_LIMIT", 500000),
|
||||
Issuer: "Poreska uprava RS",
|
||||
counters: map[string]int{
|
||||
"pp": 0, "pr": 0, "ap": 0, "ar": 0,
|
||||
"kp": 0, "kr": 0, "op": 0, "or": 0,
|
||||
"rp": 0, "rr": 0,
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
k.ValidFrom = now.Format("2006-01-02T00:00:00+01:00")
|
||||
k.ValidTo = now.AddDate(3, 0, 0).Format("2006-01-02T00:00:00+01:00")
|
||||
|
||||
// čitaj podatke firme, PIN i limit iz baze (env var ima prednost)
|
||||
k.ucitajFirmu(db)
|
||||
|
||||
slog.Info("kartica emulator inicijalizovan",
|
||||
"jid", k.JID,
|
||||
"tin", k.TINPlain,
|
||||
"firma", k.Name,
|
||||
"limit", k.Limit,
|
||||
)
|
||||
return k
|
||||
}
|
||||
|
||||
func (k *Kartica) ucitajFirmu(db *sql.DB) {
|
||||
rows, err := db.Query(
|
||||
"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')",
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("be: ne mogu da čitam podesavanja", "error", err)
|
||||
k.postaviTestPodatke()
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
p := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k2, v string
|
||||
if err := rows.Scan(&k2, &v); err == nil {
|
||||
p[k2] = v
|
||||
}
|
||||
}
|
||||
|
||||
naziv := p["naziv_firme"]
|
||||
if naziv == "" {
|
||||
naziv = "Test Company DOO"
|
||||
}
|
||||
pib := p["pib"]
|
||||
if pib == "" {
|
||||
pib = "123456789"
|
||||
}
|
||||
|
||||
k.Name = naziv
|
||||
k.TINPlain = pib
|
||||
k.TIN = "RS" + pib
|
||||
k.Address = p["adresa"]
|
||||
if k.Address == "" {
|
||||
k.Address = "Test Adresa 1"
|
||||
}
|
||||
k.City = p["grad"]
|
||||
if k.City == "" {
|
||||
k.City = "Beograd"
|
||||
}
|
||||
k.District = p["opstina"]
|
||||
if k.District == "" {
|
||||
k.District = "Savski Venac"
|
||||
}
|
||||
k.LocationName = p["poslovna_jedinica_naziv"]
|
||||
if k.LocationName == "" {
|
||||
k.LocationName = naziv
|
||||
}
|
||||
k.BusinessUnitID = p["poslovna_jedinica_oznaka"]
|
||||
if k.BusinessUnitID == "" {
|
||||
k.BusinessUnitID = "BU-001"
|
||||
}
|
||||
|
||||
// PIN i limit iz baze — samo ako env var nije postavljen
|
||||
if os.Getenv("BE_PIN") == "" {
|
||||
if pin := p["be_pin"]; pin != "" {
|
||||
k.PIN = pin
|
||||
}
|
||||
}
|
||||
if os.Getenv("BE_LIMIT") == "" {
|
||||
if lim := p["be_limit"]; lim != "" {
|
||||
if f, err := strconv.ParseFloat(lim, 64); err == nil && f > 0 {
|
||||
k.Limit = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Kartica) postaviTestPodatke() {
|
||||
k.Name = "Test Company DOO"
|
||||
k.TINPlain = "123456789"
|
||||
k.TIN = "RS123456789"
|
||||
k.Address = "Test Adresa 1"
|
||||
k.City = "Beograd"
|
||||
k.District = "Savski Venac"
|
||||
k.LocationName = "Test Company DOO"
|
||||
k.BusinessUnitID = "BU-001"
|
||||
}
|
||||
|
||||
// ── Komande ────────────────────────────────────────────────
|
||||
|
||||
func (k *Kartica) cmdStatus() map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
counters := map[string]int{}
|
||||
for key, val := range k.counters {
|
||||
counters[key] = val
|
||||
}
|
||||
return map[string]any{
|
||||
"status": "ok",
|
||||
"total_counter": k.totalCounter,
|
||||
"counters": counters,
|
||||
"limit": k.Limit,
|
||||
"unread_amount": k.unreadAmount,
|
||||
"pin_required": !k.pinUnesen,
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Kartica) cmdCertificate() map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
return map[string]any{
|
||||
"status": "ok",
|
||||
"jid": k.JID,
|
||||
"tin": k.TIN,
|
||||
"tin_plain": k.TINPlain,
|
||||
"name": k.Name,
|
||||
"address": k.Address,
|
||||
"city": k.City,
|
||||
"district": k.District,
|
||||
"business_unit_id": k.BusinessUnitID,
|
||||
"location_name": k.LocationName,
|
||||
"valid_from": k.ValidFrom,
|
||||
"valid_to": k.ValidTo,
|
||||
"issuer": k.Issuer,
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Kartica) cmdVerifyPin(pin string) map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if pin != k.PIN {
|
||||
return map[string]any{"status": "error", "code": "2100", "message": "Pogrešan PIN"}
|
||||
}
|
||||
k.pinUnesen = true
|
||||
return map[string]any{"status": "ok"}
|
||||
}
|
||||
|
||||
func (k *Kartica) cmdResetAudit() map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.unreadAmount = 0
|
||||
return map[string]any{"status": "ok", "unread_amount": 0}
|
||||
}
|
||||
|
||||
func (k *Kartica) cmdSign(invoiceType, transactionType string, totalAmount float64) map[string]any {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
|
||||
// blokada limite
|
||||
if k.unreadAmount >= k.Limit {
|
||||
return map[string]any{
|
||||
"status": "blocked",
|
||||
"message": fmt.Sprintf("Limit iščitavanja dostignut (%.2f / %.2f)", k.unreadAmount, k.Limit),
|
||||
}
|
||||
}
|
||||
|
||||
// određi tip i ext
|
||||
ext, tipKey := "", "pp"
|
||||
if v, ok := tipExt[[2]string{invoiceType, transactionType}]; ok {
|
||||
ext = v[0]
|
||||
tipKey = v[1]
|
||||
} else {
|
||||
ext = "ПП"
|
||||
tipKey = "pp"
|
||||
}
|
||||
|
||||
k.totalCounter++
|
||||
k.counters[tipKey]++
|
||||
typCounter := k.counters[tipKey]
|
||||
|
||||
// za fiskalne račune akumuliraj neisčitani iznos
|
||||
nonFiscal := strings.HasPrefix(tipKey, "k") || strings.HasPrefix(tipKey, "o") || strings.HasPrefix(tipKey, "r")
|
||||
if !nonFiscal && transactionType == "Sale" {
|
||||
k.unreadAmount += totalAmount
|
||||
} else if !nonFiscal && transactionType == "Refund" {
|
||||
k.unreadAmount -= totalAmount
|
||||
if k.unreadAmount < 0 {
|
||||
k.unreadAmount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// lažni potpis — random base64 dovoljno dug da izgleda realistično
|
||||
sig := mockPotpis()
|
||||
|
||||
return map[string]any{
|
||||
"status": "ok",
|
||||
"counter": k.totalCounter,
|
||||
"counter_extension": ext,
|
||||
"type_counter": typCounter,
|
||||
"signature": sig,
|
||||
"blocked": false,
|
||||
}
|
||||
}
|
||||
|
||||
// mockPotpis generiše 64-bajtni nasumični base64 string koji imitira RSA potpis.
|
||||
func mockPotpis() string {
|
||||
b := make([]byte, 64)
|
||||
for i := range b {
|
||||
b[i] = byte(rand.IntN(256))
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package be
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
)
|
||||
|
||||
// JeUkljucen vraća true ako emulator treba da se pokrene.
|
||||
// Prioritet: env var BE_ENABLED > DB podesavanje be_enabled > podrazumevano true.
|
||||
func JeUkljucen(db *sql.DB) bool {
|
||||
if v := os.Getenv("BE_ENABLED"); v != "" {
|
||||
return v != "false" && v != "0"
|
||||
}
|
||||
var vrednost string
|
||||
_ = db.QueryRow("SELECT vrednost FROM podesavanja WHERE kljuc='be_enabled'").Scan(&vrednost)
|
||||
return vrednost != "false" && vrednost != "0"
|
||||
}
|
||||
|
||||
// Pokreni startuje TCP listener za kartica emulator.
|
||||
// Blokira dok listener ne padne — pozivati kao goroutine.
|
||||
func Pokreni(db *sql.DB) {
|
||||
port := env("BE_PORT", "4567")
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
slog.Error("be: ne mogu da pokrenem listener", "addr", addr, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("kartica emulator sluša", "addr", addr)
|
||||
|
||||
k := novaKartica(db)
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
slog.Warn("be: greška pri Accept()", "error", err)
|
||||
continue
|
||||
}
|
||||
go obradiKonekciju(conn, k)
|
||||
}
|
||||
}
|
||||
|
||||
func obradiKonekciju(conn net.Conn, k *Kartica) {
|
||||
defer conn.Close()
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
odgovori(conn, map[string]any{"status": "error", "message": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd, _ := req["command"].(string)
|
||||
var resp map[string]any
|
||||
|
||||
switch cmd {
|
||||
case "status":
|
||||
resp = k.cmdStatus()
|
||||
|
||||
case "certificate":
|
||||
resp = k.cmdCertificate()
|
||||
|
||||
case "verify_pin":
|
||||
pin, _ := req["pin"].(string)
|
||||
resp = k.cmdVerifyPin(pin)
|
||||
|
||||
case "reset_audit":
|
||||
resp = k.cmdResetAudit()
|
||||
|
||||
case "sign":
|
||||
invType, _ := req["invoice_type"].(string)
|
||||
txType, _ := req["transaction_type"].(string)
|
||||
totalAmount, _ := req["total_amount"].(float64)
|
||||
resp = k.cmdSign(invType, txType, totalAmount)
|
||||
|
||||
default:
|
||||
resp = map[string]any{"status": "error", "message": fmt.Sprintf("nepoznata komanda: %q", cmd)}
|
||||
}
|
||||
|
||||
odgovori(conn, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func odgovori(conn net.Conn, resp map[string]any) {
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b = append(b, '\n')
|
||||
conn.Write(b)
|
||||
}
|
||||
@@ -277,6 +277,7 @@ type KorisniciRepository interface {
|
||||
Lista(ctx context.Context) ([]model.Korisnik, error)
|
||||
AzurirajUlogu(ctx context.Context, id int64, uloga string) error
|
||||
AzurirajAktivan(ctx context.Context, id int64, aktivan bool) error
|
||||
AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error
|
||||
PromeniLozinku(ctx context.Context, id int64, hash string) error
|
||||
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
|
||||
SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error
|
||||
|
||||
@@ -33,6 +33,8 @@ type korisnikOpcije struct {
|
||||
lokalnaAnimacija sql.NullString
|
||||
lokalniHover sql.NullString
|
||||
lokalnaBrzinaAnimacije sql.NullString
|
||||
ime sql.NullString
|
||||
prezime sql.NullString
|
||||
}
|
||||
|
||||
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik
|
||||
@@ -50,6 +52,8 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) {
|
||||
k.LokalnaAnimacija = o.lokalnaAnimacija.String
|
||||
k.LokalniHover = o.lokalniHover.String
|
||||
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
|
||||
k.Ime = o.ime.String
|
||||
k.Prezime = o.prezime.String
|
||||
}
|
||||
|
||||
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
|
||||
@@ -62,6 +66,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
|
||||
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
|
||||
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
|
||||
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
|
||||
&o.ime, &o.prezime,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -107,7 +112,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
COALESCE(lokalna_brzina_animacije, ''),
|
||||
COALESCE(ime, ''), COALESCE(prezime, '')
|
||||
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
|
||||
k, err := skeniraiKorisnika(row)
|
||||
if err != nil {
|
||||
@@ -125,7 +131,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
COALESCE(lokalna_brzina_animacije, ''),
|
||||
COALESCE(ime, ''), COALESCE(prezime, '')
|
||||
FROM korisnici WHERE id = ?`, id)
|
||||
k, err := skeniraiKorisnika(row)
|
||||
if err != nil {
|
||||
@@ -143,7 +150,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
|
||||
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
|
||||
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''),
|
||||
COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''),
|
||||
COALESCE(lokalna_brzina_animacije, '')
|
||||
COALESCE(lokalna_brzina_animacije, ''),
|
||||
COALESCE(ime, ''), COALESCE(prezime, '')
|
||||
FROM korisnici ORDER BY datum_kreiranja ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
@@ -159,6 +167,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
|
||||
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
|
||||
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
|
||||
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
|
||||
&o.ime, &o.prezime,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
|
||||
}
|
||||
@@ -169,6 +178,15 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
|
||||
return lista, nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) AzurirajImePrezime(ctx context.Context, id int64, ime, prezime string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE korisnici SET ime = ?, prezime = ? WHERE id = ?`, ime, prezime, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: korisnici.AzurirajImePrezime: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sqliteKorisniciRepo) SacuvajAvatar(ctx context.Context, id int64, putanja string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE korisnici SET avatar_putanja = ? WHERE id = ?`, putanja, id)
|
||||
|
||||
@@ -36,6 +36,8 @@ type podaciAdminProfil struct {
|
||||
LokalnaTema string
|
||||
KoristiLokalnuTemu bool
|
||||
JelDemo bool
|
||||
Ime string
|
||||
Prezime string
|
||||
}
|
||||
|
||||
type podaciProfilTema struct {
|
||||
@@ -310,6 +312,8 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
||||
LokalnaTema: svezi.LokalnaTema,
|
||||
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu,
|
||||
JelDemo: h.JelDemo,
|
||||
Ime: svezi.Ime,
|
||||
Prezime: svezi.Prezime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -405,6 +409,29 @@ func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/profil?sacuvano=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminSacuvajImePrezime snima ime i prezime prijavljenog korisnika
|
||||
func (h *Handler) AdminSacuvajImePrezime(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
if k == nil {
|
||||
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ime := r.FormValue("ime")
|
||||
prezime := r.FormValue("prezime")
|
||||
if err := h.KorisniciRepo.AzurirajImePrezime(r.Context(), k.ID, ime, prezime); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Ime i prezime su sačuvani.")
|
||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AdminTotpPokreni generiše TOTP tajnu i prikazuje QR kod
|
||||
func (h *Handler) AdminTotpPokreni(w http.ResponseWriter, r *http.Request) {
|
||||
k := middleware.KorisnikIzKonteksta(r.Context())
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// FiskalVlPodaci su podaci dekodirani iz vl parametra QR koda.
|
||||
type FiskalVlPodaci struct {
|
||||
InvoiceNumber string `json:"n"`
|
||||
InvoiceCounter string `json:"ic"`
|
||||
DateTime string `json:"t"`
|
||||
TotalAmount float64 `json:"a"`
|
||||
TIN string `json:"c"`
|
||||
Company string `json:"co"`
|
||||
Store string `json:"lo"`
|
||||
Address string `json:"ad"`
|
||||
City string `json:"g"`
|
||||
District string `json:"di"`
|
||||
InvoiceType string `json:"it"`
|
||||
TransactionType string `json:"tr"`
|
||||
TaxItems []FiskalTaxItem `json:"tx"`
|
||||
Payments []FiskalPayment `json:"pm"`
|
||||
Cashier string `json:"ca"`
|
||||
BuyerID string `json:"bi"`
|
||||
Items []FiskalItem `json:"items"`
|
||||
}
|
||||
|
||||
type FiskalTaxItem struct {
|
||||
Label string `json:"label"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Rate float64 `json:"rate"`
|
||||
Base float64 `json:"base"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type FiskalPayment struct {
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type FiskalItem struct {
|
||||
Name string `json:"name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
TotalAmount float64 `json:"totalAmount"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
|
||||
type fiskalVerifikacijaPodaci struct {
|
||||
Podaci *FiskalVlPodaci
|
||||
Greska string
|
||||
JeFiskalni bool
|
||||
}
|
||||
|
||||
func (h *Handler) FiskalVerifikacija(w http.ResponseWriter, r *http.Request) {
|
||||
vl := r.URL.Query().Get("vl")
|
||||
|
||||
podaci := &fiskalVerifikacijaPodaci{}
|
||||
|
||||
if vl == "" {
|
||||
podaci.Greska = "Nedostaje vl parametar."
|
||||
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
||||
return
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(vl)
|
||||
if err != nil {
|
||||
// proba RawStdEncoding (bez paddinga) i URLEncoding
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(vl)
|
||||
if err != nil {
|
||||
podaci.Greska = "Neispravan QR kod."
|
||||
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var vlPodaci FiskalVlPodaci
|
||||
if err := json.Unmarshal(decoded, &vlPodaci); err != nil {
|
||||
podaci.Greska = "Neispravan format podataka."
|
||||
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
||||
return
|
||||
}
|
||||
|
||||
podaci.Podaci = &vlPodaci
|
||||
podaci.JeFiskalni = vlPodaci.InvoiceType != "Copy" &&
|
||||
vlPodaci.InvoiceType != "Training" &&
|
||||
vlPodaci.InvoiceType != "Proforma"
|
||||
|
||||
h.renderujStandalone(w, "fiskal_verifikacija", podaci)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ var saSidebar = []string{
|
||||
|
||||
// standalone su šabloni bez base layouta
|
||||
var standaloneIme = []string{
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list",
|
||||
"prijava", "setup", "totp_provera", "prodaja_stampa", "servis_radni_nalog", "servis_otpremnica", "servis_revers", "servis_predracun", "servis_nalepnica", "servis_status_javni", "servis_garantni_list", "fiskal_verifikacija",
|
||||
}
|
||||
|
||||
// sablonskeFunkcije su pomoćne funkcije dostupne u svim šablonima.
|
||||
|
||||
@@ -76,6 +76,12 @@ type PodaciPodesavanja struct {
|
||||
QrBazniUrl string
|
||||
PfrUrl string
|
||||
PfrTip string
|
||||
PfrKasir string
|
||||
VerifyHost string
|
||||
BePin string
|
||||
BeLimit string
|
||||
BeEnabled string
|
||||
FiskalPismo string
|
||||
LoginPozadina string
|
||||
LoginPozadinaOpacity string
|
||||
LoginPozadinaBlurPozadine string
|
||||
@@ -331,9 +337,15 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
"firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"),
|
||||
"firma_fiskalizacija": r.FormValue("firma_fiskalizacija"),
|
||||
"firma_rezim": r.FormValue("firma_rezim"),
|
||||
// fiskalizacija — L-PFR podešavanja
|
||||
"pfr_url": r.FormValue("pfr_url"),
|
||||
"pfr_tip": r.FormValue("pfr_tip"),
|
||||
// fiskalizacija — L-PFR i kartica emulator podešavanja
|
||||
"pfr_url": r.FormValue("pfr_url"),
|
||||
"pfr_tip": r.FormValue("pfr_tip"),
|
||||
"pfr_kasir": r.FormValue("pfr_kasir"),
|
||||
"verify_host": r.FormValue("verify_host"),
|
||||
"be_pin": r.FormValue("be_pin"),
|
||||
"be_limit": r.FormValue("be_limit"),
|
||||
"be_enabled": r.FormValue("be_enabled"),
|
||||
"fiskalni_pismo": r.FormValue("fiskalni_pismo"),
|
||||
}
|
||||
|
||||
for kljuc, vrednost := range polja {
|
||||
@@ -865,6 +877,12 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
|
||||
QrBazniUrl: podesavanja["qr_bazni_url"],
|
||||
PfrUrl: vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566"),
|
||||
PfrTip: vrednostIliDefault(podesavanja, "pfr_tip", "teron"),
|
||||
PfrKasir: podesavanja["pfr_kasir"],
|
||||
VerifyHost: podesavanja["verify_host"],
|
||||
BePin: vrednostIliDefault(podesavanja, "be_pin", "1234"),
|
||||
BeLimit: vrednostIliDefault(podesavanja, "be_limit", "500000"),
|
||||
BeEnabled: vrednostIliDefault(podesavanja, "be_enabled", "true"),
|
||||
FiskalPismo: vrednostIliDefault(podesavanja, "fiskalni_pismo", "latin"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -968,6 +986,95 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
|
||||
</div>`, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime))
|
||||
}
|
||||
|
||||
// BeStatus se spaja na lokalni kartica emulator (TCP :4567) i vraća HTMX fragment sa statusom.
|
||||
func (h *Handler) BeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
bePort := os.Getenv("BE_PORT")
|
||||
if bePort == "" {
|
||||
bePort = "4567"
|
||||
}
|
||||
addr := "127.0.0.1:" + bePort
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Emulator nije dostupan na %s — %s</div>`,
|
||||
html.EscapeString(addr), html.EscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cmd := `{"command":"status"}` + "\n"
|
||||
conn.Write([]byte(cmd))
|
||||
conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := conn.Read(buf)
|
||||
conn.Close()
|
||||
|
||||
var st map[string]any
|
||||
if err := json.Unmarshal(buf[:n], &st); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Neispravan odgovor</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := st["total_counter"].(float64)
|
||||
unread, _ := st["unread_amount"].(float64)
|
||||
limit, _ := st["limit"].(float64)
|
||||
pinReq, _ := st["pin_required"].(bool)
|
||||
|
||||
pinTekst := "PIN unesen"
|
||||
if pinReq {
|
||||
pinTekst = "Čeka PIN"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div class="fisk-status uspeh">
|
||||
✓ Kartica emulator aktivan
|
||||
<div class="fisk-status-detalji">
|
||||
<span>Ukupno računa: %d</span>
|
||||
<span>Neisčitano: %s / %s din</span>
|
||||
<span>%s</span>
|
||||
</div>
|
||||
</div>`,
|
||||
int(total),
|
||||
html.EscapeString(formatirajDinare(unread, 0)),
|
||||
html.EscapeString(formatirajDinare(limit, 0)),
|
||||
html.EscapeString(pinTekst),
|
||||
)
|
||||
}
|
||||
|
||||
// BeResetAudit šalje reset_audit komandu kartica emulatoru i vraća HTMX fragment.
|
||||
func (h *Handler) BeResetAudit(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmena"); !ok {
|
||||
return
|
||||
}
|
||||
bePort := os.Getenv("BE_PORT")
|
||||
if bePort == "" {
|
||||
bePort = "4567"
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+bePort, 2*time.Second)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div class="fisk-status greska">✗ Emulator nije dostupan</div>`)
|
||||
return
|
||||
}
|
||||
conn.Write([]byte(`{"command":"reset_audit"}` + "\n"))
|
||||
conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 512)
|
||||
n, _ := conn.Read(buf)
|
||||
conn.Close()
|
||||
|
||||
var resp map[string]any
|
||||
json.Unmarshal(buf[:n], &resp)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div class="fisk-status uspeh">✓ Neisčitani iznos resetovan na 0 (Proof of Audit simuliran)</div>`)
|
||||
}
|
||||
|
||||
// jePrivatnaAdresa proverava da li je hostname u opsegu privatnih/lokalnih mreža.
|
||||
// Dozvoljeni: 127.x.x.x, localhost, 10.x.x.x, 172.16-31.x.x, 192.168.x.x.
|
||||
func jePrivatnaAdresa(hostname string) bool {
|
||||
|
||||
+79
-34
@@ -526,23 +526,37 @@ func (h *Handler) DetaljiNaloga(w http.ResponseWriter, r *http.Request) {
|
||||
// self-heal: ako u međuvremenu ima stanja na magacinu za potraživane artikle,
|
||||
// povuci ih (delimično ili u celosti) i otključaj nalog ako je sve pokriveno.
|
||||
// Hvata sve načine na koje stanje poraste, ne samo nabavku/izmenu/popis.
|
||||
if potrazivani, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil && len(potrazivani) > 0 {
|
||||
vidjeni := map[int64]bool{}
|
||||
for _, p := range potrazivani {
|
||||
if vidjeni[p.ArtikalID] {
|
||||
continue
|
||||
// Samo predlozeno=false redovi blokiraju nalog — predloženi se ignorišu.
|
||||
if potrazivaniSvi, e := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id); e == nil {
|
||||
var potrazivani []model.ServisniPotrazivaniDeo
|
||||
for _, p := range potrazivaniSvi {
|
||||
if !p.Predlozeno {
|
||||
potrazivani = append(potrazivani, p)
|
||||
}
|
||||
vidjeni[p.ArtikalID] = true
|
||||
otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID)
|
||||
if err != nil {
|
||||
slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(potrazivani) == 0 && nalog.Status == model.StatusCekaDelove {
|
||||
// nema predlozeno=0 redova koji blokiraju — resetuj status odmah
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), id, model.StatusPrimljeno); err != nil {
|
||||
slog.Error("self-heal reset statusa (nema predlozeno=0) nije uspeo", "nalog_id", id, "error", err)
|
||||
}
|
||||
for _, nalogID := range otkljucani {
|
||||
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
|
||||
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
|
||||
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
|
||||
} else if len(potrazivani) > 0 {
|
||||
vidjeni := map[int64]bool{}
|
||||
for _, p := range potrazivani {
|
||||
if vidjeni[p.ArtikalID] {
|
||||
continue
|
||||
}
|
||||
vidjeni[p.ArtikalID] = true
|
||||
otkljucani, err := h.ServisniPotrazivaniDeloviRepo.ProveriIPocistiZaArtikal(r.Context(), p.ArtikalID)
|
||||
if err != nil {
|
||||
slog.Error("self-heal potraživanih delova nije uspeo", "artikal_id", p.ArtikalID, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, nalogID := range otkljucani {
|
||||
// reset samo ako je nalog čekao delove — ne sme da menja U dijagnostici, U popravci itd.
|
||||
if tekuci, e := h.ServisRepo.DohvatiID(r.Context(), nalogID); e == nil && tekuci != nil && tekuci.Status == model.StatusCekaDelove {
|
||||
if err := h.ServisRepo.AzurirajStatus(r.Context(), nalogID, model.StatusPrimljeno); err != nil {
|
||||
slog.Error("self-heal reset statusa naloga nije uspeo", "nalog_id", nalogID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1929,11 +1943,19 @@ func (h *Handler) PromeniStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi —
|
||||
// prvo nabaviti delove, pa obrisati iz potraživanih (ili kroz nabavku)
|
||||
// zabrana izlaska iz „Čeka delove" dok postoje potraživani delovi (predlozeno=false) —
|
||||
// predloženi delovi (predlozeno=true) ne blokiraju nalog
|
||||
if e == nil && trenutni.Status == model.StatusCekaDelove && noviStatus != model.StatusCekaDelove {
|
||||
potrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
if err == nil && len(potrazivani) > 0 {
|
||||
sviPotrazivani, err := h.ServisniPotrazivaniDeloviRepo.DohvatiZaNalog(r.Context(), id)
|
||||
var blokirajuci int
|
||||
if err == nil {
|
||||
for _, p := range sviPotrazivani {
|
||||
if !p.Predlozeno {
|
||||
blokirajuci++
|
||||
}
|
||||
}
|
||||
}
|
||||
if blokirajuci > 0 {
|
||||
middleware.SetFlash(w, r, h.DB, "greska",
|
||||
"Nalog ne može da napusti „Čeka delove\" dok ima delova koji nedostaju. Obrišite ih iz tabele ili dopunite zalihe.")
|
||||
http.Redirect(w, r, "/servis/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||
@@ -2096,7 +2118,17 @@ func (h *Handler) fiskalizujServis(ctx context.Context, servisID int64, klijent
|
||||
}
|
||||
}
|
||||
|
||||
kasir, _ := sqlite.DohvatiPodesavanje(ctx, h.DB, "pfr_kasir")
|
||||
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"
|
||||
}
|
||||
@@ -2159,11 +2191,6 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
nalog, _ := h.ServisRepo.DohvatiID(r.Context(), id)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
|
||||
<style>body{font-family:monospace;font-size:12px;white-space:pre;padding:20px;max-width:400px;margin:0 auto;}
|
||||
@media print{body{font-size:11px;padding:10px}}</style></head><body>`)
|
||||
|
||||
journal := ""
|
||||
if fr.SiroviOdgovor != "" {
|
||||
var raw map[string]any
|
||||
@@ -2173,17 +2200,35 @@ func (h *Handler) StampaFiskalnog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// zameni QR placeholder pravim QR kodom
|
||||
if fr.QRKod != "" && strings.Contains(journal, "{{{{QR-KOD}}}}") {
|
||||
qrImg := `<img src="data:image/png;base64,` + fr.QRKod + `" style="width:25mm;height:25mm;display:block;margin:8px auto;">`
|
||||
journal = strings.ReplaceAll(journal, "{{{{QR-KOD}}}}", qrImg)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, journal)
|
||||
if nalog != nil {
|
||||
fmt.Fprintf(w, "\n\n--- NTech servisni nalog: %s ---\n", nalog.BrojNaloga)
|
||||
journal += "\n\n--- NTech servisni nalog: " + nalog.BrojNaloga + " ---\n"
|
||||
}
|
||||
|
||||
// Podeli journal na deo pre i posle QR placeholder-a
|
||||
const qrPlaceholder = "{{{{QR-KOD}}}}"
|
||||
pre, post, hasQR := strings.Cut(journal, qrPlaceholder)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Fiskalni račun</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
body{font-family:monospace;font-size:12px;padding:20px;max-width:max-content;margin:0 auto;}
|
||||
pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;display:block;}
|
||||
@media print{body{font-size:11px;padding:10px;}}
|
||||
</style></head><body>`)
|
||||
|
||||
fmt.Fprint(w, `<pre>`)
|
||||
fmt.Fprint(w, pre)
|
||||
fmt.Fprint(w, `</pre>`)
|
||||
|
||||
if hasQR && fr.QRKod != "" {
|
||||
fmt.Fprintf(w, `<div style="margin:10px 0;"><img src="data:image/png;base64,%s" style="display:block;margin:0 auto;width:72mm;height:72mm;"></div>`, fr.QRKod)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, `<pre>`)
|
||||
fmt.Fprint(w, post)
|
||||
fmt.Fprint(w, `</pre>`)
|
||||
|
||||
fmt.Fprint(w, `<p style="margin-top:16px;"><button onclick="window.print()" style="padding:8px 16px;">Štampaj</button></p></body></html>`)
|
||||
}
|
||||
func (h *Handler) AzurirajGaranciju(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import "time"
|
||||
type Korisnik struct {
|
||||
ID int64
|
||||
KorisnickoIme string
|
||||
Ime string
|
||||
Prezime string
|
||||
LozinkaHash string
|
||||
Uloga string // "superadmin" | "admin" | "radnik"
|
||||
Aktivan bool
|
||||
|
||||
Reference in New Issue
Block a user