Files
pgz-sport/routers/clanarine_router.py
T
CC4 3e5b98a935 CC4: 3-subagent backend hardening done + CRM audit_log fix
Sub1 (commit eb1b49f): 4 v2 listing/discovery endpoints + SQL fix
Sub2: CRM 4 modula PASS (M7 članarine, M8 liječnički, M9 obrasci, dokumenti partial)
Sub3: ERP 4 modula GREEN — racuni/putni/placanja/xlsx, E2E demo flow (7 steps) PASS

Critical fix this commit:
- erp/audit_helper.py (centralni helper za audit_log writer)
- routers/clanarine_router.py: audit hook na POST /clanarine
- routers/lijecnicki_router.py: audit hook na POST /lijecnicki
- routers/obrasci_router.py: audit hook na POST /submissions + /submit

Verify: prije 0 / poslije 1 audit entry za POST /api/crm/clanarine
   "33|create|api|clan=4946 klub=2320 300.0€"

Outstanding (next round):
- /api/v2/dokumenti plain route shadowing with RAG
- /api/v2/dokumenti/upload missing
- SQL alias bug u pgz_sport_v2_router.py:3099

Reports:
  _audit/audit_CC4_FINAL.md  (konsolidirani)
  _audit/audit_CRM_VERIFIED.md
  _audit/audit_ERP_VERIFIED.md
  _audit/audit_ENDPOINTS_ADDED.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:28:49 +02:00

531 lines
21 KiB
Python

#!/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()
try:
from erp.audit_helper import audit as _audit
_audit("pgz_sport.clanarine", "create", r["id"],
korisnik="api", field="iznos_propisan",
new=f"clan={body.clan_id} klub={klub_id} {body.iznos_propisan}")
except Exception: pass
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),
}