Compare commits
27
Commits
3a0f98622b
...
89eb4c09a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89eb4c09a5
|
||
|
|
4a94db7684
|
||
|
|
54e9439430
|
||
|
|
683764fd8a
|
||
|
|
edba23ae50
|
||
|
|
c87dc67857
|
||
|
|
524c6de6ba
|
||
|
|
85a2ee1b64
|
||
|
|
b35b41b82e
|
||
|
|
576d963f28
|
||
|
|
c82b17f9c1
|
||
|
|
fb80ee910c
|
||
|
|
7c215e70e8
|
||
|
|
56e52036b6
|
||
|
|
fcda706edd
|
||
|
|
ca1c8c8241
|
||
|
|
5b39dfd222
|
||
|
|
1027cfa526
|
||
|
|
d0b08f7013
|
||
|
|
485adffdfb
|
||
|
|
a606348180
|
||
|
|
9899c280c8
|
||
|
|
f74cb04024
|
||
|
|
3c61063bbb
|
||
|
|
2cc9c24aa9
|
||
|
|
e26bda5d34
|
||
|
|
45831467e0
|
@@ -6,9 +6,24 @@ Prati izgled definisan u LPFR VM template-u i locale fajlovima.
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import io
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
_FONT_NAME = "Courier"
|
||||
_FONT_PATH = Path("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf")
|
||||
if _FONT_PATH.exists():
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont("DejaVuSansMono", str(_FONT_PATH)))
|
||||
_FONT_NAME = "DejaVuSansMono"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_locale(lang="latin"):
|
||||
"""Učitava lokalizacioni fajl u dict."""
|
||||
filename = f"locale_{lang}.properties"
|
||||
@@ -646,3 +661,30 @@ def generate_report(report_data, lang="latin"):
|
||||
|
||||
lines.append(separator("=", W))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_report_pdf(report_text):
|
||||
"""Renderuje monospace tekst izveštaja (48 kolona) u pravi PDF (A4, Courier).
|
||||
Vraća bajtove gotovog PDF fajla."""
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=A4)
|
||||
width, height = A4
|
||||
|
||||
font_name = _FONT_NAME
|
||||
font_size = 10
|
||||
line_height = font_size * 1.15
|
||||
margin_left = 20 * (72 / 25.4) # 20mm u tačkama
|
||||
margin_top = 15 * (72 / 25.4) # 15mm u tačkama
|
||||
|
||||
c.setFont(font_name, font_size)
|
||||
y = height - margin_top
|
||||
for line in report_text.split("\n"):
|
||||
if y < margin_top:
|
||||
c.showPage()
|
||||
c.setFont(font_name, font_size)
|
||||
y = height - margin_top
|
||||
c.drawString(margin_left, y, line)
|
||||
y -= line_height
|
||||
c.showPage()
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
qrcode
|
||||
pillow
|
||||
reportlab
|
||||
|
||||
+155
-1
@@ -21,7 +21,7 @@ import socket
|
||||
import urllib.parse
|
||||
|
||||
import qrcode
|
||||
from receipt import generate_receipt, generate_receipt_html, generate_report, load_locale
|
||||
from receipt import generate_receipt, generate_receipt_html, generate_report, render_report_pdf, load_locale
|
||||
|
||||
# ── Konfiguracija ──────────────────────────────────────────
|
||||
PORT = 4566 # Teron standard port
|
||||
@@ -38,6 +38,7 @@ QR_DIR = DATA_DIR / "qr"
|
||||
RECEIPTS_DIR = DATA_DIR / "receipts"
|
||||
LOG_FILE = DATA_DIR / "server.log"
|
||||
COUNTER_DIR = DATA_DIR / "counters"
|
||||
PERIOD_FILE = DATA_DIR / "period_start.txt"
|
||||
|
||||
for d in [DATA_DIR, INVOICES_DIR, QR_DIR, RECEIPTS_DIR, COUNTER_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -525,6 +526,151 @@ def resp_invoice_search(request_body=None):
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Dnevni pazar / presek stanja ───────────────────────────
|
||||
|
||||
def get_period_start():
|
||||
"""Vreme poslednjeg preseka stanja; ako ga još nema, postavlja se na sada."""
|
||||
if PERIOD_FILE.exists():
|
||||
return PERIOD_FILE.read_text().strip()
|
||||
ts = sada()
|
||||
PERIOD_FILE.write_text(ts)
|
||||
return ts
|
||||
|
||||
def reset_period():
|
||||
"""Zatvara tekući period (presek stanja) — sledeći GET summary počinje od sada."""
|
||||
PERIOD_FILE.write_text(sada())
|
||||
|
||||
def compute_summary(from_iso=None, to_iso=None):
|
||||
"""Sabira promet iz sačuvanih fiskalnih računa u zadatom periodu.
|
||||
Bez argumenata: od poslednjeg preseka stanja (get_period_start) do sada."""
|
||||
start = from_iso or get_period_start()
|
||||
end = to_iso or sada()
|
||||
|
||||
total = 0.0
|
||||
total_cash = 0.0
|
||||
count = 0
|
||||
by_tax = {}
|
||||
by_cashier = {}
|
||||
by_payment = {}
|
||||
by_article = {}
|
||||
by_article_advance = {}
|
||||
|
||||
for f in sorted(INVOICES_DIR.glob("*.json")):
|
||||
try:
|
||||
inv = json.loads(f.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
t = inv.get("sdcDateTime", "")
|
||||
if not t or not (start <= t <= end) or not inv.get("isFiscal", True):
|
||||
continue
|
||||
count += 1
|
||||
|
||||
amount = float(inv.get("totalAmount", 0))
|
||||
is_refund = inv.get("transactionType") == "Refund"
|
||||
znak = -1 if is_refund else 1
|
||||
total += znak * amount
|
||||
|
||||
for p in inv.get("payments", []):
|
||||
ptype = p.get("paymentType") or p.get("type") or "Other"
|
||||
pamt = znak * float(p.get("amount", 0))
|
||||
by_payment[ptype] = by_payment.get(ptype, 0.0) + pamt
|
||||
if ptype == "Cash":
|
||||
total_cash += pamt
|
||||
|
||||
kasir = inv.get("cashier", "Kasir")
|
||||
by_cashier[kasir] = by_cashier.get(kasir, 0.0) + znak * amount
|
||||
|
||||
for ti in inv.get("taxItems", []):
|
||||
label = ti.get("label", "")
|
||||
if label not in by_tax:
|
||||
by_tax[label] = {"label": label, "rate": ti.get("rate", 0),
|
||||
"category": "VAT" if ti.get("rate", 0) > 0 else "N-TAX",
|
||||
"amount": 0.0, "osnovica": 0.0}
|
||||
by_tax[label]["amount"] += znak * float(ti.get("amount", 0))
|
||||
|
||||
cilj = by_article_advance if inv.get("invoiceType") == "Advance" else by_article
|
||||
for item in inv.get("items", []):
|
||||
naziv = item.get("name", "")
|
||||
if naziv not in cilj:
|
||||
cilj[naziv] = {"articleName": naziv, "gtin": None, "plu": None,
|
||||
"taxLabel": (item.get("labels") or [""])[0],
|
||||
"amount": 0.0, "quantity": 0.0}
|
||||
cilj[naziv]["amount"] += znak * float(item.get("totalAmount", 0))
|
||||
cilj[naziv]["quantity"] += znak * float(item.get("quantity", 0))
|
||||
for label in item.get("labels", []):
|
||||
if label in by_tax:
|
||||
by_tax[label]["osnovica"] += znak * float(item.get("totalAmount", 0))
|
||||
|
||||
return {
|
||||
"startOfPeriod": start,
|
||||
"endOfPeriod": end,
|
||||
"invoiceCount": count,
|
||||
"total": round(total, 2),
|
||||
"totalCash": round(total_cash, 2),
|
||||
"totalByTax": [
|
||||
{"amount": round(v["amount"], 4), "category": v["category"], "label": v["label"],
|
||||
"rate": v["rate"], "osnovica": round(v["osnovica"], 2)}
|
||||
for v in by_tax.values()
|
||||
],
|
||||
"totalByCashier": [{"amount": round(v, 2), "name": k} for k, v in by_cashier.items()],
|
||||
"totalByPaymentType": [{"amount": round(v, 2), "paymentType": k} for k, v in by_payment.items()],
|
||||
"totalByArticle": [
|
||||
{**v, "amount": round(v["amount"], 2)} for v in by_article.values()
|
||||
],
|
||||
"totalByArticleAdvance": [
|
||||
{**v, "amount": round(v["amount"], 2)} for v in by_article_advance.values()
|
||||
],
|
||||
}
|
||||
|
||||
def resp_financial_summary_get():
|
||||
return compute_summary()
|
||||
|
||||
def resp_financial_summary_delete():
|
||||
reset_period()
|
||||
log(" 📊 Presek stanja urađen — brojači prometa resetovani")
|
||||
return ("", 204)
|
||||
|
||||
def resp_financial_report_summary(request_body=None):
|
||||
body = request_body or {}
|
||||
from_date = body.get("fromDate")
|
||||
to_date = body.get("toDate")
|
||||
from_iso = f"{from_date}T00:00:00.000+02:00" if from_date else None
|
||||
to_iso = f"{to_date}T23:59:59.999+02:00" if to_date else None
|
||||
summary = compute_summary(from_iso, to_iso)
|
||||
|
||||
cert = be_command({"command": "certificate"})
|
||||
firma = ucitaj_firmu()
|
||||
lang = "cyrillic" if str(body.get("language", "")).lower().startswith("sr-cyrl") else "latin"
|
||||
title = "ДНЕВНИ ИЗВЕШТАЈ" if lang == "cyrillic" else "DNEVNI IZVEŠTAJ"
|
||||
|
||||
report_data = {
|
||||
"title": title,
|
||||
"number": get_counter("report"),
|
||||
"dateTime": sada(),
|
||||
"tin": firma["tinPlain"],
|
||||
"businessName": firma["name"],
|
||||
"locationName": firma["locationName"],
|
||||
"address": firma["address"],
|
||||
"district": firma["district"],
|
||||
"uid": cert.get("jid", ESIR_ID),
|
||||
"startDate": (from_date or summary["startOfPeriod"][:10]),
|
||||
"endDate": (to_date or summary["endOfPeriod"][:10]),
|
||||
"total": {
|
||||
"invoiceCount": summary["invoiceCount"],
|
||||
"payments": [{"paymentType": p["paymentType"], "amount": p["amount"]} for p in summary["totalByPaymentType"]],
|
||||
"totalPayments": summary["total"],
|
||||
"taxItems": [{"label": t["label"], "rate": t["rate"], "total": t["osnovica"], "amount": t["amount"]} for t in summary["totalByTax"]],
|
||||
"totalTax": round(sum(t["amount"] for t in summary["totalByTax"]), 2),
|
||||
},
|
||||
"perTransactionType": [],
|
||||
}
|
||||
tekst = generate_report(report_data, lang)
|
||||
pdf_bytes = render_report_pdf(tekst)
|
||||
pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii")
|
||||
filename = f"{title} - {report_data['startDate']} - {report_data['endDate']}.pdf"
|
||||
log(f" 📄 Dnevni izveštaj #{report_data['number']} generisan ({summary['invoiceCount']} računa)")
|
||||
return {"reportPdfBase64": pdf_b64, "reportName": title, "filename": filename}
|
||||
|
||||
# ── Rute ────────────────────────────────────────────────────
|
||||
|
||||
ROUTES = [
|
||||
@@ -540,6 +686,9 @@ ROUTES = [
|
||||
("GET", "api/invoices/:invoiceNumber", "invoice_by_number"),
|
||||
("POST", "api/invoices/search", "invoice_search"),
|
||||
("POST", "api/invoices", "invoice"),
|
||||
("GET", "api/financial/summary", "financial_summary_get"),
|
||||
("DELETE", "api/financial/summary", "financial_summary_delete"),
|
||||
("POST", "api/financial/report/summary", "financial_report_summary"),
|
||||
]
|
||||
|
||||
HANDLERS = {
|
||||
@@ -555,6 +704,9 @@ HANDLERS = {
|
||||
"invoice_by_request": resp_invoice_by_request,
|
||||
"invoice_by_number": resp_invoice_by_number,
|
||||
"invoice_search": resp_invoice_search,
|
||||
"financial_summary_get": resp_financial_summary_get,
|
||||
"financial_summary_delete": resp_financial_summary_delete,
|
||||
"financial_report_summary": resp_financial_report_summary,
|
||||
}
|
||||
|
||||
# ── HTTP Handler ─────────────────────────────────────────────
|
||||
@@ -619,6 +771,8 @@ class FiscalHandler(http.server.BaseHTTPRequestHandler):
|
||||
result = handler(params.get("invoiceNumber", ""))
|
||||
elif r_name == "invoice_search":
|
||||
result = handler(self._read_body())
|
||||
elif r_name == "financial_report_summary":
|
||||
result = handler(self._read_body())
|
||||
elif r_name in ("settings_post",):
|
||||
self._read_body()
|
||||
result = handler()
|
||||
|
||||
@@ -291,6 +291,9 @@ func main() {
|
||||
r.Get("/podesavanja/fiskalizacija/test", h.TestFiskalizacije)
|
||||
r.Get("/podesavanja/fiskalizacija/be-status", h.BeStatus)
|
||||
r.Post("/podesavanja/fiskalizacija/be-reset-audit", h.BeResetAudit)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "fiskal.pazar")).Get("/fiskal/pazar", h.FiskalniPazar)
|
||||
r.With(doz("fiskal.pazar")).Post("/fiskal/pazar/izvestaj", h.FiskalniIzvestaj)
|
||||
r.With(doz("fiskal.zakljucenje")).Post("/fiskal/pazar/zakljuci", h.ZakljuciFiskalniDan)
|
||||
r.Get("/admin/podesavanja/kalkulacija-pdv", h.PdvStope)
|
||||
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/dodaj", h.DodajPdvStopu)
|
||||
r.With(doz("podesavanja.izmeni")).Post("/podesavanja/pdv-stope/{id}/izmeni", h.IzmeniPdvStopu)
|
||||
@@ -312,6 +315,7 @@ func main() {
|
||||
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/nova", h.SacuvajPdvKir)
|
||||
r.With(modul("pdv"), doz("pdv.obrisi")).Post("/pdv/kir/obrisi/{id}", h.ObrisiPdvKir)
|
||||
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-prodaje", h.KirBackfillProdaje)
|
||||
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/backfill-servisa", h.KirBackfillServisa)
|
||||
r.With(modul("pdv"), doz("pdv.dodaj")).Get("/pdv/kir/dnevni-pazar", h.DnevniPazarKir)
|
||||
r.With(modul("pdv"), doz("pdv.dodaj")).Post("/pdv/kir/dnevni-pazar", h.SacuvajDnevniPazarKir)
|
||||
r.With(modul("pdv")).Get("/pdv/kpr", h.PdvKpr)
|
||||
@@ -380,6 +384,7 @@ func main() {
|
||||
r.Get("/servis/izmeni/{id}", h.IzmeniNalog)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/izmeni/{id}", h.SacuvajIzmenaNaloga)
|
||||
r.With(doz("servis.obrisi")).Post("/servis/obrisi/{id}", h.ObrisiNalog)
|
||||
r.With(doz("servis.storno")).Post("/servis/storno/{id}", h.StornoNaloga)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}", h.DetaljiNaloga)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/radni-nalog", h.StampaRadnogNaloga)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/otpremnica", h.StampaOtpremnice)
|
||||
@@ -390,6 +395,7 @@ func main() {
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/garantni-list", h.StampaGarantnog)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "servis.pregled")).Get("/servis/{id}/eskalacioni-list", h.StampaEskalacionogLista)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/fiskalizuj", h.RetryFiskalizacija)
|
||||
r.With(doz("servis.storno")).Post("/servis/{id}/refund-fiskalizuj", h.PokusajRefundServisa)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/status", h.PromeniStatus)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija", h.AzurirajGaranciju)
|
||||
r.With(doz("servis.izmeni")).Post("/servis/{id}/garancija-dana", h.AzurirajGarancijaDana)
|
||||
@@ -418,6 +424,7 @@ func main() {
|
||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/nova", h.SacuvajProdaju)
|
||||
r.With(doz("prodaja.storno")).Post("/prodaja/storno/{id}", h.StornoProdaje)
|
||||
r.With(doz("prodaja.dodaj")).Post("/prodaja/{id}/fiskalizuj", h.RetryFiskalizacijaProdaje)
|
||||
r.With(doz("prodaja.storno")).Post("/prodaja/{id}/refund-fiskalizuj", h.PokusajRefundProdaje)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/stampa", h.StampaProdaje)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}/fiskalni-racun", h.StampaFiskalnogProdaje)
|
||||
r.With(ntechmw.RequireDozvola(h.DozvoleRepo.ImaDozvolu, "prodaja.pregled")).Get("/prodaja/{id}", h.DetaljiProdaje)
|
||||
@@ -450,6 +457,8 @@ func main() {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(ntechmw.RequireSuperAdmin)
|
||||
r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu)
|
||||
r.Get("/admin/blokirane-ip", h.AdminBlokiraneIP)
|
||||
r.Post("/admin/blokirane-ip/odblokiraj", h.AdminOdblokirajIP)
|
||||
})
|
||||
r.Get("/admin/profil", h.AdminProfil)
|
||||
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
|
||||
|
||||
@@ -58,6 +58,9 @@ type PdvStopaRepository interface {
|
||||
Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, error)
|
||||
Izmeni(ctx context.Context, s *model.PdvStopa) error
|
||||
PostaviAktivnu(ctx context.Context, id int64, aktivna bool) error
|
||||
// PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu),
|
||||
// ili nil ako u šifarniku nema nijedne aktivne opšte stope.
|
||||
PodrazumevanaOpsta(ctx context.Context) (*model.PdvStopa, error)
|
||||
}
|
||||
|
||||
// PdvKirRepository definiše operacije nad knjigom izdatih računa (KIR)
|
||||
@@ -66,8 +69,9 @@ type PdvKirRepository interface {
|
||||
DohvatiID(ctx context.Context, id int64) (*model.PdvKir, error)
|
||||
Kreiraj(ctx context.Context, k *model.PdvKir) (int64, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri stornu prodaje)
|
||||
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
|
||||
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje —
|
||||
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
|
||||
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error)
|
||||
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
|
||||
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
|
||||
// PostojiPoBrojuDokumenta vraća true ako postoji zapis sa datim brojem dokumenta
|
||||
@@ -83,8 +87,9 @@ type PdvKprRepository interface {
|
||||
DohvatiID(ctx context.Context, id int64) (*model.PdvKpr, error)
|
||||
Kreiraj(ctx context.Context, k *model.PdvKpr) (int64, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
// ObrisiPoIzvoru briše zapise vezane za dati izvor (npr. pri brisanju nabavke)
|
||||
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
|
||||
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno nabavke —
|
||||
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
|
||||
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error)
|
||||
// PostojiZaIzvor vraća true ako postoji bar jedan zapis za dati izvor i izvorID
|
||||
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
|
||||
}
|
||||
@@ -215,6 +220,9 @@ type ServisRepository interface {
|
||||
SacuvajOdlukuKlijenta(ctx context.Context, id int64, odluka string, odgovor string) error
|
||||
ObrisiOdlukuKlijenta(ctx context.Context, id int64) error
|
||||
Obrisi(ctx context.Context, id int64, korisnikID *int64) error
|
||||
// Storno stornira nalog (vraća ugrađene delove na stanje, markira stornirano=1)
|
||||
// bez brisanja — koristi se kad nalog već ima finansijske zapise (KIR/KPO/fiskalni)
|
||||
Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error
|
||||
SledeciBroj(ctx context.Context) (string, error)
|
||||
SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error
|
||||
}
|
||||
@@ -331,6 +339,10 @@ type PokusajiPrijaveRepository interface {
|
||||
BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error)
|
||||
VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error)
|
||||
ObrisiStare(ctx context.Context, pre time.Time) error
|
||||
// ListaBlokiranih vraća IP adrese sa >= prag neuspelih pokušaja od zadatog trenutka
|
||||
ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error)
|
||||
// Odblokiraj briše neuspele pokušaje za IP adresu, čime se ona odmah oslobađa blokade
|
||||
Odblokiraj(ctx context.Context, ip string) error
|
||||
}
|
||||
|
||||
// LoginIstorijsaRepository definiše operacije nad evidencijom prijava
|
||||
@@ -371,6 +383,8 @@ type IzvestajRepository interface {
|
||||
// izveštaji
|
||||
MesecniPrihodProdaja(ctx context.Context) ([]model.MesecniIznos, error)
|
||||
MesecniPrihodServis(ctx context.Context) ([]model.MesecniIznos, error)
|
||||
MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error)
|
||||
MesecniZaradaServis(ctx context.Context) ([]model.MesecniIznos, error)
|
||||
StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error)
|
||||
TopArtikli(ctx context.Context, limit int) ([]model.TopArtikalRed, error)
|
||||
TopKlijenti(ctx context.Context, limit int) ([]model.TopKlijentRed, error)
|
||||
@@ -396,6 +410,11 @@ type FiskalRepository interface {
|
||||
Kreiraj(ctx context.Context, fr *model.FiskalniRacun) (int64, error)
|
||||
DohvatiPoProdaji(ctx context.Context, prodajaID int64) (*model.FiskalniRacun, error)
|
||||
DohvatiPoServisu(ctx context.Context, servisID int64) (*model.FiskalniRacun, error)
|
||||
// DohvatiPoServisuITip vraća poslednji nestorniran račun tačno određenog tipa
|
||||
// (npr. avansni Sale) — za razliku od DohvatiPoServisu (samo najnoviji, bilo kog tipa)
|
||||
DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error)
|
||||
// SumaAvansaPoServisu vraća neto fiskalizovan avans (Sale - Refund) za nalog
|
||||
SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error)
|
||||
OznačiKaoStorniran(ctx context.Context, id int64) error
|
||||
ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
||||
ProdajeBezFiskalnog(ctx context.Context) (map[int64]bool, error)
|
||||
@@ -413,5 +432,7 @@ type KpoRepository interface {
|
||||
Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error)
|
||||
Obrisi(ctx context.Context, id int64) error
|
||||
PostojiZaIzvor(ctx context.Context, izvor string, izvorID int64) (bool, error)
|
||||
ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error
|
||||
// DohvatiPoIzvoru vraća zapise vezane za dati izvor (npr. za storno prodaje —
|
||||
// original se ne briše, storno stavka se dograđuje na osnovu ovih zapisa)
|
||||
DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,61 @@ func (r *FiskalRepo) DohvatiPoServisu(ctx context.Context, servisID int64) (*mod
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
// DohvatiPoServisuITip vraća poslednji nestorniran fiskalni račun za dati servisni
|
||||
// nalog sa tačno određenim tipom (npr. tip_racuna="Advance", tip_transakcije="Sale"
|
||||
// za avansni račun) — za razliku od DohvatiPoServisu koji vraća samo najnoviji
|
||||
// zapis bilo kog tipa. Vraća nil bez greške ako takav račun ne postoji.
|
||||
func (r *FiskalRepo) DohvatiPoServisuITip(ctx context.Context, servisID int64, tipRacuna, tipTransakcije string) (*model.FiskalniRacun, error) {
|
||||
fr := &model.FiskalniRacun{}
|
||||
var storniran int
|
||||
var sID, pID sql.NullInt64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, prodaja_id, servis_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 servis_id = ? AND tip_racuna = ? AND tip_transakcije = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, servisID, tipRacuna, tipTransakcije).Scan(
|
||||
&fr.ID, &pID, &sID, &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
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: FiskalRepo.DohvatiPoServisuITip: %w", err)
|
||||
}
|
||||
if pID.Valid {
|
||||
fr.ProdajaID = pID.Int64
|
||||
}
|
||||
if sID.Valid {
|
||||
fr.ServisID = sID.Int64
|
||||
}
|
||||
fr.Storniran = storniran != 0
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
// SumaAvansaPoServisu vraća neto fiskalizovan avans za dati servisni nalog —
|
||||
// zbir Advance/Sale iznosa umanjen za Advance/Refund iznose (nestornirani).
|
||||
// Koristi se da se pri izmeni avansa fiskalizuje samo razlika (delta), ne ceo
|
||||
// novi iznos ponovo.
|
||||
func (r *FiskalRepo) SumaAvansaPoServisu(ctx context.Context, servisID int64) (float64, error) {
|
||||
var suma float64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(CASE WHEN tip_transakcije = 'Refund' THEN -ukupno_za_naplatu ELSE ukupno_za_naplatu END), 0)
|
||||
FROM fiskalni_racuni
|
||||
WHERE servis_id = ? AND tip_racuna = 'Advance' AND storniran = 0
|
||||
`, servisID).Scan(&suma)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: FiskalRepo.SumaAvansaPoServisu: %w", err)
|
||||
}
|
||||
return suma, nil
|
||||
}
|
||||
|
||||
// ServisiBezFiskalnog vraća skup ID-eva servisnih naloga koji su u statusu "Preuzeto"
|
||||
// a nemaju fiskalni račun (za oznaku u listi naloga).
|
||||
func (r *FiskalRepo) ServisiBezFiskalnog(ctx context.Context) (map[int64]bool, error) {
|
||||
|
||||
@@ -165,6 +165,42 @@ func (r *sqliteIzvestajRepo) MesecniPrihodServis(ctx context.Context) ([]model.M
|
||||
GROUP BY substr(datum_zavrsetka, 1, 7)`, "MesecniPrihodServis")
|
||||
}
|
||||
|
||||
// MesecniZaradaProdaja je zarada prodaje (ukupno − nabavna cena × količina),
|
||||
// nabavna_cena je zamrznuta na stavci u trenutku prodaje (vidi migraciju 101).
|
||||
func (r *sqliteIzvestajRepo) MesecniZaradaProdaja(ctx context.Context) ([]model.MesecniIznos, error) {
|
||||
return r.mesecniPrihod(ctx, `
|
||||
SELECT substr(pn.datum, 1, 7), SUM(sp.ukupno - sp.nabavna_cena * sp.kolicina)
|
||||
FROM prodajni_nalozi pn
|
||||
JOIN stavke_prodaje sp ON sp.nalog_id = pn.id
|
||||
WHERE pn.stornirano = 0
|
||||
AND substr(pn.datum, 1, 10) >= date('now', '-11 months', 'start of month')
|
||||
GROUP BY substr(pn.datum, 1, 7)`, "MesecniZaradaProdaja")
|
||||
}
|
||||
|
||||
// MesecniZaradaServis je zarada servisa: ugrađeni delovi (cena − nabavna cena) +
|
||||
// rad u punom iznosu (servis_radovi nema polje za trošak). Isti filter statusa/
|
||||
// naplate kao MesecniPrihodServis.
|
||||
func (r *sqliteIzvestajRepo) MesecniZaradaServis(ctx context.Context) ([]model.MesecniIznos, error) {
|
||||
return r.mesecniPrihod(ctx, `
|
||||
SELECT mesec, SUM(zarada) FROM (
|
||||
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
|
||||
(sd.cena_komada - sd.nabavna_cena) * sd.kolicina AS zarada
|
||||
FROM servisni_delovi sd
|
||||
JOIN servisni_nalozi sn ON sn.id = sd.nalog_id
|
||||
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
|
||||
AND (sn.naplaceno > 0 OR sn.avans > 0) AND sd.predlozeno = 0
|
||||
UNION ALL
|
||||
SELECT substr(sn.datum_zavrsetka, 1, 7) AS mesec,
|
||||
sr.cena_komada * sr.kolicina AS zarada
|
||||
FROM servis_radovi sr
|
||||
JOIN servisni_nalozi sn ON sn.id = sr.nalog_id
|
||||
WHERE sn.datum_zavrsetka IS NOT NULL AND sn.status = 'Preuzeto'
|
||||
AND (sn.naplaceno > 0 OR sn.avans > 0)
|
||||
)
|
||||
WHERE mesec >= strftime('%Y-%m', date('now', '-11 months', 'start of month'))
|
||||
GROUP BY mesec`, "MesecniZaradaServis")
|
||||
}
|
||||
|
||||
func (r *sqliteIzvestajRepo) StariOtvoreniNalozi(ctx context.Context) ([]model.StariNalogRed, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT sn.id, sn.broj_naloga, sn.uredjaj, sn.status, sn.datum_prijema,
|
||||
|
||||
@@ -70,12 +70,14 @@ func (r *KpoRepo) Lista(ctx context.Context, od, do time.Time) ([]model.KpoZapis
|
||||
return rezultat, redovi.Err()
|
||||
}
|
||||
|
||||
// Kreiraj upisuje novi KPO zapis i vraća njegov ID.
|
||||
// Kreiraj upisuje novi KPO zapis i vraća njegov ID. redni_broj se dodeljuje
|
||||
// automatski kao sledeći u nizu (Pravilnik o KPO zahteva kontinuiran redni broj
|
||||
// bez prekida — knjiga se nikad ne briše, pa brojevi ostaju u nizu i posle storna).
|
||||
func (r *KpoRepo) Kreiraj(ctx context.Context, z *model.KpoZapis) (int64, error) {
|
||||
rez, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO kpo_unosi
|
||||
(datum_prometa, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(datum_prometa, redni_broj, broj_dokumenta, opis, prihod, nacin_placanja, napomena, izvor, izvor_id)
|
||||
VALUES (?, (SELECT COALESCE(MAX(redni_broj), 0) + 1 FROM kpo_unosi), ?, ?, ?, ?, ?, ?, ?)`,
|
||||
z.DatumPrometa.Format("2006-01-02"),
|
||||
z.BrojDokumenta,
|
||||
nullString(z.Opis),
|
||||
@@ -115,10 +117,41 @@ func (r *KpoRepo) PostojiZaIzvor(ctx context.Context, izvor string, izvorID int6
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ObrisiPoIzvoru briše KPO zapise vezane za dati izvor (npr. pri stornu prodaje).
|
||||
func (r *KpoRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
|
||||
if _, err := r.db.ExecContext(ctx, "DELETE FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
|
||||
return fmt.Errorf("ntech: KpoRepo.ObrisiPoIzvoru: %w", err)
|
||||
// DohvatiPoIzvoru vraća sve KPO zapise vezane za dati izvor (npr. za storno prodaje).
|
||||
func (r *KpoRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.KpoZapis, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, datum_prometa, redni_broj, broj_dokumenta, opis, prihod,
|
||||
nacin_placanja, napomena, izvor, izvor_id, datum_unosa
|
||||
FROM kpo_unosi WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: %w", err)
|
||||
}
|
||||
return nil
|
||||
defer redovi.Close()
|
||||
|
||||
var rezultat []model.KpoZapis
|
||||
for redovi.Next() {
|
||||
var z model.KpoZapis
|
||||
var opis, nacinPlacanja, napomena sql.NullString
|
||||
var redniBroj sql.NullInt64
|
||||
var izvorIDCol sql.NullInt64
|
||||
|
||||
if err := redovi.Scan(
|
||||
&z.ID, &z.DatumPrometa, &redniBroj, &z.BrojDokumenta, &opis, &z.Prihod,
|
||||
&nacinPlacanja, &napomena, &z.Izvor, &izvorIDCol, &z.DatumUnosa,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("ntech: KpoRepo.DohvatiPoIzvoru: scan: %w", err)
|
||||
}
|
||||
if redniBroj.Valid {
|
||||
n := int(redniBroj.Int64)
|
||||
z.RedniBroj = &n
|
||||
}
|
||||
z.Opis = opis.String
|
||||
z.NacinPlacanja = nacinPlacanja.String
|
||||
z.Napomena = napomena.String
|
||||
if izvorIDCol.Valid {
|
||||
z.IzvorID = &izvorIDCol.Int64
|
||||
}
|
||||
rezultat = append(rezultat, z)
|
||||
}
|
||||
return rezultat, redovi.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// TestKpoRedniBroj: redni_broj mora biti dodeljen automatski i bez prekida —
|
||||
// Pravilnik o KPO zahteva kontinuiran niz, i knjiga se nikad ne briše (storno
|
||||
// se dograđuje kao nov red, ne zamenjuje stari).
|
||||
func TestKpoRedniBroj(t *testing.T) {
|
||||
db := testDB(t)
|
||||
repo := NoviKpoRepo(db)
|
||||
ctx := context.Background()
|
||||
dp := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, brojDok := range []string{"SN-1", "SN-2", "SN-3"} {
|
||||
if _, err := repo.Kreiraj(ctx, &model.KpoZapis{
|
||||
DatumPrometa: dp, BrojDokumenta: brojDok, Prihod: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("Kreiraj %s: %v", brojDok, err)
|
||||
}
|
||||
}
|
||||
|
||||
zapisi, err := repo.Lista(ctx, time.Time{}, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("Lista: %v", err)
|
||||
}
|
||||
if len(zapisi) != 3 {
|
||||
t.Fatalf("očekivano 3 zapisa, dobijeno %d", len(zapisi))
|
||||
}
|
||||
for i, z := range zapisi {
|
||||
if z.RedniBroj == nil || *z.RedniBroj != i+1 {
|
||||
t.Errorf("zapis %q: redni_broj=%v, očekivano %d", z.BrojDokumenta, z.RedniBroj, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,12 +128,32 @@ func (r *PdvKirRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObrisiPoIzvoru briše KIR zapise vezane za dati izvor (npr. pri stornu/brisanju prodaje)
|
||||
func (r *PdvKirRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
|
||||
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kir WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
|
||||
return fmt.Errorf("ntech: PdvKirRepo.ObrisiPoIzvoru: %w", err)
|
||||
// DohvatiPoIzvoru vraća sve KIR zapise vezane za dati izvor (npr. za storno prodaje)
|
||||
func (r *PdvKirRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKir, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, datum_prometa, datum_knjizenja, broj_dokumenta,
|
||||
kupac_naziv, COALESCE(kupac_pib, ''), COALESCE(kupac_mesto, ''),
|
||||
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
|
||||
osloboden_sa_pravom, osloboden_bez_prava, ukupno,
|
||||
COALESCE(napomena, ''), izvor, izvor_id, datum_unosa
|
||||
FROM pdv_kir WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
|
||||
}
|
||||
return nil
|
||||
defer redovi.Close()
|
||||
|
||||
var rezultat []model.PdvKir
|
||||
for redovi.Next() {
|
||||
k, err := skenirajKir(redovi.Scan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: scan: %w", err)
|
||||
}
|
||||
rezultat = append(rezultat, k)
|
||||
}
|
||||
if err := redovi.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKirRepo.DohvatiPoIzvoru: %w", err)
|
||||
}
|
||||
return rezultat, nil
|
||||
}
|
||||
|
||||
// PostojiZaIzvor vraća true ako postoji bar jedan KIR zapis za dati izvor i izvorID
|
||||
@@ -318,12 +338,32 @@ func (r *PdvKprRepo) Obrisi(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObrisiPoIzvoru briše KPR zapise vezane za dati izvor (npr. pri brisanju nabavke)
|
||||
func (r *PdvKprRepo) ObrisiPoIzvoru(ctx context.Context, izvor string, izvorID int64) error {
|
||||
if _, err := r.db.ExecContext(ctx, "DELETE FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?", izvor, izvorID); err != nil {
|
||||
return fmt.Errorf("ntech: PdvKprRepo.ObrisiPoIzvoru: %w", err)
|
||||
// DohvatiPoIzvoru vraća sve KPR zapise vezane za dati izvor (npr. za storno nabavke)
|
||||
func (r *PdvKprRepo) DohvatiPoIzvoru(ctx context.Context, izvor string, izvorID int64) ([]model.PdvKpr, error) {
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, datum_prometa, datum_knjizenja, datum_placanja, broj_dokumenta,
|
||||
dobavljac_naziv, COALESCE(dobavljac_pib, ''), COALESCE(dobavljac_mesto, ''),
|
||||
osnovica_opsta, pdv_opsta, osnovica_posebna, pdv_posebna,
|
||||
pdv_bez_odbitka, osloboden_nabavka, ukupno,
|
||||
COALESCE(napomena, ''), izvor, izvor_id, uvoz, datum_unosa
|
||||
FROM pdv_kpr WHERE izvor = ? AND izvor_id = ?`, izvor, izvorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
|
||||
}
|
||||
return nil
|
||||
defer redovi.Close()
|
||||
|
||||
var rezultat []model.PdvKpr
|
||||
for redovi.Next() {
|
||||
k, err := skenirajKpr(redovi.Scan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: scan: %w", err)
|
||||
}
|
||||
rezultat = append(rezultat, k)
|
||||
}
|
||||
if err := redovi.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvKprRepo.DohvatiPoIzvoru: %w", err)
|
||||
}
|
||||
return rezultat, nil
|
||||
}
|
||||
|
||||
// PostojiZaIzvor vraća true ako postoji bar jedan KPR zapis za dati izvor i izvorID
|
||||
|
||||
@@ -162,12 +162,12 @@ func TestPdvKirIzvor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ObrisiPoIzvoru briše samo vezani auto zapis, ručni ostaje
|
||||
if err := repo.ObrisiPoIzvoru(ctx, "prodaja", 7); err != nil {
|
||||
t.Fatalf("ObrisiPoIzvoru: %v", err)
|
||||
// DohvatiPoIzvoru vraća samo vezani auto zapis
|
||||
poIzvoru, err := repo.DohvatiPoIzvoru(ctx, "prodaja", 7)
|
||||
if err != nil {
|
||||
t.Fatalf("DohvatiPoIzvoru: %v", err)
|
||||
}
|
||||
preostali, _ := repo.Lista(ctx, time.Time{}, time.Time{})
|
||||
if len(preostali) != 1 || preostali[0].BrojDokumenta != "R-1" {
|
||||
t.Errorf("posle ObrisiPoIzvoru očekivan samo ručni zapis, dobijeno %d", len(preostali))
|
||||
if len(poIzvoru) != 1 || poIzvoru[0].BrojDokumenta != "P-1" {
|
||||
t.Errorf("očekivan samo auto zapis P-1, dobijeno %d", len(poIzvoru))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,28 @@ func (r *PdvStopaRepo) Kreiraj(ctx context.Context, s *model.PdvStopa) (int64, e
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PodrazumevanaOpsta vraća aktivnu opštu stopu iz šifarnika (prva po redosledu),
|
||||
// ili nil ako u šifarniku nema nijedne aktivne opšte stope — poziocalac tada mora
|
||||
// sam odlučiti o fallback vrednosti (šifarnik nikad ne sme biti prazan u praksi,
|
||||
// ali ne oslanjamo se na to).
|
||||
func (r *PdvStopaRepo) PodrazumevanaOpsta(ctx context.Context) (*model.PdvStopa, error) {
|
||||
var s model.PdvStopa
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, naziv, stopa, oznaka, aktivna, redosled, datum_unosa
|
||||
FROM pdv_stope
|
||||
WHERE oznaka = 'opsta' AND aktivna = 1
|
||||
ORDER BY redosled ASC, stopa DESC
|
||||
LIMIT 1`).
|
||||
Scan(&s.ID, &s.Naziv, &s.Stopa, &s.Oznaka, &s.Aktivna, &s.Redosled, &s.DatumUnosa)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PdvStopaRepo.PodrazumevanaOpsta: %w", err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Izmeni menja podatke postojeće stope (osim datuma unosa)
|
||||
func (r *PdvStopaRepo) Izmeni(ctx context.Context, s *model.PdvStopa) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite
|
||||
@@ -77,3 +79,51 @@ func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListaBlokiranih vraća IP adrese sa najmanje prag neuspelih pokušaja prijave od
|
||||
// zadatog trenutka, grupisano po IP adresi, poređano od najskorije zaključane.
|
||||
func (r *PokusajiPrijaveRepo) ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error) {
|
||||
redovi, err := r.db.QueryContext(ctx,
|
||||
`SELECT ip, COUNT(*), MAX(vreme) FROM pokusaji_prijave
|
||||
WHERE uspeh = 0 AND vreme > ?
|
||||
GROUP BY ip HAVING COUNT(*) >= ?
|
||||
ORDER BY MAX(vreme) DESC`,
|
||||
od.UTC().Format("2006-01-02 15:04:05"), prag,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err)
|
||||
}
|
||||
defer redovi.Close()
|
||||
|
||||
var lista []model.BlokiranaIP
|
||||
for redovi.Next() {
|
||||
var b model.BlokiranaIP
|
||||
var poslednji string
|
||||
if err := redovi.Scan(&b.IP, &b.BrojNeuspeha, &poslednji); err != nil {
|
||||
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: scan: %w", err)
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", poslednji, time.UTC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: parse vremena: %w", err)
|
||||
}
|
||||
b.PoslednjiPokusaj = t
|
||||
lista = append(lista, b)
|
||||
}
|
||||
if err := redovi.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err)
|
||||
}
|
||||
return lista, nil
|
||||
}
|
||||
|
||||
// Odblokiraj briše sve neuspele pokušaje prijave za datu IP adresu — brojač
|
||||
// neuspeha odmah pada na nulu, pa IP prestaje da bude zaključana.
|
||||
func (r *PokusajiPrijaveRepo) Odblokiraj(ctx context.Context, ip string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"DELETE FROM pokusaji_prijave WHERE ip = ? AND uspeh = 0",
|
||||
ip,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: PokusajiPrijaveRepo.Odblokiraj: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,9 +204,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
s := stavke[i]
|
||||
var naziv, tip string
|
||||
var stanjePre int
|
||||
var nabavnaCena float64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT naziv, kolicina, tip FROM artikli WHERE id = ?", s.ArtikalID,
|
||||
).Scan(&naziv, &stanjePre, &tip)
|
||||
"SELECT naziv, kolicina, tip, nabavna_cena FROM artikli WHERE id = ?", s.ArtikalID,
|
||||
).Scan(&naziv, &stanjePre, &tip, &nabavnaCena)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: dohvati artikal: %w", err)
|
||||
}
|
||||
@@ -248,10 +249,10 @@ func (r *ProdajaRepo) Kreiraj(ctx context.Context, n *model.ProdajniNalog, stavk
|
||||
stavke[i].Ukupno = ukupnoStavke
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO stavke_prodaje
|
||||
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(nalog_id, artikal_id, kolicina, cena_po_komadu, popust_procenat, ukupno, pdv_stopa, pdv_iznos, cena_bez_pdv, nabavna_cena)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
nalogID, s.ArtikalID, s.Kolicina, s.CenaPoKomadu, s.PopustProcenat, ukupnoStavke,
|
||||
s.PdvStopa, pdvIznos, cenaBezPdv,
|
||||
s.PdvStopa, pdvIznos, cenaBezPdv, nabavnaCena,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ntech: ProdajaRepo.Kreiraj: insert stavka: %w", err)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (r *ServisRepo) Lista(ctx context.Context, pretraga, status string) ([]mode
|
||||
sn.id, sn.klijent_id, sn.tehnicar_id, sn.broj_naloga, sn.uredjaj, sn.serijski_broj,
|
||||
sn.opis_kvara, sn.trazene_nadogradnje, sn.status, sn.cena_od, sn.cena_do, sn.cena_konacna,
|
||||
sn.avans, sn.napomena, sn.garancija_do, sn.garancija_dana, sn.datum_prijema, sn.datum_zavrsetka, sn.predvidjen_datum,
|
||||
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno,
|
||||
sn.ostecenja, sn.pin_uredjaja, sn.pribor, sn.napomena_klijentu, sn.nalaz_dijagnostike, sn.uradjeno, sn.cena_dijagnostike, sn.popravka_odbijena, sn.javni_token, sn.komentar_klijenta, sn.odluka_klijenta, sn.datum_odluke, sn.nacin_placanja, sn.naplaceno, sn.stornirano, sn.razlog_storniranja,
|
||||
COALESCE(kp.naziv, '') AS klijent_naziv,
|
||||
(EXISTS(SELECT 1 FROM servis_radovi sr WHERE sr.nalog_id = sn.id AND sr.predlozeno = 1)
|
||||
OR EXISTS(SELECT 1 FROM servisni_delovi sd WHERE sd.nalog_id = sn.id AND sd.predlozeno = 1)
|
||||
@@ -108,7 +108,7 @@ func (r *ServisRepo) DohvatiID(ctx context.Context, id int64) (*model.ServisniNa
|
||||
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
|
||||
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
|
||||
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
|
||||
FROM servisni_nalozi WHERE id = ?`, id)
|
||||
|
||||
var n model.ServisniNalog
|
||||
@@ -160,7 +160,7 @@ func (r *ServisRepo) DohvatiJavniToken(ctx context.Context, token string) (*mode
|
||||
id, klijent_id, tehnicar_id, broj_naloga, uredjaj, serijski_broj,
|
||||
opis_kvara, trazene_nadogradnje, status, cena_od, cena_do, cena_konacna,
|
||||
avans, napomena, garancija_do, garancija_dana, datum_prijema, datum_zavrsetka, predvidjen_datum,
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno
|
||||
ostecenja, pin_uredjaja, pribor, napomena_klijentu, nalaz_dijagnostike, uradjeno, cena_dijagnostike, popravka_odbijena, javni_token, komentar_klijenta, odluka_klijenta, datum_odluke, nacin_placanja, naplaceno, stornirano, razlog_storniranja
|
||||
FROM servisni_nalozi WHERE javni_token = ?`, token)
|
||||
|
||||
var n model.ServisniNalog
|
||||
@@ -461,6 +461,86 @@ func (r *ServisRepo) Obrisi(ctx context.Context, id int64, korisnikID *int64) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// Storno stornira servisni nalog: vraća ugrađene delove na stanje (isto kao
|
||||
// Obrisi) ali NALOG SE NE BRIŠE — samo se markira stornirano=1, po istom principu
|
||||
// kao Prodaja. Finansijski zapisi (KIR/KPO/fiskalni) se ne diraju ovde — to radi
|
||||
// handler posle uspešnog storna, dograđivanjem storno stavki na osnovu izvor_id.
|
||||
func (r *ServisRepo) Storno(ctx context.Context, id int64, razlog string, korisnikID *int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var vecStornirano bool
|
||||
if err := tx.QueryRowContext(ctx, "SELECT stornirano FROM servisni_nalozi WHERE id = ?", id).Scan(&vecStornirano); err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: provera: %w", err)
|
||||
}
|
||||
if vecStornirano {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: nalog je već storniran")
|
||||
}
|
||||
|
||||
redovi, err := tx.QueryContext(ctx,
|
||||
"SELECT artikal_id, kolicina FROM servisni_delovi WHERE nalog_id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati delove: %w", err)
|
||||
}
|
||||
type povrat struct {
|
||||
artikalID int64
|
||||
kolicina int
|
||||
}
|
||||
var delovi []povrat
|
||||
for redovi.Next() {
|
||||
var p povrat
|
||||
if err := redovi.Scan(&p.artikalID, &p.kolicina); err != nil {
|
||||
redovi.Close()
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: scan dela: %w", err)
|
||||
}
|
||||
delovi = append(delovi, p)
|
||||
}
|
||||
redovi.Close()
|
||||
|
||||
for _, p := range delovi {
|
||||
var stanjePre int
|
||||
var tip string
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT kolicina, tip FROM artikli WHERE id = ?", p.artikalID,
|
||||
).Scan(&stanjePre, &tip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: dohvati stanje: %w", err)
|
||||
}
|
||||
if !(tip == model.TipProizvod || tip == "") {
|
||||
continue
|
||||
}
|
||||
|
||||
stanjePosle := stanjePre + p.kolicina
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE artikli SET kolicina = ? WHERE id = ?", stanjePosle, p.artikalID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: vrati stanje: %w", err)
|
||||
}
|
||||
|
||||
err = zabeleziMagacinPromenu(ctx, tx, p.artikalID, model.PromenaPovracaj,
|
||||
p.kolicina, stanjePre, stanjePosle, id, korisnikID, "storno servisnog naloga")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: magacin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE servisni_nalozi SET stornirano = 1, razlog_storniranja = ? WHERE id = ?",
|
||||
nullString(razlog), id,
|
||||
); err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: update: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("ntech: ServisRepo.Storno: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SacuvajNaplatu beleži način plaćanja i naplaćeni iznos pri preuzimanju uređaja.
|
||||
func (r *ServisRepo) SacuvajNaplatu(ctx context.Context, id int64, nacinPlacanja string, naplaceno float64) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
@@ -485,13 +565,15 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
|
||||
var nacinPlacanja sql.NullString
|
||||
var naplaceno sql.NullFloat64
|
||||
var stornirano sql.NullInt64
|
||||
var razlogStorniranja sql.NullString
|
||||
|
||||
args := []any{
|
||||
&n.ID, &klijentID, &tehnicarID, &n.BrojNaloga, &n.Uredjaj, &serijskiBroj,
|
||||
&n.OpisKvara, &n.TrazeneNadogradnje, &n.Status, &cenaOd, &cenaDo, &cenaKonacna,
|
||||
&avans, &napomena, &garancijaDo, &garancijaDana, &n.DatumPrijema, &datumZavrsetka, &predvidjenDatum,
|
||||
&ostecenja, &pinUredjaja, &pribor, &napomenaKlijentu, &nalazDijagnostike, &uradjeno, &n.CenaDijagnostike, &popravkaOdbijena, &javniToken, &komentarKlijenta, &odlukaKlijenta, &datumOdluke,
|
||||
&nacinPlacanja, &naplaceno,
|
||||
&nacinPlacanja, &naplaceno, &stornirano, &razlogStorniranja,
|
||||
}
|
||||
|
||||
if klijentNaziv != nil {
|
||||
@@ -565,6 +647,8 @@ func scanNalog(scan func(...any) error, n *model.ServisniNalog, klijentNaziv *st
|
||||
if naplaceno.Valid {
|
||||
n.Naplaceno = naplaceno.Float64
|
||||
}
|
||||
n.Stornirano = stornirano.Int64 != 0
|
||||
n.RazlogStorniranja = razlogStorniranja.String
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,9 +95,10 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
|
||||
|
||||
// Ugrađeni delovi: standardna logika — skidamo sa lagera
|
||||
var stanjePre int
|
||||
var nabavnaCena float64
|
||||
if err = tx.QueryRowContext(ctx,
|
||||
"SELECT kolicina FROM artikli WHERE id = ?", artikalID,
|
||||
).Scan(&stanjePre); err != nil {
|
||||
"SELECT kolicina, nabavna_cena FROM artikli WHERE id = ?", artikalID,
|
||||
).Scan(&stanjePre, &nabavnaCena); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: dohvati stanje: %w", err)
|
||||
}
|
||||
|
||||
@@ -119,15 +120,15 @@ func ugradiIliPotrazujTx(ctx context.Context, tx *sql.Tx, nalogID, artikalID int
|
||||
).Scan(&postojeciID, &postojeciKol)
|
||||
if errRed == nil {
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
|
||||
postojeciKol+ugradjeno, cenaKomada, postojeciID,
|
||||
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
|
||||
postojeciKol+ugradjeno, cenaKomada, nabavnaCena, postojeciID,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: merge: %w", err)
|
||||
}
|
||||
} else if errors.Is(errRed, sql.ErrNoRows) {
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno) VALUES (?, ?, ?, ?, 0)",
|
||||
nalogID, artikalID, ugradjeno, cenaKomada,
|
||||
"INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, predlozeno, nabavna_cena) VALUES (?, ?, ?, ?, 0, ?)",
|
||||
nalogID, artikalID, ugradjeno, cenaKomada, nabavnaCena,
|
||||
); err != nil {
|
||||
return 0, 0, fmt.Errorf("ntech: ugradiIliPotrazujTx: insert: %w", err)
|
||||
}
|
||||
|
||||
@@ -196,6 +196,13 @@ func (r *ServisniPotrazivaniDeloviRepo) ProveriIPocistiZaArtikal(ctx context.Con
|
||||
// uvećava postojeći red za isti artikal na nalogu ili kreira novi. Radi unutar
|
||||
// prosleđene transakcije. Ne dira magacin — to radi skiniSaMagacina zasebno.
|
||||
func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sql.Tx, nalogID, artikalID int64, kolicina int, cenaKomada float64) error {
|
||||
var nabavnaCena float64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT nabavna_cena FROM artikli WHERE id = ?", artikalID,
|
||||
).Scan(&nabavnaCena); err != nil {
|
||||
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: nabavna cena: %w", err)
|
||||
}
|
||||
|
||||
var postojeciID int64
|
||||
var postojeciKol int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
@@ -207,8 +214,8 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
|
||||
// ne pregazi postojeću cenu nulom (npr. legacy potraživani red bez cene)
|
||||
if cenaKomada > 0 {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ? WHERE id = ?",
|
||||
postojeciKol+kolicina, cenaKomada, postojeciID,
|
||||
"UPDATE servisni_delovi SET kolicina = ?, cena_komada = ?, nabavna_cena = ? WHERE id = ?",
|
||||
postojeciKol+kolicina, cenaKomada, nabavnaCena, postojeciID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
@@ -223,9 +230,9 @@ func (r *ServisniPotrazivaniDeloviRepo) ugradiUNalog(ctx context.Context, tx *sq
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
nalogID, artikalID, kolicina, cenaKomada,
|
||||
INSERT INTO servisni_delovi (nalog_id, artikal_id, kolicina, cena_komada, nabavna_cena)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
nalogID, artikalID, kolicina, cenaKomada, nabavnaCena,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: ServisniPotrazivaniDeloviRepo.ugradiUNalog: insert: %w", err)
|
||||
|
||||
@@ -20,9 +20,14 @@ func NoviServisniRadoviRepo(baza *sql.DB) *ServisniRadoviRepo {
|
||||
|
||||
// DohvatiZaNalog vraća sve radove (usluge) jednog naloga, redom unosa
|
||||
func (r *ServisniRadoviRepo) DohvatiZaNalog(ctx context.Context, nalogID int64) ([]model.ServisniRad, error) {
|
||||
// COALESCE fallback je za „ad-hoc" rad bez veze na cenovnik usluga
|
||||
// (sr.usluga_id NULL) — tad se PDV stopa uzima iz šifarnika (Podešavanja →
|
||||
// Kalkulacija i PDV), ne hardkodovana vrednost. 20.0 ostaje samo kao
|
||||
// apsolutno poslednje utočište ako šifarnik nema nijednu aktivnu opštu stopu.
|
||||
redovi, err := r.db.QueryContext(ctx, `
|
||||
SELECT sr.id, sr.nalog_id, COALESCE(sr.usluga_id, 0), sr.naziv, sr.kolicina, sr.cena_komada, sr.datum, sr.predlozeno,
|
||||
COALESCE(u.sifra, ''), COALESCE(u.pdv_stopa, 20.0)
|
||||
COALESCE(u.sifra, ''),
|
||||
COALESCE(u.pdv_stopa, (SELECT stopa FROM pdv_stope WHERE oznaka = 'opsta' AND aktivna = 1 ORDER BY redosled ASC, stopa DESC LIMIT 1), 20.0)
|
||||
FROM servis_radovi sr
|
||||
LEFT JOIN usluge u ON u.id = sr.usluga_id
|
||||
WHERE sr.nalog_id = ? ORDER BY sr.id`, nalogID)
|
||||
|
||||
+152
-1
@@ -29,9 +29,15 @@ func NoviKlijent(baseURL, apiKey string) *Klijent {
|
||||
}
|
||||
}
|
||||
|
||||
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat.
|
||||
// InvoiceRequest je omotač za Teron invoiceRequest JSON objekat. Polja Advance*
|
||||
// su na vrhu (van invoiceRequest) — Teron ih tako očekuje za konačni račun koji
|
||||
// zatvara prethodno izdat avansni račun (v. NapraviKonacniZahtevSaAvansom).
|
||||
type InvoiceRequest struct {
|
||||
InvoiceRequest InvoiceRequestBody `json:"invoiceRequest"`
|
||||
AdvancePaid *float64 `json:"advancePaid,omitempty"`
|
||||
AdvanceTax *float64 `json:"advanceTax,omitempty"`
|
||||
AdvanceLastInvoiceNumber string `json:"advanceLastInvoiceNumber,omitempty"`
|
||||
AdvanceLastInvoiceDateTime string `json:"advanceLastInvoiceDateTime,omitempty"`
|
||||
}
|
||||
|
||||
// InvoiceRequestBody su podaci koje Teron očekuje unutar invoiceRequest polja.
|
||||
@@ -147,6 +153,151 @@ func (k *Klijent) IzdajRacun(ctx context.Context, zahtev InvoiceRequest) (*Invoi
|
||||
return &odgovor, nil
|
||||
}
|
||||
|
||||
// FinancialSummary je odgovor na GET /api/financial/summary — promet od
|
||||
// poslednjeg preseka stanja (dnevni pazar), razložen po više kategorija.
|
||||
type FinancialSummary struct {
|
||||
StartOfPeriod string `json:"startOfPeriod"`
|
||||
EndOfPeriod string `json:"endOfPeriod"`
|
||||
InvoiceCount int `json:"invoiceCount"`
|
||||
Total float64 `json:"total"`
|
||||
TotalCash float64 `json:"totalCash"`
|
||||
TotalByTax []SummaryTaxStavka `json:"totalByTax"`
|
||||
TotalByCashier []SummaryImeIznos `json:"totalByCashier"`
|
||||
TotalByPaymentType []SummaryPlacanje `json:"totalByPaymentType"`
|
||||
TotalByArticle []SummaryArtikal `json:"totalByArticle"`
|
||||
TotalByArticleAdvance []SummaryArtikal `json:"totalByArticleAdvance"`
|
||||
}
|
||||
|
||||
// SummaryTaxStavka je promet po jednoj poreskoj oznaci/stopi.
|
||||
type SummaryTaxStavka struct {
|
||||
Label string `json:"label"`
|
||||
Category string `json:"category"`
|
||||
Rate float64 `json:"rate"`
|
||||
Amount float64 `json:"amount"`
|
||||
Osnovica float64 `json:"osnovica"`
|
||||
}
|
||||
|
||||
// SummaryImeIznos je generički par ime-iznos (kasir, i sl.).
|
||||
type SummaryImeIznos struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// SummaryPlacanje je promet po vrsti plaćanja (Cash, Card, ...).
|
||||
type SummaryPlacanje struct {
|
||||
PaymentType string `json:"paymentType"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// SummaryArtikal je promet po artiklu.
|
||||
type SummaryArtikal struct {
|
||||
ArticleName string `json:"articleName"`
|
||||
TaxLabel string `json:"taxLabel"`
|
||||
Amount float64 `json:"amount"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ReportSummaryZahtev je telo zahteva za POST /api/financial/report/summary.
|
||||
// FromDate/ToDate su opcioni (YYYY-MM-DD) — bez njih izveštaj pokriva period
|
||||
// od poslednjeg preseka stanja.
|
||||
type ReportSummaryZahtev struct {
|
||||
FromDate string `json:"fromDate,omitempty"`
|
||||
ToDate string `json:"toDate,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ReportSummaryOdgovor je odgovor na generisanje dnevnog/periodičnog izveštaja.
|
||||
type ReportSummaryOdgovor struct {
|
||||
ReportPdfBase64 string `json:"reportPdfBase64"`
|
||||
ReportName string `json:"reportName"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// Presek vraća promet od poslednjeg preseka stanja (GET /api/financial/summary).
|
||||
// Poziv je bezbedan (read-only), ne menja stanje na PFR-u.
|
||||
func (k *Klijent) Presek(ctx context.Context) (*FinancialSummary, error) {
|
||||
url := k.BaseURL + "/api/financial/summary"
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.Presek: %w", err)
|
||||
}
|
||||
k.dodajAuth(req)
|
||||
|
||||
resp, err := k.HTTPKlijent.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.Presek: server nedostupan: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
telo, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("ntech: fiskal.Presek: HTTP %d: %s", resp.StatusCode, string(telo))
|
||||
}
|
||||
|
||||
var rezultat FinancialSummary
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rezultat); err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.Presek: JSON parse: %w", err)
|
||||
}
|
||||
return &rezultat, nil
|
||||
}
|
||||
|
||||
// ZakljuciDan svodi promet PFR-a na nulu — dnevni presek stanja
|
||||
// (DELETE /api/financial/summary). Nepovratna akcija.
|
||||
func (k *Klijent) ZakljuciDan(ctx context.Context) error {
|
||||
url := k.BaseURL + "/api/financial/summary"
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: fiskal.ZakljuciDan: %w", err)
|
||||
}
|
||||
k.dodajAuth(req)
|
||||
|
||||
resp, err := k.HTTPKlijent.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntech: fiskal.ZakljuciDan: server nedostupan: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
telo, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("ntech: fiskal.ZakljuciDan: HTTP %d: %s", resp.StatusCode, string(telo))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerisiIzvestaj traži od PFR-a dnevni/periodični izveštaj u PDF formatu
|
||||
// (POST /api/financial/report/summary).
|
||||
func (k *Klijent) GenerisiIzvestaj(ctx context.Context, zahtev ReportSummaryZahtev) (*ReportSummaryOdgovor, error) {
|
||||
telo, err := json.Marshal(zahtev)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: marshal: %w", err)
|
||||
}
|
||||
|
||||
url := k.BaseURL + "/api/financial/report/summary"
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(telo))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: %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.GenerisiIzvestaj: server nedostupan: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
teloOdg, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: HTTP %d: %s", resp.StatusCode, string(teloOdg))
|
||||
}
|
||||
|
||||
var odgovor ReportSummaryOdgovor
|
||||
if err := json.NewDecoder(resp.Body).Decode(&odgovor); err != nil {
|
||||
return nil, fmt.Errorf("ntech: fiskal.GenerisiIzvestaj: 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 != "" {
|
||||
|
||||
@@ -64,10 +64,11 @@ func NapraviZahtev(
|
||||
ukupanIznos := 0.0
|
||||
|
||||
for _, s := range stavke {
|
||||
stopa := 20.0 // podrazumevano 20% ako stopa nije eksplicitno postavljena
|
||||
if s.PdvStopa > 0 {
|
||||
stopa = s.PdvStopa
|
||||
}
|
||||
// stopa je uvek ono što je snimljeno na stavci — 0 je legitimna vrednost
|
||||
// (van sistema PDV/neobveznik), ne „nije postavljeno". Nikad je ne prepisivati
|
||||
// nazad na opštu stopu, jer bi to poništilo nuliranje PDV-a za ne-PDV obveznike
|
||||
// (v. docs/Greške.md).
|
||||
stopa := s.PdvStopa
|
||||
// CenaBezPdv iz baze je već diskontovana (popust primenjen pri kreiranju naloga);
|
||||
// popust se ponovo primenjuje samo u fallback grani kad CenaBezPdv nije popunjena.
|
||||
netoCena := s.CenaBezPdv
|
||||
@@ -142,3 +143,47 @@ func NapraviRefundZahtev(
|
||||
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
|
||||
return zahtev
|
||||
}
|
||||
|
||||
// NazivAvansneStavke je naziv stavke na avansnim fiskalnim računima (isti naziv
|
||||
// mora biti dosledan, jer ga PFR ne povezuje po ID-u već po sadržaju stavke).
|
||||
const NazivAvansneStavke = "Аванс"
|
||||
|
||||
// NapraviAvansZahtev gradi Advance/Sale zahtev za primljeni avans — jedna stavka
|
||||
// "Аванс" u punom (bruto) iznosu. stopa je PDV stopa avansa — u trenutku uplate
|
||||
// stavke (radovi/delovi) obično još nisu poznate, pa pozivalac prosleđuje opštu
|
||||
// stopu iz šifarnika (Podešavanja → Kalkulacija i PDV) kad je firma PDV obveznik,
|
||||
// ili 0 kad nije — nikad hardkodovanu vrednost (v. docs/Greške.md §4.1).
|
||||
func NapraviAvansZahtev(iznos, stopa float64, nacinPlacanja, kasir string) InvoiceRequest {
|
||||
return InvoiceRequest{
|
||||
InvoiceRequest: InvoiceRequestBody{
|
||||
InvoiceType: "Advance",
|
||||
TransactionType: "Sale",
|
||||
Payment: []PaymentItem{
|
||||
{Amount: iznos, PaymentType: TipPlacanja(nacinPlacanja)},
|
||||
},
|
||||
Items: []InvoiceItem{
|
||||
{Name: NazivAvansneStavke, Labels: []string{OznakaPDV(stopa)}, TotalAmount: iznos, UnitPrice: iznos, Quantity: 1},
|
||||
},
|
||||
Cashier: kasir,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NapraviAvansRefundZahtev gradi Advance/Refund zahtev za povraćaj dela ili celog
|
||||
// avansa (npr. kad avans premaši konačnu cenu popravke). referentBroj je PfrBroj
|
||||
// originalnog avansnog računa.
|
||||
func NapraviAvansRefundZahtev(iznos, stopa float64, nacinPlacanja, kasir, referentBroj string) InvoiceRequest {
|
||||
zahtev := NapraviAvansZahtev(iznos, stopa, nacinPlacanja, kasir)
|
||||
zahtev.InvoiceRequest.TransactionType = "Refund"
|
||||
zahtev.InvoiceRequest.ReferentDocumentNumber = referentBroj
|
||||
return zahtev
|
||||
}
|
||||
|
||||
// PorezIzBrutoAvansa izvlači poreski deo iz bruto iznosa avansa po datoj stopi
|
||||
// (npr. 500 din avansa po 20% → porez ≈ 83.33 din). stopa=0 (ne-PDV obveznik) → 0.
|
||||
func PorezIzBrutoAvansa(bruto, stopa float64) float64 {
|
||||
if stopa <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(bruto*stopa/(100+stopa)*100) / 100
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// stavkaBlokiraneIP je jedan red na stranici blokiranih IP adresa, sa
|
||||
// unapred izračunatim preostalim vremenom zaključavanja (ili "" ako je isteklo).
|
||||
type stavkaBlokiraneIP struct {
|
||||
model.BlokiranaIP
|
||||
Preostalo string
|
||||
}
|
||||
|
||||
type podaciBlokiraneIP struct {
|
||||
model.PodaciStranice
|
||||
Lista []stavkaBlokiraneIP
|
||||
}
|
||||
|
||||
// AdminBlokiraneIP prikazuje IP adrese trenutno zaključane zbog previše neuspelih
|
||||
// pokušaja prijave, sa mogućnošću ručnog odblokiranja (superadmin).
|
||||
func (h *Handler) AdminBlokiraneIP(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok {
|
||||
return
|
||||
}
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "blokirane-ip"
|
||||
ps.NaslovStranice = "Blokirane IP adrese"
|
||||
|
||||
od := time.Now().Add(-prozorPrijave)
|
||||
blokirane, err := h.PokusajiRepo.ListaBlokiranih(r.Context(), od, maxNeuspehaPrijave)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju blokiranih IP adresa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
lista := make([]stavkaBlokiraneIP, 0, len(blokirane))
|
||||
for _, b := range blokirane {
|
||||
preostalo, zakljucano := h.preostaloBruteforce(r.Context(), b.IP, od)
|
||||
if !zakljucano {
|
||||
continue
|
||||
}
|
||||
lista = append(lista, stavkaBlokiraneIP{BlokiranaIP: b, Preostalo: preostalo})
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "blokirane_ip", podaciBlokiraneIP{PodaciStranice: ps, Lista: lista})
|
||||
}
|
||||
|
||||
// AdminOdblokirajIP briše neuspele pokušaje prijave za datu IP adresu, čime se ona
|
||||
// odmah oslobađa bruteforce blokade.
|
||||
func (h *Handler) AdminOdblokirajIP(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ip := strings.TrimSpace(r.FormValue("ip"))
|
||||
if ip == "" {
|
||||
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := h.PokusajiRepo.Odblokiraj(r.Context(), ip); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Odblokiranje nije uspelo.")
|
||||
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "IP adresa "+ip+" je odblokirana.")
|
||||
http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"ntech/internal/config"
|
||||
ntechsqlite "ntech/internal/db/sqlite"
|
||||
"ntech/internal/fiskal"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
)
|
||||
|
||||
// contentDispositionZaFajl gradi Content-Disposition header za dati naziv fajla.
|
||||
// HTTP header vrednosti moraju biti ASCII — naziv sa slovima kao što su Š/Č/Ž
|
||||
// (npr. iz PFR-a: "DNEVNI IZVEŠTAJ - ...pdf") bi inače napravio nevalidan header
|
||||
// koji pregledač ne može da parsira. RFC 6266 filename* nosi pravo UTF-8 ime,
|
||||
// dok filename (ASCII) služi kao fallback za starije klijente.
|
||||
func contentDispositionZaFajl(ime string) string {
|
||||
ascii := strings.Map(func(r rune) rune {
|
||||
if r > unicode.MaxASCII {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, ime)
|
||||
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, ascii, url.PathEscape(ime))
|
||||
}
|
||||
|
||||
// PodaciFiskalniPazar su podaci za stranicu dnevnog fiskalnog preseka (pazara).
|
||||
type PodaciFiskalniPazar struct {
|
||||
model.PodaciStranice
|
||||
Presek *fiskal.FinancialSummary
|
||||
Greska string
|
||||
FiskalPodesen bool // true: pfr_url je podešen
|
||||
Danas string // YYYY-MM-DD, podrazumevani datum za izveštaj (od=do=danas)
|
||||
}
|
||||
|
||||
// FiskalniPazar prikazuje trenutni promet od poslednjeg preseka stanja
|
||||
// (GET /api/financial/summary na PFR-u) — read-only, ne menja stanje.
|
||||
func (h *Handler) FiskalniPazar(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
|
||||
return
|
||||
}
|
||||
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "fiskal-pazar"
|
||||
ps.NaslovStranice = "Dnevni fiskalni presek"
|
||||
|
||||
podaci := PodaciFiskalniPazar{PodaciStranice: ps, Danas: time.Now().Format("2006-01-02")}
|
||||
|
||||
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||
podaci.Greska = "Fiskalizacija nije uključena u profilu firme — Podešavanja → Opšte → Pravni i poreski status."
|
||||
h.renderujTemplate(w, "fiskal_pazar", podaci)
|
||||
return
|
||||
}
|
||||
|
||||
klijent := h.fiskalKlijent()
|
||||
if klijent == nil {
|
||||
podaci.Greska = "Fiskalizacija nije podešena — unesi URL PFR servera u Podešavanja → Fiskalizacija."
|
||||
h.renderujTemplate(w, "fiskal_pazar", podaci)
|
||||
return
|
||||
}
|
||||
podaci.FiskalPodesen = true
|
||||
|
||||
presek, err := klijent.Presek(r.Context())
|
||||
if err != nil {
|
||||
podaci.Greska = "Fiskalni server nije dostupan — proveri da li je PFR uređaj/mock pokrenut."
|
||||
} else {
|
||||
podaci.Presek = presek
|
||||
}
|
||||
h.renderujTemplate(w, "fiskal_pazar", podaci)
|
||||
}
|
||||
|
||||
// ZakljuciFiskalniDan svodi promet PFR-a na nulu (DELETE /api/financial/summary).
|
||||
// OPCIONA radnja — samo resetuje brojače na ekranu „Promet od poslednjeg preseka";
|
||||
// nije zakonska obaveza (za razliku od starih fiskalnih kasa, novi sistem izveštava
|
||||
// SUF kontinuirano po računu). Pravi dnevni/periodični izveštaj (FiskalniIzvestaj)
|
||||
// radi nezavisno preko datumskog opsega, bez obzira na to da li je ovo ikad urađeno.
|
||||
func (h *Handler) ZakljuciFiskalniDan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.zakljucenje"); !ok {
|
||||
return
|
||||
}
|
||||
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
klijent := h.fiskalKlijent()
|
||||
if klijent == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := klijent.ZakljuciDan(r.Context()); err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Presek stanja nije uspeo — fiskalni server nije dostupan.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Brojači prometa na ekranu su resetovani.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// FiskalniIzvestaj traži od PFR-a dnevni/periodični izveštaj (POST
|
||||
// /api/financial/report/summary) i vraća ga korisniku kao fajl za preuzimanje.
|
||||
// FromDate/ToDate dolaze iz forme (podrazumevano danas-danas) — izveštaj radi
|
||||
// nezavisno od preseka stanja, pa je tačan bez obzira na to da li je Zaključi dan
|
||||
// ikad korišćeno.
|
||||
func (h *Handler) FiskalniIzvestaj(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "fiskal.pazar"); !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.modulUkljucen(r.Context(), config.ModulFiskalizacija) {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije uključena u profilu firme.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
klijent := h.fiskalKlijent()
|
||||
if klijent == nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Fiskalizacija nije podešena.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
zahtev := fiskal.ReportSummaryZahtev{
|
||||
FromDate: strings.TrimSpace(r.FormValue("od_datuma")),
|
||||
ToDate: strings.TrimSpace(r.FormValue("do_datuma")),
|
||||
}
|
||||
odgovor, err := klijent.GenerisiIzvestaj(r.Context(), zahtev)
|
||||
if err != nil {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", "Generisanje izveštaja nije uspelo — fiskalni server nije dostupan.")
|
||||
http.Redirect(w, r, "/fiskal/pazar", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
sadrzaj, err := base64.StdEncoding.DecodeString(odgovor.ReportPdfBase64)
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan sadržaj izveštaja", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
imeFajla := odgovor.Filename
|
||||
if imeFajla == "" {
|
||||
imeFajla = "dnevni-izvestaj.pdf"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", contentDispositionZaFajl(imeFajla))
|
||||
w.Write(sadrzaj)
|
||||
}
|
||||
@@ -168,6 +168,35 @@ func (h *Handler) modulUkljucen(ctx context.Context, modul string) bool {
|
||||
return config.ModulUkljucen(podesavanja, modul)
|
||||
}
|
||||
|
||||
// podrazumevanaPdvStopa vraća opštu PDV stopu iz šifarnika (Podešavanja → Kalkulacija
|
||||
// i PDV → Šifarnik PDV stopa) — jedini izvor „koja je opšta stopa" u programu.
|
||||
// 20.0 se koristi samo kao krajnji fallback ako šifarnik nema nijednu aktivnu
|
||||
// opštu stopu (npr. korisnik ih sve arhivirao) — ne kao podrazumevana poslovna
|
||||
// pretpostavka, već da se izbegne prekid rada.
|
||||
func (h *Handler) podrazumevanaPdvStopa(ctx context.Context) float64 {
|
||||
stopa, err := h.PdvStopeRepo.PodrazumevanaOpsta(ctx)
|
||||
if err != nil || stopa == nil {
|
||||
return 20.0
|
||||
}
|
||||
return stopa.Stopa
|
||||
}
|
||||
|
||||
// validnePdvStope vraća skup PDV stopa koje korisnik sme da izabere na stavci —
|
||||
// aktivne stope iz šifarnika (Podešavanja → Kalkulacija i PDV), plus 0 koje je
|
||||
// uvek dozvoljeno (van sistema PDV/oslobođeno). Zamenjuje hardkodovanu proveru
|
||||
// „0, 10 ili 20" — šifarnik je jedini izvor važećih stopa.
|
||||
func (h *Handler) validnePdvStope(ctx context.Context) map[float64]bool {
|
||||
validne := map[float64]bool{0: true}
|
||||
stope, err := h.PdvStopeRepo.Lista(ctx, true)
|
||||
if err != nil {
|
||||
return validne
|
||||
}
|
||||
for _, s := range stope {
|
||||
validne[s.Stopa] = true
|
||||
}
|
||||
return validne
|
||||
}
|
||||
|
||||
// zahtevajDozvolu vraća prijavljenog korisnika ako njegova uloga sme da izvrši akciju.
|
||||
// U suprotnom šalje 403 sa srpskom porukom i vraća ok=false (handler tada return-uje).
|
||||
func (h *Handler) zahtevajDozvolu(w http.ResponseWriter, r *http.Request, akcija string) (*model.Korisnik, bool) {
|
||||
|
||||
@@ -57,6 +57,9 @@ type MesecniPrihod struct {
|
||||
Prodaja float64
|
||||
Servis float64
|
||||
Ukupno float64
|
||||
Zarada float64
|
||||
ZaradaProdaja float64
|
||||
ZaradaServis float64
|
||||
}
|
||||
|
||||
// StariNalog je servisni nalog bez datuma završetka stariji od 14 dana
|
||||
@@ -120,35 +123,68 @@ func (h *Handler) Izvestaji(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- mesečna zarada: prodaja ---
|
||||
zaradaProdajaPoMesecu := map[string]float64{}
|
||||
if redovi, err := h.IzvestajRepo.MesecniZaradaProdaja(ctx); err != nil {
|
||||
slog.Error("izvestaji: zarada prodaja", "error", err)
|
||||
} else {
|
||||
for _, m := range redovi {
|
||||
zaradaProdajaPoMesecu[m.Mesec] = m.Iznos
|
||||
}
|
||||
}
|
||||
|
||||
// --- mesečna zarada: servis ---
|
||||
zaradaServisPoMesecu := map[string]float64{}
|
||||
if redovi, err := h.IzvestajRepo.MesecniZaradaServis(ctx); err != nil {
|
||||
slog.Error("izvestaji: zarada servis", "error", err)
|
||||
} else {
|
||||
for _, m := range redovi {
|
||||
zaradaServisPoMesecu[m.Mesec] = m.Iznos
|
||||
}
|
||||
}
|
||||
|
||||
// gradimo niz za poslednjih 12 meseci (hronološki)
|
||||
var mesecniPrihodi []MesecniPrihod
|
||||
var grafikonLabele []string
|
||||
var grafikonProdaja []float64
|
||||
var grafikonServis []float64
|
||||
var grafikonZaradaProdaja []float64
|
||||
var grafikonZaradaServis []float64
|
||||
|
||||
for _, kljuc := range kljuceviMeseci(time.Now(), 12) {
|
||||
prod := prodajaPoMesecu[kljuc]
|
||||
serv := servisPoMesecu[kljuc]
|
||||
zaradaProd := zaradaProdajaPoMesecu[kljuc]
|
||||
zaradaServ := zaradaServisPoMesecu[kljuc]
|
||||
mesecniPrihodi = append(mesecniPrihodi, MesecniPrihod{
|
||||
MesecPrikaz: formatujMesec(kljuc),
|
||||
Prodaja: prod,
|
||||
Servis: serv,
|
||||
Ukupno: prod + serv,
|
||||
Zarada: zaradaProd + zaradaServ,
|
||||
ZaradaProdaja: zaradaProd,
|
||||
ZaradaServis: zaradaServ,
|
||||
})
|
||||
grafikonLabele = append(grafikonLabele, formatujMesec(kljuc))
|
||||
grafikonProdaja = append(grafikonProdaja, prod)
|
||||
grafikonServis = append(grafikonServis, serv)
|
||||
grafikonZaradaProdaja = append(grafikonZaradaProdaja, zaradaProd)
|
||||
grafikonZaradaServis = append(grafikonZaradaServis, zaradaServ)
|
||||
}
|
||||
|
||||
type grafikonPodaci struct {
|
||||
Labele []string `json:"labele"`
|
||||
Prodaja []float64 `json:"prodaja"`
|
||||
Servis []float64 `json:"servis"`
|
||||
ZaradaProdaja []float64 `json:"zaradaProdaja"`
|
||||
ZaradaServis []float64 `json:"zaradaServis"`
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(grafikonPodaci{
|
||||
Labele: grafikonLabele,
|
||||
Prodaja: grafikonProdaja,
|
||||
Servis: grafikonServis,
|
||||
ZaradaProdaja: grafikonZaradaProdaja,
|
||||
ZaradaServis: grafikonZaradaServis,
|
||||
})
|
||||
|
||||
// --- stari otvoreni nalozi (>14 dana bez završetka) ---
|
||||
|
||||
@@ -19,7 +19,7 @@ var bazniSabloni = []string{
|
||||
|
||||
// saSidebar su šabloni koji koriste base layout (sidebar + topbar)
|
||||
var saSidebar = []string{
|
||||
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole",
|
||||
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "blokirane_ip",
|
||||
"dashboard",
|
||||
"dobavljaci", "dobavljac_forma",
|
||||
"izvestaji", "prometni_list", "stanje_zaliha", "popis",
|
||||
@@ -28,6 +28,7 @@ var saSidebar = []string{
|
||||
"magacin", "magacin_forma", "magacin_kartica",
|
||||
"nabavke", "nabavka_forma", "nabavka_detalji",
|
||||
"podesavanja", "podesavanja_opste", "podesavanja_izgled", "podesavanja_sistem", "podesavanja_servis", "podesavanja_fiskalizacija",
|
||||
"fiskal_pazar",
|
||||
"pdv_stope",
|
||||
"pdv_kir", "pdv_kir_forma", "pdv_kir_dnevni_pazar",
|
||||
"pdv_kpr", "pdv_kpr_forma",
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *Handler) NoviArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
PodaciStranice: ps,
|
||||
Kategorije: kategorije,
|
||||
Dobavljaci: dobavljaci,
|
||||
Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: 20.0},
|
||||
Artikal: model.Artikal{Sifra: predlogSifre, Tip: tip, JedinicaMere: "kom", PdvStopa: h.podrazumevanaPdvStopa(r.Context())},
|
||||
Izmena: false,
|
||||
})
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (h *Handler) SacuvajArtikal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
artikal, greska := parseFormuArtikla(r)
|
||||
artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
|
||||
if greska != "" {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
@@ -210,7 +210,7 @@ func (h *Handler) SacuvajIzmenuArtikla(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
artikal, greska := parseFormuArtikla(r)
|
||||
artikal, greska := parseFormuArtikla(r, h.podrazumevanaPdvStopa(r.Context()))
|
||||
if greska != "" {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
kategorije, _ := h.KategorijeRepo.Lista(r.Context())
|
||||
@@ -325,7 +325,9 @@ func citajDobavljaceForme(r *http.Request) []int64 {
|
||||
}
|
||||
|
||||
// parseFormuArtikla čita polja iz forme i vraća artikal i eventualnu grešku
|
||||
func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
// podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) —
|
||||
// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost.
|
||||
func parseFormuArtikla(r *http.Request, podrazumevanaStopa float64) (model.Artikal, string) {
|
||||
naziv := r.FormValue("naziv")
|
||||
if naziv == "" {
|
||||
return model.Artikal{}, "Naziv artikla je obavezan."
|
||||
@@ -389,8 +391,8 @@ func parseFormuArtikla(r *http.Request) (model.Artikal, string) {
|
||||
artikal.NabavnaCena = v
|
||||
}
|
||||
|
||||
// PDV stopa — podrazumevano 20%
|
||||
pdvStopa := 20.0
|
||||
// PDV stopa — podrazumevano opšta stopa iz šifarnika
|
||||
pdvStopa := podrazumevanaStopa
|
||||
if p := r.FormValue("pdv_stopa"); p != "" {
|
||||
if v, err := strconv.ParseFloat(p, 64); err == nil && v >= 0 {
|
||||
pdvStopa = v
|
||||
|
||||
@@ -377,9 +377,17 @@ func (h *Handler) StornoNabavke(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?greska=storno", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// stornirana nabavka ne ulazi u PDV — ukloni vezani auto-KPR zapis
|
||||
if err := h.PdvKprRepo.ObrisiPoIzvoru(r.Context(), "nabavka", id); err != nil {
|
||||
slog.Error("brisanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
|
||||
// stornirana nabavka se ne briše iz KPR (zakon o računovodstvu ne dozvoljava
|
||||
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
|
||||
if zapisi, err := h.PdvKprRepo.DohvatiPoIzvoru(r.Context(), "nabavka", id); err != nil {
|
||||
slog.Error("čitanje vezanog KPR zapisa nije uspelo", "nabavka_id", id, "error", err)
|
||||
} else {
|
||||
for _, z := range zapisi {
|
||||
storno := model.KprStorno(z, razlog, time.Now())
|
||||
if _, e := h.PdvKprRepo.Kreiraj(r.Context(), &storno); e != nil {
|
||||
slog.Error("upis storno KPR zapisa nije uspeo", "nabavka_id", id, "error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/nabavke/"+strconv.FormatInt(id, 10)+"?sacuvano=1", http.StatusSeeOther)
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestParseFormuArtikla(t *testing.T) {
|
||||
t.Run("validan", func(t *testing.T) {
|
||||
a, greska := parseFormuArtikla(formZahtev(url.Values{
|
||||
"naziv": {"Pumpa"}, "kolicina": {"10"}, "prodajna_cena": {"1500.50"},
|
||||
}))
|
||||
}), 20.0)
|
||||
if greska != "" {
|
||||
t.Fatalf("neočekivana greška: %q", greska)
|
||||
}
|
||||
@@ -31,18 +31,18 @@ func TestParseFormuArtikla(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("naziv obavezan", func(t *testing.T) {
|
||||
if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}})); greska == "" {
|
||||
if _, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {""}}), 20.0); greska == "" {
|
||||
t.Fatal("prazan naziv mora dati grešku")
|
||||
}
|
||||
})
|
||||
t.Run("negativna količina", func(t *testing.T) {
|
||||
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}}))
|
||||
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "kolicina": {"-5"}}), 20.0)
|
||||
if greska == "" {
|
||||
t.Fatal("negativna količina mora dati grešku")
|
||||
}
|
||||
})
|
||||
t.Run("neispravna cena", func(t *testing.T) {
|
||||
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}}))
|
||||
_, greska := parseFormuArtikla(formZahtev(url.Values{"naziv": {"X"}, "prodajna_cena": {"abc"}}), 20.0)
|
||||
if greska == "" {
|
||||
t.Fatal("neispravna cena mora dati grešku")
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestParseFormuKlijenta(t *testing.T) {
|
||||
|
||||
func TestParseFormuProdaje(t *testing.T) {
|
||||
t.Run("bez stavki", func(t *testing.T) {
|
||||
_, _, greska := parseFormuProdaje(formZahtev(url.Values{}))
|
||||
_, _, greska := parseFormuProdaje(formZahtev(url.Values{}), map[float64]bool{0: true, 10: true, 20: true})
|
||||
if greska == "" {
|
||||
t.Fatal("prodaja bez stavki mora dati grešku")
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestParseFormuProdaje(t *testing.T) {
|
||||
t.Run("količina nula", func(t *testing.T) {
|
||||
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
|
||||
"artikal_id[]": {"1"}, "kolicina[]": {"0"}, "cena_po_komadu[]": {"100"},
|
||||
}))
|
||||
}), map[float64]bool{0: true, 10: true, 20: true})
|
||||
if greska == "" {
|
||||
t.Fatal("količina 0 mora dati grešku")
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestParseFormuProdaje(t *testing.T) {
|
||||
t.Run("neispravan artikal", func(t *testing.T) {
|
||||
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
|
||||
"artikal_id[]": {"0"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
|
||||
}))
|
||||
}), map[float64]bool{0: true, 10: true, 20: true})
|
||||
if greska == "" {
|
||||
t.Fatal("artikal_id 0 mora dati grešku")
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestParseFormuProdaje(t *testing.T) {
|
||||
t.Run("validna stavka", func(t *testing.T) {
|
||||
nalog, stavke, greska := parseFormuProdaje(formZahtev(url.Values{
|
||||
"artikal_id[]": {"3"}, "kolicina[]": {"2"}, "cena_po_komadu[]": {"250"}, "pdv_stopa[]": {"20"},
|
||||
}))
|
||||
}), map[float64]bool{0: true, 10: true, 20: true})
|
||||
if greska != "" {
|
||||
t.Fatalf("neočekivana greška: %q", greska)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestParseFormuProdaje(t *testing.T) {
|
||||
t.Run("nesklad broja stavki", func(t *testing.T) {
|
||||
_, _, greska := parseFormuProdaje(formZahtev(url.Values{
|
||||
"artikal_id[]": {"1", "2"}, "kolicina[]": {"1"}, "cena_po_komadu[]": {"100"},
|
||||
}))
|
||||
}), map[float64]bool{0: true, 10: true, 20: true})
|
||||
if greska == "" {
|
||||
t.Fatal("nesklad broja stavki mora dati grešku")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appdb "ntech/internal/db"
|
||||
"ntech/internal/db/sqlite"
|
||||
"ntech/internal/middleware"
|
||||
"ntech/internal/model"
|
||||
@@ -22,9 +23,10 @@ type PodaciPdvKir struct {
|
||||
model.PodaciStranice
|
||||
Zapisi []model.PdvKir
|
||||
Sume model.PdvKirSume
|
||||
Od string // filter perioda (YYYY-MM-DD), prazno = bez granice
|
||||
Od string
|
||||
Do string
|
||||
Zastareo map[int64]bool // ID zapisa dnevnog pazara čiji je iznos van sinhronizacije sa trenutnim prometom (npr. posle naknadnog storna)
|
||||
Zastareo map[int64]bool
|
||||
SumnjiviDuplikati []model.ProdajniNalogSaDetaljem // B2B prodaje preskočene pri backfill-u (double-submit)
|
||||
}
|
||||
|
||||
// PodaciPdvKirForma su podaci za formu unosa zapisa KIR
|
||||
@@ -105,6 +107,21 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
|
||||
ps := h.popuniPodaciStranice(r, podesavanja)
|
||||
ps.Stranica = "pdv-kir"
|
||||
ps.NaslovStranice = "KIR — knjiga izdatih računa"
|
||||
|
||||
// detektuj sumnjive duplikate (double-submit) da ih korisnik vidi
|
||||
var sumnjivi []model.ProdajniNalogSaDetaljem
|
||||
if _, _, kandidati, err := h.kirKandidatiProdaje(r.Context()); err == nil {
|
||||
duplikati := sumnjiviDuplikati(kandidati)
|
||||
if len(duplikati) > 0 {
|
||||
nalozi, _ := h.ProdajaRepo.Lista(r.Context(), appdb.ProdajaFilter{})
|
||||
for _, nd := range nalozi {
|
||||
if duplikati[nd.ID] {
|
||||
sumnjivi = append(sumnjivi, nd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.renderujTemplate(w, "pdv_kir", PodaciPdvKir{
|
||||
PodaciStranice: ps,
|
||||
Zapisi: zapisi,
|
||||
@@ -112,6 +129,7 @@ func (h *Handler) PdvKir(w http.ResponseWriter, r *http.Request) {
|
||||
Od: odStr,
|
||||
Do: doStr,
|
||||
Zastareo: zastareo,
|
||||
SumnjiviDuplikati: sumnjivi,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,6 +296,75 @@ func (h *Handler) KirBackfillProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// KirBackfillServisa kreira KIR zapise za sve preuzete servisne naloge sa
|
||||
// identifikovanim kupcem koji ih nemaju. Namenjen za jednokratno popunjavanje.
|
||||
func (h *Handler) KirBackfillServisa(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "pdv.dodaj"); !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sada := time.Now()
|
||||
|
||||
nalozi, postojiMap, kandidati, err := h.kirKandidatiServisa(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Greška pri učitavanju servisa", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
duplikati := sumnjiviDuplikati(kandidati)
|
||||
|
||||
var kreirano, preskoceno, sumnjivo int
|
||||
for _, sn := range nalozi {
|
||||
if postojiMap[sn.ID] {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
if duplikati[sn.ID] {
|
||||
sumnjivo++
|
||||
continue
|
||||
}
|
||||
radovi, err := h.ServisniRadoviRepo.DohvatiZaNalog(ctx, sn.ID)
|
||||
if err != nil {
|
||||
slog.Error("backfill KIR: greška pri čitanju radova", "servis_id", sn.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
delovi, err := h.ServisniDeloviRepo.DohvatiZaNalog(ctx, sn.ID)
|
||||
if err != nil {
|
||||
slog.Error("backfill KIR: greška pri čitanju delova", "servis_id", sn.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
klijent, err := h.KlijentiRepo.DohvatiID(ctx, *sn.KlijentID)
|
||||
if err != nil {
|
||||
slog.Error("backfill KIR: greška pri čitanju klijenta", "servis_id", sn.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
pib := klijent.PIB
|
||||
if klijent.Tip != "pravno" {
|
||||
pib = klijent.JMBG
|
||||
}
|
||||
datumPrometa := sn.DatumPrijema
|
||||
if sn.DatumZavrsetka != nil {
|
||||
datumPrometa = *sn.DatumZavrsetka
|
||||
}
|
||||
kir := model.KirIzServisa(sn.ServisniNalog, radovi, delovi, klijent.PunoIme(), pib, klijent.Mesto, datumPrometa, sada)
|
||||
if kir.Ukupno <= 0 {
|
||||
preskoceno++
|
||||
continue
|
||||
}
|
||||
if _, e := h.PdvKirRepo.Kreiraj(ctx, &kir); e != nil {
|
||||
slog.Error("backfill KIR: greška pri kreiranju zapisa", "servis_id", sn.ID, "error", e)
|
||||
continue
|
||||
}
|
||||
kreirano++
|
||||
}
|
||||
|
||||
poruka := fmt.Sprintf("Backfill završen: %d novih KIR zapisa, %d preskočeno.", kreirano, preskoceno)
|
||||
if sumnjivo > 0 {
|
||||
poruka += fmt.Sprintf(" %d sumnjivih duplikata NIJE upisano — proveri ručno.", sumnjivo)
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", poruka)
|
||||
http.Redirect(w, r, "/pdv/kir", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// PodaciDnevniPazar su podaci za formu zbirnog KIR unosa (maloprodaja)
|
||||
type PodaciDnevniPazar struct {
|
||||
model.PodaciStranice
|
||||
|
||||
@@ -285,6 +285,20 @@ func validirajMaticniBroj(broj string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// validirajProfilFirme proverava da li je kombinacija pravnog/poreskog statusa
|
||||
// pravno moguća — vidi docs/Greške.md §1. „Samo evidencija” gasi ceo zakonski
|
||||
// sloj, pa dok je firma u tom režimu ne postoji osnov da bude PDV obveznik
|
||||
// niti da izdaje fiskalne račune.
|
||||
func validirajProfilFirme(rezim, pdvObveznik, fiskalizacija string) string {
|
||||
if rezim != "samo_evidencija" {
|
||||
return ""
|
||||
}
|
||||
if pdvObveznik == "da" || fiskalizacija == "da" {
|
||||
return "Režim „Samo evidencija” ne dozvoljava PDV obveznika ni izdavanje fiskalnih računa — prebacite firmu na režim „Pun” ako stvarno posluje."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SacuvajPodesavanja prima POST i čuva podešavanja u bazu
|
||||
func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
|
||||
@@ -320,6 +334,19 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, jeFirmaSekcija := r.Form["_sekcija_firma"]; jeFirmaSekcija {
|
||||
greska := validirajProfilFirme(r.FormValue("firma_rezim"), r.FormValue("firma_pdv_obveznik"), r.FormValue("firma_fiskalizacija"))
|
||||
if greska != "" {
|
||||
middleware.SetFlash(w, r, h.DB, "greska", greska)
|
||||
sledeci := "/podesavanja"
|
||||
if r.FormValue("_next") == "/admin/podesavanja/opste" {
|
||||
sledeci = "/admin/podesavanja/opste"
|
||||
}
|
||||
http.Redirect(w, r, sledeci, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
polja := map[string]string{
|
||||
"naziv_firme": r.FormValue("naziv_firme"),
|
||||
"podnazlov": r.FormValue("podnazlov"),
|
||||
|
||||
+107
-32
@@ -54,7 +54,9 @@ type PodaciDetaljiProdaje struct {
|
||||
KlijentNaziv string
|
||||
FiskalniRacun *model.FiskalniRacun // nil ako nije fiskalizovano
|
||||
FiskalGreska bool // true: fiskalizacija aktivna, nalog nije storniran, ali nema fiskalnog računa
|
||||
StornoBezRefunda bool // true: nalog je storniran, ali fiskalni refund ka PFR-u nije uspeo
|
||||
PotrebnaIdentKupca bool // true: refundacija zahteva ručni unos PIB/JMBG kupca (nema ga iz klijenta)
|
||||
PotrebnaIdentKupcaRetry bool // isto, ali za retry dugme na već storniranom nalogu (StornoBezRefunda)
|
||||
Sacuvano bool
|
||||
}
|
||||
|
||||
@@ -243,7 +245,7 @@ func (h *Handler) SacuvajProdaju(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
nalog, stavke, greska := parseFormuProdaje(r)
|
||||
nalog, stavke, greska := parseFormuProdaje(r, h.validnePdvStope(r.Context()))
|
||||
|
||||
renderujGresku := func(poruka string) {
|
||||
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
|
||||
@@ -410,7 +412,9 @@ func (h *Handler) DetaljiProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
KlijentNaziv: klijentNaziv,
|
||||
FiskalniRacun: fr,
|
||||
FiskalGreska: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr == nil,
|
||||
StornoBezRefunda: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran,
|
||||
PotrebnaIdentKupca: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && !nalog.Stornirano && fr != nil && !fr.Storniran && !imaIdentKupca,
|
||||
PotrebnaIdentKupcaRetry: h.modulUkljucen(r.Context(), config.ModulFiskalizacija) && nalog.Stornirano && fr != nil && fr.TipTransakcije == "Sale" && !fr.Storniran && !imaIdentKupca,
|
||||
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
|
||||
}
|
||||
|
||||
@@ -532,7 +536,9 @@ pre{white-space:pre;margin:0;padding:0;font-family:inherit;font-size:inherit;dis
|
||||
}
|
||||
|
||||
// parseFormuProdaje čita zaglavlje i stavke iz HTTP forme i vraća model i eventualnu grešku
|
||||
func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProdaje, string) {
|
||||
// validneStope su dozvoljene PDV stope na stavci (v. Handler.validnePdvStope) —
|
||||
// izvedene iz šifarnika, ne hardkodovane.
|
||||
func parseFormuProdaje(r *http.Request, validneStope map[float64]bool) (model.ProdajniNalog, []model.StavkaProdaje, string) {
|
||||
var nalog model.ProdajniNalog
|
||||
|
||||
if klijentIDStr := r.FormValue("klijent_id"); klijentIDStr != "" {
|
||||
@@ -585,8 +591,8 @@ func parseFormuProdaje(r *http.Request) (model.ProdajniNalog, []model.StavkaProd
|
||||
return nalog, nil, "Neispravna PDV stopa u stavci."
|
||||
}
|
||||
}
|
||||
if pdvStopa != 0 && pdvStopa != 10 && pdvStopa != 20 {
|
||||
return nalog, nil, "PDV stopa mora biti 0, 10 ili 20."
|
||||
if !validneStope[pdvStopa] {
|
||||
return nalog, nil, "PDV stopa u stavci nije u šifarniku PDV stopa."
|
||||
}
|
||||
|
||||
var popust float64
|
||||
@@ -687,17 +693,68 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
||||
return err
|
||||
}
|
||||
|
||||
// stornirana prodaja ne ulazi u PDV — ukloni vezani auto-KIR zapis
|
||||
if err := h.PdvKirRepo.ObrisiPoIzvoru(ctx, "prodaja", id); err != nil {
|
||||
slog.Error("brisanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
|
||||
// stornirana prodaja se ne briše iz KIR (zakon o računovodstvu ne dozvoljava
|
||||
// brisanje poslovnih knjiga) — dograđuje se storno stavka koja poništava original
|
||||
if zapisi, err := h.PdvKirRepo.DohvatiPoIzvoru(ctx, "prodaja", id); err != nil {
|
||||
slog.Error("čitanje vezanog KIR zapisa nije uspelo", "prodaja_id", id, "error", err)
|
||||
} else {
|
||||
for _, z := range zapisi {
|
||||
storno := model.KirStorno(z, razlog, time.Now())
|
||||
if _, e := h.PdvKirRepo.Kreiraj(ctx, &storno); e != nil {
|
||||
slog.Error("upis storno KIR zapisa nije uspeo", "prodaja_id", id, "error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fiskalni refund — best-effort
|
||||
if h.modulUkljucen(ctx, config.ModulFiskalizacija) {
|
||||
if fr, _ := h.FiskalRepo.DohvatiPoProdaji(ctx, id); fr != nil && !fr.Storniran {
|
||||
if fk := h.fiskalKlijent(); fk != nil {
|
||||
if nalogFisk, e := h.ProdajaRepo.DohvatiID(ctx, id); e == nil {
|
||||
if stavkeFisk, e2 := h.ProdajaRepo.DohvatiStavke(ctx, id); e2 == nil {
|
||||
if err := h.posaljiFiskalniRefundProdaje(ctx, id, poreskiBrojKupca); err != nil {
|
||||
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stornirana prodaja se ne briše iz KPO (redni broj mora ostati bez prekida) —
|
||||
// dograđuje se storno stavka koja poništava original
|
||||
if h.modulUkljucen(ctx, "kpo") {
|
||||
if zapisi, e := h.KpoRepo.DohvatiPoIzvoru(ctx, "prodaja", id); e != nil {
|
||||
slog.Error("čitanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
|
||||
} else {
|
||||
for _, z := range zapisi {
|
||||
storno := model.KpoStorno(z, razlog, time.Now())
|
||||
if _, e := h.KpoRepo.Kreiraj(ctx, &storno); e != nil {
|
||||
slog.Error("upis storno KPO zapisa nije uspeo", "prodaja_id", id, "error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// posaljiFiskalniRefundProdaje šalje fiskalni refund za već storniranu prodaju čiji
|
||||
// originalni fiskalni (Sale) račun još nije označen kao storniran — koristi ga i
|
||||
// stornirajProdaju (odmah posle storna) i ručni retry (PokusajRefundProdaje) kad prvi
|
||||
// pokušaj padne. poreskiBrojKupca se koristi samo ako klijent nema PIB/JMBG na kartici.
|
||||
func (h *Handler) posaljiFiskalniRefundProdaje(ctx context.Context, id int64, poreskiBrojKupca string) error {
|
||||
fr, err := h.FiskalRepo.DohvatiPoProdaji(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fr == nil || fr.Storniran || fr.TipTransakcije != "Sale" {
|
||||
return nil
|
||||
}
|
||||
fk := h.fiskalKlijent()
|
||||
if fk == nil {
|
||||
return errors.New("fiskalizacija nije podešena")
|
||||
}
|
||||
nalogFisk, err := h.ProdajaRepo.DohvatiID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stavkeFisk, err := h.ProdajaRepo.DohvatiStavke(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kasirFisk := h.imeKasira(ctx)
|
||||
var pibFisk, jmbgFisk string
|
||||
if nalogFisk.KlijentID != nil {
|
||||
@@ -707,11 +764,15 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
||||
}
|
||||
if pibFisk == "" && jmbgFisk == "" {
|
||||
pibFisk, jmbgFisk = klasifikujPoreskiBroj(poreskiBrojKupca)
|
||||
if pibFisk == "" && jmbgFisk == "" {
|
||||
return errPotrebnaIdentKupca
|
||||
}
|
||||
}
|
||||
zahtevFisk := fiskal.NapraviRefundZahtev(*nalogFisk, stavkeFisk, pibFisk, jmbgFisk, kasirFisk, fr.PfrBroj)
|
||||
if odgFisk, errFisk := fk.IzdajRacun(ctx, zahtevFisk); errFisk != nil {
|
||||
slog.Error("fiskalni refund nije uspeo", "prodaja_id", id, "error", errFisk)
|
||||
} else {
|
||||
odgFisk, err := fk.IzdajRacun(ctx, zahtevFisk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poreskeJSON, _ := json.Marshal(odgFisk.TaxItems)
|
||||
siroviJSON, _ := json.Marshal(odgFisk)
|
||||
refundFr := &model.FiskalniRacun{
|
||||
@@ -732,26 +793,40 @@ func (h *Handler) stornirajProdaju(ctx context.Context, id int64, razlog string,
|
||||
Zatrazio: odgFisk.RequestedBy,
|
||||
Poruka: odgFisk.Messages,
|
||||
}
|
||||
if _, errFisk = h.FiskalRepo.Kreiraj(ctx, refundFr); errFisk != nil {
|
||||
slog.Error("čuvanje fiskalnog refunda nije uspelo", "prodaja_id", id, "error", errFisk)
|
||||
if _, err := h.FiskalRepo.Kreiraj(ctx, refundFr); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
|
||||
}
|
||||
|
||||
// PokusajRefundProdaje je ručni retry za "storno bez fiskalnog refunda" — kad je
|
||||
// prodaja već stornirana u NTech-u, ali fiskalni refund ka PFR-u nije uspeo pri
|
||||
// samom stornu (best-effort poziv je pao). Prikazuje se kao dugme na stranici detalja.
|
||||
func (h *Handler) PokusajRefundProdaje(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "prodaja.storno"); !ok {
|
||||
return
|
||||
}
|
||||
id, err := parseID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Neispravan ID naloga", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
poreskiBrojKupca := strings.TrimSpace(r.FormValue("poreski_broj_kupca"))
|
||||
|
||||
if err := h.posaljiFiskalniRefundProdaje(r.Context(), id, poreskiBrojKupca); err != nil {
|
||||
poruka := "Fiskalni refund nije uspeo — fiskalni server nije dostupan."
|
||||
if errors.Is(err, errPotrebnaIdentKupca) {
|
||||
poruka = errPotrebnaIdentKupca.Error()
|
||||
}
|
||||
middleware.SetFlash(w, r, h.DB, "greska", poruka)
|
||||
} else {
|
||||
_ = h.FiskalRepo.OznačiKaoStorniran(ctx, fr.ID)
|
||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Fiskalni refund je uspešno poslat.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// brisanje KPO zapisa na storno
|
||||
if h.modulUkljucen(ctx, "kpo") {
|
||||
if e := h.KpoRepo.ObrisiPoIzvoru(ctx, "prodaja", id); e != nil {
|
||||
slog.Error("brisanje vezanog KPO zapisa nije uspelo", "prodaja_id", id, "error", e)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
http.Redirect(w, r, "/prodaja/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// fiskalizujProdaju šalje fiskalni zahtev za prodajni nalog. Best-effort: greške
|
||||
|
||||
+658
-115
File diff suppressed because it is too large
Load Diff
@@ -59,10 +59,18 @@ func sumnjiviDuplikati(svi []kandidatUskladjivanja) map[int64]bool {
|
||||
// KIR upis) sa mapom postojećih upisa i kandidatima za detekciju duplikata. Koristi se
|
||||
// i za backfill (KirBackfillProdaje) i za brojanje praznina na dashboard-u.
|
||||
func (h *Handler) kirKandidatiProdaje(ctx context.Context) ([]model.ProdajniNalogSaDetaljem, map[int64]bool, []kandidatUskladjivanja, error) {
|
||||
nalozi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||
svi, err := h.ProdajaRepo.Lista(ctx, appdb.ProdajaFilter{})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
// stornirani nalozi se nikad ne razmatraju za KIR upis — ni za backfill ni za
|
||||
// brojanje praznina — jer stornirana prodaja ne predstavlja oporeziv promet
|
||||
var nalozi []model.ProdajniNalogSaDetaljem
|
||||
for _, nd := range svi {
|
||||
if !nd.Stornirano {
|
||||
nalozi = append(nalozi, nd)
|
||||
}
|
||||
}
|
||||
postojiMap := make(map[int64]bool, len(nalozi))
|
||||
var kandidati []kandidatUskladjivanja
|
||||
for _, nd := range nalozi {
|
||||
@@ -110,7 +118,7 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
|
||||
}
|
||||
naplaceni = make([]model.ServisniNalogSaKlijentom, 0, len(servisNalozi))
|
||||
for _, sn := range servisNalozi {
|
||||
if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 {
|
||||
if sn.Status == model.StatusPreuzeto && sn.Naplaceno != 0 && !sn.Stornirano {
|
||||
naplaceni = append(naplaceni, sn)
|
||||
} else {
|
||||
nenaplaceniBroj++
|
||||
@@ -131,6 +139,36 @@ func (h *Handler) kpoKandidatiServisa(ctx context.Context) (naplaceni []model.Se
|
||||
return naplaceni, postojiMap, kandidati, nenaplaceniBroj, nil
|
||||
}
|
||||
|
||||
// kirKandidatiServisa vraća sve preuzete servisne naloge sa identifikovanim kupcem
|
||||
// (KlijentID != nil, isti kriterijum kao u Prodaji — B2B/identifikovan kupac) sa
|
||||
// mapom postojećih KIR upisa i kandidatima za detekciju duplikata.
|
||||
func (h *Handler) kirKandidatiServisa(ctx context.Context) ([]model.ServisniNalogSaKlijentom, map[int64]bool, []kandidatUskladjivanja, error) {
|
||||
svi, err := h.ServisRepo.Lista(ctx, "", "")
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var kandidatNalozi []model.ServisniNalogSaKlijentom
|
||||
for _, sn := range svi {
|
||||
if sn.Status == model.StatusPreuzeto && !sn.Stornirano && !sn.PopravkaOdbijena && sn.KlijentID != nil {
|
||||
kandidatNalozi = append(kandidatNalozi, sn)
|
||||
}
|
||||
}
|
||||
postojiMap := make(map[int64]bool, len(kandidatNalozi))
|
||||
var kandidati []kandidatUskladjivanja
|
||||
for _, sn := range kandidatNalozi {
|
||||
postoji, _ := h.PdvKirRepo.PostojiZaIzvor(ctx, "servis", sn.ID)
|
||||
postojiMap[sn.ID] = postoji
|
||||
datum := sn.DatumPrijema
|
||||
if sn.DatumZavrsetka != nil {
|
||||
datum = *sn.DatumZavrsetka
|
||||
}
|
||||
kandidati = append(kandidati, kandidatUskladjivanja{
|
||||
ID: sn.ID, KlijentID: sn.KlijentID, Iznos: sn.Naplaceno, Datum: datum, ImaUpis: postoji,
|
||||
})
|
||||
}
|
||||
return kandidatNalozi, postojiMap, kandidati, nil
|
||||
}
|
||||
|
||||
// PraznineKnjigovodstva broji naloge (prodaja + servis) koji čekaju KIR/KPO/fiskalni
|
||||
// upis prema TRENUTNO aktivnim modulima firme, i naloge koji su sumnjivi duplikati
|
||||
// (double-submit) pa ih automatski backfill preskače.
|
||||
@@ -138,21 +176,38 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
|
||||
var p model.PrazninaKnjigovodstva
|
||||
|
||||
if h.modulUkljucen(ctx, "pdv") {
|
||||
_, postojiMap, kandidati, err := h.kirKandidatiProdaje(ctx)
|
||||
_, postojiMapP, kandidatiP, err := h.kirKandidatiProdaje(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
duplikati := sumnjiviDuplikati(kandidati)
|
||||
for id, postoji := range postojiMap {
|
||||
_, postojiMapS, kandidatiS, err := h.kirKandidatiServisa(ctx)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
duplikatiP := sumnjiviDuplikati(kandidatiP)
|
||||
duplikatiS := sumnjiviDuplikati(kandidatiS)
|
||||
for id, postoji := range postojiMapP {
|
||||
if postoji {
|
||||
continue
|
||||
}
|
||||
if duplikati[id] {
|
||||
if duplikatiP[id] {
|
||||
p.SumnjiviDupli++
|
||||
continue
|
||||
}
|
||||
p.BezKir++
|
||||
p.BezKirProdajaID = append(p.BezKirProdajaID, id)
|
||||
}
|
||||
for id, postoji := range postojiMapS {
|
||||
if postoji {
|
||||
continue
|
||||
}
|
||||
if duplikatiS[id] {
|
||||
p.SumnjiviDupli++
|
||||
continue
|
||||
}
|
||||
p.BezKirServisID = append(p.BezKirServisID, id)
|
||||
}
|
||||
p.BezKirProdaja = len(p.BezKirProdajaID)
|
||||
p.BezKirServis = len(p.BezKirServisID)
|
||||
}
|
||||
|
||||
if h.modulUkljucen(ctx, "kpo") {
|
||||
@@ -174,7 +229,7 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
|
||||
p.SumnjiviDupli++
|
||||
continue
|
||||
}
|
||||
p.BezKpo++
|
||||
p.BezKpoProdajaID = append(p.BezKpoProdajaID, id)
|
||||
}
|
||||
for id, postoji := range postojiMapS {
|
||||
if postoji {
|
||||
@@ -184,38 +239,86 @@ func (h *Handler) PraznineKnjigovodstva(ctx context.Context) (model.PrazninaKnji
|
||||
p.SumnjiviDupli++
|
||||
continue
|
||||
}
|
||||
p.BezKpo++
|
||||
p.BezKpoServisID = append(p.BezKpoServisID, id)
|
||||
}
|
||||
p.BezKpoProdaja = len(p.BezKpoProdajaID)
|
||||
p.BezKpoServis = len(p.BezKpoServisID)
|
||||
}
|
||||
|
||||
if h.modulUkljucen(ctx, "fiskalizacija") {
|
||||
if m, err := h.FiskalRepo.ProdajeBezFiskalnog(ctx); err == nil {
|
||||
p.BezFiskalnog += len(m)
|
||||
for id := range m {
|
||||
p.BezFiskalnogProdajaID = append(p.BezFiskalnogProdajaID, id)
|
||||
}
|
||||
p.BezFiskalnogProdaja = len(p.BezFiskalnogProdajaID)
|
||||
}
|
||||
if m, err := h.FiskalRepo.ServisiBezFiskalnog(ctx); err == nil {
|
||||
p.BezFiskalnog += len(m)
|
||||
for id := range m {
|
||||
p.BezFiskalnogServisID = append(p.BezFiskalnogServisID, id)
|
||||
}
|
||||
if n, err := h.stornoBezRefunda(ctx); err == nil {
|
||||
p.BezRefunda = n
|
||||
p.BezFiskalnogServis = len(p.BezFiskalnogServisID)
|
||||
}
|
||||
if idsP, idsS, err := h.stornoBezRefunda(ctx); err == nil {
|
||||
p.BezRefundaProdajaID = idsP
|
||||
p.BezRefundaServisID = idsS
|
||||
p.BezRefundaProdaja = len(idsP)
|
||||
p.BezRefundaServis = len(idsS)
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// stornoBezRefunda broji stornirane prodajne naloge čiji je originalni fiskalni
|
||||
// račun i dalje neoznačen kao storniran — znači da je storno u bazi prošao, ali
|
||||
// fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort poziv je pao).
|
||||
// Servis nema storno funkciju — obuhvaćena je samo prodaja.
|
||||
func (h *Handler) stornoBezRefunda(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := h.DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM prodajni_nalozi pn
|
||||
// stornoBezRefunda vraća ID-jeve storniranih prodajnih i servisnih naloga čiji je
|
||||
// originalni fiskalni račun i dalje neoznačen kao storniran — znači da je storno u
|
||||
// bazi prošao, ali fiskalni refund ka ESIR/L-PFR uređaju NIJE uspeo (best-effort
|
||||
// poziv je pao).
|
||||
func (h *Handler) stornoBezRefunda(ctx context.Context) (prodajaIDs, servisIDs []int64, err error) {
|
||||
redoviP, err := h.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT pn.id FROM prodajni_nalozi pn
|
||||
JOIN fiskalni_racuni fr ON fr.prodaja_id = pn.id
|
||||
AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
|
||||
AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
|
||||
WHERE pn.stornirano = 1
|
||||
`).Scan(&n)
|
||||
return n, err
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer redoviP.Close()
|
||||
for redoviP.Next() {
|
||||
var id int64
|
||||
if err := redoviP.Scan(&id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
prodajaIDs = append(prodajaIDs, id)
|
||||
}
|
||||
if err := redoviP.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// tip_racuna='Normal' je bitno: servis sa avansom ima i Advance/Sale zapis koji
|
||||
// se NIKAD ne stornira pri storno servisa (samo konačan Normal/Sale) — bez ovog
|
||||
// filtera bi JOIN dao dva reda za isti servis (duplikat u listi na dashboard-u)
|
||||
redoviS, err := h.DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT sn.id FROM servisni_nalozi sn
|
||||
JOIN fiskalni_racuni fr ON fr.servis_id = sn.id
|
||||
AND fr.tip_racuna = 'Normal' AND fr.tip_transakcije = 'Sale' AND fr.storniran = 0
|
||||
WHERE sn.stornirano = 1
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer redoviS.Close()
|
||||
for redoviS.Next() {
|
||||
var id int64
|
||||
if err := redoviS.Scan(&id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
servisIDs = append(servisIDs, id)
|
||||
}
|
||||
if err := redoviS.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return prodajaIDs, servisIDs, nil
|
||||
}
|
||||
|
||||
func istKlijent(a, b *int64) bool {
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) NovaUsluga(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
sifra = "USL-001"
|
||||
}
|
||||
h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: 20, JedinicaMere: "usluga"}, false, "")
|
||||
h.renderujFormuUsluge(w, r, model.Usluga{Sifra: sifra, PdvStopa: h.podrazumevanaPdvStopa(r.Context()), JedinicaMere: "usluga"}, false, "")
|
||||
}
|
||||
|
||||
// IzmeniUslugu prikazuje formu sa postojećom uslugom
|
||||
@@ -86,7 +86,7 @@ func (h *Handler) SacuvajUslugu(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.zahtevajDozvolu(w, r, "artikal.dodaj"); !ok {
|
||||
return
|
||||
}
|
||||
usluga, greska := parseFormuUsluge(r)
|
||||
usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
|
||||
if greska != "" {
|
||||
h.renderujFormuUsluge(w, r, usluga, false, greska)
|
||||
return
|
||||
@@ -108,7 +108,7 @@ func (h *Handler) SacuvajIzmenuUsluge(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Neispravan ID usluge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
usluga, greska := parseFormuUsluge(r)
|
||||
usluga, greska := parseFormuUsluge(r, h.podrazumevanaPdvStopa(r.Context()))
|
||||
usluga.ID = id
|
||||
if greska != "" {
|
||||
h.renderujFormuUsluge(w, r, usluga, true, greska)
|
||||
@@ -139,7 +139,9 @@ func (h *Handler) ObrisiUslugu(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// parseFormuUsluge čita i validira polja forme usluge. Vraća model i poruku o grešci.
|
||||
func parseFormuUsluge(r *http.Request) (model.Usluga, string) {
|
||||
// podrazumevanaStopa je opšta PDV stopa iz šifarnika (v. Handler.podrazumevanaPdvStopa) —
|
||||
// koristi se samo kad korisnik nije uneo stopu, nikad hardkodovana vrednost.
|
||||
func parseFormuUsluge(r *http.Request, podrazumevanaStopa float64) (model.Usluga, string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return model.Usluga{}, "Greška pri čitanju forme."
|
||||
}
|
||||
@@ -154,7 +156,7 @@ func parseFormuUsluge(r *http.Request) (model.Usluga, string) {
|
||||
}
|
||||
pdv, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("pdv_stopa")), 64)
|
||||
if err != nil || pdv < 0 {
|
||||
pdv = 20
|
||||
pdv = podrazumevanaStopa
|
||||
}
|
||||
|
||||
jm := strings.TrimSpace(r.FormValue("jedinica_mere"))
|
||||
|
||||
@@ -24,6 +24,7 @@ var sveAkcije = []string{
|
||||
"servis.dodaj",
|
||||
"servis.izmeni",
|
||||
"servis.obrisi",
|
||||
"servis.storno",
|
||||
"prodaja.pregled",
|
||||
"prodaja.dodaj",
|
||||
"prodaja.storno",
|
||||
@@ -45,6 +46,9 @@ var sveAkcije = []string{
|
||||
"kpo.pregled",
|
||||
"kpo.dodaj",
|
||||
"kpo.obrisi",
|
||||
"fiskal.pazar",
|
||||
"fiskal.zakljucenje",
|
||||
"bezbednost.odblokiraj",
|
||||
}
|
||||
|
||||
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
|
||||
@@ -77,7 +81,7 @@ func ImaDozvolu(uloga, akcija string) bool {
|
||||
return true
|
||||
// servis
|
||||
case "servis.pregled", "servis.dodaj",
|
||||
"servis.izmeni", "servis.obrisi":
|
||||
"servis.izmeni", "servis.obrisi", "servis.storno":
|
||||
return true
|
||||
// prodaja
|
||||
case "prodaja.pregled", "prodaja.dodaj", "prodaja.storno":
|
||||
@@ -108,6 +112,11 @@ func ImaDozvolu(uloga, akcija string) bool {
|
||||
// KPO (knjiga o ostvarenom prometu) — administrativno, radnik nema
|
||||
case "kpo.pregled", "kpo.dodaj", "kpo.obrisi":
|
||||
return true
|
||||
// dnevni fiskalni pazar — administrativno, radnik nema (zaključenje dana je nepovratno)
|
||||
case "fiskal.pazar", "fiskal.zakljucenje":
|
||||
return true
|
||||
// "bezbednost.odblokiraj" NAMERNO izostavljeno iz admin uloge — odblokiranje IP
|
||||
// adrese zaobilazi bruteforce zaštitu, pa je rezervisano isključivo za superadmin.
|
||||
}
|
||||
return false
|
||||
|
||||
|
||||
@@ -22,3 +22,24 @@ type KpoSume struct {
|
||||
Prihod float64
|
||||
Broj int
|
||||
}
|
||||
|
||||
// KpoStorno gradi storno zapis za dati KPO red: negiran prihod, upisan pod istim
|
||||
// izvorom/izvor_id kao original. Knjiga o ostvarenom prometu paušalaca vodi redni
|
||||
// broj bez prekida (Pravilnik) — original se nikad ne briše, storno se dodaje kao
|
||||
// poništavajuća stavka koja upućuje na original.
|
||||
func KpoStorno(original KpoZapis, razlog string, datumStorna time.Time) KpoZapis {
|
||||
napomena := "Storno naloga " + original.BrojDokumenta
|
||||
if razlog != "" {
|
||||
napomena += ": " + razlog
|
||||
}
|
||||
return KpoZapis{
|
||||
DatumPrometa: datumStorna,
|
||||
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
|
||||
Opis: original.Opis,
|
||||
Prihod: -original.Prihod,
|
||||
NacinPlacanja: original.NacinPlacanja,
|
||||
Napomena: napomena,
|
||||
Izvor: original.Izvor,
|
||||
IzvorID: original.IzvorID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,10 @@ type LoginPokusaj struct {
|
||||
Razlog string
|
||||
Vreme time.Time
|
||||
}
|
||||
|
||||
// BlokiranaIP predstavlja IP adresu trenutno zaključanu zbog previše neuspelih pokušaja prijave
|
||||
type BlokiranaIP struct {
|
||||
IP string
|
||||
BrojNeuspeha int
|
||||
PoslednjiPokusaj time.Time
|
||||
}
|
||||
|
||||
@@ -129,6 +129,68 @@ func KirIzProdaje(nalog ProdajniNalog, stavke []StavkaProdaje, kupacNaziv, kupac
|
||||
return k
|
||||
}
|
||||
|
||||
// KirIzServisa gradi KIR zapis iz preuzetog servisnog naloga: radovi i ugrađeni
|
||||
// delovi (bez predloženih koji čekaju odobrenje klijenta) se sabiraju kao NETO
|
||||
// stavke po svojoj PDV stopi. datumPrometa je trenutak prometa (za sveže naloge
|
||||
// „sada", za backfill starih naloga datum preuzimanja); datumKnjizenja je kad se
|
||||
// zapis stvarno upisuje u knjigu (za backfill kasni za periodom).
|
||||
func KirIzServisa(nalog ServisniNalog, radovi []ServisniRad, delovi []ServisniDeoSaArtiklom, kupacNaziv, kupacPib, kupacMesto string, datumPrometa, datumKnjizenja time.Time) PdvKir {
|
||||
id := nalog.ID
|
||||
k := PdvKir{
|
||||
DatumPrometa: datumPrometa,
|
||||
DatumKnjizenja: datumKnjizenja,
|
||||
BrojDokumenta: nalog.BrojNaloga,
|
||||
KupacNaziv: kupacNaziv,
|
||||
KupacPib: kupacPib,
|
||||
KupacMesto: kupacMesto,
|
||||
Izvor: "servis",
|
||||
IzvorID: &id,
|
||||
}
|
||||
for _, r := range radovi {
|
||||
if r.Predlozeno {
|
||||
continue
|
||||
}
|
||||
k.DodajNeto(r.Ukupno(), r.PdvStopa)
|
||||
}
|
||||
for _, d := range delovi {
|
||||
if d.Predlozeno {
|
||||
continue
|
||||
}
|
||||
k.DodajNeto(d.Ukupno(), d.PdvStopa)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// KirStorno gradi storno (knjižno odobrenje) zapis za dati KIR red: iznosi su
|
||||
// negirani original, upisan pod istim izvorom/izvor_id tako da PostojiZaIzvor
|
||||
// ostane tačan i backfill ne pokuša ponovni upis. Zakon o računovodstvu ne
|
||||
// dozvoljava brisanje knjigovodstvenih evidencija — storno se knjiži kao
|
||||
// poništavajuća stavka koja upućuje na original, a ne kao brisanje reda.
|
||||
func KirStorno(original PdvKir, razlog string, datumStorna time.Time) PdvKir {
|
||||
napomena := "Storno računa " + original.BrojDokumenta
|
||||
if razlog != "" {
|
||||
napomena += ": " + razlog
|
||||
}
|
||||
return PdvKir{
|
||||
DatumPrometa: datumStorna,
|
||||
DatumKnjizenja: datumStorna,
|
||||
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
|
||||
KupacNaziv: original.KupacNaziv,
|
||||
KupacPib: original.KupacPib,
|
||||
KupacMesto: original.KupacMesto,
|
||||
OsnovicaOpsta: -original.OsnovicaOpsta,
|
||||
PdvOpsta: -original.PdvOpsta,
|
||||
OsnovicaPosebna: -original.OsnovicaPosebna,
|
||||
PdvPosebna: -original.PdvPosebna,
|
||||
OslobodenSaPravom: -original.OslobodenSaPravom,
|
||||
OslobodenBezPrava: -original.OslobodenBezPrava,
|
||||
Ukupno: -original.Ukupno,
|
||||
Napomena: napomena,
|
||||
Izvor: original.Izvor,
|
||||
IzvorID: original.IzvorID,
|
||||
}
|
||||
}
|
||||
|
||||
// PdvKpr je jedan zapis u knjizi primljenih računa (ulazni PDV).
|
||||
type PdvKpr struct {
|
||||
ID int64
|
||||
@@ -153,6 +215,33 @@ type PdvKpr struct {
|
||||
DatumUnosa time.Time
|
||||
}
|
||||
|
||||
// KprStorno gradi storno zapis za dati KPR red — vidi KirStorno.
|
||||
func KprStorno(original PdvKpr, razlog string, datumStorna time.Time) PdvKpr {
|
||||
napomena := "Storno dokumenta " + original.BrojDokumenta
|
||||
if razlog != "" {
|
||||
napomena += ": " + razlog
|
||||
}
|
||||
return PdvKpr{
|
||||
DatumPrometa: datumStorna,
|
||||
DatumKnjizenja: datumStorna,
|
||||
BrojDokumenta: "STORNO-" + original.BrojDokumenta,
|
||||
DobavljacNaziv: original.DobavljacNaziv,
|
||||
DobavljacPib: original.DobavljacPib,
|
||||
DobavljacMesto: original.DobavljacMesto,
|
||||
OsnovicaOpsta: -original.OsnovicaOpsta,
|
||||
PdvOpsta: -original.PdvOpsta,
|
||||
OsnovicaPosebna: -original.OsnovicaPosebna,
|
||||
PdvPosebna: -original.PdvPosebna,
|
||||
PdvBezOdbitka: -original.PdvBezOdbitka,
|
||||
OslobodenNabavka: -original.OslobodenNabavka,
|
||||
Ukupno: -original.Ukupno,
|
||||
Napomena: napomena,
|
||||
Izvor: original.Izvor,
|
||||
IzvorID: original.IzvorID,
|
||||
Uvoz: original.Uvoz,
|
||||
}
|
||||
}
|
||||
|
||||
// OznakaPoreskogBroja vraća „JMBG" za 13-cifreni broj, inače „PIB" (dobavljači su obično firme).
|
||||
func (k PdvKpr) OznakaPoreskogBroja() string {
|
||||
cifre := 0
|
||||
|
||||
@@ -70,6 +70,70 @@ func TestPdvKirDodajNeto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestKirIzServisa: radovi i ugrađeni delovi se sabiraju kao NETO stavke po
|
||||
// PDV stopi; predloženi (neprihvaćeni) redovi se ne naplaćuju.
|
||||
func TestKirIzServisa(t *testing.T) {
|
||||
nalog := ServisniNalog{ID: 7, BrojNaloga: "SN-2607-001"}
|
||||
radovi := []ServisniRad{
|
||||
{Naziv: "Zamena ekrana", Kolicina: 1, CenaKomada: 1000, PdvStopa: 20}, // osnovica 1000, PDV 200
|
||||
{Naziv: "Predlog", Kolicina: 1, CenaKomada: 5000, PdvStopa: 20, Predlozeno: true},
|
||||
}
|
||||
delovi := []ServisniDeoSaArtiklom{
|
||||
{ServisniDeo: ServisniDeo{Kolicina: 2, CenaKomada: 300}, PdvStopa: 10}, // osnovica 600, PDV 60
|
||||
}
|
||||
|
||||
k := KirIzServisa(nalog, radovi, delovi, "Kupac doo", "123456789", "Niš",
|
||||
time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
if k.Izvor != "servis" || k.IzvorID == nil || *k.IzvorID != 7 {
|
||||
t.Errorf("izvor=%q izvor_id=%v, očekivano servis/7", k.Izvor, k.IzvorID)
|
||||
}
|
||||
if k.BrojDokumenta != "SN-2607-001" {
|
||||
t.Errorf("broj_dokumenta=%q, očekivano SN-2607-001", k.BrojDokumenta)
|
||||
}
|
||||
if !blizu(k.OsnovicaOpsta, 1000) || !blizu(k.PdvOpsta, 200) {
|
||||
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano 1000/200 (predloženi rad se ne računa)", k.OsnovicaOpsta, k.PdvOpsta)
|
||||
}
|
||||
if !blizu(k.OsnovicaPosebna, 600) || !blizu(k.PdvPosebna, 60) {
|
||||
t.Errorf("posebna: osnovica=%v pdv=%v, očekivano 600/60", k.OsnovicaPosebna, k.PdvPosebna)
|
||||
}
|
||||
if !blizu(k.Ukupno, 1860) {
|
||||
t.Errorf("ukupno=%v, očekivano 1860 (1200+660)", k.Ukupno)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKirStorno: storno stavka negira sve kolone originala, čuva izvor/izvor_id
|
||||
// (da PostojiZaIzvor ostane tačan i backfill ne pokuša ponovni upis) i original
|
||||
// se ne dira — storno se samo dograđuje kao novi red.
|
||||
func TestKirStorno(t *testing.T) {
|
||||
izvorID := int64(18)
|
||||
original := PdvKir{
|
||||
BrojDokumenta: "PR-2607-0013", KupacNaziv: "Kupac doo", KupacPib: "123456789",
|
||||
OsnovicaOpsta: 1000, PdvOpsta: 200, OsnovicaPosebna: 100, PdvPosebna: 10,
|
||||
OslobodenSaPravom: 50, OslobodenBezPrava: 20, Ukupno: 1380,
|
||||
Izvor: "prodaja", IzvorID: &izvorID,
|
||||
}
|
||||
datumStorna := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
s := KirStorno(original, "kupac odustao", datumStorna)
|
||||
|
||||
if s.BrojDokumenta != "STORNO-PR-2607-0013" {
|
||||
t.Errorf("broj_dokumenta=%q, očekivano STORNO-PR-2607-0013", s.BrojDokumenta)
|
||||
}
|
||||
if s.Izvor != "prodaja" || s.IzvorID == nil || *s.IzvorID != 18 {
|
||||
t.Errorf("izvor=%q izvor_id=%v, očekivano prodaja/18 (isto kao original)", s.Izvor, s.IzvorID)
|
||||
}
|
||||
if !blizu(s.OsnovicaOpsta, -1000) || !blizu(s.PdvOpsta, -200) {
|
||||
t.Errorf("opšta: osnovica=%v pdv=%v, očekivano -1000/-200", s.OsnovicaOpsta, s.PdvOpsta)
|
||||
}
|
||||
if !blizu(s.Ukupno, -1380) {
|
||||
t.Errorf("ukupno=%v, očekivano -1380", s.Ukupno)
|
||||
}
|
||||
if original.Ukupno != 1380 {
|
||||
t.Errorf("original izmenjen: ukupno=%v, očekivano da ostane 1380", original.Ukupno)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKprIzNabavke(t *testing.T) {
|
||||
nabavka := Nabavka{ID: 3, Napomena: "test", Datum: time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)}
|
||||
stavke := []NabavkaStavkaPdv{
|
||||
|
||||
@@ -60,6 +60,8 @@ type ServisniNalog struct {
|
||||
JavniToken string
|
||||
NacinPlacanja string // način plaćanja pri preuzimanju (Gotovina, Kartica, Virman...)
|
||||
Naplaceno float64 // iznos koji je naplaćen pri preuzimanju
|
||||
Stornirano bool
|
||||
RazlogStorniranja string
|
||||
}
|
||||
|
||||
// ServisniLog je jedan zapis u istoriji događaja servisnog naloga.
|
||||
|
||||
@@ -78,16 +78,50 @@ type PodaciDashboarda struct {
|
||||
// sumnjivih duplikata (naloga koje automatski backfill namerno preskače) — prikazuje
|
||||
// se kao kartica na dashboard-u.
|
||||
type PrazninaKnjigovodstva struct {
|
||||
BezKir int
|
||||
BezKpo int
|
||||
BezFiskalnog int
|
||||
BezRefunda int // stornirani nalozi čiji fiskalni refund nije uspeo (best-effort poziv pao)
|
||||
BezKirProdaja int // B2B prodaje bez KIR upisa
|
||||
BezKirServis int // B2B servisi bez KIR upisa
|
||||
BezKirProdajaID []int64 // ID-jevi — za direktan link sa dashboard-a
|
||||
BezKirServisID []int64
|
||||
BezKpoProdaja int // prodaje bez KPO upisa
|
||||
BezKpoServis int // servisi bez KPO upisa
|
||||
BezKpoProdajaID []int64
|
||||
BezKpoServisID []int64
|
||||
BezFiskalnogProdaja int // prodajni nalozi bez izdatog fiskalnog računa
|
||||
BezFiskalnogServis int // preuzeti servisni nalozi bez izdatog fiskalnog računa
|
||||
BezFiskalnogProdajaID []int64
|
||||
BezFiskalnogServisID []int64
|
||||
BezRefundaProdaja int // stornirane prodaje čiji fiskalni refund nije uspeo (best-effort poziv pao)
|
||||
BezRefundaServis int // stornirani servisi čiji fiskalni refund nije uspeo (best-effort poziv pao)
|
||||
BezRefundaProdajaID []int64 // ID-jevi stornirane prodaje bez refunda — za direktan link sa dashboard-a
|
||||
BezRefundaServisID []int64 // ID-jevi storniranih servisa bez refunda — za direktan link sa dashboard-a
|
||||
SumnjiviDupli int
|
||||
}
|
||||
|
||||
// BezKir vraća ukupan broj naloga (prodaja + servis) bez KIR upisa.
|
||||
func (p PrazninaKnjigovodstva) BezKir() int {
|
||||
return p.BezKirProdaja + p.BezKirServis
|
||||
}
|
||||
|
||||
// BezKpo vraća ukupan broj naloga (prodaja + servis) bez KPO upisa.
|
||||
func (p PrazninaKnjigovodstva) BezKpo() int {
|
||||
return p.BezKpoProdaja + p.BezKpoServis
|
||||
}
|
||||
|
||||
// BezFiskalnog vraća ukupan broj naloga (prodaja + servis) bez izdatog fiskalnog
|
||||
// računa — koristi se za prikaz zbirnog broja na dashboard-u.
|
||||
func (p PrazninaKnjigovodstva) BezFiskalnog() int {
|
||||
return p.BezFiskalnogProdaja + p.BezFiskalnogServis
|
||||
}
|
||||
|
||||
// BezRefunda vraća ukupan broj storniranih naloga (prodaja + servis) čiji fiskalni
|
||||
// refund nije uspeo — koristi se za prikaz zbirnog broja na dashboard-u.
|
||||
func (p PrazninaKnjigovodstva) BezRefunda() int {
|
||||
return p.BezRefundaProdaja + p.BezRefundaServis
|
||||
}
|
||||
|
||||
// Ukupno vraća zbir svih praznina UKLJUČUJUĆI sumnjive duplikate — koristi se da se
|
||||
// odluči da li se kartica uopšte prikazuje na dashboard-u (i kad ostanu samo
|
||||
// duplikati za ručnu proveru, kartica mora da ostane vidljiva).
|
||||
func (p PrazninaKnjigovodstva) Ukupno() int {
|
||||
return p.BezKir + p.BezKpo + p.BezFiskalnog + p.BezRefunda + p.SumnjiviDupli
|
||||
return p.BezKir() + p.BezKpo() + p.BezFiskalnog() + p.BezRefunda() + p.SumnjiviDupli
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Servisni nalog dobija storno (kao Prodaja i Nabavka) — do sada je jedini način
|
||||
-- da se poništi naplaćen/preuzet nalog bio fizičko brisanje, koje ostavlja mrtve
|
||||
-- KIR/KPO/fiskalne zapise iza sebe.
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN stornirano INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE servisni_nalozi ADD COLUMN razlog_storniranja TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Zamrzava nabavnu cenu na stavci prodaje i ugrađenom servisnom delu u trenutku
|
||||
-- unosa — potrebno za obračun prave zarade (cena − nabavna cena) u Izveštajima
|
||||
-- bez retroaktivnog preračunavanja ako se artikli.nabavna_cena kasnije promeni.
|
||||
ALTER TABLE stavke_prodaje ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE servisni_delovi ADD COLUMN nabavna_cena REAL NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE stavke_prodaje
|
||||
SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = stavke_prodaje.artikal_id), 0);
|
||||
|
||||
UPDATE servisni_delovi
|
||||
SET nabavna_cena = COALESCE((SELECT a.nabavna_cena FROM artikli a WHERE a.id = servisni_delovi.artikal_id), 0);
|
||||
@@ -226,6 +226,10 @@ document.addEventListener('alpine:init', () => {
|
||||
this.artikliOpcije = window._ntechArtikli || []
|
||||
this.klijenti = window._ntechKlijenti || []
|
||||
this.pdvObveznik = window._ntechPdvObveznik === true
|
||||
// ako firma ne fiskalizuje, ne otvaramo ni prazan tab u pripremiFiskalniTab() —
|
||||
// stranica detalja nikad neće imati FiskalniRacun da ga popuni, pa bi ostao
|
||||
// trajno na about:blank (v. docs/Greške.md)
|
||||
this.prikaziRacun = window._ntechFiskalizacija === true
|
||||
this.isMobile = window.matchMedia('(max-width: 768px)').matches
|
||||
window.matchMedia('(max-width: 768px)').addEventListener('change', e => {
|
||||
this.isMobile = e.matches
|
||||
|
||||
@@ -100,9 +100,10 @@
|
||||
{{/* Knjigovodstvo — grupa: KIR/KPR/Obračun (modul „pdv") + Nivelacije (dozvola izmene cena) */}}
|
||||
{{$pdvVid := and (index .Moduli "pdv") (index .Dozvole "pdv.pregled")}}
|
||||
{{$kpoVid := and (index .Moduli "kpo") (index .Dozvole "kpo.pregled")}}
|
||||
{{$knjVid := or $pdvVid $kpoVid (index .Dozvole "artikal.izmeni")}}
|
||||
{{$pazarVid := and (index .Moduli "fiskalizacija") (index .Dozvole "fiskal.pazar")}}
|
||||
{{$knjVid := or $pdvVid $kpoVid $pazarVid (index .Dozvole "artikal.izmeni")}}
|
||||
{{if $knjVid}}
|
||||
{{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "kpo") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun")}}
|
||||
{{$knjAktivan := or (eq .Stranica "nivelacije") (eq .Stranica "kpo") (eq .Stranica "pdv-kir") (eq .Stranica "pdv-kpr") (eq .Stranica "pdv-obracun") (eq .Stranica "fiskal-pazar")}}
|
||||
<div>
|
||||
<button type="button" data-podmeni-dugme
|
||||
class="nav-stavka {{if $knjAktivan}}aktivan{{end}}"
|
||||
@@ -149,6 +150,13 @@
|
||||
<span class="nav-tooltip">PDV obračun za period</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if $pazarVid}}
|
||||
<a href="/fiskal/pazar" class="nav-stavka nav-podstavka {{if eq .Stranica "fiskal-pazar"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
<span>Dnevni pazar</span>
|
||||
<span class="nav-tooltip">Dnevni pazar</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -207,19 +215,19 @@
|
||||
{{if index .Dozvole "podesavanja.pregled"}}
|
||||
<div>
|
||||
<button type="button" data-podmeni-dugme
|
||||
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}aktivan{{end}}"
|
||||
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}aktivan{{end}}"
|
||||
style="width:100%;background:none;border:none;cursor:pointer;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>Podešavanja</span>
|
||||
<span class="nav-strelica">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}">
|
||||
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}rotate(180deg){{else}}rotate(0deg){{end}}">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-tooltip">Podešavanja</span>
|
||||
</button>
|
||||
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole")}}otvoren{{end}}">
|
||||
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}otvoren{{end}}">
|
||||
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
|
||||
<span>Opšte</span>
|
||||
@@ -257,6 +265,13 @@
|
||||
<span class="nav-tooltip">Dozvole</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{if index .Dozvole "bezbednost.odblokiraj"}}
|
||||
<a href="/admin/blokirane-ip" class="nav-stavka nav-podstavka {{if eq .Stranica "blokirane-ip"}}aktivan{{end}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<span>Blokirane IP adrese</span>
|
||||
<span class="nav-tooltip">Blokirane IP adrese</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{{template "base" .}} {{define "naslov"}}Blokirane IP adrese — NTech{{end}} {{define "sadrzaj"}}
|
||||
<div class="stranica-stack" style="display: flex; flex-direction: column; gap: 16px">
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap">
|
||||
<span class="naslov-traka" style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">Blokirane IP adrese</span>
|
||||
</div>
|
||||
|
||||
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
|
||||
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
|
||||
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
|
||||
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
|
||||
</div>
|
||||
|
||||
{{if .Lista}}
|
||||
<div style="overflow-x: auto">
|
||||
<table class="tabela">
|
||||
<thead>
|
||||
<tr style="border-bottom: 0.5px solid var(--ivica)">
|
||||
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">IP adresa</th>
|
||||
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Neuspelih pokušaja</th>
|
||||
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni); white-space: nowrap">Poslednji pokušaj</th>
|
||||
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Preostalo</th>
|
||||
<th style="padding: 10px 20px; text-align: right; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Lista}}
|
||||
<tr class="animiraj" style="border-bottom: 0.5px solid var(--ivica)">
|
||||
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni); font-family: monospace">{{.IP}}</td>
|
||||
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni)">{{.BrojNeuspeha}}</td>
|
||||
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}</td>
|
||||
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni)">{{.Preostalo}}</td>
|
||||
<td style="padding: 10px 20px; text-align: right">
|
||||
<form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload
|
||||
onsubmit="return confirm('Odblokirati IP adresu {{.IP}}? Ovo odmah briše sve neuspele pokušaje prijave za nju.')">
|
||||
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
|
||||
<input type="hidden" name="ip" value="{{.IP}}">
|
||||
<button type="submit" class="btn-sekundarno" style="font-size: 13px; padding: 6px 14px">Odblokiraj</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="padding: 40px 20px; text-align: center; color: var(--tekst-sporedni); font-size: 14px">Trenutno nema blokiranih IP adresa.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -22,18 +22,35 @@
|
||||
{{ if gt .PrazninaKnjige.Ukupno 0 }}
|
||||
<div class="kartica animiraj" style="margin-bottom:16px;background:#fef3c7;border:1px solid #fde68a;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;">⚠</div>
|
||||
<div>
|
||||
<div style="font-weight:600;color:#92400e;">Knjigovodstvo — praznine u evidenciji</div>
|
||||
<div style="font-size:13px;color:#92400e;margin-top:2px;">
|
||||
{{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}{{ .PrazninaKnjige.BezFiskalnog }} bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }}
|
||||
{{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnog 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }}
|
||||
<div style="font-weight:600;color:#fdba74;">Knjigovodstvo — praznine u evidenciji</div>
|
||||
<div style="font-size:13px;color:#fdba74;margin-top:2px;">
|
||||
{{ if gt .PrazninaKnjige.BezKir 0 }}{{ .PrazninaKnjige.BezKir }} bez KIR upisa{{ if or (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezKpo 0 }}{{ .PrazninaKnjige.BezKpo }} bez KPO upisa{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogProdaja 0 }}{{ .PrazninaKnjige.BezFiskalnogProdaja }} prodaja bez fiskalnog računa{{ if or (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezFiskalnogServis 0 }}{{ .PrazninaKnjige.BezFiskalnogServis }} servis bez fiskalnog računa{{ if gt .PrazninaKnjige.BezRefunda 0 }}, {{ end }}{{ end }}{{ if gt .PrazninaKnjige.BezRefunda 0 }}{{ .PrazninaKnjige.BezRefunda }} storno bez fiskalnog refunda{{ end }}
|
||||
{{ if gt .PrazninaKnjige.SumnjiviDupli 0 }}{{ if or (gt .PrazninaKnjige.BezKir 0) (gt .PrazninaKnjige.BezKpo 0) (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) (gt .PrazninaKnjige.BezRefunda 0) }} · {{ end }}{{ .PrazninaKnjige.SumnjiviDupli }} sumnjivih duplikata (proveri ručno){{ end }}
|
||||
{{ if or (gt .PrazninaKnjige.BezFiskalnogProdaja 0) (gt .PrazninaKnjige.BezFiskalnogServis 0) }}
|
||||
<div style="margin-top:4px;">
|
||||
Bez fiskalnog računa:
|
||||
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
|
||||
{{ if and .PrazninaKnjige.BezFiskalnogProdajaID .PrazninaKnjige.BezFiskalnogServisID }}, {{ end }}
|
||||
{{ range $i, $id := .PrazninaKnjige.BezFiskalnogServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezRefunda 0 }}
|
||||
<div style="margin-top:4px;">
|
||||
Storno bez refunda:
|
||||
{{ range $i, $id := .PrazninaKnjige.BezRefundaProdajaID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Prodaja #{{ $id }}</a>{{ end }}
|
||||
{{ if and .PrazninaKnjige.BezRefundaProdajaID .PrazninaKnjige.BezRefundaServisID }}, {{ end }}
|
||||
{{ range $i, $id := .PrazninaKnjige.BezRefundaServisID }}{{ if $i }}, {{ end }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/servis/{{ $id }}" style="color:#2563eb;text-decoration:underline;">Servis #{{ $id }}</a>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR</a>{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO</a>{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezFiskalnog 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Fiskalizacija</a>{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezRefunda 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/prodaja?stornirano=1&prikazi=sve" class="btn-sekundarno" style="font-size:13px;">Storno bez refunda</a>{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezKir 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir" class="btn-sekundarno" style="font-size:13px;">KIR — pokreni usklađivanje</a>{{ end }}
|
||||
{{ if gt .PrazninaKnjige.BezKpo 0 }}<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/kpo" class="btn-sekundarno" style="font-size:13px;">KPO — pokreni usklađivanje</a>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "naslov"}}Dnevni fiskalni presek — NTech{{end}}
|
||||
|
||||
{{define "sadrzaj"}}
|
||||
<div class="stranica-stack" style="width:100%;max-width:720px;">
|
||||
|
||||
<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"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Dnevni fiskalni presek</span>
|
||||
</div>
|
||||
|
||||
{{if .Greska}}
|
||||
<div class="fisk-status greska" style="margin-bottom:16px;">✗ {{.Greska}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Presek}}
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:16px;">
|
||||
Kumulativni promet od poslednjeg (opcionog) reseta brojača: <strong>{{.Presek.StartOfPeriod}}</strong> do sada.
|
||||
Za tačan izveštaj za određeni dan/period koristi „Preuzmi izveštaj za period" ispod.
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;">
|
||||
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);">Broj računa</div>
|
||||
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{.Presek.InvoiceCount}}</div>
|
||||
</div>
|
||||
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);">Ukupan promet</div>
|
||||
<div style="font-size:22px;font-weight:700;color:var(--tekst-glavni);">{{dinari .Presek.Total}} din</div>
|
||||
</div>
|
||||
<div style="padding:14px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:10px;">
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);">Gotovina</div>
|
||||
<div style="font-size:18px;font-weight:600;color:var(--tekst-glavni);">{{dinari .Presek.TotalCash}} din</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Presek.TotalByPaymentType}}
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po vrsti plaćanja</div>
|
||||
{{range .Presek.TotalByPaymentType}}
|
||||
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
|
||||
<span style="color:var(--tekst-sporedni);">{{.PaymentType}}</span>
|
||||
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Presek.TotalByTax}}
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po poreskim stopama</div>
|
||||
{{range .Presek.TotalByTax}}
|
||||
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
|
||||
<span style="color:var(--tekst-sporedni);">{{.Label}} ({{.Rate}}%)</span>
|
||||
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Presek.TotalByCashier}}
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Po kasiru</div>
|
||||
{{range .Presek.TotalByCashier}}
|
||||
<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:0.5px solid var(--ivica);font-size:13px;">
|
||||
<span style="color:var(--tekst-sporedni);">{{.Name}}</span>
|
||||
<span style="color:var(--tekst-glavni);">{{dinari .Amount}} din</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .FiskalPodesen}}
|
||||
<div style="padding-top:12px;border-top:0.5px solid var(--ivica);">
|
||||
<div style="font-size:13px;font-weight:500;color:var(--tekst-glavni);margin-bottom:8px;">Preuzmi izveštaj za period</div>
|
||||
<form method="POST" action="/fiskal/pazar/izvestaj" data-full-reload style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:16px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Od datuma</label>
|
||||
<input type="date" name="od_datuma" value="{{.Danas}}" class="polje-forme" style="font-size:13px;padding:7px 10px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Do datuma</label>
|
||||
<input type="date" name="do_datuma" value="{{.Danas}}" class="polje-forme" style="font-size:13px;padding:7px 10px;">
|
||||
</div>
|
||||
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Preuzmi izveštaj</button>
|
||||
</form>
|
||||
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a href="/fiskal/pazar" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;">Osveži</a>
|
||||
|
||||
{{if index .Dozvole "fiskal.zakljucenje"}}
|
||||
<form method="POST" action="/fiskal/pazar/zakljuci" style="display:inline;" data-full-reload
|
||||
onsubmit="return confirm('Resetovati brojače na ekranu? Ovo NIJE zakonska obaveza — izveštaj po periodu radi nezavisno od ovoga. Samo svodi prikaz Promet od poslednjeg preseka na nulu.')">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;color:var(--tekst-sporedni);">Resetuj brojače (opciono)</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -51,23 +51,41 @@
|
||||
<thead>
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:8px 12px;text-align:left;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Mesec</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Prodaja</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Servis</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Prodaja</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Servis</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:12px;font-weight:500;color:var(--tekst-sporedni);">Ukupno</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .MesecniPrihodi}}
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);">
|
||||
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-glavni);">{{.MesecPrikaz}}</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Prodaja 0.0}}{{dinariCeli .Prodaja}} din{{else}}—{{end}}
|
||||
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Prodaja 0.0}}
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
|
||||
<span style="text-align:right;">{{dinariCeli .Prodaja}} din</span>
|
||||
<span>-</span>
|
||||
<span style="text-align:left;">{{dinariCeli .ZaradaProdaja}} din</span>
|
||||
</div>
|
||||
{{else}}<div style="text-align:center;">—</div>{{end}}
|
||||
</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Servis 0.0}}{{dinariCeli .Servis}} din{{else}}—{{end}}
|
||||
<td style="padding:8px 12px;font-size:13px;color:var(--tekst-sporedni);">
|
||||
{{if gt .Servis 0.0}}
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
|
||||
<span style="text-align:right;">{{dinariCeli .Servis}} din</span>
|
||||
<span>-</span>
|
||||
<span style="text-align:left;">{{dinariCeli .ZaradaServis}} din</span>
|
||||
</div>
|
||||
{{else}}<div style="text-align:center;">—</div>{{end}}
|
||||
</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if gt .Ukupno 0.0}}{{dinariCeli .Ukupno}} din{{else}}—{{end}}
|
||||
<td style="padding:8px 12px;font-size:13px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if gt .Ukupno 0.0}}
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:6px;">
|
||||
<span style="text-align:right;">{{dinariCeli .Ukupno}} din</span>
|
||||
<span>-</span>
|
||||
<span style="text-align:left;">{{dinariCeli .Zarada}} din</span>
|
||||
</div>
|
||||
{{else}}<div style="text-align:center;">—</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -207,18 +225,40 @@
|
||||
labels: podaci.labele,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Prodaja',
|
||||
label: 'Prodaja — ukupno',
|
||||
data: podaci.prodaja,
|
||||
backgroundColor: 'rgba(79, 126, 248, 0.75)',
|
||||
backgroundColor: 'rgba(79, 126, 248, 0.35)',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
stack: 'prodaja',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: 'Servis',
|
||||
label: 'Servis — ukupno',
|
||||
data: podaci.servis,
|
||||
backgroundColor: 'rgba(22, 163, 74, 0.75)',
|
||||
backgroundColor: 'rgba(22, 163, 74, 0.35)',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
stack: 'servis',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: 'Zarada — prodaja',
|
||||
data: podaci.zaradaProdaja,
|
||||
backgroundColor: 'rgba(29, 66, 168, 0.9)',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
stack: 'prodaja',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: 'Zarada — servis',
|
||||
data: podaci.zaradaServis,
|
||||
backgroundColor: 'rgba(21, 94, 51, 0.9)',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
stack: 'servis',
|
||||
order: 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -239,11 +279,12 @@
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: false,
|
||||
stacked: true,
|
||||
ticks: { color: tekstBoja, font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' } },
|
||||
grid: { color: ivicaBoja }
|
||||
},
|
||||
y: {
|
||||
stacked: false,
|
||||
ticks: {
|
||||
color: tekstBoja,
|
||||
font: { size: 11, family: 'DejaVu Sans, Ubuntu, system-ui, sans-serif' },
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
Uvezi iz prodaje
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/pdv/kir/backfill-servisa" data-full-reload>
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="btn-sekundarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;"
|
||||
data-potvrda="Uvesti sve preuzete servisne naloge koji još nisu u KIR-u?">
|
||||
Uvezi iz servisa
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir/nova" class="btn-primarno" style="font-size:13px;padding:8px 16px;white-space:nowrap;">+ Nov izlazni račun</a>
|
||||
</div>
|
||||
@@ -101,5 +108,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- sumnjivi duplikati (double-submit) — prodaje preskočene pri automatskom uvozu -->
|
||||
{{if .SumnjiviDuplikati}}
|
||||
<div class="kartica animiraj" style="margin-top:24px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
|
||||
<span style="font-size:14px;font-weight:600;">⚠ Sumnjivi duplikati</span>
|
||||
<span style="font-size:12px;color:var(--tekst-sporedni);">({{len .SumnjiviDuplikati}} preskočeno pri uvozu — mogući double-submit, proveri ručno)</span>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="tabela" style="width:100%;border-collapse:collapse;font-size:13px;min-width:600px;">
|
||||
<thead>
|
||||
<tr style="text-align:left;color:var(--tekst-sporedni);border-bottom:0.5px solid var(--ivica);">
|
||||
<th style="padding:8px 12px;">Broj</th>
|
||||
<th style="padding:8px 12px;">Datum</th>
|
||||
<th style="padding:8px 12px;">Kupac</th>
|
||||
<th style="padding:8px 12px;text-align:right;">Iznos</th>
|
||||
<th style="padding:8px 12px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .SumnjiviDuplikati}}
|
||||
<tr style="border-bottom:0.5px solid var(--ivica);{{if .Stornirano}}opacity:.5;{{end}}">
|
||||
<td style="padding:8px 12px;font-family:monospace;">{{.BrojNaloga}}</td>
|
||||
<td style="padding:8px 12px;white-space:nowrap;">{{.Datum.Format "02.01.2006. 15:04"}}</td>
|
||||
<td style="padding:8px 12px;">{{.KlijentNaziv}}</td>
|
||||
<td style="padding:8px 12px;text-align:right;font-weight:500;">{{dinari .Ukupno}}</td>
|
||||
<td style="padding:8px 12px;text-align:right;white-space:nowrap;">
|
||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML"
|
||||
href="/prodaja/{{.ID}}" class="btn-sekundarno" style="font-size:12px;padding:4px 10px;">Pregled</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:12px;color:var(--tekst-sporedni);line-height:1.6;">
|
||||
Ovo su prodaje koje izgledaju kao dupli unos (isti kupac, isti iznos, unutar 60 sekundi).
|
||||
Automatski uvoz ih preskače da ne bi duplirao prihod u KIR-u.
|
||||
<strong>Ako su to stvarno dve odvojene prodaje</strong> — unesi ručno preko
|
||||
<a hx-boost="true" hx-target="#glavni-sadrzaj" hx-select="#glavni-sadrzaj" hx-swap="outerHTML" href="/pdv/kir/nova">+ Nov izlazni račun</a>.
|
||||
<strong>Ako je greška (double-submit)</strong> — storniraj/obriši duplikat u Prodaji.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -55,9 +55,10 @@
|
||||
.potpis-linija { border-top: 1px solid #888; margin-top: 28px; padding-top: 3px; font-size: 10px; color: #777; }
|
||||
|
||||
@media print {
|
||||
body { font-size: 11px; }
|
||||
.strana { padding: 20px 28px; max-width: 780px; }
|
||||
@page { size: A4 landscape; margin: 8mm 10mm; }
|
||||
body { font-size: 10px; min-width: 0; }
|
||||
.strana { padding: 0; max-width: 100%; }
|
||||
@page { size: A4 portrait; margin: 8mm 10mm; }
|
||||
.tabela th, .tabela td { padding: 3px 4px; }
|
||||
.tabela tr { break-inside: avoid; }
|
||||
.tabela thead { display: table-header-group; }
|
||||
.dugme-stampa { display: none !important; }
|
||||
|
||||
@@ -52,29 +52,6 @@
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<a
|
||||
href="/prodaja/{{.Nalog.ID}}/stampa"
|
||||
target="_blank"
|
||||
class="btn-sekundarno"
|
||||
style="gap: 6px">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path
|
||||
d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
Štampaj
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
@@ -252,23 +229,66 @@
|
||||
Storno vraća sve artikle na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" style="display:flex;flex-direction:column;gap:8px;align-items:flex-end;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<input type="text" name="razlog" placeholder="Razlog storniranja (opciono)" style="width:240px;">
|
||||
{{if .PotrebnaIdentKupca}}
|
||||
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="width:240px;">
|
||||
{{end}}
|
||||
<button type="submit" class="btn-upozorenje"
|
||||
data-potvrda="Stornirati nalog {{.Nalog.BrojNaloga}}?">
|
||||
<button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
|
||||
Storniraj nalog
|
||||
</button>
|
||||
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
|
||||
<form method="dialog" class="premesti-zaglavlje">
|
||||
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
|
||||
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
||||
</form>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
|
||||
<form method="POST" action="/prodaja/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<div>
|
||||
<label class="polje-labela">Razlog storniranja (opciono)</label>
|
||||
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||
</div>
|
||||
{{if .PotrebnaIdentKupca}}
|
||||
<div>
|
||||
<label class="polje-labela">PIB ili JMBG kupca</label>
|
||||
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
|
||||
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
|
||||
Potvrdi storno
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// isti obrazac kao u Servisu (servis_detalji.html) — v. komentar tamo zašto
|
||||
// se NE koristi ntechOtvoriModal (auto-submit na zatvaranje dialoga).
|
||||
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
|
||||
window._ntechPotvrdi(poruka).then(function (ok) {
|
||||
if (!ok) return;
|
||||
var d = btn.nextElementSibling;
|
||||
if (d && d.tagName === 'DIALOG') d.showModal();
|
||||
});
|
||||
};
|
||||
var pib = document.getElementById('storno-pib');
|
||||
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
|
||||
if (pib && btnPotvrdi) {
|
||||
pib.addEventListener('input', function () {
|
||||
var ok = pib.value.trim() !== '';
|
||||
btnPotvrdi.disabled = !ok;
|
||||
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
|
||||
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{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="margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 style="margin:0;font-size:16px;">Fiskalni račun</h3>
|
||||
<a href="/prodaja/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
|
||||
</div>
|
||||
<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>
|
||||
@@ -296,14 +316,27 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{else}}
|
||||
<script>
|
||||
(function(){
|
||||
// fiskalizacija je pokušana (racun=1 tražen pri naplati) ali računa nema —
|
||||
// PFR nedostupan ili slično (v. FiskalGreska ispod). Prazan tab je već otvoren
|
||||
// pre redirekta — zatvori ga umesto da ostane trajno na about:blank.
|
||||
if (new URLSearchParams(location.search).get('racun') === '1') {
|
||||
var t = window.open('', 'ntech-fiskalni-tab');
|
||||
if (t) t.close();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .FiskalGreska}}
|
||||
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #f59e0b;border-radius:8px;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
||||
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;">⚠</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">⚠ Fiskalni račun nije izdat</div>
|
||||
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
|
||||
<div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
|
||||
<div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri kreiranju prodaje.</div>
|
||||
</div>
|
||||
{{if index $.Dozvole "prodaja.dodaj"}}
|
||||
<form method="POST" action="/prodaja/{{.Nalog.ID}}/fiskalizuj">
|
||||
@@ -315,6 +348,27 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .StornoBezRefunda}}
|
||||
<div class="fiskalni-racun" style="padding:14px;border:1.5px solid #dc2626;border-radius:8px;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;">⚠</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
|
||||
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
|
||||
{{if index $.Dozvole "prodaja.storno"}}
|
||||
<form method="POST" action="/prodaja/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
{{if .PotrebnaIdentKupcaRetry}}
|
||||
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
|
||||
{{end}}
|
||||
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
var _ntechArtikli = {{.ArtikliJSON}};
|
||||
var _ntechKlijenti = {{.KlijentiJSON}};
|
||||
var _ntechPdvObveznik = {{if index .Moduli "pdv"}}true{{else}}false{{end}};
|
||||
var _ntechFiskalizacija = {{if index .Moduli "fiskalizacija"}}true{{else}}false{{end}};
|
||||
</script>
|
||||
|
||||
<div style="width: 100%" x-data="prodajaForma">
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
<span style="font-size:20px;font-weight:600;color:var(--tekst-glavni);font-family:monospace;">
|
||||
{{.Nalog.BrojNaloga}}
|
||||
</span>
|
||||
{{if .Nalog.Stornirano}}
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:500;background:rgba(220,38,38,0.12);color:#dc2626;">
|
||||
Stornirano
|
||||
</span>
|
||||
{{end}}
|
||||
{{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>
|
||||
@@ -125,19 +130,34 @@
|
||||
<span style="font-size:16px;font-weight:600;color:var(--tekst-glavni);">Za naplatu</span>
|
||||
<span style="font-size:18px;font-weight:600;color:var(--tekst-glavni);" id="za-naplatu-iznos">{{if index .Moduli "pdv"}}{{dinari .PreostaloSveSaPdv}}{{else}}{{dinari .PreostaloSve}}{{end}} din</span>
|
||||
</div>
|
||||
{{$visak := .VisakAvansa}}{{if index .Moduli "pdv"}}{{$visak = .VisakAvansaSaPdv}}{{end}}
|
||||
{{if gt $visak 0.0}}
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,#f59e0b 12%,transparent);border:0.5px solid #f59e0b;border-radius:8px;">
|
||||
<span style="color:#92400e;font-weight:500;">Avans premašuje cenu — vratiti klijentu</span>
|
||||
<span style="font-size:16px;font-weight:600;color:#92400e;">{{dinari $visak}} din</span>
|
||||
</div>
|
||||
<div class="pomocni-tekst">Ovaj iznos se automatski vraća kroz avansni povraćaj na fiskalnom računu.</div>
|
||||
{{end}}
|
||||
{{$zaNaplatu := .PreostaloSve}}{{if index .Moduli "pdv"}}{{$zaNaplatu = .PreostaloSveSaPdv}}{{end}}
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<input type="hidden" name="status" value="Preuzeto">
|
||||
<input type="hidden" name="naplaceno" value="{{printf "%.2f" $zaNaplatu}}">
|
||||
{{if gt $zaNaplatu 0.0}}
|
||||
<div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);">
|
||||
<label class="polje-labela">Primljeno (din)</label>
|
||||
<input type="number" id="primljeno" name="primljeno" min="0" step="0.01" placeholder="Unesite primljeni iznos" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:14px;box-sizing:border-box;" oninput="ntechPrimljenoUpdate(this)">
|
||||
</div>
|
||||
<div id="kusur-blok" style="display:none;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,var(--uspeh) 10%,transparent);border:0.5px solid var(--uspeh);border-radius:8px;">
|
||||
<div id="kusur-blok" style="display:none;justify-content:space-between;align-items:center;padding:8px 12px;background:color-mix(in srgb,var(--uspeh) 10%,transparent);border:0.5px solid var(--uspeh);border-radius:8px;margin-top:10px;">
|
||||
<span style="color:var(--uspeh);font-weight:500;">Kusur (vratiti)</span>
|
||||
<span id="kusur" style="font-size:16px;font-weight:600;color:var(--uspeh);">0,00 din</span>
|
||||
</div>
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/status" data-refresh="#sadrzaj" style="margin-top:8px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<input type="hidden" name="status" value="Preuzeto">
|
||||
<input type="hidden" name="naplaceno" value="{{if index .Moduli "pdv"}}{{printf "%.2f" .PreostaloSveSaPdv}}{{else}}{{printf "%.2f" .PreostaloSve}}{{end}}">
|
||||
<div style="margin-bottom:10px;">
|
||||
{{else}}
|
||||
<div style="margin-top:10px;padding-top:10px;border-top:0.5px solid var(--ivica);font-size:13px;color:var(--tekst-sporedni);">
|
||||
Avans pokriva ceo iznos — ništa se dodatno ne naplaćuje.
|
||||
</div>
|
||||
{{end}}
|
||||
<div style="margin-bottom:10px;margin-top:10px;">
|
||||
<label class="polje-labela">Način plaćanja</label>
|
||||
<select name="nacin_placanja" id="nacin-placanja-sel" style="width:100%;padding:8px 12px;background:var(--pozadina);border:0.5px solid var(--ivica);border-radius:8px;color:var(--tekst-glavni);font-size:13px;" onchange="ntechNacinPlacanjaBroj(this)">
|
||||
<option value="Gotovina">Gotovina</option>
|
||||
@@ -150,7 +170,11 @@
|
||||
style="width:16px;height:16px;accent-color:var(--sb-akcent);cursor:pointer;flex-shrink:0;">
|
||||
<span style="font-size:13px;color:var(--tekst-glavni);">Prikaži fiskalni račun</span>
|
||||
</label>
|
||||
{{if gt $zaNaplatu 0.0}}
|
||||
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;opacity:0.4;cursor:not-allowed;" disabled>Naplati i preuzmi</button>
|
||||
{{else}}
|
||||
<button type="submit" id="btn-naplati" class="btn-primarno" style="width:100%;">Preuzmi</button>
|
||||
{{end}}
|
||||
</form>
|
||||
<script>
|
||||
(function(){
|
||||
@@ -190,11 +214,27 @@
|
||||
f.addEventListener('submit', function(){
|
||||
var cb = f.querySelector('#prikazi-racun');
|
||||
if (!cb || !cb.checked) return;
|
||||
// otvori prazan imenovani tab SINHRONO unutar submit-a (očuvan user
|
||||
// gesture) — isti obrazac kao pripremiFiskalniTab u Prodaji (ntech.js).
|
||||
// Naplata ovde ide preko AJAX-a (data-refresh), pa se popunjava tek
|
||||
// kad MutationObserver javi da je swap gotov — a to je već van user
|
||||
// gesture-a, pa direktan window.open() u tom trenutku brauzer tretira
|
||||
// kao blokiran popup i otvara about:blank (v. docs/Greške.md).
|
||||
var tab = window.open('', 'ntech-fiskalni-tab');
|
||||
var sadrzaj = document.getElementById('sadrzaj');
|
||||
if (!sadrzaj) return;
|
||||
if (!sadrzaj) {
|
||||
if (tab) tab.close();
|
||||
return;
|
||||
}
|
||||
var observer = new MutationObserver(function(){
|
||||
observer.disconnect();
|
||||
// jedan prozor — server u istu stranicu ubacuje i povraćaj viška
|
||||
// avansa (ako postoji), da brauzer ne blokira drugi popup
|
||||
if (tab && !tab.closed) {
|
||||
tab.location.href = '/servis/{{.Nalog.ID}}/fiskalni-racun';
|
||||
} else {
|
||||
window.open('/servis/{{.Nalog.ID}}/fiskalni-racun', '_blank');
|
||||
}
|
||||
});
|
||||
observer.observe(sadrzaj.parentElement, {childList: true});
|
||||
});
|
||||
@@ -325,7 +365,7 @@
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Garancija</div>
|
||||
<div style="font-size:14px;{{if not .BezGarancije}}color:#16a34a;font-weight:500;{{else}}color:var(--tekst-sporedni);{{end}}">
|
||||
{{garancijaTekst .Nalog.GarancijaDana}}
|
||||
{{if index .Dozvole "servis.izmeni"}}
|
||||
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "Preuzeto")}}
|
||||
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
|
||||
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
|
||||
<form method="dialog" class="premesti-zaglavlje">
|
||||
@@ -366,7 +406,7 @@
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin-bottom:4px;">Serviser</div>
|
||||
<div style="font-size:14px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if .TehnicarNaziv}}{{.TehnicarNaziv}}{{else}}<span style="color:var(--tekst-sporedni);font-weight:normal;">— nedodeljen —</span>{{end}}
|
||||
{{if index .Dozvole "servis.izmeni"}}
|
||||
{{if and (index .Dozvole "servis.izmeni") (ne .Nalog.Status "Preuzeto")}}
|
||||
<button type="button" class="izmeni-polje" onclick="ntechOtvoriModal(this)">Promeni</button>
|
||||
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
|
||||
<form method="dialog" class="premesti-zaglavlje">
|
||||
@@ -977,9 +1017,14 @@
|
||||
{{if .FiskalniRacun}}
|
||||
<div class="kartica detalji-kartica animiraj">
|
||||
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Fiskalni račun</span>
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">
|
||||
{{if eq .FiskalniRacun.TipRacuna "Advance"}}Avansni fiskalni račun{{else}}Fiskalni račun{{end}}
|
||||
</span>
|
||||
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
|
||||
</div>
|
||||
{{if eq .FiskalniRacun.TipRacuna "Advance"}}
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Ovo je račun za primljeni avans — konačan račun se izdaje pri preuzimanju uređaja.</div>
|
||||
{{end}}
|
||||
<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>
|
||||
@@ -1000,12 +1045,57 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AvansRacun}}
|
||||
<div class="kartica detalji-kartica animiraj">
|
||||
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Avansni fiskalni račun</span>
|
||||
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun?tip=avans" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Račun za primljeni avans, izdat pre preuzimanja uređaja.</div>
|
||||
<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> {{.AvansRacun.PfrBroj}}</p>
|
||||
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.AvansRacun.PfrVreme}}</p>
|
||||
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.AvansRacun.Brojac}}</p>
|
||||
</div>
|
||||
{{if .AvansRacun.QRKod}}
|
||||
<div>
|
||||
<img src="data:image/png;base64,{{.AvansRacun.QRKod}}" alt="QR kod" style="width:25mm;height:25mm;">
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .PovracajAvansa}}
|
||||
<div class="kartica detalji-kartica animiraj">
|
||||
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:0.5px solid var(--ivica);display:flex;justify-content:space-between;align-items:center;">
|
||||
<span style="font-size:15px;font-weight:500;color:var(--tekst-glavni);">Povraćaj viška avansa</span>
|
||||
<a href="/servis/{{.Nalog.ID}}/fiskalni-racun?tip=povracaj" target="_blank" class="btn-sekundarno" style="font-size:12px;padding:4px 12px;">Štampaj kopiju</a>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--tekst-sporedni);margin:-4px 0 10px 0;">Avans je premašio konačnu cenu — razlika je vraćena klijentu posebnim fiskalnim dokumentom.</div>
|
||||
<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> {{.PovracajAvansa.PfrBroj}}</p>
|
||||
<p style="margin:0 0 4px 0;"><strong>Vreme:</strong> {{.PovracajAvansa.PfrVreme}}</p>
|
||||
<p style="margin:0 0 4px 0;color:var(--tekst-sporedno);"><strong>Brojač:</strong> {{.PovracajAvansa.Brojac}}</p>
|
||||
</div>
|
||||
{{if .PovracajAvansa.QRKod}}
|
||||
<div>
|
||||
<img src="data:image/png;base64,{{.PovracajAvansa.QRKod}}" alt="QR kod" style="width:25mm;height:25mm;">
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .FiskalGreska}}
|
||||
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #f59e0b;background:color-mix(in srgb,#fef3c7 60%,var(--pozadina));">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
|
||||
<div style="font-size:46px;line-height:1;color:#fdba74;flex-shrink:0;">⚠</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:14px;font-weight:600;color:#92400e;margin-bottom:4px;">⚠ Fiskalni račun nije izdat</div>
|
||||
<div style="font-size:13px;color:#78350f;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div>
|
||||
<div style="font-size:14px;font-weight:600;color:#fdba74;margin-bottom:4px;">Fiskalni račun nije izdat</div>
|
||||
<div style="font-size:13px;color:#fdba74;">Veza sa ESIR/PFR serverom nije bila dostupna pri preuzimanju. Status naloga je promenjen, ali fiskalni račun nije poslat.</div>
|
||||
</div>
|
||||
{{if index .Dozvole "servis.izmeni"}}
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/fiskalizuj">
|
||||
@@ -1017,6 +1107,96 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .StornoBezRefunda}}
|
||||
<div class="kartica detalji-kartica animiraj" style="border:1.5px solid #dc2626;background:color-mix(in srgb,#fee2e2 60%,var(--pozadina));">
|
||||
<div style="display:flex;align-items:center;gap:14px;">
|
||||
<div style="font-size:46px;line-height:1;color:#fca5a5;flex-shrink:0;">⚠</div>
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:14px;font-weight:600;color:#fca5a5;margin-bottom:4px;">Storno bez fiskalnog refunda</div>
|
||||
<div style="font-size:13px;color:#fca5a5;margin-bottom:10px;">Storno je upisan u NTech, ali fiskalni refund ka PFR uređaju nije uspeo (originalni fiskalni račun i dalje nije označen kao storniran).</div>
|
||||
{{if index .Dozvole "servis.storno"}}
|
||||
<form method="POST" action="/servis/{{.Nalog.ID}}/refund-fiskalizuj" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
{{if .PotrebnaIdentKupcaRetry}}
|
||||
<input type="text" name="poreski_broj_kupca" placeholder="PIB ili JMBG kupca (obavezno za refundaciju)" required style="padding:7px 10px;border:1px solid #dc2626;border-radius:6px;font-size:13px;">
|
||||
{{end}}
|
||||
<button type="submit" style="padding:8px 16px;background:#dc2626;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:500;cursor:pointer;white-space:nowrap;">Pokušaj refund ponovo</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- storno -->
|
||||
{{if and (index .Dozvole "servis.storno") (eq .Nalog.Status "Preuzeto") (not .Nalog.Stornirano)}}
|
||||
<div class="kartica detalji-kartica animiraj" style="border-color:#dc262633;">
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:200px;">
|
||||
<div style="font-size:14px;font-weight:500;color:#f97316;margin-bottom:4px;">Storniranje naloga</div>
|
||||
<div class="pomocni-tekst">
|
||||
Storno vraća ugrađene delove na stanje u magacinu. Nalog ostaje u evidenciji sa oznakom storniran.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-upozorenje" onclick="ntechPotvrdiPaOtvoriStorno(this, 'Stornirati nalog {{.Nalog.BrojNaloga}}?')">
|
||||
Storniraj nalog
|
||||
</button>
|
||||
<dialog class="premesti-modal" onclick="if(event.target===this)this.close()">
|
||||
<form method="dialog" class="premesti-zaglavlje">
|
||||
<h3>Storniranje naloga {{.Nalog.BrojNaloga}}</h3>
|
||||
<button type="submit" class="premesti-zatvori" aria-label="Zatvori">×</button>
|
||||
</form>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;">
|
||||
<form method="POST" action="/servis/storno/{{.Nalog.ID}}" data-refresh="#sadrzaj" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
|
||||
<div>
|
||||
<label class="polje-labela">Razlog storniranja (opciono)</label>
|
||||
<input type="text" name="razlog" placeholder="Razlog storniranja" style="width:100%;padding:8px 12px;border:0.5px solid var(--ivica);border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||
</div>
|
||||
{{if .PotrebnaIdentKupca}}
|
||||
<div>
|
||||
<label class="polje-labela">PIB ili JMBG kupca</label>
|
||||
<input type="text" id="storno-pib" name="poreski_broj_kupca" placeholder="Obavezno za fiskalni refund" required
|
||||
style="width:100%;padding:8px 12px;border:1px solid #dc2626;border-radius:8px;font-size:13px;background:var(--pozadina);color:var(--tekst-glavni);outline:none;">
|
||||
</div>
|
||||
{{end}}
|
||||
<button type="submit" id="btn-storno-potvrdi" class="btn-upozorenje" style="width:100%;{{if .PotrebnaIdentKupca}}opacity:0.4;cursor:not-allowed;{{end}}" {{if .PotrebnaIdentKupca}}disabled{{end}}>
|
||||
Potvrdi storno
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// storno: potvrda namere PRE otvaranja modala (dvostepeni tok), pa tek onda
|
||||
// polje za PIB/JMBG (obavezno samo ako je nalog fiskalizovan — v. .PotrebnaIdentKupca).
|
||||
// NE koristi ntechOtvoriModal ovde — ta funkcija snima početno stanje forme i
|
||||
// auto-submituje na zatvaranje dialoga (za popup polja bez dugmeta Sačuvaj), što
|
||||
// bi za storno značilo da se nalog stornira i pri slučajnom zatvaranju bez klika
|
||||
// na „Potvrdi storno".
|
||||
window.ntechPotvrdiPaOtvoriStorno = function (btn, poruka) {
|
||||
window._ntechPotvrdi(poruka).then(function (ok) {
|
||||
if (!ok) return;
|
||||
var d = btn.nextElementSibling;
|
||||
if (d && d.tagName === 'DIALOG') d.showModal();
|
||||
});
|
||||
};
|
||||
var pib = document.getElementById('storno-pib');
|
||||
var btnPotvrdi = document.getElementById('btn-storno-potvrdi');
|
||||
if (pib && btnPotvrdi) {
|
||||
pib.addEventListener('input', function () {
|
||||
var ok = pib.value.trim() !== '';
|
||||
btnPotvrdi.disabled = !ok;
|
||||
btnPotvrdi.style.opacity = ok ? '1' : '0.4';
|
||||
btnPotvrdi.style.cursor = ok ? 'pointer' : 'not-allowed';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
<!-- zona za brisanje -->
|
||||
{{/* Brisanje je moguće samo dok je „Primljeno" (greška pri unosu); čim uđe u proceduru naplaćuje se dijagnostika */}}
|
||||
{{if eq .Nalog.Status "Primljeno"}}
|
||||
|
||||
@@ -152,11 +152,19 @@
|
||||
<td></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .Nalog.DatumOdluke}}
|
||||
<tr class="auto">
|
||||
<td>Poziv klijenta (obaveštenje o dijagnozi)</td>
|
||||
<td><strong>{{.Nalog.DatumOdluke.Format "02.01.2006."}}</strong> <span class="badge-auto">auto</span></td>
|
||||
<td>Klijent odgovorio preko javne stranice (QR kod)</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td>Poziv klijenta (obaveštenje o dijagnozi)</td>
|
||||
<td><span class="linija-unos"></span></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .Nalog.DatumOdluke}}
|
||||
<tr class="auto">
|
||||
<td>Odobrenje klijenta</td>
|
||||
|
||||
@@ -152,9 +152,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Opis rada</th>
|
||||
<th>Kol.</th>
|
||||
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
|
||||
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
|
||||
<th style="text-align:right;">Kol.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -162,8 +160,6 @@
|
||||
<tr>
|
||||
<td>{{.Naziv}}</td>
|
||||
<td style="text-align:right;">{{printf "%.0f" .Kolicina}}</td>
|
||||
<td style="text-align:right;">{{if index $.Moduli "pdv"}}{{dinari .CenaSaPdv}}{{else}}{{dinari .CenaKomada}}{{end}}</td>
|
||||
<td>{{if index $.Moduli "pdv"}}{{dinari .UkupnoSaPdv}}{{else}}{{dinari .Ukupno}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@@ -182,9 +178,7 @@
|
||||
<tr>
|
||||
<th>Naziv</th>
|
||||
<th>Šifra</th>
|
||||
<th>Kol.</th>
|
||||
<th>Cena{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
|
||||
<th>Ukupno{{if index .Moduli "pdv"}} sa PDV{{end}}</th>
|
||||
<th style="text-align:right;">Kol.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -194,8 +188,6 @@
|
||||
<td>{{.ArtikalNaziv}}</td>
|
||||
<td style="font-family:monospace;">{{.ArtikalSifra}}</td>
|
||||
<td style="text-align:right;">{{.Kolicina}}</td>
|
||||
<td style="text-align:right;">{{if index $.Moduli "pdv"}}{{dinari .CenaSaPdv}}{{else}}{{dinari .CenaKomada}}{{end}}</td>
|
||||
<td>{{if index $.Moduli "pdv"}}{{dinari .UkupnoSaPdv}}{{else}}{{dinari .Ukupno}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user