#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: routers/clanarine_router.py | v1.0.0 | 04.05.2026 # Autor: Damir Radulić / 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), }