Compare commits

...

4 Commits

Author SHA1 Message Date
Damir Radulić 1bd34ed678 M7 CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR
- /api/crm/clanarine[CRUD] s filterima (godina/klub/clan/status), summary
- /api/crm/clanarine/dug — dužnici (z opcionim days_overdue)
- /api/crm/clanarine/{id}/uplata — registracija parcijalne/cijele uplate
- /api/crm/clanarine/notify-bulk — mock e-mail kampanja (lista primatelja)
- /api/crm/clanarine/{id}/uplatnica.pdf — HUB-3 A4 PDF s ugrađenim EPC QR
- /api/crm/clanarine/{id}/qr.png — samo EPC BCD/002 SCT QR PNG
- /api/crm/clanarine/{id}/payment-info — JSON za UI gumbe + bank deep linkovi

crm/payments.py — HUB-3 PDF generator (ReportLab) + EPC QR (qrcode lib),
poziv-na-broj model HR00 = OIB-godina-id, format_eur HR notation.
2026-05-04 23:54:26 +02:00
CC4-PGZ-Sport 834b7bf89f M5.1 OCR upload + parse + invoices CRUD (ERP)
- erp/ocr.py: FastAPI router under /api/erp/*
- POST /ocr/upload: file → pgz_sport.invoice_uploads (sha256, mime, klub_id, tenant_id)
- POST /ocr/parse: Tesseract+pdftotext OCR + DeepSeek V3 LLM extraction
- GET/POST/PUT /invoices, /invoices/{id}/pay, uploads list
- Wired into pgz_sport_api.py
- HR invoice regex (OIB, IBAN, datum DD.MM.YYYY i ISO, ukupno/PDV)
- DeepSeek V3 returns JSON object {izdavatelj_*, kupac_*, iznos_neto/pdv/brutto, stavke[], vrsta_troska...}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:53:22 +02:00
CC6 Worker f19d70b96a M11.1: blockchain/seal.py — Polygon PoS sealing module
- seal_to_polygon(data_hash, ref_id, action) → {tx_hash, status, polygonscan_url}
- Live mode (web3 + POLYGON_PRIVKEY) broadcasts 0-MATIC self-tx with
  PGZ|action|ref_id|0x<hash> memo encoded in data field; chain 137.
- Pending mode persists row in pgz_sport.polygon_seals when key not loaded.
- verify_seal/list_seals helpers for the audit-seal UI.
- Wallet: 0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:52:00 +02:00
claude-cc1 b7cb050843 CC1 R2 — full Round 2 done (8/8 stavki)
- geocode_objekti_v2.py + DB updates (Kastav, Rujevica, Platak, Petehovac, Crikvenica, Krk hand-curated)
- Maps URL → /maps/search/?api=1 format for proper pin
- Dashboard: year selector for nositelji, click → klub/PDF panel; top savezi clickable
- Universal sort (asc/desc) on Savezi/Klubovi/Sportaši/Objekti/Manifestacije/Financije
- Card↔Table toggle on Financije
- Manifestacije: source_url direct open, Google fallback
- Forenzika: severity/tip filter, search, run-scan, Liverić PEP custom findings + DB alerts
- Enrich endpoint /api/v2/enrich/{kind}/{id} + button on savez/klub/sportaš panels
- New 'Mreža' section: D3 force graph from /api/v1/presenter/graph-real

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:50:30 +02:00
12 changed files with 6349 additions and 11 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
View File
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""
seal.py — Polygon PoS sealing module for PGŽ Sport audit log
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
Date: 2026-05-04
Version: 1.0.0
Seals critical audit events to Polygon PoS (chain 137) using the wallet
0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368.
Two operating modes:
1. LIVE — environment provides POLYGON_PRIVKEY (and web3 is installed).
A 0-MATIC self-transaction is sent with the sha256 data hash encoded
in the `data` field. Returns the real 0x… 64-char tx hash.
2. PENDING — no key configured. The seal record is queued in
pgz_sport.polygon_seals with status='pending' and a deterministic
pseudo-tx-hash (the seal_id, prefixed with 'pending:'). A later
batch job (or operator) can flush the queue once a key is loaded.
Public surface
--------------
seal_to_polygon(data_hash, ref_id, action, **kw) -> dict
Returns: { seal_id, tx_hash, status, polygonscan_url, ... }
verify_seal(seal_id) -> dict
Read-back utility. Cross-checks the on-chain receipt (if web3 is wired up)
and returns the canonical row from polygon_seals.
list_seals(action=None, ref_type=None, ref_id=None, limit=50) -> list[dict]
Lightweight reader for the audit-seal UI.
The module is import-safe even on hosts without web3 installed; the LIVE branch
just becomes a no-op.
"""
from __future__ import annotations
import os
import json
import hashlib
import time
from datetime import datetime, timezone
from typing import Optional, Any
import psycopg2
import psycopg2.extras
# ─── Optional web3 dependency ────────────────────────────────────────────
try:
from web3 import Web3
from eth_account import Account
HAS_WEB3 = True
except Exception:
HAS_WEB3 = False
# ─── Configuration (env-driven) ──────────────────────────────────────────
POLYGON_RPC = os.environ.get("POLYGON_RPC", "https://polygon-rpc.com")
POLYGON_CHAIN_ID = int(os.environ.get("POLYGON_CHAIN_ID", "137"))
POLYGON_WALLET = os.environ.get(
"POLYGON_WALLET", "0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368"
).strip()
POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip()
POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com")
DB = dict(
host=os.environ.get("PG_HOST", "10.10.0.2"),
port=int(os.environ.get("PG_PORT", "6432")),
dbname=os.environ.get("PG_DB", "rinet_v3"),
user=os.environ.get("PG_USER", "rinet"),
password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"),
)
# ─── helpers ─────────────────────────────────────────────────────────────
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _sha256(*parts: Any) -> str:
h = hashlib.sha256()
for p in parts:
if p is None:
continue
if isinstance(p, (dict, list)):
p = json.dumps(p, sort_keys=True, ensure_ascii=False, default=str)
h.update(str(p).encode("utf-8", errors="replace"))
h.update(b"\x00")
return h.hexdigest()
def hash_payload(payload: Any) -> str:
"""Public helper — stable sha256 of a payload, JSON-canonicalised."""
if isinstance(payload, (dict, list)):
payload = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(str(payload).encode("utf-8", errors="replace")).hexdigest()
def polygonscan_url(tx_hash: str) -> Optional[str]:
if not tx_hash or tx_hash.startswith("pending:"):
return None
if not tx_hash.startswith("0x"):
tx_hash = "0x" + tx_hash
return f"{POLYGONSCAN_BASE}/tx/{tx_hash}"
# ─── live broadcast path ─────────────────────────────────────────────────
def _broadcast_live(data_hash: str, action: str, ref_id: str) -> dict:
"""Send a 0-MATIC self-tx encoding `data_hash` in the data field.
Returns dict with tx_hash, block_number (if mined within wait window),
and status. Raises on RPC errors so the caller can fall back.
"""
if not HAS_WEB3:
raise RuntimeError("web3 not installed")
if not POLYGON_PRIVKEY:
raise RuntimeError("POLYGON_PRIVKEY missing")
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 15}))
acct = Account.from_key(POLYGON_PRIVKEY)
if acct.address.lower() != POLYGON_WALLET.lower():
raise RuntimeError(
f"key/address mismatch: key={acct.address} wallet={POLYGON_WALLET}"
)
nonce = w3.eth.get_transaction_count(acct.address)
gas_price = w3.eth.gas_price
# Encode "PGZ|action|ref_id|data_hash" into the data field as utf-8 hex.
memo = f"PGZ|{action}|{ref_id}|0x{data_hash}".encode("utf-8")
tx = {
"to": acct.address,
"value": 0,
"data": "0x" + memo.hex(),
"nonce": nonce,
"chainId": POLYGON_CHAIN_ID,
"gas": 60000,
"gasPrice": gas_price,
}
signed = acct.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction).hex()
block_number = None
try:
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
block_number = int(receipt.blockNumber)
status = "confirmed" if receipt.status == 1 else "failed"
except Exception:
status = "broadcast"
return {"tx_hash": tx_hash, "block_number": block_number, "status": status}
# ─── public API ──────────────────────────────────────────────────────────
def seal_to_polygon(
data_hash: str,
ref_id: str,
action: str,
*,
ref_type: Optional[str] = None,
payload: Optional[Any] = None,
user_id: Optional[int] = None,
user_email: Optional[str] = None,
) -> dict:
"""Seal a sha256 hash to Polygon PoS.
Always persists a row in pgz_sport.polygon_seals. If LIVE mode succeeds,
the row carries the real tx_hash; otherwise it is left in 'pending' state
so a worker can flush the queue later.
Parameters
----------
data_hash : str
sha256 hex digest of the payload being sealed.
ref_id : str
opaque reference (e.g. "klub:42", "sufinanciranje:2026-001").
action : str
canonical action name (e.g. "sufinanciranje.approved").
"""
if not data_hash:
raise ValueError("data_hash required")
data_hash = data_hash.lower().lstrip("0x")
if len(data_hash) != 64 or not all(c in "0123456789abcdef" for c in data_hash):
raise ValueError("data_hash must be 64-char sha256 hex")
nonce = f"{int(time.time() * 1000):x}"
seal_id = _sha256(action, ref_id, data_hash, nonce)
row = {
"seal_id": seal_id,
"action": action[:80],
"ref_type": (ref_type or "")[:50] or None,
"ref_id": str(ref_id)[:80] if ref_id is not None else None,
"data_hash": data_hash,
"payload": json.dumps(payload, default=str) if payload is not None else None,
"wallet": POLYGON_WALLET,
"chain_id": POLYGON_CHAIN_ID,
"user_id": user_id,
"user_email": user_email,
}
tx_hash: Optional[str] = None
block_number: Optional[int] = None
error: Optional[str] = None
status = "pending"
if HAS_WEB3 and POLYGON_PRIVKEY:
try:
r = _broadcast_live(data_hash, action, str(ref_id))
tx_hash = r["tx_hash"]
block_number = r.get("block_number")
status = r.get("status", "broadcast")
except Exception as e:
error = f"{type(e).__name__}: {e}"[:500]
status = "pending"
tx_hash = None
else:
# No live key: deterministic "pending" reference.
tx_hash = "pending:" + seal_id[:32]
if not HAS_WEB3:
error = "web3 not installed"
elif not POLYGON_PRIVKEY:
error = "POLYGON_PRIVKEY not set"
sealed_at = datetime.now(timezone.utc) if status in ("broadcast", "confirmed") else None
with _db() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO pgz_sport.polygon_seals
(seal_id, action, ref_type, ref_id, data_hash, payload, tx_hash,
chain_id, wallet, status, block_number, error,
user_id, user_email, sealed_at)
VALUES (%(seal_id)s, %(action)s, %(ref_type)s, %(ref_id)s, %(data_hash)s,
%(payload)s::jsonb, %(tx_hash)s, %(chain_id)s, %(wallet)s,
%(status)s, %(block_number)s, %(error)s,
%(user_id)s, %(user_email)s, %(sealed_at)s)
ON CONFLICT (seal_id) DO UPDATE
SET tx_hash = EXCLUDED.tx_hash,
status = EXCLUDED.status,
block_number = EXCLUDED.block_number,
error = EXCLUDED.error,
sealed_at = EXCLUDED.sealed_at
RETURNING id, created_at
""",
{
**row,
"tx_hash": tx_hash,
"status": status,
"block_number": block_number,
"error": error,
"sealed_at": sealed_at,
},
)
rid, created_at = cur.fetchone()
return {
"id": rid,
"seal_id": seal_id,
"action": action,
"ref_type": ref_type,
"ref_id": ref_id,
"data_hash": data_hash,
"tx_hash": tx_hash,
"status": status,
"block_number": block_number,
"wallet": POLYGON_WALLET,
"chain_id": POLYGON_CHAIN_ID,
"polygonscan_url": polygonscan_url(tx_hash),
"error": error,
"created_at": created_at.isoformat() if created_at else None,
"live": HAS_WEB3 and bool(POLYGON_PRIVKEY),
}
def verify_seal(seal_id: str) -> Optional[dict]:
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash,
chain_id, wallet, status, block_number, error,
user_id, user_email, created_at, sealed_at, payload
FROM pgz_sport.polygon_seals WHERE seal_id=%s""",
(seal_id,),
)
row = cur.fetchone()
if not row:
return None
row = dict(row)
row["polygonscan_url"] = polygonscan_url(row.get("tx_hash"))
if row.get("created_at"):
row["created_at"] = row["created_at"].isoformat()
if row.get("sealed_at"):
row["sealed_at"] = row["sealed_at"].isoformat()
if HAS_WEB3 and row.get("tx_hash") and not str(row["tx_hash"]).startswith("pending:"):
try:
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 8}))
r = w3.eth.get_transaction_receipt(row["tx_hash"])
row["onchain"] = {
"block_number": int(r.blockNumber),
"status": int(r.status),
"from": r["from"],
"to": r["to"],
}
except Exception as e:
row["onchain"] = {"error": str(e)[:200]}
return row
def list_seals(
action: Optional[str] = None,
ref_type: Optional[str] = None,
ref_id: Optional[str] = None,
limit: int = 50,
) -> list[dict]:
where, params = [], []
if action:
where.append("action = %s")
params.append(action)
if ref_type:
where.append("ref_type = %s")
params.append(ref_type)
if ref_id is not None:
where.append("ref_id = %s")
params.append(str(ref_id))
sql = (
"SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash, "
" chain_id, wallet, status, block_number, error, "
" user_id, user_email, created_at, sealed_at "
"FROM pgz_sport.polygon_seals "
+ ("WHERE " + " AND ".join(where) + " " if where else "")
+ "ORDER BY id DESC LIMIT %s"
)
params.append(min(int(limit or 50), 500))
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
rows = [dict(r) for r in cur.fetchall()]
for r in rows:
r["polygonscan_url"] = polygonscan_url(r.get("tx_hash"))
if r.get("created_at"):
r["created_at"] = r["created_at"].isoformat()
if r.get("sealed_at"):
r["sealed_at"] = r["sealed_at"].isoformat()
return rows
# ─── self-test ───────────────────────────────────────────────────────────
if __name__ == "__main__":
payload = {"demo": True, "ts": int(time.time()), "msg": "PGŽ seal self-test"}
h = hash_payload(payload)
res = seal_to_polygon(
h,
ref_id="selftest:1",
action="selftest.run",
ref_type="selftest",
payload=payload,
)
print(json.dumps(res, indent=2, default=str, ensure_ascii=False))
View File
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: crm/payments.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/crm/payments.py
# Svrha: HUB-3 uplatnica PDF + EPC QR (BCD/002) generatori za HR mobilno banking
# ═══════════════════════════════════════════════════════════════════
"""HUB-3 + EPC QR helpers.
HUB-3: standardna hrvatska uplatnica (HR pravilnik o izgledu).
EPC QR: BCD/002/SCT (SEPA Credit Transfer) — čita Zaba, PBZ, Erste, OTP, RBA mobilne aplikacije.
Format reference: https://www.europeanpaymentscouncil.eu (EPC069-12)
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime
from typing import Optional
import qrcode
from qrcode.constants import ERROR_CORRECT_M
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
def normalize_iban(iban: str) -> str:
"""Strip whitespace + uppercase. HR IBAN = 21 chars (HR + 19 digits)."""
return re.sub(r"\s+", "", (iban or "").upper())
def format_iban(iban: str) -> str:
"""Pretty-print: HR12 3456 7890 1234 5678 9."""
s = normalize_iban(iban)
return " ".join(s[i:i + 4] for i in range(0, len(s), 4))
def format_eur(amount: float) -> str:
"""1234.5 → '1.234,50' (HR notation)."""
s = f"{float(amount):,.2f}"
return s.replace(",", "X").replace(".", ",").replace("X", ".")
def make_poziv_na_broj(klub_oib: Optional[str], godina: int, clanarina_id: int,
model: str = "HR00") -> str:
"""
Model HR00 (slobodni format) — koristimo: KLUB_OIB-GODINA-ID
Ako nema OIB-a, samo GODINA-ID.
Vraća (model, poziv) zaista nije separiran u 1 stringu — model je odvojen.
"""
parts = []
if klub_oib:
# samo digiti
oib_digits = re.sub(r"\D", "", str(klub_oib))[:11]
if oib_digits:
parts.append(oib_digits)
parts.append(str(godina))
parts.append(str(clanarina_id))
return "-".join(parts)
# ─────────────────────────────────────────────────────────────────────
# EPC QR (BCD/002) — readable by HR mobile banking apps
# ─────────────────────────────────────────────────────────────────────
def build_epc_payload(*, primatelj: str, iban: str, amount_eur: float,
opis: str, model: str = "HR00",
poziv_na_broj: str = "",
bic: str = "",
purpose: str = "OTHR") -> str:
"""
EPC069-12 SCT QR payload (version 002):
BCD ← service tag
002 ← version
1 ← character set (1 = UTF-8)
SCT ← SEPA Credit Transfer
[BIC] ← optional in v002
[Beneficiary] ← max 70
[IBAN] ← max 34
EUR{amount} ← e.g. EUR12.50
[Purpose] ← max 4 (e.g. OTHR)
[Reference] ← max 25 (structured)
[Remittance] ← max 140 (unstructured) — used if no structured ref
[Hint] ← optional
Ne smije premašiti ~331 byte ukupno.
"""
iban = normalize_iban(iban)
amount = f"EUR{float(amount_eur):.2f}"
primatelj = (primatelj or "")[:70]
# structured ref ima prednost; ako nemamo, koristimo opis kao remittance
structured_ref = ""
remittance = ""
if poziv_na_broj:
structured_ref = f"{model} {poziv_na_broj}"[:35]
remittance = (opis or "")[:140]
else:
remittance = (opis or "")[:140]
lines = [
"BCD",
"002",
"1",
"SCT",
bic or "",
primatelj,
iban,
amount,
purpose,
structured_ref,
remittance,
"", # hint
]
return "\n".join(lines)
def build_epc_qr_png(payload: str, box_size: int = 8) -> bytes:
"""Vraća PNG bytes (Pillow) za EPC payload."""
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_M,
box_size=box_size,
border=2,
)
qr.add_data(payload)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────
# HUB-3 PDF (A4, jedan list, gornji dio = barkod stripa, donji = pregled)
# ─────────────────────────────────────────────────────────────────────
# Font ćemo koristiti Helvetica (default ReportLab) — sigurno dostupan.
# Ako želiš proper HR diakritike, registriraj DejaVu:
def _ensure_font():
try:
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
for path in (
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
):
try:
pdfmetrics.registerFont(TTFont("DejaVu", path))
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
return "DejaVu", "DejaVu-Bold"
except Exception:
continue
except Exception:
pass
return "Helvetica", "Helvetica-Bold"
def build_hub3_pdf(*, platitelj_naziv: str, platitelj_adresa: str,
primatelj_naziv: str, primatelj_adresa: str,
iban: str, amount_eur: float, model: str,
poziv_na_broj: str, opis: str,
sifra_namjene: str = "OTHR",
datum: Optional[date] = None,
epc_payload: Optional[str] = None) -> bytes:
"""
Generira A4 HUB-3 uplatnicu (jednostavna verzija, čitljiva, s EPC QR-om).
Layout otprilike kao standardna HR uplatnica iz banaka — gornji dio je
duplikat za platitelja, donji za banku, oba s istim podacima i QR kodom
desno.
"""
font_reg, font_bold = _ensure_font()
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
W, H = A4
datum = datum or date.today()
iban_fmt = format_iban(iban)
if epc_payload is None:
epc_payload = build_epc_payload(
primatelj=primatelj_naziv, iban=iban, amount_eur=amount_eur,
opis=opis, model=model, poziv_na_broj=poziv_na_broj,
)
qr_png = build_epc_qr_png(epc_payload, box_size=6)
qr_img = ImageReader(io.BytesIO(qr_png))
def section(top_y: float, title: str):
# Title bar
c.setFillColorRGB(0.13, 0.20, 0.32)
c.rect(15 * mm, top_y - 8 * mm, 180 * mm, 8 * mm, fill=1, stroke=0)
c.setFillColorRGB(1, 1, 1)
c.setFont(font_bold, 10)
c.drawString(20 * mm, top_y - 5.5 * mm, title)
# Frame
c.setFillColorRGB(1, 1, 1)
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.rect(15 * mm, top_y - 8 * mm - 100 * mm, 180 * mm, 100 * mm, fill=0, stroke=1)
# Inner labels + values (left column = data, right column = QR)
c.setFillColorRGB(0, 0, 0)
def label(x_mm, y_offset_mm, txt):
c.setFont(font_reg, 7)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(x_mm * mm, top_y - 8 * mm - y_offset_mm * mm, txt)
def value(x_mm, y_offset_mm, txt, bold=False, size=10, max_w_mm=None):
c.setFont(font_bold if bold else font_reg, size)
c.setFillColorRGB(0, 0, 0)
v = str(txt or "")
if max_w_mm:
w_pt = pdfmetrics.stringWidth(v, font_bold if bold else font_reg, size)
while w_pt > max_w_mm * mm and len(v) > 5:
v = v[:-2]
w_pt = pdfmetrics.stringWidth(v, font_bold if bold else font_reg, size)
c.drawString(x_mm * mm, top_y - 8 * mm - y_offset_mm * mm, v)
# Platitelj
label(20, 7, "PLATITELJ")
value(20, 12, platitelj_naziv, bold=True, size=10, max_w_mm=85)
value(20, 17, platitelj_adresa, size=9, max_w_mm=85)
# Primatelj
label(20, 26, "PRIMATELJ")
value(20, 31, primatelj_naziv, bold=True, size=10, max_w_mm=85)
value(20, 36, primatelj_adresa, size=9, max_w_mm=85)
# IBAN
label(20, 45, "IBAN PRIMATELJA")
value(20, 50, iban_fmt, bold=True, size=11, max_w_mm=85)
# Iznos
label(20, 59, "IZNOS")
c.setFont(font_bold, 16)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(20 * mm, top_y - 8 * mm - 67 * mm, f"{format_eur(amount_eur)} EUR")
c.setFillColorRGB(0, 0, 0)
# Model + poziv
label(20, 75, "MODEL")
value(20, 80, model, bold=True, size=10)
label(40, 75, "POZIV NA BROJ")
value(40, 80, poziv_na_broj, bold=True, size=10, max_w_mm=65)
# Šifra namjene + datum
label(20, 88, "ŠIFRA NAMJENE")
value(20, 93, sifra_namjene, size=9)
label(50, 88, "DATUM IZVRŠENJA")
value(50, 93, datum.strftime("%d.%m.%Y."), size=9)
# Opis plaćanja
label(75, 88, "OPIS PLAĆANJA")
value(75, 93, opis[:60], size=9, max_w_mm=50)
# QR (right side)
c.drawImage(qr_img, 140 * mm, top_y - 8 * mm - 65 * mm,
width=45 * mm, height=45 * mm, mask='auto')
c.setFont(font_reg, 6)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(140 * mm, top_y - 8 * mm - 70 * mm,
"Skenirajte QR mobilnom bankom (Zaba/PBZ/Erste/OTP/RBA)")
# Page header
c.setFont(font_bold, 14)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(15 * mm, H - 18 * mm, "HUB-3 UPLATNICA")
c.setFont(font_reg, 9)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(15 * mm, H - 23 * mm,
f"Generirao: PGŽ Sport platforma • {datetime.now().strftime('%d.%m.%Y. %H:%M')}")
# Two identical copies (kopija za platitelja + kopija za banku)
section(H - 30 * mm, "KOPIJA ZA PLATITELJA")
section(H - 150 * mm, "KOPIJA ZA BANKU")
# Footer
c.setFont(font_reg, 7)
c.setFillColorRGB(0.55, 0.55, 0.55)
c.drawString(15 * mm, 12 * mm,
"PGŽ Sport ERP/CRM • Plaćanje: skeniraj QR kod ili unesi IBAN/poziv na broj ručno • EPC QR (BCD/002 SCT)")
c.save()
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────
# Mobile-banking deep links (HR best-effort)
# ─────────────────────────────────────────────────────────────────────
def build_bank_deep_links(iban: str, amount_eur: float, opis: str,
model: str = "HR00", poziv_na_broj: str = "") -> dict:
"""
Best-effort deep linkovi za HR mobilne banking aplikacije.
Pravi standard je EPC QR — ovi linkovi su dodatak.
"""
iban = normalize_iban(iban)
from urllib.parse import quote
opis_q = quote(opis or "")
return {
"epc_qr": "ugrađen u PDF",
"zaba": f"https://m.zaba.hr/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}&desc={opis_q}",
"pbz": f"https://pbz.hr/mtoken/pay?iban={iban}&amt={amount_eur:.2f}&ref={poziv_na_broj}",
"erste": f"https://erstebank.hr/mbanking/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
"otp": f"https://otpbanka.hr/mbanking/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
"rba": f"https://rba.hr/mtoken/pay?iban={iban}&amount={amount_eur:.2f}&ref={poziv_na_broj}",
}
+1
View File
@@ -0,0 +1 @@
# PGŽ Sport ERP package — Round 3
+659
View File
@@ -0,0 +1,659 @@
#!/usr/bin/env python3
# erp/ocr.py — PGŽ Sport ERP OCR router (M5)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction
# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse.
from __future__ import annotations
import os
import re
import json
import hashlib
import subprocess
import tempfile
import traceback
from datetime import datetime, date
from pathlib import Path
from typing import Optional, List, Any
import psycopg2
import psycopg2.extras
import requests
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
# === Config ===
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7")
DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
MAX_BYTES = 12 * 1024 * 1024 # 12 MB
ADMIN_TOKEN = "admin-pgz-2026"
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _is_admin(authorization: Optional[str]) -> bool:
if not authorization:
return False
t = authorization.replace("Bearer ", "").strip()
return t == ADMIN_TOKEN
def _safe_filename(orig: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
if not base:
base = "upload"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{ts}_{base}"
def _extract_text(path: Path) -> tuple[str, str]:
"""Return (text, method). Tries pdftotext first, falls back to tesseract."""
suf = path.suffix.lower()
if suf == ".pdf":
try:
r = subprocess.run(
["pdftotext", "-layout", "-q", str(path), "-"],
capture_output=True, timeout=45,
)
txt = r.stdout.decode("utf-8", "ignore")
if len(txt.strip()) > 80:
return txt, "pdftotext"
except Exception:
pass
# Rasterize + tesseract
try:
with tempfile.TemporaryDirectory(prefix="ocr_") as td:
subprocess.run(
["pdftoppm", "-r", "200", str(path), f"{td}/page"],
timeout=120, check=True,
)
chunks = []
for img in sorted(Path(td).glob("page-*.ppm"))[:5]:
r = subprocess.run(
["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=90,
)
chunks.append(r.stdout.decode("utf-8", "ignore"))
return "\n".join(chunks), "tesseract"
except Exception as e:
return "", f"pdf_err:{e}"
if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}:
try:
r = subprocess.run(
["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=120,
)
return r.stdout.decode("utf-8", "ignore"), "tesseract"
except Exception as e:
return "", f"img_err:{e}"
return "", f"unsupported:{suf}"
# === HR invoice regex helpers ===
_OIB = re.compile(r"\b(\d{11})\b")
_IBAN = re.compile(r"\b(HR\d{19})\b")
_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b")
_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b")
_AMOUNT_TOTAL = re.compile(
r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})"
)
_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})")
_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})")
def _parse_amount(s: str) -> Optional[float]:
if not s:
return None
s = s.replace(" ", "").replace("\xa0", "")
# Croatian style "1.234,56" → 1234.56
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", ".")
try:
return float(s)
except Exception:
return None
def regex_extract(text: str) -> dict:
out: dict[str, Any] = {"raw_chars": len(text or "")}
if not text:
return out
oibs = list(dict.fromkeys(_OIB.findall(text)))
if oibs:
out["oibs_found"] = oibs
out["vendor_oib"] = oibs[0]
if len(oibs) > 1:
out["customer_oib"] = oibs[1]
m = _IBAN.search(text.replace(" ", ""))
if m:
out["iban"] = m.group(1)
m = _INVOICE_NO.search(text)
if m:
out["invoice_no"] = m.group(1).strip().rstrip(".,;")
for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]:
m = rx.search(text)
if m:
g = m.groups()
try:
if order == "dmy":
out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}"
else:
out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}"
# validate
date.fromisoformat(out["invoice_date"])
break
except Exception:
out.pop("invoice_date", None)
totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)]
totals = [t for t in totals if t and t > 0.01]
if totals:
out["amount_gross"] = max(totals)
out["amounts_found"] = totals[:6]
vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)]
vats = [v for v in vats if v and v > 0.01]
if vats:
# smallest plausible PDV (less than gross)
if "amount_gross" in out:
cand = [v for v in vats if v < out["amount_gross"]]
if cand:
out["amount_vat"] = max(cand)
else:
out["amount_vat"] = max(vats)
if "amount_gross" in out and "amount_vat" in out:
out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2)
# Vendor name guess: first non-numeric, non-OIB line in header
for line in text.split("\n")[:12]:
ln = line.strip()
if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln):
out["vendor_name"] = ln
break
# Crude vendor guess for known HR sellers
upper = text.upper()
for keyword, label in [
("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"),
("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"),
("HRVATSKE AUTOCESTE", "HAC"),
]:
if keyword in upper:
out.setdefault("vendor_brand", label)
break
return out
# === DeepSeek V3 LLM extraction ===
SYSTEM_PROMPT = (
"Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). "
"Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. "
"Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'."
)
LLM_SCHEMA_HINT = """{
"izdavatelj_naziv": str|null,
"izdavatelj_oib": str|null,
"izdavatelj_adresa": str|null,
"kupac_naziv": str|null,
"kupac_oib": str|null,
"datum": "YYYY-MM-DD"|null,
"broj_racuna": str|null,
"iznos_neto": float|null,
"iznos_pdv": float|null,
"iznos_brutto": float|null,
"stopa_pdv": float|null,
"valuta": "EUR"|"HRK"|null,
"nacin_placanja": str|null,
"IBAN": str|null,
"opis_svrhe": str|null,
"vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null,
"stavke": [
{"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float}
]
}"""
def deepseek_extract(text: str, hint: dict | None = None) -> dict:
"""Call DeepSeek chat completions for structured JSON extraction."""
if not DEEPSEEK_API_KEY:
return {"error": "no_api_key"}
if not text or len(text.strip()) < 20:
return {"error": "empty_text"}
user_msg = (
f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n"
f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n"
f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---"
)
payload = {
"model": DEEPSEEK_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"response_format": {"type": "json_object"},
"temperature": 0.0,
"max_tokens": 1200,
}
headers = {
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json",
}
try:
r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60)
except Exception as e:
return {"error": f"net:{e}"}
if r.status_code != 200:
return {"error": f"http_{r.status_code}", "detail": r.text[:300]}
try:
body = r.json()
content = body["choices"][0]["message"]["content"]
return json.loads(content)
except Exception as e:
return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")}
# === Endpoints ===
@router.post("/ocr/upload")
async def ocr_upload(
file: UploadFile = File(...),
klub_id: Optional[int] = Form(None),
tenant_id: int = Form(1),
invoice_kind: str = Form("ostalo"),
authorization: Optional[str] = Header(None),
):
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
if len(raw) > MAX_BYTES:
raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)")
sha256 = hashlib.sha256(raw).hexdigest()
fname = _safe_filename(file.filename or "upload")
if not fname.endswith(suffix):
fname += suffix
path = UPLOAD_DIR / fname
path.write_bytes(raw)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""
INSERT INTO pgz_sport.invoice_uploads
(klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta)
VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s)
RETURNING id, klub_id, file_name, ocr_status, uploaded_at
""",
(klub_id, file.filename, str(path), len(raw), file.content_type or "",
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
)
row = cur.fetchone()
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
@router.post("/ocr/parse")
async def ocr_parse(
upload_id: Optional[int] = Form(None),
file: Optional[UploadFile] = File(None),
use_llm: bool = Form(True),
authorization: Optional[str] = Header(None),
):
"""Run OCR + (optional) DeepSeek LLM extraction.
Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot)."""
tmp_to_clean: Optional[Path] = None
upload_row = None
try:
if upload_id:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,))
upload_row = cur.fetchone()
if not upload_row:
raise HTTPException(404, f"Upload id={upload_id} ne postoji")
target = Path(upload_row["file_path"])
if not target.exists():
raise HTTPException(404, f"Datoteka ne postoji na disku: {target}")
elif file:
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False)
tmp.write(raw); tmp.close()
target = Path(tmp.name)
tmp_to_clean = target
else:
raise HTTPException(400, "Treba poslati upload_id ILI file")
text, method = _extract_text(target)
if len(text.strip()) < 20:
return {"ok": False, "ocr_method": method, "raw_chars": len(text),
"error": "OCR nije uspio izvući dovoljno teksta"}
regex_fields = regex_extract(text)
regex_fields["ocr_method"] = method
llm_fields: dict = {}
if use_llm:
llm_fields = deepseek_extract(text, hint=regex_fields)
# Merge: LLM overrides regex when valid
merged = dict(regex_fields)
for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum",
"broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto",
"stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska",
"izdavatelj_adresa", "nacin_placanja"):
v = llm_fields.get(k) if isinstance(llm_fields, dict) else None
if v not in (None, "", "null"):
merged[k] = v
# Normalize aliases for UI / DB
if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"])
if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"])
if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"])
if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"])
if "datum" in merged: merged.setdefault("invoice_date", merged["datum"])
if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"])
if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"])
if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"])
if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"])
if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"])
if "valuta" in merged: merged.setdefault("currency", merged["valuta"])
if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"])
if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"])
if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"])
# Persist back to invoice_uploads when we have upload_row
if upload_row:
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoice_uploads
SET ocr_status='done', processed_at=NOW(),
ocr_engine=%s, ocr_text=%s,
ai_invoice_no=%s, ai_invoice_date=%s,
ai_vendor_name=%s, ai_vendor_oib=%s,
ai_amount_gross=%s, ai_currency=%s, ai_iban=%s,
ai_extracted=%s, ai_engine=%s
WHERE id=%s""",
(
method, text[:50000],
merged.get("invoice_no"),
merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None,
merged.get("vendor_name"),
merged.get("vendor_oib"),
merged.get("amount_gross"),
merged.get("currency", "EUR"),
merged.get("iban"),
json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged},
ensure_ascii=False, default=str),
("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"),
upload_row["id"],
),
)
except Exception as e:
merged["_persist_warn"] = str(e)[:200]
return {
"ok": True,
"upload_id": (upload_row["id"] if upload_row else None),
"ocr_method": method,
"raw_chars": len(text),
"regex": regex_fields,
"llm": llm_fields,
"extracted": merged,
"raw_text_preview": text[:1500],
}
finally:
if tmp_to_clean and tmp_to_clean.exists():
try:
tmp_to_clean.unlink()
except Exception:
pass
# === Invoices CRUD (M5) ===
@router.get("/invoices")
def invoices_list(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
kind: Optional[str] = Query(None),
limit: int = Query(100, le=500),
offset: int = Query(0),
):
sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv,
i.invoice_kind, i.invoice_no, i.internal_no,
i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib,
i.invoice_date, i.due_date, i.paid_date, i.currency,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.payment_status, i.payment_method, i.iban_to,
i.description, i.category, i.tenant_id,
i.created_at, i.approved_at
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None:
sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None:
sql += " AND i.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND i.payment_status=%s"; args.append(status)
if kind:
sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/invoices/{invoice_id}")
def invoices_get(invoice_id: int):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id",
(invoice_id,))
lines = cur.fetchall()
cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s",
(invoice_id,))
uploads = cur.fetchall()
return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads}
@router.post("/invoices")
def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Create an invoice from parsed OCR result.
Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib,
invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency,
iban_to, description, category, lines:[{...}], upload_id?}"""
required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"]
for k in required:
if body.get(k) in (None, ""):
raise HTTPException(400, f"Nedostaje polje: {k}")
klub_id = body.get("klub_id")
tenant_id = body.get("tenant_id", 1)
upload_id = body.get("upload_id")
lines = body.get("lines") or []
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.invoices
(klub_id, invoice_kind, invoice_no, internal_no,
vendor_oib, vendor_name, vendor_address,
customer_oib, customer_name,
invoice_date, due_date, currency,
amount_net, amount_vat, amount_gross, vat_rate,
payment_status, payment_method, iban_to,
description, category, account_code, tenant_id, meta)
VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s,
%s,%s,COALESCE(%s,'EUR'),
%s,%s,%s,%s,
COALESCE(%s,'unpaid'),%s,%s,
%s,%s,%s,%s,%s)
ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib)
DO UPDATE SET amount_gross=EXCLUDED.amount_gross,
amount_net=EXCLUDED.amount_net,
amount_vat=EXCLUDED.amount_vat,
updated_at=NOW()
RETURNING id, invoice_no, amount_gross, payment_status""",
(
klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"),
body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"),
body.get("customer_oib"), body.get("customer_name"),
body["invoice_date"], body.get("due_date"), body.get("currency"),
body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"),
body.get("payment_status"), body.get("payment_method"), body.get("iban_to"),
body.get("description"), body.get("category"), body.get("account_code"),
tenant_id, json.dumps(body.get("meta", {})),
),
)
inv = cur.fetchone()
inv_id = inv["id"]
# Replace lines
cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,))
for i, ln in enumerate(lines, start=1):
cur.execute(
"""INSERT INTO pgz_sport.invoice_lines
(invoice_id, line_no, description, quantity, unit, unit_price,
vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(
inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "",
ln.get("quantity") or ln.get("kolicina") or 1,
ln.get("unit") or ln.get("jedinica") or "kom",
ln.get("unit_price") or ln.get("cijena"),
ln.get("vat_rate", 25),
ln.get("line_net"), ln.get("line_vat"),
ln.get("line_gross") or ln.get("ukupno"),
ln.get("account_code"), ln.get("cost_center"),
json.dumps(ln.get("meta", {})),
),
)
# Link upload to invoice
if upload_id:
cur.execute(
"UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s",
(inv_id, upload_id),
)
return {"ok": True, "invoice": inv}
@router.put("/invoices/{invoice_id}")
def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Update / approve invoice. Body may include any of: payment_status, paid_date,
approved (bool), notes, category, account_code, due_date."""
fields = []
args: list = []
for col in ("payment_status", "paid_date", "due_date", "category",
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
"amount_gross", "payment_method", "iban_to"):
if col in body:
fields.append(f"{col}=%s")
args.append(body[col])
if body.get("approved"):
fields.append("approved_at=NOW()")
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at=NOW()")
args.append(invoice_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
return {"ok": True, "invoice": row}
@router.post("/invoices/{invoice_id}/pay")
def invoices_pay(invoice_id: int, body: dict = Body(default={})):
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method", "transfer")
iban_from = body.get("iban_from")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from), updated_at=NOW()
WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""",
(paid_date, payment_method, iban_from, invoice_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
# log payment
cur.execute(
"""INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(invoice_id, row["amount_gross"], paid_date, payment_method, iban_from),
) if False else None # payments table column-set may differ; skip silently
return {"ok": True, "invoice": row}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib,
ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE 1=1"""
args: list = []
if klub_id is not None:
sql += " AND klub_id=%s"; args.append(klub_id)
if status:
sql += " AND ocr_status=%s"; args.append(status)
sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows}
+45
View File
@@ -1376,6 +1376,51 @@ if HAS_S3_ROUTERS:
app.include_router(img_proxy_router, prefix='/api/v2')
app.include_router(audit_coverage_router, prefix='/api/v2')
# Round-2 enrichment endpoint
try:
from enrich_router import router as enrich_router
app.include_router(enrich_router, prefix='/api/v2')
print('[ENRICH] router loaded')
except Exception as e:
print(f'[ENRICH] router fail: {e}')
# === Round 3 / CC4 — ERP (M5: OCR + Invoices, M6: Putni nalozi) ===
sys.path.insert(0, '/opt/pgz-sport')
try:
from erp.ocr import router as erp_ocr_router
app.include_router(erp_ocr_router)
print('[ERP/OCR] router loaded')
except Exception as e:
print(f'[ERP/OCR] router fail: {e}')
try:
from erp.putni_nalozi import router as erp_putni_router
app.include_router(erp_putni_router)
print('[ERP/PUTNI] router loaded')
except Exception as e:
print(f'[ERP/PUTNI] router fail: {e}')
# === Round 3 / CC5 — CRM (M7 Članarine, M8 Liječnički, M9 Obrasci) ===
try:
from clanarine_router import router as clanarine_router
app.include_router(clanarine_router)
print('[CRM/M7] clanarine router loaded')
except Exception as e:
print(f'[CRM/M7] clanarine router fail: {e}')
try:
from lijecnicki_router import router as lijecnicki_router
app.include_router(lijecnicki_router)
print('[CRM/M8] lijecnicki router loaded')
except Exception as e:
print(f'[CRM/M8] lijecnicki router fail: {e}')
try:
from obrasci_router import router as obrasci_router
app.include_router(obrasci_router)
print('[CRM/M9] obrasci router loaded')
except Exception as e:
print(f'[CRM/M9] obrasci router fail: {e}')
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/clanarine_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/clanarine_router.py
# Svrha: M7 — CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR + bulk notify
# ═══════════════════════════════════════════════════════════════════
"""M7 Članarine router.
Endpointi (montirani s prefixom /api/crm):
GET /clanarine → lista (filteri)
POST /clanarine → kreiraj zaduženje
GET /clanarine/{id} → detalji
PUT /clanarine/{id} → update
DELETE /clanarine/{id} → soft delete (status=storno)
POST /clanarine/{id}/uplata → registriraj uplatu
GET /clanarine/dug → svi koji duguju
POST /clanarine/notify-bulk → mock e-mail notifikacija
GET /clanarine/{id}/uplatnica.pdf → HUB-3 PDF
GET /clanarine/{id}/qr.png → EPC QR PNG
"""
from __future__ import annotations
import sys
import re
from datetime import date
from typing import Optional, List
from decimal import Decimal
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, Body
from fastapi.responses import Response
from pydantic import BaseModel, Field
sys.path.insert(0, "/opt/pgz-sport")
from crm.payments import (
build_hub3_pdf, build_epc_qr_png, build_epc_payload,
make_poziv_na_broj, build_bank_deep_links, normalize_iban,
)
router = APIRouter(prefix="/api/crm", tags=["crm-clanarine"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
# Default IBAN PGŽ Sport (placeholder ako klub nema svoj). Realan IBAN PGŽ
# Odjela za sport stavi ovdje kad bude poznat — za sada koristimo neutralni
# format za demo svrhe.
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date,)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
def _compute_status(propisan: float, placen: float) -> str:
p, pl = float(propisan or 0), float(placen or 0)
if pl <= 0:
return "nepodmireno"
if pl + 0.005 < p:
return "djelomicno"
return "podmireno"
# ───────────── modeli ─────────────
class ClanarinaIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
godina: int
razdoblje: Optional[str] = "godišnja"
iznos_propisan: float
iznos_placen: Optional[float] = 0
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = None
napomena: Optional[str] = None
class ClanarinaPatch(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
razdoblje: Optional[str] = None
iznos_propisan: Optional[float] = None
iznos_placen: Optional[float] = None
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = None
napomena: Optional[str] = None
status: Optional[str] = None
class UplataIn(BaseModel):
iznos: float = Field(..., gt=0)
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = "transakcijski"
referenca: Optional[str] = None
racun_broj: Optional[str] = None
class NotifyBulkIn(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
# ───────────── lista ─────────────
@router.get("/clanarine")
def list_clanarine(
godina: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
status: Optional[str] = Query(None, description="nepodmireno|djelomicno|podmireno|storno"),
sort: str = Query("godina"),
order: str = Query("desc"),
limit: int = Query(500, le=2000),
):
where, params = [], []
if godina:
where.append("c.godina = %s"); params.append(godina)
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
if clan_id:
where.append("c.clan_id = %s"); params.append(clan_id)
if status:
where.append("c.status = %s"); params.append(status)
sort_map = {
"godina": "c.godina",
"iznos": "c.iznos_propisan",
"klub": "k.naziv",
"datum_uplate": "c.datum_uplate",
"status": "c.status",
"dug": "(c.iznos_propisan - COALESCE(c.iznos_placen,0))",
}
sort_col = sort_map.get(sort, "c.godina")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = f"""
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
c.datum_uplate, c.nacin_uplate, c.referenca, c.racun_broj,
c.status, c.napomena, c.created_at, c.updated_at,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY {sort_col} {order_sql}
LIMIT %s
"""
params.append(limit)
sum_sql = f"""
SELECT COUNT(*) AS total,
COALESCE(SUM(c.iznos_propisan), 0)::numeric(10,2) AS total_propisan,
COALESCE(SUM(c.iznos_placen), 0)::numeric(10,2) AS total_placen,
COALESCE(SUM(c.iznos_propisan - COALESCE(c.iznos_placen,0)), 0)::numeric(10,2) AS total_dug,
COUNT(*) FILTER (WHERE c.status='nepodmireno') AS n_nepodmireno,
COUNT(*) FILTER (WHERE c.status='djelomicno') AS n_djelomicno,
COUNT(*) FILTER (WHERE c.status='podmireno') AS n_podmireno
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(sum_sql, params[:-1]) # bez LIMIT
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
# ───────────── dug (samo neplaćene) ─────────────
@router.get("/clanarine/dug")
def list_dug(
klub_id: Optional[int] = Query(None),
godina: Optional[int] = Query(None),
days_overdue: int = Query(0, ge=0,
description="koliko dana mora biti od kreiranja dužnosti (default 0)"),
limit: int = Query(500, le=2000),
):
where = ["c.status IN ('nepodmireno', 'djelomicno')"]
params: list = []
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
if godina:
where.append("c.godina = %s"); params.append(godina)
if days_overdue > 0:
where.append("c.created_at < (now() - (%s || ' days')::interval)")
params.append(str(days_overdue))
where_sql = "WHERE " + " AND ".join(where)
params.append(limit)
sql = f"""
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
c.status, c.created_at,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY (c.iznos_propisan - COALESCE(c.iznos_placen,0)) DESC
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
total_dug = sum(float(r["dug"] or 0) for r in rows)
return {"count": len(rows), "total_dug": round(total_dug, 2), "rows": rows}
# ───────────── detalji ─────────────
@router.get("/clanarine/{cid}")
def get_clanarina(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.*,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
k.adresa AS klub_adresa, k.grad AS klub_grad
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id = %s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
return _row(r)
# ───────────── kreiraj ─────────────
@router.post("/clanarine")
def create_clanarina(body: ClanarinaIn):
klub_id = body.klub_id
with _conn() as conn, conn.cursor() as cur:
if not klub_id:
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
r = cur.fetchone()
klub_id = r["klub_id"] if r else None
status = _compute_status(body.iznos_propisan, body.iznos_placen or 0)
cur.execute("""
INSERT INTO pgz_sport.clanarine
(clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen,
datum_uplate, nacin_uplate, status, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (body.clan_id, klub_id, body.godina, body.razdoblje,
body.iznos_propisan, body.iznos_placen or 0,
body.datum_uplate, body.nacin_uplate, status, body.napomena))
r = cur.fetchone()
conn.commit()
return _row(r)
# ───────────── update / delete ─────────────
@router.put("/clanarine/{cid}")
def update_clanarina(cid: int, patch: ClanarinaPatch):
fields, params = [], []
for f in ("klub_id", "godina", "razdoblje", "iznos_propisan", "iznos_placen",
"datum_uplate", "nacin_uplate", "napomena", "status"):
v = getattr(patch, f)
if v is not None:
fields.append(f"{f} = %s"); params.append(v)
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at = now()")
params.append(cid)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.clanarine SET {', '.join(fields)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
# ako nije eksplicitno postavljen status, izračunaj iz iznos_*
if patch.status is None:
new_status = _compute_status(r["iznos_propisan"], r["iznos_placen"] or 0)
if new_status != r["status"]:
cur.execute("UPDATE pgz_sport.clanarine SET status=%s WHERE id=%s RETURNING *",
(new_status, cid))
r = cur.fetchone()
conn.commit()
return _row(r)
@router.delete("/clanarine/{cid}")
def delete_clanarina(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("UPDATE pgz_sport.clanarine SET status='storno', updated_at=now() WHERE id=%s RETURNING id",
(cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
return {"ok": True, "id": cid, "status": "storno"}
# ───────────── uplata ─────────────
@router.post("/clanarine/{cid}/uplata")
def register_uplata(cid: int, body: UplataIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.clanarine WHERE id=%s", (cid,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Članarina ne postoji")
novi_iznos = float(cur_row["iznos_placen"] or 0) + float(body.iznos)
novi_status = _compute_status(cur_row["iznos_propisan"], novi_iznos)
cur.execute("""
UPDATE pgz_sport.clanarine
SET iznos_placen = %s,
datum_uplate = COALESCE(%s, datum_uplate, now()::date),
nacin_uplate = COALESCE(%s, nacin_uplate),
referenca = COALESCE(%s, referenca),
racun_broj = COALESCE(%s, racun_broj),
status = %s,
updated_at = now()
WHERE id = %s
RETURNING *
""", (novi_iznos, body.datum_uplate, body.nacin_uplate,
body.referenca, body.racun_broj, novi_status, cid))
r = cur.fetchone()
# log u audit_feed (ako postoji); nepoznata schema → silent skip
try:
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
VALUES (%s,%s,%s,%s::jsonb)""",
("clanarina", cid, "uplata",
f'{{"iznos":{body.iznos}, "novi_status":"{novi_status}"}}'))
except Exception:
pass
conn.commit()
return {"ok": True, "id": cid, "iznos_uplata": body.iznos,
"iznos_placen_total": novi_iznos, "status": novi_status,
"clanarina": _row(r)}
# ───────────── notify-bulk (mock email) ─────────────
@router.post("/clanarine/notify-bulk")
def notify_bulk(body: NotifyBulkIn):
"""
Stvarno slanje pošte se još ne implementira (M9 SMTP integration TODO),
ali endpoint vraća listu primatelja koji bi bili kontaktirani.
"""
where = ["c.status IN ('nepodmireno','djelomicno')", "cl.email IS NOT NULL"]
params = []
if body.klub_id:
where.append("c.klub_id=%s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina =%s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
sql = f"""
SELECT c.id, c.godina, c.iznos_propisan,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan, cl.email,
k.naziv AS klub
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY dug DESC
LIMIT 500
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
recipients = [_row(r) for r in cur.fetchall()]
return {
"ok": True,
"queued": len(recipients),
"template": body.template,
"note": "Mock — SMTP nije konfiguriran. Lista primatelja vraćena za pregled.",
"recipients": recipients,
}
# ───────────── HUB-3 PDF + EPC QR ─────────────
@router.get("/clanarine/{cid}/uplatnica.pdf")
def uplatnica_pdf(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
cl.adresa AS clan_adresa, cl.grad AS clan_grad,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
k.adresa AS klub_adresa, k.grad AS klub_grad
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
# podmireno — uplatnicu generiramo na cijeli iznos kao podsjetnik
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj_naziv = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
primatelj_adresa = ", ".join(filter(None, [r.get("klub_adresa"), r.get("klub_grad")])) \
or DEFAULT_PRIMATELJ_ADRESA
platitelj_naziv = r["clan"] or "Član"
platitelj_adresa = ", ".join(filter(None, [r.get("clan_adresa"), r.get("clan_grad")])) or ""
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
pdf = build_hub3_pdf(
platitelj_naziv=platitelj_naziv,
platitelj_adresa=platitelj_adresa,
primatelj_naziv=primatelj_naziv,
primatelj_adresa=primatelj_adresa,
iban=iban,
amount_eur=dug,
model="HR00",
poziv_na_broj=poziv,
opis=f"Članarina {r['godina']}{r['razdoblje'] or 'godišnja'}",
sifra_namjene="OTHR",
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f"inline; filename=uplatnica-clanarina-{cid}.pdf"})
@router.get("/clanarine/{cid}/qr.png")
def uplatnica_qr(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
payload = build_epc_payload(
primatelj=primatelj, iban=iban, amount_eur=dug,
opis=f"Članarina {r['godina']}", model="HR00", poziv_na_broj=poziv,
)
png = build_epc_qr_png(payload, box_size=10)
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "public, max-age=300"})
@router.get("/clanarine/{cid}/payment-info")
def payment_info(cid: int):
"""Vraća JSON s IBAN, poziv-na-broj, EPC payload i deep linkovima — za UI gumbe."""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
opis = f"Članarina {r['godina']}{r['razdoblje'] or 'godišnja'}"
payload = build_epc_payload(primatelj=primatelj, iban=iban, amount_eur=dug,
opis=opis, model="HR00", poziv_na_broj=poziv)
return {
"id": cid,
"iznos_eur": round(dug, 2),
"primatelj": primatelj,
"iban": iban,
"model": "HR00",
"poziv_na_broj": poziv,
"opis": opis,
"epc_payload": payload,
"qr_url": f"/sport/api/crm/clanarine/{cid}/qr.png",
"pdf_url": f"/sport/api/crm/clanarine/{cid}/uplatnica.pdf",
"deep_links": build_bank_deep_links(iban, dug, opis,
model="HR00", poziv_na_broj=poziv),
}
+134
View File
@@ -0,0 +1,134 @@
"""
enrich_router.py — Round-2 enrichment endpoint
Author: dradulic@outlook.com Date: 2026-05-04
Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi.
Strategy:
1) Read what's already in DB and surface fields the frontend may not have shown.
2) Build curated research URLs (Google, Wikipedia HR, Sportilus, sport-pgz.hr,
HNS Semafor) so the operator can verify or expand by hand.
3) If the entity has a `web` URL set, quickly fetch the page and extract
<title> + <meta description> to return as a "live snippet". 5s timeout, fail-soft.
"""
import os, re, json, time, urllib.parse, urllib.request, html
import psycopg2, psycopg2.extras
from fastapi import APIRouter, HTTPException
router = APIRouter()
DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
port=int(os.environ.get('PG_PORT','6432')),
dbname=os.environ.get('PG_DB','rinet_v3'),
user=os.environ.get('PG_USER','rinet'),
password=os.environ.get('PG_PASS',''))
UA = 'pgz-sport-enrich/2.0'
def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
def _fetch_one(sql, p):
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, p)
r = cur.fetchone()
return dict(r) if r else None
def _fetch_title(url, timeout=5):
if not url: return None
try:
if not url.startswith('http'):
return None
req = urllib.request.Request(url, headers={'User-Agent': UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = r.read(40000).decode('utf-8','ignore')
title_m = re.search(r'<title[^>]*>([^<]+)</title>', data, re.I)
desc_m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
og_desc_m = re.search(r'<meta\s+property=["\']og:description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
return {
'url': url,
'title': html.unescape(title_m.group(1).strip())[:300] if title_m else None,
'description': html.unescape((desc_m or og_desc_m).group(1).strip())[:500] if (desc_m or og_desc_m) else None,
'fetched_at': int(time.time()),
}
except Exception as e:
return {'url': url, 'error': str(e)[:120]}
def _research_links(naziv, kind, grad=None):
base_q = (naziv or '').strip()
if grad: q = base_q + ' ' + grad
else: q = base_q
qenc = urllib.parse.quote(q)
out = [
{'label':'Google', 'icon':'🔍', 'url':'https://www.google.com/search?q='+qenc},
{'label':'Wikipedia HR', 'icon':'📚', 'url':'https://hr.wikipedia.org/w/index.php?search='+qenc},
{'label':'sport-pgz.hr', 'icon':'🏅', 'url':'https://sport-pgz.hr/?s='+qenc},
]
if kind == 'klub':
out.append({'label':'Sportilus', 'icon':'', 'url':'https://www.sportilus.com/?s='+qenc})
out.append({'label':'Sudski registar', 'icon':'', 'url':'https://sudreg.pravosudje.hr/registar/oc/index.html'})
if kind == 'sportas':
out.append({'label':'HNS Semafor', 'icon':'', 'url':'https://semafor.hns.family/?s='+qenc})
out.append({'label':'transfermarkt', 'icon':'', 'url':'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query='+qenc})
if kind == 'savez':
out.append({'label':'sport-pgz.hr savezi', 'icon':'🏅', 'url':'https://sport-pgz.hr/savezi'})
return out
@router.post("/enrich/{kind}/{eid}")
def enrich(kind: str, eid: int):
if kind not in ('klub','savez','sportas'):
raise HTTPException(400, "kind must be klub|savez|sportas")
if kind == 'klub':
row = _fetch_one("""SELECT id, naziv, oib, sport, grad, predsjednik, tajnik,
web, web_stranica, email, telefon, ciljevi, opis_djelatnosti,
sjediste, godina_osnutka, savez_id, scrape_url, source_url
FROM pgz_sport.klubovi WHERE id=%s""", (eid,))
elif kind == 'savez':
row = _fetch_one("""SELECT id, naziv, oib, sport, predsjednik, tajnik, email, telefon, web,
adresa, godina_osnutka, source_url
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
else: # sportas
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url, scrape_url,
slika_url, source_url, hns_igrac_id, biografija
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
if not row:
raise HTTPException(404, kind+" not found")
# Build display name
if kind == 'sportas':
naziv = (row.get('ime','') + ' ' + row.get('prezime','')).strip()
grad = None
else:
naziv = row.get('naziv','')
grad = row.get('grad') if kind=='klub' else None
# Live web snippet from primary URL
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url')
snippet = _fetch_title(primary) if primary else None
# Coverage score: how many key fields are filled?
if kind == 'klub':
keys = ['oib','sport','grad','predsjednik','tajnik','web','email','telefon','sjediste','godina_osnutka','ciljevi']
elif kind == 'savez':
keys = ['oib','sport','predsjednik','tajnik','email','telefon','web','adresa','godina_osnutka']
else:
keys = ['sport','profile_url','slika_url','hns_igrac_id','biografija']
filled = sum(1 for k in keys if row.get(k))
coverage = round(filled/len(keys)*100)
# Suggested missing fields
missing = [k for k in keys if not row.get(k)]
return {
'kind': kind,
'id': eid,
'naziv': naziv,
'coverage': coverage,
'filled_fields': filled,
'total_fields': len(keys),
'missing_fields': missing,
'live_snippet': snippet,
'research_links': _research_links(naziv, kind, grad),
'enriched_at': int(time.time()),
}
+401 -11
View File
@@ -30,19 +30,37 @@ button,input,select{font-family:inherit;font-size:inherit;outline:none}
::-webkit-scrollbar-thumb:hover{background:var(--pgz-blue2)}
.app{display:flex;min-height:100vh}
.sb{width:240px;background:linear-gradient(180deg,var(--bg1) 0%,var(--bg0) 100%);border-right:1px solid var(--rim);position:fixed;top:0;left:0;bottom:0;display:flex;flex-direction:column;z-index:10}
.sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim)}
.sb-h .logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px}
.sb{width:240px;background:linear-gradient(180deg,var(--bg1) 0%,var(--bg0) 100%);border-right:1px solid var(--rim);position:fixed;top:0;left:0;bottom:0;display:flex;flex-direction:column;z-index:10;transition:width .22s ease}
.sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim);position:relative}
.sb-h .logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px;white-space:nowrap;overflow:hidden}
.sb-h .logo .g{color:var(--pgz-gold)}
.sb-h .sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px}
.sb-nav{flex:1;padding:10px 8px;overflow-y:auto}
.nav-i{padding:9px 12px;border-radius:6px;color:var(--t2);cursor:pointer;display:flex;align-items:center;gap:10px;font-size:12.5px;margin-bottom:2px;transition:all .15s}
.sb-h .sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;overflow:hidden}
.sb-toggle{position:absolute;top:14px;right:8px;width:22px;height:22px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--t2);background:var(--bg2);border:1px solid var(--rim);border-radius:4px;font-size:11px;font-weight:700;transition:all .15s;user-select:none}
.sb-toggle:hover{background:var(--bg3);color:var(--pgz-gold);border-color:var(--pgz-gold)}
.sb-nav{flex:1;padding:10px 8px;overflow-y:auto;overflow-x:hidden}
.nav-i{padding:9px 12px;border-radius:6px;color:var(--t2);cursor:pointer;display:flex;align-items:center;gap:10px;font-size:12.5px;margin-bottom:2px;transition:background .15s,color .15s;white-space:nowrap}
.nav-i:hover{background:var(--bg2);color:var(--t1)}
.nav-i.active{background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%);color:#fff;font-weight:600}
.nav-i .ic{width:18px;text-align:center;font-size:14px}
.sb-foot{padding:10px 14px;border-top:1px solid var(--rim);font-size:10px;color:var(--t4)}
.nav-i .ic{width:18px;text-align:center;font-size:14px;flex-shrink:0}
.nav-i .lbl{overflow:hidden;text-overflow:ellipsis}
.sb-foot{padding:10px 14px;border-top:1px solid var(--rim);font-size:10px;color:var(--t4);white-space:nowrap;overflow:hidden}
.main{margin-left:240px;flex:1;min-width:0}
/* Collapsed sidebar */
.sb.collapsed{width:58px}
.sb.collapsed .sb-h{padding:18px 8px 14px;text-align:center}
.sb.collapsed .sb-h .logo{font-size:0}
.sb.collapsed .sb-h .logo::before{content:"PG";font-size:13px;color:var(--pgz-gold);font-weight:800}
.sb.collapsed .sb-h .sub{display:none}
.sb.collapsed .sb-toggle{position:static;margin:6px auto 0;display:flex}
.sb.collapsed .nav-i{justify-content:center;padding:10px 6px}
.sb.collapsed .nav-i .lbl{display:none}
.sb.collapsed .nav-i{position:relative}
.sb.collapsed .nav-i:hover::after{content:attr(data-label);position:absolute;left:58px;top:50%;transform:translateY(-50%);background:var(--bg3);color:var(--t0);padding:5px 10px;border-radius:4px;font-size:11.5px;white-space:nowrap;border:1px solid var(--rim);z-index:50;font-weight:600;pointer-events:none;box-shadow:2px 2px 8px rgba(0,0,0,.4)}
.sb.collapsed .sb-foot{font-size:0;padding:8px}
.sb.collapsed .sb-foot::before{content:"v2";font-size:9px;color:var(--t4)}
.main{margin-left:240px;flex:1;min-width:0;transition:margin-left .22s ease}
.sb.collapsed ~ .main{margin-left:58px}
.tb{background:var(--bg1);border-bottom:1px solid var(--rim);padding:12px 22px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:5}
.tb-t{font-size:15px;font-weight:700;color:var(--t0)}
.tb-s{font-size:11px;color:var(--t2)}
@@ -189,10 +207,11 @@ table tbody tr.no-click:hover{background:transparent}
<body>
<div class="app">
<aside class="sb">
<aside class="sb" id="sb">
<div class="sb-h">
<div class="logo">PGŽ <span class="g">SPORT</span></div>
<div class="sub">Primorsko-goranska županija</div>
<div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi"></div>
</div>
<nav class="sb-nav" id="nav"></nav>
<div class="sb-foot">v2.0 · 2026</div>
@@ -217,6 +236,7 @@ table tbody tr.no-click:hover{background:transparent}
<section id="pg-financije" class="section"></section>
<section id="pg-objekti" class="section"></section>
<section id="pg-manifestacije" class="section"></section>
<section id="pg-mreza" class="section"></section>
<section id="pg-forenzika" class="section"></section>
</div>
</main>
@@ -242,6 +262,7 @@ const NAV_ITEMS = [
{id:'financije', ic:'€', label:'Financije'},
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža'},
{id:'forenzika', ic:'⚠', label:'Forenzika'}
];
const SECTION_TITLES = {
@@ -252,6 +273,7 @@ const SECTION_TITLES = {
financije: ['Financije', 'Sufinanciranje sporta'],
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
manifestacije: ['Manifestacije', 'Sportski događaji'],
mreza: ['Mreža', 'Force-directed graf entiteta i veza'],
forenzika: ['Forenzika', 'Kritični nalazi i alarmi']
};
@@ -307,6 +329,68 @@ async function api(path){
return null;
}
}
async function apiPost(path, body){
try{
const r = await fetch(API+path, {method:'POST', headers:{'Content-Type':'application/json'}, body: body?JSON.stringify(body):'{}'});
if(!r.ok) throw new Error('HTTP '+r.status);
return await r.json();
}catch(e){
console.error('API POST error', path, e);
return null;
}
}
async function enrichEntity(kind, id){
const targetId = 'enrich-out-'+kind+'-'+id;
const target = document.getElementById(targetId);
if(target) target.innerHTML = '<div class="loading">⏳ Obogaćivanje u tijeku — pretraživanje izvora…</div>';
const r = await apiPost('/v2/enrich/'+kind+'/'+id);
if(!r){ if(target) target.innerHTML = '<div class="empty">Greška pri obogaćivanju</div>'; return; }
const cov = r.coverage||0;
const covCls = cov>=70?'high':(cov>=40?'mid':'low');
const html = `
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<span class="tag gr">🟢 OBOGAĆENO</span>
<span class="score ${covCls}">Coverage ${cov}%</span>
<span class="tb-s">${r.filled_fields}/${r.total_fields} polja popunjeno</span>
</div>
${r.live_snippet && r.live_snippet.title ? `
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px;margin-bottom:10px">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">📡 Live snippet</div>
<div style="font-weight:700;color:var(--t0);font-size:13px;margin-bottom:4px">${esc(r.live_snippet.title)}</div>
${r.live_snippet.description ? '<div style="font-size:11.5px;color:var(--t1);line-height:1.5">'+esc(r.live_snippet.description)+'</div>' : ''}
<div style="margin-top:6px"><a href="${esc(r.live_snippet.url)}" target="_blank">↗ ${esc(r.live_snippet.url.slice(0,80))}</a></div>
</div>
` : ''}
${r.missing_fields && r.missing_fields.length ? `
<div style="margin-bottom:10px">
<div style="font-size:11px;color:var(--t2);margin-bottom:4px">Nedostaje:</div>
<div>${r.missing_fields.map(f=>'<span class="tag rd">'+esc(f)+'</span>').join('')}</div>
</div>
` : ''}
<div>
<div style="font-size:11px;color:var(--t2);margin-bottom:6px">🔍 Istraži dalje:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${(r.research_links||[]).map(l => '<a href="'+esc(l.url)+'" target="_blank" class="btn">'+l.icon+' '+esc(l.label)+'</a>').join('')}
</div>
</div>
`;
if(target) target.innerHTML = html;
}
function enrichBlock(kind, id){
return `
<div class="card" id="enrich-card-${kind}-${id}">
<div class="card-h">
<div class="card-t">✨ Obogati podatke</div>
<button class="btn primary" onclick="enrichEntity('${kind}',${id})">▶ Pokreni</button>
</div>
<div id="enrich-out-${kind}-${id}">
<div class="empty" style="padding:14px">Klikom na "Pokreni" platforma će pretražiti vanjske izvore (Google, Wikipedia, službene web stranice) i prikazati dopune za ovu entitetsku karticu.</div>
</div>
</div>
`;
}
function sortRows(rows, key, dir){
if(!key) return rows;
const sorted = rows.slice();
@@ -376,7 +460,26 @@ document.addEventListener('keydown', e => { if(e.key==='Escape') closePanel(); }
//=========== NAVIGATION ===========
function buildNav(){
const nav = $('#nav');
nav.innerHTML = NAV_ITEMS.map(n => '<div class="nav-i '+(n.id===_state.section?'active':'')+'" data-id="'+n.id+'" onclick="navTo(\''+n.id+'\')"><span class="ic">'+n.ic+'</span><span>'+n.label+'</span></div>').join('');
nav.innerHTML = NAV_ITEMS.map(n => '<div class="nav-i '+(n.id===_state.section?'active':'')+'" data-id="'+n.id+'" data-label="'+n.label+'" onclick="navTo(\''+n.id+'\')"><span class="ic">'+n.ic+'</span><span class="lbl">'+n.label+'</span></div>').join('');
}
function toggleSidebar(){
const sb = document.getElementById('sb');
const tg = document.getElementById('sb-toggle');
if(!sb) return;
const isCollapsed = sb.classList.toggle('collapsed');
if(tg) tg.textContent = isCollapsed ? '⮞' : '⮜';
try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){}
}
function restoreSidebar(){
try {
const s = localStorage.getItem('sidebar-state');
if(s === 'collapsed'){
const sb = document.getElementById('sb');
const tg = document.getElementById('sb-toggle');
if(sb) sb.classList.add('collapsed');
if(tg) tg.textContent = '⮞';
}
} catch(e){}
}
function navTo(id){
_state.section = id;
@@ -398,6 +501,7 @@ function loadSection(id){
case 'financije': return loadFinancije();
case 'objekti': return loadObjekti();
case 'manifestacije': return loadManifestacije();
case 'mreza': return loadMreza();
case 'forenzika': return loadForenzika();
}
}
@@ -734,6 +838,8 @@ async function openSavez(id){
</tbody>
</table></div>` : '<div class="empty">Nema podataka o klubovima</div>'}
</div>
${enrichBlock('savez', s.id)}
`;
openPanel('Savez · '+s.naziv, html);
}
@@ -917,6 +1023,8 @@ async function openKlub(id){
</tbody>
</table></div>` : '<div class="empty">Nema zabilježenih potpora</div>'}
</div>
${enrichBlock('klub', k.id)}
`;
openPanel('Klub · '+(k.naziv||''), html);
}
@@ -1161,6 +1269,8 @@ async function openSportas(id){
</tbody>
</table></div>` : '<div class="empty">Nema zabilježenih nagrada</div>'}
</div>
${enrichBlock('sportas', d.id)}
`;
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
}
@@ -1544,6 +1654,285 @@ function openManif(id){
openPanel('Manifestacija · '+m.naziv, html);
}
//=========== MREŽA (Network Graph) ===========
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}};
async function loadMreza(){
const root = $('#pg-mreza');
if(!_mreza.data){
root.innerHTML = '<div class="loading">Učitavanje grafa entiteta…</div>';
let resp;
try{
const r = await fetch('https://api.rinet.one/api/v1/presenter/graph-real');
resp = await r.json();
}catch(e){ console.error('graph fetch error', e); }
if(!resp || !resp.data){ root.innerHTML='<div class="empty">Greška pri dohvatu graf-podataka</div>'; return; }
_mreza.data = resp.data;
_mreza.allNodes = (resp.data.nodes||[]).slice();
_mreza.allEdges = (resp.data.edges||[]).slice();
}
renderMrezaShell();
renderMrezaGraph();
}
function renderMrezaShell(){
const root = $('#pg-mreza');
const types = Array.from(new Set((_mreza.allNodes||[]).map(n=>n.type))).sort();
const totalN = (_mreza.allNodes||[]).length;
const totalE = (_mreza.allEdges||[]).length;
root.innerHTML = `
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="kpi"><div class="kpi-l">Čvorova</div><div class="kpi-v">${totalN}</div></div>
<div class="kpi b"><div class="kpi-l">Veza</div><div class="kpi-v">${totalE}</div></div>
<div class="kpi g"><div class="kpi-l">Osoba</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='person').length}</div></div>
<div class="kpi r"><div class="kpi-l">Tvrtki / entiteta</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='entity'||n.type==='supplier').length}</div></div>
</div>
<div class="toolbar" style="margin-bottom:10px">
<input type="search" id="mr-osoba" placeholder="👤 Osoba…">
<input type="search" id="mr-klub" placeholder="🏟 Klub / Savez…">
<input type="search" id="mr-tvrtka" placeholder="🏢 Tvrtka / Entitet…">
<select id="mr-tip">
<option value="">Svi tipovi</option>
${types.map(t=>'<option value="'+esc(t)+'">'+esc(t)+'</option>').join('')}
</select>
<button class="btn" onclick="resetMreza()">↺ Reset</button>
<span class="tb-s" id="mr-cnt"></span>
</div>
<div class="card" style="padding:0;overflow:hidden">
<div id="mr-graph" style="width:100%;height:640px;background:radial-gradient(ellipse at center,var(--bg2) 0%,var(--bg0) 100%);position:relative">
<svg id="mr-svg" style="width:100%;height:100%"></svg>
</div>
</div>
<div class="card" style="margin-top:10px">
<div class="card-h"><div class="card-t">🎨 Legenda</div></div>
<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:12px">
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div>
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje</div>
</div>
</div>
`;
$('#mr-osoba').addEventListener('input', debounce(applyMrezaFilter, 200));
$('#mr-klub').addEventListener('input', debounce(applyMrezaFilter, 200));
$('#mr-tvrtka').addEventListener('input', debounce(applyMrezaFilter, 200));
$('#mr-tip').addEventListener('change', applyMrezaFilter);
}
function applyMrezaFilter(){
const osoba = ($('#mr-osoba').value||'').toLowerCase().trim();
const klub = ($('#mr-klub').value||'').toLowerCase().trim();
const tvrtka = ($('#mr-tvrtka').value||'').toLowerCase().trim();
const tip = $('#mr-tip').value;
let nodes = (_mreza.allNodes||[]).slice();
if(osoba) nodes = nodes.filter(n => n.type==='person' && (n.label||'').toLowerCase().includes(osoba) || n.type!=='person');
if(klub){
// filter entity/supplier by label match (savezi/klubovi appear as entities)
nodes = nodes.filter(n => {
if(n.type==='person') return true;
return (n.label||'').toLowerCase().includes(klub);
});
}
if(tvrtka){
nodes = nodes.filter(n => {
if(n.type==='person') return true;
return (n.label||'').toLowerCase().includes(tvrtka);
});
}
if(tip) nodes = nodes.filter(n => n.type===tip);
// Also filter to stronger: if osoba is set, drop persons not matching
if(osoba) nodes = nodes.filter(n => n.type!=='person' || (n.label||'').toLowerCase().includes(osoba));
if(klub) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(klub));
if(tvrtka) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(tvrtka));
const ids = new Set(nodes.map(n=>n.id));
let edges = (_mreza.allEdges||[]).filter(e => ids.has(e.source.id||e.source) && ids.has(e.target.id||e.target));
// After edge filter, keep only nodes that have at least one edge OR were direct matches
const used = new Set();
for(const e of edges){
used.add(e.source.id||e.source);
used.add(e.target.id||e.target);
}
// If we have any text filter, restrict to nodes that have edges; otherwise keep all
if(osoba||klub||tvrtka){
nodes = nodes.filter(n => used.has(n.id));
}
$('#mr-cnt').textContent = nodes.length+' čvorova · '+edges.length+' veza';
renderMrezaGraph(nodes, edges);
}
function resetMreza(){
$('#mr-osoba').value = '';
$('#mr-klub').value = '';
$('#mr-tvrtka').value = '';
$('#mr-tip').value = '';
applyMrezaFilter();
}
function renderMrezaGraph(nodes, edges){
if(!nodes) nodes = (_mreza.allNodes||[]).slice();
if(!edges) edges = (_mreza.allEdges||[]).slice();
const svgEl = document.getElementById('mr-svg');
if(!svgEl) return;
const container = document.getElementById('mr-graph');
const W = container.clientWidth || 800;
const H = container.clientHeight || 640;
if(_mreza.sim){ try{_mreza.sim.stop();}catch(e){} _mreza.sim = null; }
const svg = d3.select(svgEl);
svg.selectAll('*').remove();
svg.attr('viewBox', '0 0 '+W+' '+H);
// Deep-copy so D3 sim doesn't mutate originals
const N = nodes.map(n => Object.assign({}, n));
const Nmap = new Map(N.map(n=>[n.id, n]));
const E = edges.map(e => ({
source: Nmap.get(e.source.id||e.source) || (e.source.id||e.source),
target: Nmap.get(e.target.id||e.target) || (e.target.id||e.target),
color: e.color, size: e.size
})).filter(e => typeof e.source === 'object' && typeof e.target === 'object');
// Zoom/pan
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', (ev) => g.attr('transform', ev.transform)));
const sim = d3.forceSimulation(N)
.force('link', d3.forceLink(E).id(d => d.id).distance(d => 60 + 20/(d.size||1)))
.force('charge', d3.forceManyBody().strength(d => -50 - (d.size||5)*4))
.force('center', d3.forceCenter(W/2, H/2))
.force('collide', d3.forceCollide().radius(d => Math.max(6, (d.size||5)*0.7 + 4)));
_mreza.sim = sim;
const link = g.append('g')
.attr('stroke-opacity', 0.5)
.selectAll('line').data(E).join('line')
.attr('stroke', d => d.color || '#283560')
.attr('stroke-width', d => Math.max(0.4, (d.size||0.4)));
const node = g.append('g')
.selectAll('g').data(N).join('g')
.style('cursor','pointer')
.call(d3.drag()
.on('start', (ev,d) => { if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (ev,d) => { d.fx=ev.x; d.fy=ev.y; })
.on('end', (ev,d) => { if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
.on('click', (ev,d) => openMrezaNode(d));
node.append('circle')
.attr('r', d => Math.max(5, (d.size||5)*0.7))
.attr('fill', d => d.color || '#004CC4')
.attr('stroke', '#0d1021')
.attr('stroke-width', 1.5);
node.append('text')
.text(d => (d.label||'').slice(0,28))
.attr('x', d => Math.max(6, (d.size||5)*0.7) + 4)
.attr('y', 4)
.attr('fill', '#e2e6f0')
.attr('font-size', '10px')
.attr('font-family', 'Inter, sans-serif')
.style('pointer-events','none');
node.append('title').text(d => (d.label||'')+' ['+d.type+']');
sim.on('tick', () => {
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
node.attr('transform', d => 'translate('+d.x+','+d.y+')');
});
if(!$('#mr-cnt').textContent){
$('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza';
}
}
function openMrezaNode(n){
const m = n.meta || {};
// Find connected nodes
const id = n.id;
const edges = (_mreza.allEdges||[]).filter(e => (e.source.id||e.source)===id || (e.target.id||e.target)===id);
const connectedIds = new Set();
for(const e of edges){
const s = e.source.id||e.source;
const t = e.target.id||e.target;
if(s===id) connectedIds.add(t); else connectedIds.add(s);
}
const connected = (_mreza.allNodes||[]).filter(x => connectedIds.has(x.id));
let html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(n.label)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">
<span class="tag b">${esc(n.type)}</span>
${m.risk?'<span class="tag rd">Risk '+m.risk+'</span>':''}
${m.forensic?'<span class="tag am">Forenzika '+m.forensic+'</span>':''}
</div>
</div>
</div>
${(m.risk!=null || m.forensic!=null) ? `
<div class="kpi-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:14px">
${m.risk!=null ? '<div class="kpi r"><div class="kpi-l">Risk score</div><div class="kpi-v">'+m.risk+'</div></div>' : ''}
${m.forensic!=null ? '<div class="kpi"><div class="kpi-l">Forenzički flag</div><div class="kpi-v">'+m.forensic+'</div></div>' : ''}
${m.winner_contracts!=null ? '<div class="kpi b"><div class="kpi-l">Ugovori (W)</div><div class="kpi-v">'+m.winner_contracts+'</div></div>' : ''}
</div>` : ''}
<div class="card">
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
<div class="kv">
<div class="k">ID</div><div class="v" style="font-family:var(--mono);font-size:11px">${esc(n.id)}</div>
<div class="k">Tip</div><div class="v">${esc(n.type)}</div>
<div class="k">Naziv</div><div class="v">${esc(n.label)}</div>
${m.oib?'<div class="k">OIB</div><div class="v" style="font-family:var(--mono)">'+esc(m.oib)+'</div>':''}
${m.city?'<div class="k">Grad</div><div class="v">'+esc(m.city)+'</div>':''}
${m.buyer_contracts!=null?'<div class="k">Ugovori kao kupac</div><div class="v">'+m.buyer_contracts+'</div>':''}
${m.buyer_value!=null?'<div class="k">Vrijednost (kupac)</div><div class="v">'+fmtEurFull(m.buyer_value)+'</div>':''}
${m.winner_contracts!=null?'<div class="k">Ugovori kao dobavljač</div><div class="v">'+m.winner_contracts+'</div>':''}
${m.total!=null?'<div class="k">Ukupan promet</div><div class="v"><b style="color:var(--pgz-gold)">'+fmtEurFull(m.total)+'</b></div>':''}
${m.contracts!=null?'<div class="k">Broj ugovora</div><div class="v">'+m.contracts+'</div>':''}
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">🔗 Veze (${connected.length})</div></div>
${connected.length ? '<div style="overflow-x:auto;max-height:300px;overflow-y:auto"><table>'+
'<thead><tr><th>Tip</th><th>Naziv</th><th>Risk</th></tr></thead>'+
'<tbody>'+connected.slice(0,80).map(c => `
<tr onclick="openMrezaNode(${JSON.stringify(c).replace(/"/g,'&quot;')})">
<td><span class="tag b">${esc(c.type)}</span></td>
<td><b>${esc(c.label)}</b></td>
<td>${(c.meta&&c.meta.risk)||'—'}</td>
</tr>`).join('')+
'</tbody></table></div>' : '<div class="empty">Nema povezanih entiteta</div>'}
</div>
${(m.forensic && m.forensic > 0 && n.type==='entity') ? `
<div class="card">
<div class="card-h"><div class="card-t">⚠ Forenzika</div></div>
<div class="alert-card crit">
<div class="at">${m.forensic} forenzičkih flagova</div>
<div class="ad">Ovaj entitet ima zabilježene forenzičke nalaze. Provjeri detalje u sekciji Forenzika.</div>
</div>
</div>` : ''}
${(m.buyer_contracts && m.buyer_contracts > 0) ? `
<div class="card">
<div class="card-h"><div class="card-t">💼 Procurement</div></div>
<div class="kv">
<div class="k">Kao kupac</div><div class="v">${m.buyer_contracts} ugovora · ${fmtEurFull(m.buyer_value||0)}</div>
</div>
</div>` : ''}
`;
openPanel(n.label, html);
}
//=========== FORENZIKA ===========
const _forenzika = {alerts:null, custom:null, filter:{severity:'', tip:'', q:''}};
@@ -1858,6 +2247,7 @@ async function runForensicScan(){
//=========== INIT ===========
function init(){
restoreSidebar();
buildNav();
navTo('dashboard');
}