Compare commits
4 Commits
a7ec0a86be
...
1bd34ed678
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bd34ed678 | |||
| 834b7bf89f | |||
| f19d70b96a | |||
| b7cb050843 |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||||
+316
@@ -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}",
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# PGŽ Sport ERP package — Round 3
|
||||||
+659
@@ -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}
|
||||||
@@ -1376,6 +1376,51 @@ if HAS_S3_ROUTERS:
|
|||||||
app.include_router(img_proxy_router, prefix='/api/v2')
|
app.include_router(img_proxy_router, prefix='/api/v2')
|
||||||
app.include_router(audit_coverage_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}')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
@@ -30,19 +30,37 @@ button,input,select{font-family:inherit;font-size:inherit;outline:none}
|
|||||||
::-webkit-scrollbar-thumb:hover{background:var(--pgz-blue2)}
|
::-webkit-scrollbar-thumb:hover{background:var(--pgz-blue2)}
|
||||||
|
|
||||||
.app{display:flex;min-height:100vh}
|
.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{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)}
|
.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}
|
.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 .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-h .sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;overflow:hidden}
|
||||||
.sb-nav{flex:1;padding:10px 8px;overflow-y:auto}
|
.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}
|
||||||
.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-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: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.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}
|
.nav-i .ic{width:18px;text-align:center;font-size:14px;flex-shrink:0}
|
||||||
.sb-foot{padding:10px 14px;border-top:1px solid var(--rim);font-size:10px;color:var(--t4)}
|
.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{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-t{font-size:15px;font-weight:700;color:var(--t0)}
|
||||||
.tb-s{font-size:11px;color:var(--t2)}
|
.tb-s{font-size:11px;color:var(--t2)}
|
||||||
@@ -189,10 +207,11 @@ table tbody tr.no-click:hover{background:transparent}
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<aside class="sb">
|
<aside class="sb" id="sb">
|
||||||
<div class="sb-h">
|
<div class="sb-h">
|
||||||
<div class="logo">PGŽ <span class="g">SPORT</span></div>
|
<div class="logo">PGŽ <span class="g">SPORT</span></div>
|
||||||
<div class="sub">Primorsko-goranska županija</div>
|
<div class="sub">Primorsko-goranska županija</div>
|
||||||
|
<div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi">⮜</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sb-nav" id="nav"></nav>
|
<nav class="sb-nav" id="nav"></nav>
|
||||||
<div class="sb-foot">v2.0 · 2026</div>
|
<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-financije" class="section"></section>
|
||||||
<section id="pg-objekti" class="section"></section>
|
<section id="pg-objekti" class="section"></section>
|
||||||
<section id="pg-manifestacije" class="section"></section>
|
<section id="pg-manifestacije" class="section"></section>
|
||||||
|
<section id="pg-mreza" class="section"></section>
|
||||||
<section id="pg-forenzika" class="section"></section>
|
<section id="pg-forenzika" class="section"></section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -242,6 +262,7 @@ const NAV_ITEMS = [
|
|||||||
{id:'financije', ic:'€', label:'Financije'},
|
{id:'financije', ic:'€', label:'Financije'},
|
||||||
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
|
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
|
||||||
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
|
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
|
||||||
|
{id:'mreza', ic:'\u{1F578}', label:'Mreža'},
|
||||||
{id:'forenzika', ic:'⚠', label:'Forenzika'}
|
{id:'forenzika', ic:'⚠', label:'Forenzika'}
|
||||||
];
|
];
|
||||||
const SECTION_TITLES = {
|
const SECTION_TITLES = {
|
||||||
@@ -252,6 +273,7 @@ const SECTION_TITLES = {
|
|||||||
financije: ['Financije', 'Sufinanciranje sporta'],
|
financije: ['Financije', 'Sufinanciranje sporta'],
|
||||||
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
|
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
|
||||||
manifestacije: ['Manifestacije', 'Sportski događaji'],
|
manifestacije: ['Manifestacije', 'Sportski događaji'],
|
||||||
|
mreza: ['Mreža', 'Force-directed graf entiteta i veza'],
|
||||||
forenzika: ['Forenzika', 'Kritični nalazi i alarmi']
|
forenzika: ['Forenzika', 'Kritični nalazi i alarmi']
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,6 +329,68 @@ async function api(path){
|
|||||||
return null;
|
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){
|
function sortRows(rows, key, dir){
|
||||||
if(!key) return rows;
|
if(!key) return rows;
|
||||||
const sorted = rows.slice();
|
const sorted = rows.slice();
|
||||||
@@ -376,7 +460,26 @@ document.addEventListener('keydown', e => { if(e.key==='Escape') closePanel(); }
|
|||||||
//=========== NAVIGATION ===========
|
//=========== NAVIGATION ===========
|
||||||
function buildNav(){
|
function buildNav(){
|
||||||
const nav = $('#nav');
|
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){
|
function navTo(id){
|
||||||
_state.section = id;
|
_state.section = id;
|
||||||
@@ -398,6 +501,7 @@ function loadSection(id){
|
|||||||
case 'financije': return loadFinancije();
|
case 'financije': return loadFinancije();
|
||||||
case 'objekti': return loadObjekti();
|
case 'objekti': return loadObjekti();
|
||||||
case 'manifestacije': return loadManifestacije();
|
case 'manifestacije': return loadManifestacije();
|
||||||
|
case 'mreza': return loadMreza();
|
||||||
case 'forenzika': return loadForenzika();
|
case 'forenzika': return loadForenzika();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,6 +838,8 @@ async function openSavez(id){
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table></div>` : '<div class="empty">Nema podataka o klubovima</div>'}
|
</table></div>` : '<div class="empty">Nema podataka o klubovima</div>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${enrichBlock('savez', s.id)}
|
||||||
`;
|
`;
|
||||||
openPanel('Savez · '+s.naziv, html);
|
openPanel('Savez · '+s.naziv, html);
|
||||||
}
|
}
|
||||||
@@ -917,6 +1023,8 @@ async function openKlub(id){
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table></div>` : '<div class="empty">Nema zabilježenih potpora</div>'}
|
</table></div>` : '<div class="empty">Nema zabilježenih potpora</div>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${enrichBlock('klub', k.id)}
|
||||||
`;
|
`;
|
||||||
openPanel('Klub · '+(k.naziv||''), html);
|
openPanel('Klub · '+(k.naziv||''), html);
|
||||||
}
|
}
|
||||||
@@ -1161,6 +1269,8 @@ async function openSportas(id){
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table></div>` : '<div class="empty">Nema zabilježenih nagrada</div>'}
|
</table></div>` : '<div class="empty">Nema zabilježenih nagrada</div>'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${enrichBlock('sportas', d.id)}
|
||||||
`;
|
`;
|
||||||
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
|
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
|
||||||
}
|
}
|
||||||
@@ -1544,6 +1654,285 @@ function openManif(id){
|
|||||||
openPanel('Manifestacija · '+m.naziv, html);
|
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,'"')})">
|
||||||
|
<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 ===========
|
//=========== FORENZIKA ===========
|
||||||
const _forenzika = {alerts:null, custom:null, filter:{severity:'', tip:'', q:''}};
|
const _forenzika = {alerts:null, custom:null, filter:{severity:'', tip:'', q:''}};
|
||||||
|
|
||||||
@@ -1858,6 +2247,7 @@ async function runForensicScan(){
|
|||||||
|
|
||||||
//=========== INIT ===========
|
//=========== INIT ===========
|
||||||
function init(){
|
function init(){
|
||||||
|
restoreSidebar();
|
||||||
buildNav();
|
buildNav();
|
||||||
navTo('dashboard');
|
navTo('dashboard');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user