Fiskalizacija: inicijalna implementacija (8 koraka)
1. migracija 081 — tabela fiskalni_racuni + podešavanja pfr_api_key/pfr_kasir 2. model FiskalniRacun (internal/model/fiskal.go) 3. FiskalRepository interfejs (repository.go) 4. SQLite repozitorijum (sqlite/fiskal.go — Kreiraj, DohvatiPoProdaji) 5. Paket internal/fiskal/ — HTTP klijent + mapiranje prodaja→invoiceRequest 6. Handler proširenje — FiskalRepo + fiskalKlijent() dinamički helper 7. Inicijalizacija kroz Novi() / reinicializuj — klijent se čita dinamički iz podešavanja 8. Prodaja integracija — fiskalizacija u SacuvajProdaju + QR prikaz u prodaja_detalji.html
This commit is contained in:
@@ -367,3 +367,10 @@ type PodsetnikRepository interface {
|
|||||||
Obrisi(ctx context.Context, id int64) error
|
Obrisi(ctx context.Context, id int64) error
|
||||||
BrojAktivnih(ctx context.Context, filter PodsetnikFilter) (int, error)
|
BrojAktivnih(ctx context.Context, filter PodsetnikFilter) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FiskalRepository definiše operacije nad fiskalnim računima (Teron L-PFR).
|
||||||
|
// Svaki prodajni nalog ima najviše jedan fiskalni račun (UNIQUE prodaja_id).
|
||||||
|
type FiskalRepository interface {
|
||||||
|
Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
|
||||||
|
DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FiskalRepo je SQLite implementacija FiskalRepository interfejsa
|
||||||
|
type FiskalRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoviFiskalRepo kreira novi FiskalRepo
|
||||||
|
func NoviFiskalRepo(db *sql.DB) *FiskalRepo {
|
||||||
|
return &FiskalRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kreiraj dodaje novi fiskalni račun u bazu. Vraća ID kreiranog reda.
|
||||||
|
// ProdajaID je UNIQUE — drugi poziv sa istim ID-jem pada na constraint.
|
||||||
|
func (r *FiskalRepo) Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error) {
|
||||||
|
rez, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO fiskalni_racuni (
|
||||||
|
prodaja_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
|
||||||
|
brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
|
||||||
|
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||||
|
sirovi_odgovor, potpisao, zatrazio, poruka
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, fr.ProdajaID, fr.TipRacuna, fr.TipTransakcije, fr.PfrBroj, fr.PfrVreme,
|
||||||
|
fr.Brojac, fr.EkstenzijaBrojaca, fr.UrlVerifikacija, fr.QRKod,
|
||||||
|
fr.PoreskeStavke, fr.UkupnoZaNaplatu, fr.UkupanPorez,
|
||||||
|
fr.SiroviOdgovor, fr.Potpisao, fr.Zatrazio, fr.Poruka,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: FiskalRepo.Kreiraj: %w", err)
|
||||||
|
}
|
||||||
|
id, err := rez.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ntech: FiskalRepo.Kreiraj.LastInsertId: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DohvatiPoProdaji vraća fiskalni račun za dati prodajni nalog.
|
||||||
|
// Vraća nil bez greške ako račun ne postoji (nije fiskalizovano).
|
||||||
|
func (r *FiskalRepo) DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error) {
|
||||||
|
fr := &model.FiskalniRacun{}
|
||||||
|
var storniran int
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, prodaja_id, tip_racuna, tip_transakcije, pfr_broj, pfr_vreme,
|
||||||
|
brojac, ekstenzija_brojaca, url_verifikacija, qr_kod,
|
||||||
|
poreske_stavke, ukupno_za_naplatu, ukupan_porez,
|
||||||
|
sirovi_odgovor, potpisao, zatrazio, poruka, storniran, vreme_kreiranja
|
||||||
|
FROM fiskalni_racuni WHERE prodaja_id = ?
|
||||||
|
`, prodajaID).Scan(
|
||||||
|
&fr.ID, &fr.ProdajaID, &fr.TipRacuna, &fr.TipTransakcije, &fr.PfrBroj, &fr.PfrVreme,
|
||||||
|
&fr.Brojac, &fr.EkstenzijaBrojaca, &fr.UrlVerifikacija, &fr.QRKod,
|
||||||
|
&fr.PoreskeStavke, &fr.UkupnoZaNaplatu, &fr.UkupanPorez,
|
||||||
|
&fr.SiroviOdgovor, &fr.Potpisao, &fr.Zatrazio, &fr.Poruka, &storniran, &fr.VremeKreiranja,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil // nije fiskalizovano — nije greška
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: FiskalRepo.DohvatiPoProdaji: %w", err)
|
||||||
|
}
|
||||||
|
fr.Storniran = storniran != 0
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package fiskal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Klijent je HTTP klijent za Teron L-PFR mock server (ili pravi Teron uređaj).
|
||||||
|
// Komunicira JSON-om preko HTTP POST/GET na baseURL/api/....
|
||||||
|
type Klijent struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
HTTPKlijent *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoviKlijent kreira novi fiskalni klijent. baseURL je adresa PFR servera
|
||||||
|
// (npr. "http://127.0.0.1:4566"), apiKey je Bearer token.
|
||||||
|
func NoviKlijent(baseURL, apiKey string) *Klijent {
|
||||||
|
return &Klijent{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
APIKey: apiKey,
|
||||||
|
HTTPKlijent: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat.
|
||||||
|
type InvoiceRequest struct {
|
||||||
|
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
|
||||||
|
type InvoiceRequestBody struct {
|
||||||
|
InvoiceType string `json:"invoiceType"`
|
||||||
|
TransactionType string `json:"transactionType"`
|
||||||
|
Payment []PaymentItem `json:"payment"`
|
||||||
|
Items []InvoiceItem `json:"items"`
|
||||||
|
Cashier string `json:"cashier"`
|
||||||
|
BuyerID string `json:"buyerId,omitempty"`
|
||||||
|
ReferentDocumentNumber string `json:"referentDocumentNumber,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentItem opisuje jedno plaćanje (može više — keš + kartica).
|
||||||
|
type PaymentItem struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
PaymentType string `json:"paymentType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceItem je jedna stavka fiskalnog računa.
|
||||||
|
type InvoiceItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
|
UnitPrice float64 `json:"unitPrice"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvoiceResponse je odgovor PFR servera na zahtev za fiskalizaciju.
|
||||||
|
// Polja prate stvarni JSON koji Fisk server vraća (vidi NtechFisk.md §3.2).
|
||||||
|
type InvoiceResponse struct {
|
||||||
|
RequestedBy string `json:"requestedBy"`
|
||||||
|
SignedBy string `json:"signedBy"`
|
||||||
|
SdcDateTime string `json:"sdcDateTime"`
|
||||||
|
InvoiceCounter string `json:"invoiceCounter"`
|
||||||
|
InvoiceCounterExtension string `json:"invoiceCounterExtension"`
|
||||||
|
InvoiceNumber string `json:"invoiceNumber"`
|
||||||
|
VerificationURL string `json:"verificationUrl"`
|
||||||
|
VerificationQRCode string `json:"verificationQRCode"`
|
||||||
|
Journal string `json:"journal"`
|
||||||
|
TaxItems []TaxItem `json:"taxItems"`
|
||||||
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
|
TotalTax float64 `json:"totalTax"`
|
||||||
|
Messages string `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaxItem je stavka poreskog obračuna u fiskalnom odgovoru.
|
||||||
|
type TaxItem struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
CategoryType int `json:"categoryType"`
|
||||||
|
Rate float64 `json:"rate"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status vraća trenutni status PFR servera (GET /api/status).
|
||||||
|
func (k *Klijent) Status(ctx context.Context) (map[string]any, error) {
|
||||||
|
url := k.BaseURL + "/api/status"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.Status: %w", err)
|
||||||
|
}
|
||||||
|
k.dodajAuth(req)
|
||||||
|
|
||||||
|
resp, err := k.HTTPKlijent.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.Status: server nedostupan: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
telo, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.Status: HTTP %d: %s", resp.StatusCode, string(telo))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.Status: JSON parse: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IzdajRacun šalje zahtev za fiskalizaciju PFR serveru (POST /api/invoices).
|
||||||
|
func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*InvoiceResponse, error) {
|
||||||
|
telo, err := json.Marshal(zahtev)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := k.BaseURL + "/api/invoices"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(telo))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
k.dodajAuth(req)
|
||||||
|
|
||||||
|
resp, err := k.HTTPKlijent.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: server nedostupan: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
teloOdg, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: HTTP %d: %s", resp.StatusCode, string(teloOdg))
|
||||||
|
}
|
||||||
|
|
||||||
|
var odgovor InvoiceResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&odgovor); err != nil {
|
||||||
|
return nil, fmt.Errorf("ntech: fiskal.IzdajRacun: JSON parse: %w", err)
|
||||||
|
}
|
||||||
|
return &odgovor, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dodajAuth postavlja Authorization header ako je API ključ podešen.
|
||||||
|
func (k *Klijent) dodajAuth(req *http.Request) {
|
||||||
|
if k.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+k.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package fiskal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"ntech/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OznakaPDV mapira NTech PDV stopu na ćiriličnu oznaku koju Fisk/Teron očekuje.
|
||||||
|
// ⚠️ 0% → "А" (neobveznici PDV-a), NE "Е" (Е = 10% posebna stopa).
|
||||||
|
func OznakaPDV(stopa float64) string {
|
||||||
|
switch {
|
||||||
|
case stopa >= 19.9 && stopa <= 20.1:
|
||||||
|
return "Ж" // opšta stopa 20%
|
||||||
|
case stopa >= 9.9 && stopa <= 10.1:
|
||||||
|
return "Ђ" // snižena stopa 10%
|
||||||
|
case stopa >= -0.01 && stopa <= 0.01:
|
||||||
|
return "А" // oslobođen / neobveznik PDV-a
|
||||||
|
default:
|
||||||
|
return "А" // fallback — ne rizikujemo pogrešnu oznaku
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrutoCena konvertuje neto cenu u bruto (sa PDV-om) za zadatu stopu.
|
||||||
|
// Teron zahteva bruto iznose u unitPrice i totalAmount.
|
||||||
|
// Formula: bruto = neto * (1 + stopa/100), zaokruženo na 2 decimale.
|
||||||
|
func BrutoCena(neto, stopa float64) float64 {
|
||||||
|
return math.Round(neto*(1+stopa/100)*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// TipPlacanja mapira NTech način plaćanja na Teron enumeraciju.
|
||||||
|
func TipPlacanja(nacin string) string {
|
||||||
|
switch nacin {
|
||||||
|
case "Gotovina":
|
||||||
|
return "Cash"
|
||||||
|
case "Kartica":
|
||||||
|
return "Card"
|
||||||
|
case "Virman", "Račun":
|
||||||
|
return "WireTransfer"
|
||||||
|
case "Ček":
|
||||||
|
return "Check"
|
||||||
|
case "Vaučer":
|
||||||
|
return "Voucher"
|
||||||
|
default:
|
||||||
|
return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NapraviZahtev konvertuje prodajni nalog i stavke u Teron InvoiceRequest.
|
||||||
|
// VAŽNO: sve cene se konvertuju iz neto u bruto pre slanja (BrutoCena).
|
||||||
|
// pib/jmbg se koriste za buyerId identifikaciju kupca.
|
||||||
|
func NapraviZahtev(
|
||||||
|
nalog model.ProdajniNalog,
|
||||||
|
stavke []model.StavkaProdajeSaArtiklom,
|
||||||
|
pib, jmbg, kasir string,
|
||||||
|
) InvoiceRequest {
|
||||||
|
items := make([]InvoiceItem, 0, len(stavke))
|
||||||
|
ukupanIznos := 0.0
|
||||||
|
|
||||||
|
for _, s := range stavke {
|
||||||
|
stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena
|
||||||
|
if s.PdvStopa > 0 {
|
||||||
|
stopa = s.PdvStopa
|
||||||
|
}
|
||||||
|
// CenaPoKomadu je neto cena — konvertujemo u bruto
|
||||||
|
netoCena := s.CenaBezPdv
|
||||||
|
if netoCena == 0 {
|
||||||
|
netoCena = s.CenaPoKomadu // fallback: ako CenaBezPdv nije popunjena
|
||||||
|
}
|
||||||
|
brutoCena := BrutoCena(netoCena, stopa)
|
||||||
|
brutoTotal := math.Round(brutoCena*float64(s.Kolicina)*100) / 100
|
||||||
|
|
||||||
|
items = append(items, InvoiceItem{
|
||||||
|
Name: s.ArtikalNaziv,
|
||||||
|
Labels: []string{OznakaPDV(stopa)},
|
||||||
|
TotalAmount: brutoTotal,
|
||||||
|
UnitPrice: brutoCena,
|
||||||
|
Quantity: float64(s.Kolicina),
|
||||||
|
})
|
||||||
|
ukupanIznos += brutoTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ukupno za plaćanje — iz naloga (bruto)
|
||||||
|
ukupnoPlacanje := nalog.Ukupno
|
||||||
|
if ukupnoPlacanje == 0 {
|
||||||
|
ukupnoPlacanje = ukupanIznos
|
||||||
|
}
|
||||||
|
|
||||||
|
nacinPlacanja := nalog.NacinPlacanja
|
||||||
|
if nacinPlacanja == "" {
|
||||||
|
nacinPlacanja = "Gotovina"
|
||||||
|
}
|
||||||
|
|
||||||
|
zahtev := InvoiceRequest{
|
||||||
|
InvoiceRequest: InvoiceRequestBody{
|
||||||
|
InvoiceType: "Normal",
|
||||||
|
TransactionType: "Sale",
|
||||||
|
Payment: []PaymentItem{
|
||||||
|
{
|
||||||
|
Amount: ukupnoPlacanje,
|
||||||
|
PaymentType: TipPlacanja(nacinPlacanja),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Items: items,
|
||||||
|
Cashier: kasir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// buyerId: "10:PIB" za pravno lice, "01:JMBG" za fizičko
|
||||||
|
if pib != "" {
|
||||||
|
zahtev.InvoiceRequest.BuyerID = "10:" + pib
|
||||||
|
} else if jmbg != "" {
|
||||||
|
zahtev.InvoiceRequest.BuyerID = "01:" + jmbg
|
||||||
|
}
|
||||||
|
|
||||||
|
return zahtev
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"ntech/internal/config"
|
"ntech/internal/config"
|
||||||
"ntech/internal/db"
|
"ntech/internal/db"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/fiskal"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,7 @@ type Handler struct {
|
|||||||
PdvKirRepo db.PdvKirRepository
|
PdvKirRepo db.PdvKirRepository
|
||||||
PdvKprRepo db.PdvKprRepository
|
PdvKprRepo db.PdvKprRepository
|
||||||
NivelacijaRepo db.NivelacijaRepository
|
NivelacijaRepo db.NivelacijaRepository
|
||||||
|
FiskalRepo db.FiskalRepository
|
||||||
Verzija string
|
Verzija string
|
||||||
JelDemo bool
|
JelDemo bool
|
||||||
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
||||||
@@ -112,6 +114,7 @@ func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
|||||||
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
|
PdvKirRepo: sqlite.NoviPdvKirRepo(baza),
|
||||||
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
|
PdvKprRepo: sqlite.NoviPdvKprRepo(baza),
|
||||||
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
|
NivelacijaRepo: sqlite.NoviNivelacijaRepo(baza),
|
||||||
|
FiskalRepo: sqlite.NoviFiskalRepo(baza),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +148,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
|
|||||||
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
|
h.PdvKirRepo = sqlite.NoviPdvKirRepo(novaDB)
|
||||||
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
|
h.PdvKprRepo = sqlite.NoviPdvKprRepo(novaDB)
|
||||||
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
|
h.NivelacijaRepo = sqlite.NoviNivelacijaRepo(novaDB)
|
||||||
|
h.FiskalRepo = sqlite.NoviFiskalRepo(novaDB)
|
||||||
}
|
}
|
||||||
|
|
||||||
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
|
// modulUkljucen vraća da li je zakonski modul (npr. „pdv") uključen za firmu prema profilu.
|
||||||
@@ -232,3 +236,18 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
|
|||||||
|
|
||||||
return ps
|
return ps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fiskalKlijent vraća inicijalizovan fiskalni HTTP klijent na osnovu trenutnih
|
||||||
|
// podešavanja. Vraća nil ako pfr_url nije podešen. URL se čita dinamički pri
|
||||||
|
// svakom pozivu — nema keširanja, pa promena podešavanja stupa na snagu odmah.
|
||||||
|
func (h *Handler) fiskalKlijent() *fiskal.Klijent {
|
||||||
|
url, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_url")
|
||||||
|
key, _ := sqlite.DohvatiPodesavanje(context.Background(), h.DB, "pfr_api_key")
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
key = "test"
|
||||||
|
}
|
||||||
|
return fiskal.NoviKlijent(url, key)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ntech/internal/config"
|
||||||
appdb "ntech/internal/db"
|
appdb "ntech/internal/db"
|
||||||
"ntech/internal/db/sqlite"
|
"ntech/internal/db/sqlite"
|
||||||
|
"ntech/internal/fiskal"
|
||||||
"ntech/internal/middleware"
|
"ntech/internal/middleware"
|
||||||
"ntech/internal/model"
|
"ntech/internal/model"
|
||||||
|
|
||||||
@@ -39,10 +41,11 @@ type PodaciFormeProdaje struct {
|
|||||||
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
|
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
|
||||||
type PodaciDetaljiProdaje struct {
|
type PodaciDetaljiProdaje struct {
|
||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
Nalog model.ProdajniNalog
|
Nalog model.ProdajniNalog
|
||||||
Stavke []model.StavkaProdajeSaArtiklom
|
Stavke []model.StavkaProdajeSaArtiklom
|
||||||
KlijentNaziv string
|
KlijentNaziv string
|
||||||
Sacuvano bool
|
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
|
||||||
|
Sacuvano bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
|
// PodaciStampeProdaje su podaci za stranicu za štampanje priznanice
|
||||||
@@ -213,6 +216,63 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fiskalizacija — ako je modul uključen (best-effort: prodaja ostaje validna i bez fiskalizacije)
|
||||||
|
if h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||||
|
klijent := h.fiskalKlijent()
|
||||||
|
if klijent != nil {
|
||||||
|
// učitaj nalog sa ID-jem (potreban za buyerId)
|
||||||
|
spremljen, e := h.ProdajaRepo.DohvatiID(r.Context(), id)
|
||||||
|
if e == nil {
|
||||||
|
spremljeneStavke, e2 := h.ProdajaRepo.DohvatiStavke(r.Context(), id)
|
||||||
|
if e2 == nil {
|
||||||
|
kasir, _ := sqlite.DohvatiPodesavanje(r.Context(), h.DB, "pfr_kasir")
|
||||||
|
if kasir == "" {
|
||||||
|
kasir = "NTech"
|
||||||
|
}
|
||||||
|
|
||||||
|
var pib, jmbg string
|
||||||
|
if spremljen.KlijentID != nil {
|
||||||
|
if kk, e3 := h.KlijentiRepo.DohvatiID(r.Context(), *spremljen.KlijentID); e3 == nil {
|
||||||
|
pib, jmbg = kk.PIB, kk.JMBG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zahtev := fiskal.NapraviZahtev(*spremljen, spremljeneStavke, pib, jmbg, kasir)
|
||||||
|
|
||||||
|
odgovor, errFisk := klijent.IzdajRacun(r.Context(), zahtev)
|
||||||
|
if errFisk != nil {
|
||||||
|
slog.Error("fiskalizacija nije uspela", "prodaja_id", id, "error", errFisk)
|
||||||
|
} else {
|
||||||
|
poreskeJSON, _ := json.Marshal(odgovor.TaxItems)
|
||||||
|
siroviJSON, _ := json.Marshal(odgovor)
|
||||||
|
|
||||||
|
fr := &model.FiskalniRacun{
|
||||||
|
ProdajaID: id,
|
||||||
|
TipRacuna: "Normal",
|
||||||
|
TipTransakcije: "Sale",
|
||||||
|
PfrBroj: odgovor.InvoiceNumber,
|
||||||
|
PfrVreme: odgovor.SdcDateTime,
|
||||||
|
Brojac: odgovor.InvoiceCounter,
|
||||||
|
EkstenzijaBrojaca: odgovor.InvoiceCounterExtension,
|
||||||
|
UrlVerifikacija: odgovor.VerificationURL,
|
||||||
|
QRKod: odgovor.VerificationQRCode,
|
||||||
|
PoreskeStavke: string(poreskeJSON),
|
||||||
|
UkupnoZaNaplatu: odgovor.TotalAmount,
|
||||||
|
UkupanPorez: odgovor.TotalTax,
|
||||||
|
SiroviOdgovor: string(siroviJSON),
|
||||||
|
Potpisao: odgovor.SignedBy,
|
||||||
|
Zatrazio: odgovor.RequestedBy,
|
||||||
|
Poruka: odgovor.Messages,
|
||||||
|
}
|
||||||
|
if _, errFisk := h.FiskalRepo.Kreiraj(r.Context(), fr); errFisk != nil {
|
||||||
|
slog.Error("greška pri čuvanju fiskalnog računa", "prodaja_id", id, "error", errFisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +314,9 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// učitaj fiskalni račun ako postoji
|
||||||
|
fr, _ := h.FiskalRepo.DohvatiPoProdaji(r.Context(), id)
|
||||||
|
|
||||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||||
ps.Stranica = "prodaja"
|
ps.Stranica = "prodaja"
|
||||||
ps.NaslovStranice = "Detalji prodaje"
|
ps.NaslovStranice = "Detalji prodaje"
|
||||||
@@ -262,6 +325,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
|||||||
Nalog: *nalog,
|
Nalog: *nalog,
|
||||||
Stavke: stavke,
|
Stavke: stavke,
|
||||||
KlijentNaziv: klijentNaziv,
|
KlijentNaziv: klijentNaziv,
|
||||||
|
FiskalniRacun: fr,
|
||||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// FiskalniRacun je veza između prodajnog naloga i odgovora fiskalne kase (Teron L-PFR).
|
||||||
|
// Polja prate tačno ono što Fisk server vraća u JSON odgovoru (vidi NtechFisk.md §3.2).
|
||||||
|
type FiskalniRacun struct {
|
||||||
|
ID int64
|
||||||
|
ProdajaID int64
|
||||||
|
TipRacuna string // "Normal", "Advance", "Copy", "Training"
|
||||||
|
TipTransakcije string // "Sale", "Refund"
|
||||||
|
PfrBroj string // invoiceNumber — jedinstven broj fiskalnog računa
|
||||||
|
PfrVreme string // sdcDateTime — PFR vreme izdavanja
|
||||||
|
Brojac string // invoiceCounter (npr. "1/1ПП")
|
||||||
|
EkstenzijaBrojaca string // invoiceCounterExtension (npr. "ПП")
|
||||||
|
UrlVerifikacija string // verificationUrl — link ka PURS proveri
|
||||||
|
QRKod string // verificationQRCode — base64 PNG, direktno u <img src>
|
||||||
|
PoreskeStavke string // JSON niz taxItems[] iz odgovora
|
||||||
|
UkupnoZaNaplatu float64 // totalAmount
|
||||||
|
UkupanPorez float64 // totalTax
|
||||||
|
SiroviOdgovor string // ceo JSON odgovor — sadrži journal, taxItems i ostalo za audit
|
||||||
|
Potpisao string // signedBy — identifikator PFR-a koji je potpisao
|
||||||
|
Zatrazio string // requestedBy — identifikator ESIR-a
|
||||||
|
Poruka string // messages (npr. "Success")
|
||||||
|
Storniran bool // true ako je račun naknadno storniran
|
||||||
|
VremeKreiranja string
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Fiskalni računi: veza prodaje sa PFR odgovorom (Teron L-PFR).
|
||||||
|
-- Svaki prodajni nalog može imati najviše jedan fiskalni račun (UNIQUE prodaja_id).
|
||||||
|
-- QR kod se čuva kao base64 PNG (dolazi gotov iz Fisk servera).
|
||||||
|
-- sirovi_odgovor čuva ceo JSON odgovor za audit (sadrži journal, taxItems, itd.).
|
||||||
|
CREATE TABLE IF NOT EXISTS fiskalni_racuni (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
prodaja_id INTEGER NOT NULL UNIQUE,
|
||||||
|
tip_racuna TEXT NOT NULL DEFAULT 'Normal',
|
||||||
|
tip_transakcije TEXT NOT NULL DEFAULT 'Sale',
|
||||||
|
pfr_broj TEXT NOT NULL, -- invoiceNumber iz PFR odgovora
|
||||||
|
pfr_vreme TEXT NOT NULL, -- sdcDateTime
|
||||||
|
brojac TEXT, -- invoiceCounter (npr. "1/1ПП")
|
||||||
|
ekstenzija_brojaca TEXT, -- invoiceCounterExtension (npr. "ПП")
|
||||||
|
url_verifikacija TEXT, -- verificationUrl
|
||||||
|
qr_kod TEXT, -- verificationQRCode (base64 PNG)
|
||||||
|
poreske_stavke TEXT, -- JSON niz taxItems[]
|
||||||
|
ukupno_za_naplatu REAL, -- totalAmount
|
||||||
|
ukupan_porez REAL, -- totalTax
|
||||||
|
sirovi_odgovor TEXT, -- ceo JSON odgovor (audit trail)
|
||||||
|
potpisao TEXT, -- signedBy
|
||||||
|
zatrazio TEXT, -- requestedBy
|
||||||
|
poruka TEXT, -- messages
|
||||||
|
storniran INTEGER NOT NULL DEFAULT 0, -- 1 ako je fiskalni račun storniran
|
||||||
|
vreme_kreiranja TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
FOREIGN KEY (prodaja_id) REFERENCES prodajni_nalozi(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fiskalni_racuni_prodaja ON fiskalni_racuni(prodaja_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fiskalni_racuni_pfr_broj ON fiskalni_racuni(pfr_broj);
|
||||||
|
|
||||||
|
-- Podešavanja za fiskalizaciju koja nedostaju u 080
|
||||||
|
INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('pfr_api_key', '');
|
||||||
|
INSERT OR IGNORE INTO podesavanja (kljuc, vrednost) VALUES ('pfr_kasir', 'NTech');
|
||||||
@@ -246,6 +246,29 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .FiskalniRacun}}
|
||||||
|
<div class="fiskalni-racun" style="padding-top:14px;border-top:0.5px solid var(--ivica);">
|
||||||
|
<h3 style="margin:0 0 8px 0;font-size:16px;">Fiskalni račun</h3>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;">
|
||||||
|
<div style="flex:1;min-width:200px;">
|
||||||
|
<p style="margin:0 0 4px 0;"><strong>PFR broj:</strong> {{.FiskalniRacun.PfrBroj}}</p>
|
||||||
|
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.FiskalniRacun.PfrVreme}}</p>
|
||||||
|
<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" class="spoljni-link">Provera na PURS-u →</a>
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .FiskalniRacun.QRKod}}
|
||||||
|
<div>
|
||||||
|
<img src="data:image/png;base64,{{.FiskalniRacun.QRKod}}" alt="QR kod za proveru fiskalnog računa" style="width:25mm;height:25mm;">
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if index $.Dozvole "prodaja.obrisi"}}
|
{{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="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="flex:1;min-width:200px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user