M7 CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR

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

crm/payments.py — HUB-3 PDF generator (ReportLab) + EPC QR (qrcode lib),
poziv-na-broj model HR00 = OIB-godina-id, format_eur HR notation.
This commit is contained in:
Damir Radulić
2026-05-04 23:54:26 +02:00
parent 834b7bf89f
commit 1bd34ed678
4 changed files with 860 additions and 0 deletions
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/clanarine_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/clanarine_router.py
# Svrha: M7 — CRM Članarine: CRUD + dug + uplata + HUB-3 PDF + EPC QR + bulk notify
# ═══════════════════════════════════════════════════════════════════
"""M7 Članarine router.
Endpointi (montirani s prefixom /api/crm):
GET /clanarine → lista (filteri)
POST /clanarine → kreiraj zaduženje
GET /clanarine/{id} → detalji
PUT /clanarine/{id} → update
DELETE /clanarine/{id} → soft delete (status=storno)
POST /clanarine/{id}/uplata → registriraj uplatu
GET /clanarine/dug → svi koji duguju
POST /clanarine/notify-bulk → mock e-mail notifikacija
GET /clanarine/{id}/uplatnica.pdf → HUB-3 PDF
GET /clanarine/{id}/qr.png → EPC QR PNG
"""
from __future__ import annotations
import sys
import re
from datetime import date
from typing import Optional, List
from decimal import Decimal
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, Body
from fastapi.responses import Response
from pydantic import BaseModel, Field
sys.path.insert(0, "/opt/pgz-sport")
from crm.payments import (
build_hub3_pdf, build_epc_qr_png, build_epc_payload,
make_poziv_na_broj, build_bank_deep_links, normalize_iban,
)
router = APIRouter(prefix="/api/crm", tags=["crm-clanarine"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
# Default IBAN PGŽ Sport (placeholder ako klub nema svoj). Realan IBAN PGŽ
# Odjela za sport stavi ovdje kad bude poznat — za sada koristimo neutralni
# format za demo svrhe.
DEFAULT_PRIMATELJ_IBAN = "HR0000000000000000000"
DEFAULT_PRIMATELJ_NAZIV = "PGŽ Odjel za sport"
DEFAULT_PRIMATELJ_ADRESA = "Adamićeva 10, 51000 Rijeka"
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date,)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
def _compute_status(propisan: float, placen: float) -> str:
p, pl = float(propisan or 0), float(placen or 0)
if pl <= 0:
return "nepodmireno"
if pl + 0.005 < p:
return "djelomicno"
return "podmireno"
# ───────────── modeli ─────────────
class ClanarinaIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
godina: int
razdoblje: Optional[str] = "godišnja"
iznos_propisan: float
iznos_placen: Optional[float] = 0
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = None
napomena: Optional[str] = None
class ClanarinaPatch(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
razdoblje: Optional[str] = None
iznos_propisan: Optional[float] = None
iznos_placen: Optional[float] = None
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = None
napomena: Optional[str] = None
status: Optional[str] = None
class UplataIn(BaseModel):
iznos: float = Field(..., gt=0)
datum_uplate: Optional[date] = None
nacin_uplate: Optional[str] = "transakcijski"
referenca: Optional[str] = None
racun_broj: Optional[str] = None
class NotifyBulkIn(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
# ───────────── lista ─────────────
@router.get("/clanarine")
def list_clanarine(
godina: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
status: Optional[str] = Query(None, description="nepodmireno|djelomicno|podmireno|storno"),
sort: str = Query("godina"),
order: str = Query("desc"),
limit: int = Query(500, le=2000),
):
where, params = [], []
if godina:
where.append("c.godina = %s"); params.append(godina)
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
if clan_id:
where.append("c.clan_id = %s"); params.append(clan_id)
if status:
where.append("c.status = %s"); params.append(status)
sort_map = {
"godina": "c.godina",
"iznos": "c.iznos_propisan",
"klub": "k.naziv",
"datum_uplate": "c.datum_uplate",
"status": "c.status",
"dug": "(c.iznos_propisan - COALESCE(c.iznos_placen,0))",
}
sort_col = sort_map.get(sort, "c.godina")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = f"""
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
c.datum_uplate, c.nacin_uplate, c.referenca, c.racun_broj,
c.status, c.napomena, c.created_at, c.updated_at,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY {sort_col} {order_sql}
LIMIT %s
"""
params.append(limit)
sum_sql = f"""
SELECT COUNT(*) AS total,
COALESCE(SUM(c.iznos_propisan), 0)::numeric(10,2) AS total_propisan,
COALESCE(SUM(c.iznos_placen), 0)::numeric(10,2) AS total_placen,
COALESCE(SUM(c.iznos_propisan - COALESCE(c.iznos_placen,0)), 0)::numeric(10,2) AS total_dug,
COUNT(*) FILTER (WHERE c.status='nepodmireno') AS n_nepodmireno,
COUNT(*) FILTER (WHERE c.status='djelomicno') AS n_djelomicno,
COUNT(*) FILTER (WHERE c.status='podmireno') AS n_podmireno
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(sum_sql, params[:-1]) # bez LIMIT
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
# ───────────── dug (samo neplaćene) ─────────────
@router.get("/clanarine/dug")
def list_dug(
klub_id: Optional[int] = Query(None),
godina: Optional[int] = Query(None),
days_overdue: int = Query(0, ge=0,
description="koliko dana mora biti od kreiranja dužnosti (default 0)"),
limit: int = Query(500, le=2000),
):
where = ["c.status IN ('nepodmireno', 'djelomicno')"]
params: list = []
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
if godina:
where.append("c.godina = %s"); params.append(godina)
if days_overdue > 0:
where.append("c.created_at < (now() - (%s || ' days')::interval)")
params.append(str(days_overdue))
where_sql = "WHERE " + " AND ".join(where)
params.append(limit)
sql = f"""
SELECT c.id, c.clan_id, c.klub_id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
c.status, c.created_at,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY (c.iznos_propisan - COALESCE(c.iznos_placen,0)) DESC
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
total_dug = sum(float(r["dug"] or 0) for r in rows)
return {"count": len(rows), "total_dug": round(total_dug, 2), "rows": rows}
# ───────────── detalji ─────────────
@router.get("/clanarine/{cid}")
def get_clanarina(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.*,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
k.adresa AS klub_adresa, k.grad AS klub_grad
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id = %s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
return _row(r)
# ───────────── kreiraj ─────────────
@router.post("/clanarine")
def create_clanarina(body: ClanarinaIn):
klub_id = body.klub_id
with _conn() as conn, conn.cursor() as cur:
if not klub_id:
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
r = cur.fetchone()
klub_id = r["klub_id"] if r else None
status = _compute_status(body.iznos_propisan, body.iznos_placen or 0)
cur.execute("""
INSERT INTO pgz_sport.clanarine
(clan_id, klub_id, godina, razdoblje, iznos_propisan, iznos_placen,
datum_uplate, nacin_uplate, status, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (body.clan_id, klub_id, body.godina, body.razdoblje,
body.iznos_propisan, body.iznos_placen or 0,
body.datum_uplate, body.nacin_uplate, status, body.napomena))
r = cur.fetchone()
conn.commit()
return _row(r)
# ───────────── update / delete ─────────────
@router.put("/clanarine/{cid}")
def update_clanarina(cid: int, patch: ClanarinaPatch):
fields, params = [], []
for f in ("klub_id", "godina", "razdoblje", "iznos_propisan", "iznos_placen",
"datum_uplate", "nacin_uplate", "napomena", "status"):
v = getattr(patch, f)
if v is not None:
fields.append(f"{f} = %s"); params.append(v)
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at = now()")
params.append(cid)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.clanarine SET {', '.join(fields)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
# ako nije eksplicitno postavljen status, izračunaj iz iznos_*
if patch.status is None:
new_status = _compute_status(r["iznos_propisan"], r["iznos_placen"] or 0)
if new_status != r["status"]:
cur.execute("UPDATE pgz_sport.clanarine SET status=%s WHERE id=%s RETURNING *",
(new_status, cid))
r = cur.fetchone()
conn.commit()
return _row(r)
@router.delete("/clanarine/{cid}")
def delete_clanarina(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("UPDATE pgz_sport.clanarine SET status='storno', updated_at=now() WHERE id=%s RETURNING id",
(cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
return {"ok": True, "id": cid, "status": "storno"}
# ───────────── uplata ─────────────
@router.post("/clanarine/{cid}/uplata")
def register_uplata(cid: int, body: UplataIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.clanarine WHERE id=%s", (cid,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Članarina ne postoji")
novi_iznos = float(cur_row["iznos_placen"] or 0) + float(body.iznos)
novi_status = _compute_status(cur_row["iznos_propisan"], novi_iznos)
cur.execute("""
UPDATE pgz_sport.clanarine
SET iznos_placen = %s,
datum_uplate = COALESCE(%s, datum_uplate, now()::date),
nacin_uplate = COALESCE(%s, nacin_uplate),
referenca = COALESCE(%s, referenca),
racun_broj = COALESCE(%s, racun_broj),
status = %s,
updated_at = now()
WHERE id = %s
RETURNING *
""", (novi_iznos, body.datum_uplate, body.nacin_uplate,
body.referenca, body.racun_broj, novi_status, cid))
r = cur.fetchone()
# log u audit_feed (ako postoji); nepoznata schema → silent skip
try:
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
VALUES (%s,%s,%s,%s::jsonb)""",
("clanarina", cid, "uplata",
f'{{"iznos":{body.iznos}, "novi_status":"{novi_status}"}}'))
except Exception:
pass
conn.commit()
return {"ok": True, "id": cid, "iznos_uplata": body.iznos,
"iznos_placen_total": novi_iznos, "status": novi_status,
"clanarina": _row(r)}
# ───────────── notify-bulk (mock email) ─────────────
@router.post("/clanarine/notify-bulk")
def notify_bulk(body: NotifyBulkIn):
"""
Stvarno slanje pošte se još ne implementira (M9 SMTP integration TODO),
ali endpoint vraća listu primatelja koji bi bili kontaktirani.
"""
where = ["c.status IN ('nepodmireno','djelomicno')", "cl.email IS NOT NULL"]
params = []
if body.klub_id:
where.append("c.klub_id=%s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina =%s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
sql = f"""
SELECT c.id, c.godina, c.iznos_propisan,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan, cl.email,
k.naziv AS klub
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY dug DESC
LIMIT 500
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
recipients = [_row(r) for r in cur.fetchall()]
return {
"ok": True,
"queued": len(recipients),
"template": body.template,
"note": "Mock — SMTP nije konfiguriran. Lista primatelja vraćena za pregled.",
"recipients": recipients,
}
# ───────────── HUB-3 PDF + EPC QR ─────────────
@router.get("/clanarine/{cid}/uplatnica.pdf")
def uplatnica_pdf(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
cl.adresa AS clan_adresa, cl.grad AS clan_grad,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban,
k.adresa AS klub_adresa, k.grad AS klub_grad
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
# podmireno — uplatnicu generiramo na cijeli iznos kao podsjetnik
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj_naziv = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
primatelj_adresa = ", ".join(filter(None, [r.get("klub_adresa"), r.get("klub_grad")])) \
or DEFAULT_PRIMATELJ_ADRESA
platitelj_naziv = r["clan"] or "Član"
platitelj_adresa = ", ".join(filter(None, [r.get("clan_adresa"), r.get("clan_grad")])) or ""
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
pdf = build_hub3_pdf(
platitelj_naziv=platitelj_naziv,
platitelj_adresa=platitelj_adresa,
primatelj_naziv=primatelj_naziv,
primatelj_adresa=primatelj_adresa,
iban=iban,
amount_eur=dug,
model="HR00",
poziv_na_broj=poziv,
opis=f"Članarina {r['godina']}{r['razdoblje'] or 'godišnja'}",
sifra_namjene="OTHR",
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f"inline; filename=uplatnica-clanarina-{cid}.pdf"})
@router.get("/clanarine/{cid}/qr.png")
def uplatnica_qr(cid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
payload = build_epc_payload(
primatelj=primatelj, iban=iban, amount_eur=dug,
opis=f"Članarina {r['godina']}", model="HR00", poziv_na_broj=poziv,
)
png = build_epc_qr_png(payload, box_size=10)
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "public, max-age=300"})
@router.get("/clanarine/{cid}/payment-info")
def payment_info(cid: int):
"""Vraća JSON s IBAN, poziv-na-broj, EPC payload i deep linkovima — za UI gumbe."""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.id, c.godina, c.razdoblje,
c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
k.naziv AS klub, k.oib AS klub_oib, k.iban AS klub_iban
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s
""", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Članarina ne postoji")
dug = float(r["dug"] or 0)
if dug <= 0:
dug = float(r["iznos_propisan"] or 0)
iban = normalize_iban(r["klub_iban"] or DEFAULT_PRIMATELJ_IBAN)
primatelj = r["klub"] or DEFAULT_PRIMATELJ_NAZIV
poziv = make_poziv_na_broj(r.get("klub_oib"), int(r["godina"]), int(r["id"]))
opis = f"Članarina {r['godina']}{r['razdoblje'] or 'godišnja'}"
payload = build_epc_payload(primatelj=primatelj, iban=iban, amount_eur=dug,
opis=opis, model="HR00", poziv_na_broj=poziv)
return {
"id": cid,
"iznos_eur": round(dug, 2),
"primatelj": primatelj,
"iban": iban,
"model": "HR00",
"poziv_na_broj": poziv,
"opis": opis,
"epc_payload": payload,
"qr_url": f"/sport/api/crm/clanarine/{cid}/qr.png",
"pdf_url": f"/sport/api/crm/clanarine/{cid}/uplatnica.pdf",
"deep_links": build_bank_deep_links(iban, dug, opis,
model="HR00", poziv_na_broj=poziv),
}