Merge grane feature/fiskalizacija-migracija: fiskalizacija + servis unapređenja + pravni status

This commit is contained in:
2026-06-26 00:36:39 +02:00
20 changed files with 785 additions and 61 deletions
+1
View File
@@ -0,0 +1 @@
2
+1
View File
@@ -0,0 +1 @@
2
+21 -11
View File
@@ -16,10 +16,10 @@ const (
// Nazivi modula koje program pali/gasi prema profilu firme.
const (
ModulPdv = "pdv"
ModulFiskalizacija = "fiskalizacija"
ModulKpo = "kpo"
ModulDvojno = "dvojno"
ModulPdv = "pdv" // KIR/KPR evidencija, PDV obračun
ModulFiskalizacija = "fiskalizacija" // Teron L-PFR, fiskalni računi
ModulKpo = "kpo" // Knjiga o ostvarenom prometu (paušalci)
ModulDvojno = "dvojno" // Dvojno knjigovodstvo (DOO, preduzetnici koji ga vode)
)
// ModulUkljucen vraća da li je dati zakonski modul aktivan za firmu, na osnovu
@@ -36,20 +36,30 @@ func ModulUkljucen(podesavanja map[string]string, modul string) bool {
return false
}
pravniOblik := podesavanja[KljucPravniOblik]
switch modul {
case ModulFiskalizacija:
// fiskalizacija je nezavisna od pravnog oblika — zaseban prekidač
// „izdaje li račune građanima" (Project.md §3, napomena *).
// Fiskalizacija je obavezna za SVE koji izdaju račune građanima (maloprodaja),
// bez obzira na pravni oblik. Nezavisan prekidač.
return podesavanja[KljucFiskalizacija] == "da"
case ModulPdv:
// PDV evidencija se vodi kad je firma u sistemu PDV-a.
// PDV evidencija se vodi kad je firma u sistemu PDV-a (obavezno preko 8M,
// ili dobrovoljno). Dostupno svim pravnim oblicima.
return podesavanja[KljucPdvObveznik] == "da"
case ModulKpo:
// KPO (knjiga o ostvarenom prometu) je samo za paušalce.
return podesavanja[KljucPravniOblik] == "pausalac"
// KPO (knjiga o ostvarenom prometu) vode paušalci i preduzetnici
// koji vode proste poslovne knjige (ne dvojno).
return pravniOblik == "pausalac" || pravniOblik == "preduzetnik_knjige"
case ModulDvojno:
// dvojno knjigovodstvo je za DOO (za preduzetnike je buduća podela, Project.md §8).
return podesavanja[KljucPravniOblik] == "doo"
// Dvojno knjigovodstvo je obavezno za DOO. Preduzetnici koji vode
// knjige mogu birati prosto ili dvojno — dvojno je opciono
// (budući toggle: firma_knjigovodstvo = "dvojno").
return pravniOblik == "doo"
default:
return false
}
+3 -3
View File
@@ -44,12 +44,12 @@ func TestModulUkljucen(t *testing.T) {
{"pun + obveznik da", pun, ModulPdv, true},
{"pun + obveznik ne (čak i doo)", izmeni(pun, KljucPdvObveznik, "ne"), ModulPdv, false},
// kpo — samo paušalac
// kpo — paušalac i preduzetnik knjigaš (proste knjige)
{"pun + paušalac → kpo", izmeni(pun, KljucPravniOblik, "pausalac"), ModulKpo, true},
{"pun + doo → nema kpo", pun, ModulKpo, false},
{"pun + preduzetnik → nema kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, false},
{"pun + preduzetnik → kpo", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulKpo, true},
// dvojno — samo doo
// dvojno — doo uvek, preduzetnik opciono (budući toggle)
{"pun + doo → dvojno", pun, ModulDvojno, true},
{"pun + paušalac → nema dvojno", izmeni(pun, KljucPravniOblik, "pausalac"), ModulDvojno, false},
{"pun + preduzetnik → nema dvojno (buduća podela)", izmeni(pun, KljucPravniOblik, "preduzetnik_knjige"), ModulDvojno, false},
+7
View File
@@ -367,3 +367,10 @@ type PodsetnikRepository interface {
Obrisi(ctx context.Context, id int64) 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)
}
+71
View File
@@ -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
}
+155
View File
@@ -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)
}
}
+117
View File
@@ -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
}
+19
View File
@@ -11,6 +11,7 @@ import (
"ntech/internal/config"
"ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
)
@@ -44,6 +45,7 @@ type Handler struct {
PdvKirRepo db.PdvKirRepository
PdvKprRepo db.PdvKprRepository
NivelacijaRepo db.NivelacijaRepository
FiskalRepo db.FiskalRepository
Verzija string
JelDemo bool
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),
PdvKprRepo: sqlite.NoviPdvKprRepo(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.PdvKprRepo = sqlite.NoviPdvKprRepo(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.
@@ -232,3 +236,18 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
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)
}
+27 -6
View File
@@ -7,6 +7,7 @@ import (
"html"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
@@ -924,12 +925,12 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
pfrURL = vrednostIliDefault(podesavanja, "pfr_url", "http://127.0.0.1:4566")
}
// SSRF zaštita: URL validiramo, izvlačimo SAMO port, a host hardkodiramo.
// Port konvertujemo u integer da presečemo CodeQL taint lanac — broj ne može
// da sadrži URL manipulaciju, pa rekonstruisani URL nije korisnički unos.
// SSRF zaštita: dozvoljeni su samo localhost i privatne mreže (192.168.x.x,
// 10.x.x.x, 172.16-31.x.x, 127.x.x.x). Port izvlačimo kao integer da presečemo
// CodeQL taint lanac — broj ne može da sadrži URL manipulaciju.
parsedURL, err := url.Parse(pfrURL)
if err != nil || (parsedURL.Hostname() != "127.0.0.1" && parsedURL.Hostname() != "localhost") {
http.Error(w, "Nevažeći PFR URL — dozvoljen samo localhost", http.StatusBadRequest)
if err != nil || !jePrivatnaAdresa(parsedURL.Hostname()) {
http.Error(w, "Nevažeći PFR URL — dozvoljeni su samo lokalni/privatni hostovi (127.x, 192.168.x, 10.x, 172.16-31.x)", http.StatusBadRequest)
return
}
port := 4566
@@ -938,7 +939,7 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
port = n
}
}
statusURL := fmt.Sprintf("http://127.0.0.1:%d/api/status", port)
statusURL := fmt.Sprintf("http://%s:%d/api/status", parsedURL.Hostname(), port)
klijent := &http.Client{Timeout: 5 * time.Second}
resp, err := klijent.Get(statusURL)
@@ -967,6 +968,26 @@ func (h *Handler) TestFiskalizacije(w http.ResponseWriter, r *http.Request) {
</div>`, html.EscapeString(pfrURL), html.EscapeString(tin), html.EscapeString(lastInvoice), html.EscapeString(sdcDateTime))
}
// 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 {
if hostname == "localhost" {
return true
}
ip := net.ParseIP(hostname)
if ip == nil {
return false
}
ip4 := ip.To4()
if ip4 == nil {
return false
}
return ip4[0] == 127 || // loopback
ip4[0] == 10 || // privatna klasa A
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // privatna klasa B
(ip4[0] == 192 && ip4[1] == 168) // privatna klasa C
}
// PodesavanjaSistem renderuje stranicu sa sistemskim podešavanjima (backup)
func (h *Handler) PodesavanjaSistem(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.pregled"); !ok {
+68 -4
View File
@@ -10,8 +10,10 @@ import (
"strings"
"time"
"ntech/internal/config"
appdb "ntech/internal/db"
"ntech/internal/db/sqlite"
"ntech/internal/fiskal"
"ntech/internal/middleware"
"ntech/internal/model"
@@ -39,10 +41,11 @@ type PodaciFormeProdaje struct {
// PodaciDetaljiProdaje su podaci za pregled jedne prodaje sa stavkama
type PodaciDetaljiProdaje struct {
model.PodaciStranice
Nalog model.ProdajniNalog
Stavke []model.StavkaProdajeSaArtiklom
KlijentNaziv string
Sacuvano bool
Nalog model.ProdajniNalog
Stavke []model.StavkaProdajeSaArtiklom
KlijentNaziv string
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
Sacuvano bool
}
// 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)
}
@@ -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.Stranica = "prodaja"
ps.NaslovStranice = "Detalji prodaje"
@@ -262,6 +325,7 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
Nalog: *nalog,
Stavke: stavke,
KlijentNaziv: klijentNaziv,
FiskalniRacun: fr,
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
}
+23 -1
View File
@@ -1426,7 +1426,19 @@ func (h *Handler) StampaOtpremnice(w http.ResponseWriter, r *http.Request) {
}
var preostaloSve float64
var imaAvans bool
if nalog.CenaKonacna != nil {
if nalog.PopravkaOdbijena {
// klijent odbio popravku — naplaćuje se samo dijagnostika
ukupnoSve := nalog.CenaDijagnostike
avans := 0.0
if nalog.Avans != nil && *nalog.Avans > 0 {
avans = *nalog.Avans
imaAvans = true
}
preostaloSve = ukupnoSve - avans
if preostaloSve < 0 {
preostaloSve = 0
}
} else if nalog.CenaKonacna != nil {
ukupnoSve := *nalog.CenaKonacna + ukupnoDelovi
avans := 0.0
if nalog.Avans != nil && *nalog.Avans > 0 {
@@ -2282,6 +2294,16 @@ func (h *Handler) ServisJavniStatus(w http.ResponseWriter, r *http.Request) {
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
// kad je klijent odbio popravku, ne prikazujemo radove i delove
if nalog.PopravkaOdbijena {
ugrRadovi = nil
ugrDelovi = nil
predRadovi = nil
predDelovi = nil
ukupnoSve = 0
ukupnoPredlog = 0
}
h.renderujStandalone(w, "servis_status_javni", PodaciJavnogStatusa{
Nalog: *nalog,
NazivFirme: podesavanja["naziv_firme"],
+25
View File
@@ -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
}
+33
View File
@@ -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');
+2 -1
View File
@@ -1274,7 +1274,8 @@ select {
align-items: flex-start;
}
.kanban-kolona {
flex: 0 0 250px;
flex: 1 1 0;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 10px;
+91 -19
View File
@@ -209,11 +209,14 @@
<span style="font-size:13px;color:var(--tekst-glavni);">Samo evidencija</span>
</label>
</div>
<div class="pomocni-tekst" style="font-size:12px;margin-top:6px;">„Samo evidencija" gasi ceo zakonski sloj — program vodi servis i zalihe, ali ne izdaje fiskalne ni poreske dokumente.</div>
<div class="pomocni-tekst" style="font-size:12px;margin-top:6px;line-height:1.7;">
<strong>Pun:</strong> Program vodi kompletnu evidenciju uključujući poreske i fiskalne module (PDV, fiskalizacija, KPO, dvojno knjigovodstvo — prema odabranom pravnom obliku). Koristiti kad firma stvarno posluje i izdaje račune.<br>
<strong>Samo evidencija:</strong> Gasi ceo zakonski sloj. Program služi samo za vođenje servisnih naloga, magacina, klijenata i prodajne evidencije — bez fiskalnih računa, bez PDV-a, bez knjigovodstva. Koristiti za testiranje, internu evidenciju ili pre početka stvarnog poslovanja.
</div>
</div>
<!-- PDV obveznik -->
<div>
<div id="pdv-grupa">
<label class="polje-labela">U sistemu PDV-a</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<label style="display:flex;align-items:center;gap:8px;padding:10px 14px;border:0.5px solid var(--ivica);border-radius:8px;cursor:pointer;flex:1;{{if eq .FirmaPdvObveznik "da"}}border-color:var(--sb-akcent);background:var(--pozadina);{{end}}">
@@ -246,6 +249,60 @@
</div>
</form>
<!-- informativna tabela: koji moduli se pale po profilu -->
<div class="kartica animiraj" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:0.5px solid var(--ivica);">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--sb-akcent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Pregled — šta se pali po profilu</span>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="border-bottom:0.5px solid var(--ivica);">
<th style="text-align:left;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">Pravni oblik</th>
<th style="text-align:center;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">PDV evidencija</th>
<th style="text-align:center;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">Fiskalizacija</th>
<th style="text-align:center;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">KPO</th>
<th style="text-align:center;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">Dvojno knjig.</th>
<th style="text-align:right;padding:8px 12px;color:var(--tekst-sporedni);font-weight:500;">Limit prihoda</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;font-weight:500;color:var(--tekst-glavni);">Paušalac</td>
<td style="text-align:center;padding:10px 12px;color:var(--tekst-sporedni);font-size:12px;">opciono *</td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);">✔ obavezno</td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);"></td>
<td style="text-align:center;padding:10px 12px;color:var(--crvena);"></td>
<td style="text-align:right;padding:10px 12px;color:var(--tekst-sporedni);">6.000.000 RSD</td>
</tr>
<tr style="border-bottom:0.5px solid var(--ivica);">
<td style="padding:10px 12px;font-weight:500;color:var(--tekst-glavni);">Preduzetnik (knjige)</td>
<td style="text-align:center;padding:10px 12px;color:var(--tekst-sporedni);font-size:12px;">preko 8M</td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);">✔ obavezno</td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);"></td>
<td style="text-align:center;padding:10px 12px;color:var(--tekst-sporedni);font-size:12px;">opciono **</td>
<td style="text-align:right;padding:10px 12px;color:var(--tekst-sporedni);">bez limita</td>
</tr>
<tr>
<td style="padding:10px 12px;font-weight:500;color:var(--tekst-glavni);">DOO</td>
<td style="text-align:center;padding:10px 12px;color:var(--tekst-sporedni);font-size:12px;">preko 8M</td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);">✔ obavezno</td>
<td style="text-align:center;padding:10px 12px;color:var(--crvena);"></td>
<td style="text-align:center;padding:10px 12px;color:var(--zelena);">✔ obavezno</td>
<td style="text-align:right;padding:10px 12px;color:var(--tekst-sporedni);">bez limita</td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top:12px;padding-top:10px;border-top:0.5px solid var(--ivica);font-size:11px;color:var(--tekst-sporedni);line-height:1.6;">
* Paušalac može dobrovoljno ući u PDV. Preko 8M — obavezno izlazi iz paušala i ulazi u PDV.<br>
** Preduzetnik koji vodi knjige može birati prosto ili dvojno knjigovodstvo.<br>
Fiskalizacija je obavezna za sve koji izdaju račune građanima (maloprodaja) — bez obzira na pravni oblik.<br>
PDV prag: 8.000.000 RSD u prethodnih 12 meseci (Zakon o PDV-u, čl. 33). Paušalni limit: 6.000.000 RSD (Zakon o porezu na dohodak građana, čl. 40).
</div>
</div>
</div>
{{if .LogoGreska}}
@@ -266,30 +323,45 @@
{{end}}
<script>
// Fiskalizacija ima smisla samo u punom režimu. Kad je „samo evidencija", grupa se
// zasivi i forsira na „Ne". Ne koristimo disabled (jer disabled polje se ne šalje u
// POST pa bi stara vrednost ostala u bazi) — umesto toga pointer-events:none, a radio
// „Ne" ostaje aktivan i čekiran, pa se uredno sačuva „ne".
// „Samo evidencija" gasi ceo zakonski sloj — PDV i Fiskalizacija se zasive i forsiraju na „Ne".
// Ne koristimo disabled (disabled polje se ne šalje u POST) — umesto toga pointer-events:none.
(function() {
var rezim = document.querySelectorAll('input[name="firma_rezim"]');
var grupa = document.getElementById('fiskalizacija-grupa');
var fisk = document.querySelectorAll('input[name="firma_fiskalizacija"]');
if (!grupa || !rezim.length) return;
var fiskGrupa = document.getElementById('fiskalizacija-grupa');
var pdvGrupa = document.getElementById('pdv-grupa');
if (!rezim.length) return;
function lockGrupa(grupa, radioName) {
if (!grupa) return;
grupa.style.opacity = '0.45';
grupa.style.pointerEvents = 'none';
// forsiraj radio na vrednost „ne"
var radio = grupa.querySelectorAll('input[name="' + radioName + '"]');
radio.forEach(function(r) {
r.checked = (r.value === 'ne');
var lab = r.closest('label');
if (lab) {
lab.style.borderColor = r.checked ? 'var(--sb-akcent)' : 'var(--ivica)';
lab.style.background = r.checked ? 'var(--pozadina)' : 'transparent';
}
});
}
function unlockGrupa(grupa) {
if (!grupa) return;
grupa.style.opacity = '';
grupa.style.pointerEvents = '';
}
function azuriraj() {
var izabran = document.querySelector('input[name="firma_rezim"]:checked');
var samoEvidencija = izabran && izabran.value === 'samo_evidencija';
grupa.style.opacity = samoEvidencija ? '0.45' : '';
grupa.style.pointerEvents = samoEvidencija ? 'none' : '';
if (samoEvidencija) {
fisk.forEach(function(r) {
r.checked = (r.value === 'ne');
var lab = r.closest('label');
if (lab) {
lab.style.borderColor = r.checked ? 'var(--sb-akcent)' : 'var(--ivica)';
lab.style.background = r.checked ? 'var(--pozadina)' : 'transparent';
}
});
lockGrupa(fiskGrupa, 'firma_fiskalizacija');
lockGrupa(pdvGrupa, 'firma_pdv_obveznik');
} else {
unlockGrupa(fiskGrupa);
unlockGrupa(pdvGrupa);
}
}
@@ -246,6 +246,29 @@
</div>
{{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"}}
<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;">
+48 -11
View File
@@ -74,6 +74,12 @@
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
{{.Nalog.BrojNaloga}}
</span>
{{if .Nalog.PopravkaOdbijena}}
<div style="padding:8px 14px;background:color-mix(in srgb, var(--crvena, #ef4444) 14%, transparent);border:1px solid color-mix(in srgb, var(--crvena, #ef4444) 35%, transparent);border-radius:8px;">
<span style="font-size:15px;font-weight:700;color:var(--crvena, #ef4444);">✕ Klijent je odbio popravku</span>
<span style="display:block;margin-top:2px;font-size:12px;color:var(--crvena, #dc2626);">Naplaćuje se samo dijagnostika</span>
</div>
{{end}}
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
{{template "status-badge-detalji" .Nalog.Status}}
{{if eq .Nalog.Status "Primljeno"}}
@@ -809,24 +815,55 @@
</div>
<div class="kolona" style="gap:10px;">
{{if .Nalog.PopravkaOdbijena}}
{{/* klijent odbio popravku — naplaćuje se samo dijagnostika */}}
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:14px;color:var(--tekst-sporedni);">Dijagnostika</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .Nalog.CenaDijagnostike}} din</span>
</div>
{{else}}
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:14px;color:var(--tekst-sporedni);">Usluge (rad)</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .UkupnoRadovi}} din</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:14px;color:var(--tekst-sporedni);">Ugrađeni delovi</span>
<span style="font-size:14px;color:var(--tekst-glavni);">{{dinari .UkupnoDelovi}} din</span>
<!-- Radovi — listing pa subtotal -->
{{if .ServisniRadovi}}
<div>
<div style="display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:color-mix(in srgb, var(--sb-akcent) 10%, transparent);border-left:3px solid var(--sb-akcent);border-radius:6px;font-size:13px;font-weight:600;color:var(--tekst-glavni);margin-bottom:10px;">
🔧 Obavljeni radovi
</div>
<div class="kolona" style="gap:6px;">
{{range .ServisniRadovi}}
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;">
<span style="color:var(--tekst-glavni);">{{.Naziv}}{{if gt .Kolicina 1.0}} ×{{.Kolicina}}{{end}}</span>
<span style="color:var(--tekst-sporedni);">{{dinari .Ukupno}} din</span>
</div>
{{end}}
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:8px;border-top:0.5px solid var(--ivica);">
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Ukupno rad</span>
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .UkupnoRadovi}} din</span>
</div>
</div>
{{end}}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;padding-top:12px;border-top:0.5px solid var(--ivica);">
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Ukupno</span>
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .UkupnoSve}} din</span>
<!-- Delovi — listing pa subtotal -->
{{if .ServisniDelovi}}
<div style="margin-top:6px;padding-top:12px;border-top:0.5px solid var(--ivica);">
<div style="display:inline-flex;align-items:center;gap:8px;padding:6px 12px;background:color-mix(in srgb, var(--zelena, #22c55e) 10%, transparent);border-left:3px solid var(--zelena, #22c55e);border-radius:6px;font-size:13px;font-weight:600;color:var(--tekst-glavni);margin-bottom:10px;">
💾 Ugrađeni delovi
</div>
<div class="kolona" style="gap:6px;">
{{range .ServisniDelovi}}
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;">
<span style="color:var(--tekst-glavni);">{{.ArtikalNaziv}}{{if gt .Kolicina 1}} ×{{.Kolicina}}{{end}}</span>
<span style="color:var(--tekst-sporedni);">{{dinari .Ukupno}} din</span>
</div>
{{end}}
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;padding-top:8px;border-top:0.5px solid var(--ivica);">
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">Ukupno delovi</span>
<span style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">{{dinari .UkupnoDelovi}} din</span>
</div>
</div>
{{end}}
{{end}}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;padding-top:14px;border-top:1px solid var(--ivica);">
<span style="font-size:16px;font-weight:700;color:var(--tekst-glavni);">UKUPNO</span>
<span style="font-size:20px;font-weight:700;color:var(--tekst-glavni);">{{dinari .UkupnoSve}} din</span>
</div>
</div>
</div>
+31 -5
View File
@@ -161,11 +161,15 @@
<!-- opis izvršenih radova -->
<div class="odeljak">
<div class="odeljak-naslov">Opis izvršenih radova</div>
{{if .Nalog.PopravkaOdbijena}}
<div class="polje-tekst">Izvršena je samo dijagnostika — klijent nije prihvatio ponuđenu popravku.</div>
{{else}}
<div class="polje-tekst">{{if .Nalog.OpisKvara}}{{.Nalog.OpisKvara}}{{else}}<span class="prazno"></span>{{end}}</div>
{{end}}
</div>
<!-- radovi -->
{{if .Radovi}}
<!-- radovi — samo ako nije odbijena popravka -->
{{if and .Radovi (not .Nalog.PopravkaOdbijena)}}
<div class="odeljak">
<div class="odeljak-naslov">Izvršeni radovi</div>
<table class="tabela">
@@ -191,8 +195,8 @@
</div>
{{end}}
<!-- ugrađeni delovi -->
{{if .ServisniDelovi}}
<!-- ugrađeni delovi — samo ako nije odbijena popravka -->
{{if and .ServisniDelovi (not .Nalog.PopravkaOdbijena)}}
<div class="odeljak">
<div class="odeljak-naslov">Ugrađeni delovi i materijal</div>
<table class="tabela">
@@ -223,7 +227,29 @@
{{end}}
<!-- iznos za naplatu -->
{{if .Nalog.CenaKonacna}}
{{if .Nalog.PopravkaOdbijena}}
<div class="odeljak">
<div class="odeljak-naslov">Obračun</div>
<table class="tabela" style="margin-bottom:10px;">
<tbody>
<tr>
<td>Dijagnostika (popravka odbijena)</td>
<td class="desno" style="width:160px;">{{.Nalog.CenaDijagnostikeStr}} din</td>
</tr>
{{if .ImaAvans}}
<tr>
<td>Avans (plaćeno)</td>
<td class="desno"> {{.Nalog.AvansStr}} din</td>
</tr>
{{end}}
</tbody>
</table>
<div class="naplata-blok">
<div class="naplata-labela">Za naplatu pri preuzimanju:</div>
<div class="naplata-iznos">{{dinari .PreostaloSve}} din</div>
</div>
</div>
{{else if .Nalog.CenaKonacna}}
<div class="odeljak">
<div class="odeljak-naslov">Obračun</div>
<table class="tabela" style="margin-bottom:10px;">
@@ -207,6 +207,13 @@
<span class="red-vrednost">{{garancijaTekst .Nalog.GarancijaDana}}</span>
</div>
{{if .Nalog.DatumZavrsetka}}
<div class="red">
<span class="red-labela">Datum završetka</span>
<span class="red-vrednost">{{.Nalog.DatumZavrsetka.Format "02.01.2006."}}</span>
</div>
{{end}}
{{if .Nalog.NalazDijagnostike}}
<div style="margin-top:12px;">
<div class="red-labela" style="margin-bottom:6px;">Utvrđeno na dijagnostici</div>
@@ -216,6 +223,18 @@
</div>
{{if .Nalog.PopravkaOdbijena}}
<!-- klijent odbio popravku — samo dijagnostika -->
<div class="kartica" style="border-color:#ef4444;">
<div class="kartica-naslov" style="color:#ef4444;">✕ Popravka je odbijena</div>
<p style="font-size:13px;color:#94a3b8;margin:8px 0 0;">Klijent nije prihvatio ponuđenu popravku.</p>
<div style="margin-top:10px;display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#1e293b;border-radius:6px;">
<span style="font-size:13px;color:#94a3b8;">Cena dijagnostike</span>
<span style="font-size:16px;font-weight:700;color:#fbbf24;">{{.Nalog.CenaDijagnostikeStr}} din</span>
</div>
</div>
{{end}}
<!-- kartica: radovi i delovi sa procenom -->
{{if or .Radovi .ServisniDelovi}}
<div class="kartica">