#!/usr/bin/env python3 # ═══════════════════════════════════════════════════════════════════ # Fajl: routers/crm_extras_router.py | v1.0.0 | 05.05.2026 # Autor: Damir Radulić / damir@rinet.one # Lokacija: /opt/pgz-sport/routers/crm_extras_router.py # Svrha: R5 — bulk akcije za članarine, XLSX export članova, /crm/stats, # notifikacije za isteke liječničkih (Email + InApp) # ═══════════════════════════════════════════════════════════════════ """R5 CRM extras. Endpointi (montirani na /api/crm): POST /clanarine/bulk/notify → opomena svim koji duguju (mock email + InApp) POST /clanarine/bulk/uplatnice → batch HUB-3 PDF (zip ili JSON s URL-ovima) GET /clanovi/export.xlsx → XLSX svih članova (filteri klub, aktivan) GET /stats → aktivni vs neaktivni, trend uplata, ... POST /lijecnicki/notify-scan → skenira pretvorbe < N dana, kreira notifikacije GET /notifications → lista (filter user/status/channel) POST /notifications/{id}/read → mark read POST /notifications/mark-all-read → mark all read za usera """ from __future__ import annotations import io import json as _json import sys from datetime import date, datetime, timedelta from decimal import Decimal from typing import Optional import psycopg2 from psycopg2.extras import RealDictCursor from fastapi import APIRouter, HTTPException, Query from fastapi.responses import Response from pydantic import BaseModel import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side sys.path.insert(0, "/opt/pgz-sport") from crm.payments import ( build_hub3_pdf, make_poziv_na_broj, normalize_iban, ) router = APIRouter(prefix="/api/crm", tags=["crm-extras"]) DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7" # Pragovi za scan liječničkih (dana do isteka) LIJEC_THRESHOLDS = (30, 15, 7) def _conn(): return psycopg2.connect(DSN, cursor_factory=RealDictCursor) def _conv(v): if isinstance(v, (date, datetime)): return v.isoformat() if isinstance(v, Decimal): return float(v) return v def _row(d): return None if d is None else {k: _conv(v) for k, v in dict(d).items()} # ════════════════════════════════════════════════════ # #3 — BULK AKCIJE ZA ČLANARINE # ════════════════════════════════════════════════════ class BulkOpomenaIn(BaseModel): klub_id: Optional[int] = None godina: Optional[int] = None ids: Optional[list[int]] = None # specifične clanarina ID template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu." @router.post("/clanarine/bulk/notify") def bulk_opomena(body: BulkOpomenaIn): """Pošalji opomenu (mock e-mail + InApp notification) svim dužnicima.""" where = ["c.status IN ('nepodmireno','djelomicno')"] params: list = [] if body.ids: where.append("c.id = ANY(%s)"); params.append(body.ids) 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) with _conn() as conn, conn.cursor() as cur: cur.execute(f""" SELECT c.id, c.godina, c.iznos_propisan, (c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug, cl.id AS clan_id, cl.ime || ' ' || cl.prezime AS clan, cl.email AS clan_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 1000 """, params) rows = [_row(r) for r in cur.fetchall()] # Insert notifications za one s e-mailom n_email, n_inapp = 0, 0 for r in rows: subject = f"Opomena: nepodmirena članarina {r['godina']} ({r['dug']:.2f} €)" body_txt = (f"{body.template}\n\n" f"Klub: {r.get('klub')}\n" f"Iznos duga: {r['dug']:.2f} EUR\n" f"Godina: {r['godina']}\n\n" f"PGŽ Sport ERP/CRM") meta = _json.dumps({ "clanarina_id": r["id"], "clan_id": r["clan_id"], "iznos_dug": float(r["dug"]), "uplatnica_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf", }) # InApp uvijek cur.execute("""INSERT INTO pgz_sport.notifications (channel, subject, body, status, scheduled_at, meta) VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)""", (subject, body_txt, meta)) n_inapp += 1 # Email mock — samo log if r.get("clan_email"): cur.execute("""INSERT INTO pgz_sport.notifications (channel, subject, body, status, scheduled_at, meta) VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)""", (subject, body_txt, _json.dumps({**_json.loads(meta), "to": r["clan_email"]}))) n_email += 1 conn.commit() return { "ok": True, "matched": len(rows), "queued_inapp": n_inapp, "queued_email": n_email, "note": "Mock — SMTP nije konfiguriran; e-mail je upisan u notifications tablicu sa status='pending'.", "recipients_preview": rows[:20], } class BulkUplatniceIn(BaseModel): ids: Optional[list[int]] = None klub_id: Optional[int] = None godina: Optional[int] = None @router.post("/clanarine/bulk/uplatnice") def bulk_uplatnice(body: BulkUplatniceIn): """ Vraća JSON s listom uplatnica + linkovima na pojedinačne PDF-ove. (PDF-ovi se generiraju on-demand kroz /clanarine/{id}/uplatnica.pdf.) """ where = ["c.status IN ('nepodmireno','djelomicno')"] params: list = [] if body.ids: where = ["c.id = ANY(%s)"]; params = [body.ids] else: 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) with _conn() as conn, conn.cursor() as cur: cur.execute(f""" SELECT c.id, c.godina, 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, k.naziv AS klub, k.iban AS klub_iban 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 k.naziv, cl.prezime LIMIT 500 """, params) rows = [_row(r) for r in cur.fetchall()] return { "ok": True, "count": len(rows), "total_dug_eur": round(sum(float(r["dug"] or 0) for r in rows), 2), "uplatnice": [{ "id": r["id"], "clan": r["clan"], "klub": r["klub"], "godina": r["godina"], "iznos_eur": float(r["dug"] or 0), "pdf_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf", "qr_url": f"/sport/api/crm/clanarine/{r['id']}/qr.png", } for r in rows], } # ════════════════════════════════════════════════════ # #4 — XLSX EXPORT ČLANOVA # ════════════════════════════════════════════════════ @router.get("/clanovi/export.xlsx") def export_clanovi_xlsx( klub_id: Optional[int] = Query(None), aktivan: Optional[bool] = Query(None), sport: Optional[str] = Query(None), q: Optional[str] = Query(None), limit: int = Query(5000, le=20000), ): where, params = ["1=1"], [] if klub_id: where.append("c.klub_id = %s"); params.append(klub_id) if aktivan is not None: where.append("c.aktivan = %s"); params.append(aktivan) if sport: where.append("(c.sport ILIKE %s OR k.sport ILIKE %s)"); params += [f"%{sport}%", f"%{sport}%"] if q: where.append("(c.ime || ' ' || c.prezime) ILIKE %s"); params.append(f"%{q}%") params.append(limit) where_sql = "WHERE " + " AND ".join(where) sql = f""" SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.email, c.telefon, c.adresa, c.grad, c.postanski_broj, c.kategorija, c.podkategorija, c.pozicija, c.broj_dresa, c.visina_cm, c.tezina_kg, c.dominantna_noga, c.aktivan, c.datum_pristupa, c.reprezentativac, c.kategoriziran, c.kategorija_hoo, c.stipendiran, c.stipendija_iznos, c.licenca_broj, c.licenca_vrijedi_do, k.naziv AS klub, k.oib AS klub_oib, s.naziv AS savez FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id {where_sql} ORDER BY k.naziv NULLS LAST, c.prezime, c.ime LIMIT %s """ with _conn() as conn, conn.cursor() as cur: cur.execute(sql, params) rows = [_row(r) for r in cur.fetchall()] wb = openpyxl.Workbook() ws = wb.active ws.title = "Članovi PGŽ" headers = [ "ID", "Ime", "Prezime", "OIB", "Datum rođ.", "Spol", "E-mail", "Telefon", "Adresa", "Grad", "Pošt.", "Kategorija", "Podkat.", "Pozicija", "Dres", "Vis. (cm)", "Tež. (kg)", "Dom. noga", "Aktivan", "Datum prist.", "Repr.", "Kategoriziran", "HOO kat.", "Stipendiran", "Stipendija (€)", "Licenca", "Licenca do", "Klub", "OIB kluba", "Savez", ] for col, h in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=h) cell.font = Font(bold=True, color="FFFFFF", size=10) cell.fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid") cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = Border(bottom=Side(border_style="thin", color="FFFFFF")) keys = [ "id", "ime", "prezime", "oib", "datum_rodenja", "spol", "email", "telefon", "adresa", "grad", "postanski_broj", "kategorija", "podkategorija", "pozicija", "broj_dresa", "visina_cm", "tezina_kg", "dominantna_noga", "aktivan", "datum_pristupa", "reprezentativac", "kategoriziran", "kategorija_hoo", "stipendiran", "stipendija_iznos", "licenca_broj", "licenca_vrijedi_do", "klub", "klub_oib", "savez", ] for ridx, r in enumerate(rows, start=2): for cidx, k in enumerate(keys, 1): v = r.get(k) if isinstance(v, bool): v = "DA" if v else "NE" ws.cell(row=ridx, column=cidx, value=v) # Auto column widths col_letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + ["AA", "AB", "AC", "AD", "AE", "AF"] for col_letter, h in zip(col_letters, headers): ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4)) ws.freeze_panes = "A2" ws.auto_filter.ref = ws.dimensions buf = io.BytesIO() wb.save(buf) fname = f"clanovi-pgz-{date.today().isoformat()}.xlsx" return Response( content=buf.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) # ════════════════════════════════════════════════════ # #5 — /crm/stats # ════════════════════════════════════════════════════ @router.get("/stats") def crm_stats(klub_id: Optional[int] = Query(None)): """Aktivni/neaktivni članovi, trend uplata, KPI summary.""" klub_filter = "AND klub_id = %s" if klub_id else "" klub_params = [klub_id] if klub_id else [] with _conn() as conn, conn.cursor() as cur: # aktivni vs neaktivni cur.execute(f""" SELECT COUNT(*) FILTER (WHERE aktivan = TRUE) AS aktivni, COUNT(*) FILTER (WHERE aktivan = FALSE) AS neaktivni, COUNT(*) AS total, COUNT(*) FILTER (WHERE reprezentativac = TRUE) AS reprezentativci, COUNT(*) FILTER (WHERE kategoriziran = TRUE) AS kategorizirani, COUNT(*) FILTER (WHERE stipendiran = TRUE) AS stipendirani FROM pgz_sport.clanovi WHERE 1=1 {klub_filter} """, klub_params) clanovi_summary = _row(cur.fetchone()) # po spolu cur.execute(f""" SELECT spol, COUNT(*) AS n FROM pgz_sport.clanovi WHERE aktivan = TRUE {klub_filter} GROUP BY spol ORDER BY n DESC """, klub_params) po_spolu = [_row(r) for r in cur.fetchall()] # po kategoriji cur.execute(f""" SELECT COALESCE(kategorija, '(nepoznato)') AS kategorija, COUNT(*) AS n FROM pgz_sport.clanovi WHERE aktivan = TRUE {klub_filter} GROUP BY kategorija ORDER BY n DESC LIMIT 12 """, klub_params) po_kategoriji = [_row(r) for r in cur.fetchall()] # trend uplata po mjesecu — zadnjih 12 cur.execute(f""" SELECT to_char(date_trunc('month', datum_uplate), 'YYYY-MM') AS mjesec, COUNT(*) AS broj_uplata, SUM(iznos_placen)::numeric(10,2) AS iznos_total FROM pgz_sport.clanarine WHERE datum_uplate IS NOT NULL AND datum_uplate >= (CURRENT_DATE - INTERVAL '12 months') {('AND klub_id = %s' if klub_id else '')} GROUP BY date_trunc('month', datum_uplate) ORDER BY mjesec """, klub_params) trend_uplata = [_row(r) for r in cur.fetchall()] # članarine summary cur.execute(f""" SELECT COUNT(*) AS total, SUM(iznos_propisan)::numeric(10,2) AS propisan, SUM(iznos_placen)::numeric(10,2) AS placen, SUM(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug, COUNT(*) FILTER (WHERE status='nepodmireno') AS n_nepodmireno, COUNT(*) FILTER (WHERE status='djelomicno') AS n_djelomicno, COUNT(*) FILTER (WHERE status='podmireno') AS n_podmireno FROM pgz_sport.clanarine WHERE 1=1 {klub_filter} """, klub_params) clanarine_summary = _row(cur.fetchone()) # liječnički status cur.execute(f""" SELECT COUNT(*) FILTER (WHERE vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci, COUNT(*) FILTER (WHERE vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro, COUNT(*) FILTER (WHERE vrijedi_do < CURRENT_DATE) AS istekli, COUNT(*) AS total FROM pgz_sport.lijecnicki_pregledi WHERE 1=1 {klub_filter} """, klub_params) lijecnicki_summary = _row(cur.fetchone()) # najnovije uplate (zadnjih 10) cur.execute(f""" SELECT c.id, c.iznos_placen, c.datum_uplate, c.godina, cl.ime||' '||cl.prezime AS clan, k.naziv AS klub 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.datum_uplate IS NOT NULL {klub_filter.replace('klub_id', 'c.klub_id')} ORDER BY c.datum_uplate DESC LIMIT 10 """, klub_params) najnovije_uplate = [_row(r) for r in cur.fetchall()] return { "klub_id": klub_id, "clanovi": clanovi_summary, "po_spolu": po_spolu, "po_kategoriji": po_kategoriji, "trend_uplata_12m": trend_uplata, "clanarine": clanarine_summary, "lijecnicki": lijecnicki_summary, "najnovije_uplate": najnovije_uplate, } # ════════════════════════════════════════════════════ # #6 — NOTIFIKACIJE LIJEČNIČKI ISTECI # ════════════════════════════════════════════════════ class NotifScanIn(BaseModel): klub_id: Optional[int] = None thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS include_expired: bool = True # uključi i one koji su već istekli @router.post("/lijecnicki/notify-scan") def lijecnicki_notify_scan(body: NotifScanIn): """ Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock) za pragove 30/15/7 dana. Ako include_expired=True, isto kreira jednu notifikaciju (threshold=0) za već istekle. Ne duplicira: gleda meta.lijecnicki_id+threshold u zadnjih 7 dana. """ thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True) klub_filter = "AND l.klub_id = %s" if body.klub_id else "" klub_params = [body.klub_id] if body.klub_id else [] # threshold=0 → već istekli (poseban "expired" bucket) scan_buckets = [(thr, "uskoro") for thr in thresholds] if body.include_expired: scan_buckets.append((0, "expired")) created = [] with _conn() as conn, conn.cursor() as cur: for thr, kind in scan_buckets: if kind == "expired": where_window = "(l.vrijedi_do - CURRENT_DATE) < 0" where_params = [] else: where_window = "(l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s" where_params = [thr] cur.execute(f""" SELECT l.id, l.vrijedi_do, l.clan_id, (l.vrijedi_do - CURRENT_DATE)::int AS dana, cl.ime || ' ' || cl.prezime AS clan, cl.email AS clan_email, k.naziv AS klub FROM pgz_sport.lijecnicki_pregledi l LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id WHERE l.vrijedi_do IS NOT NULL AND {where_window} {klub_filter} """, where_params + klub_params) kandidati = [_row(r) for r in cur.fetchall()] for r in kandidati: # de-dup: već postoji notifikacija za ovaj lijec_id+threshold u <7 dana? cur.execute(""" SELECT 1 FROM pgz_sport.notifications WHERE meta->>'lijecnicki_id' = %s AND meta->>'threshold' = %s AND scheduled_at > now() - INTERVAL '7 days' LIMIT 1 """, (str(r["id"]), str(thr))) if cur.fetchone(): continue if r['dana'] is not None and r['dana'] < 0: subject = f"⚠ Liječnički pregled ISTEKAO ({-r['dana']} dana): {r['clan']}" msg_dana = f"istekao prije {-r['dana']} dana" else: subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}" msg_dana = f"{r['dana']} dana ostalo" body_txt = ( f"Liječnički pregled za sportaša {r['clan']} " f"({r.get('klub') or '(bez kluba)'}) — vrijedi do {r['vrijedi_do']} " f"— {msg_dana}.\n\n" f"Molimo zakažite novi termin u ZZJZ PGŽ " f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n" f"PGŽ Sport ERP/CRM" ) meta = _json.dumps({ "lijecnicki_id": r["id"], "clan_id": r["clan_id"], "threshold": thr, "vrijedi_do": str(r["vrijedi_do"]), "dana": r["dana"], "zakazi_url": f"/sport/api/crm/lijecnicki/{r['id']}/zakazi", "klub": r.get("klub"), }) cur.execute("""INSERT INTO pgz_sport.notifications (channel, subject, body, status, scheduled_at, meta) VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb) RETURNING id""", (subject, body_txt, meta)) inapp_id = cur.fetchone()["id"] created.append({"channel": "inapp", "id": inapp_id, "lijec_id": r["id"], "thr": thr}) if r.get("clan_email"): cur.execute("""INSERT INTO pgz_sport.notifications (channel, subject, body, status, scheduled_at, meta) VALUES ('email', %s, %s, 'pending', now(), %s::jsonb) RETURNING id""", (subject, body_txt, _json.dumps({**_json.loads(meta), "to": r["clan_email"]}))) em_id = cur.fetchone()["id"] created.append({"channel": "email", "id": em_id, "lijec_id": r["id"], "thr": thr, "to": r["clan_email"]}) conn.commit() return { "ok": True, "thresholds_dana": thresholds, "created": len(created), "items": created[:50], "note": "Mock — SMTP nije konfiguriran. Email notifikacije su upisane u DB sa status='pending'.", } @router.get("/notifications") def list_notifications( user_id: Optional[int] = Query(None), status: Optional[str] = Query(None, description="pending|sent|read"), channel: Optional[str] = Query(None, description="inapp|email"), limit: int = Query(100, le=500), ): where, params = [], [] if user_id is not None: where.append("user_id = %s"); params.append(user_id) if status: where.append("status = %s"); params.append(status) if channel: where.append("channel = %s"); params.append(channel) where_sql = ("WHERE " + " AND ".join(where)) if where else "" params.append(limit) with _conn() as conn, conn.cursor() as cur: cur.execute(f""" SELECT id, user_id, channel, subject, body, status, scheduled_at, sent_at, read_at, meta FROM pgz_sport.notifications {where_sql} ORDER BY scheduled_at DESC NULLS LAST LIMIT %s """, params) rows = [_row(r) for r in cur.fetchall()] cur.execute(f""" SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status='pending') AS pending, COUNT(*) FILTER (WHERE status='sent') AS sent, COUNT(*) FILTER (WHERE read_at IS NULL AND channel='inapp') AS unread_inapp FROM pgz_sport.notifications {where_sql} """, params[:-1]) summary = _row(cur.fetchone()) return {"count": len(rows), "summary": summary, "rows": rows} @router.post("/notifications/{nid}/read") def mark_read(nid: int): with _conn() as conn, conn.cursor() as cur: cur.execute("""UPDATE pgz_sport.notifications SET read_at = now(), status = 'sent' WHERE id = %s RETURNING id""", (nid,)) r = cur.fetchone() if not r: raise HTTPException(404, "Notifikacija ne postoji") conn.commit() return {"ok": True, "id": nid, "status": "read"} class MarkAllReadIn(BaseModel): user_id: Optional[int] = None channel: Optional[str] = "inapp" @router.post("/notifications/mark-all-read") def mark_all_read(body: MarkAllReadIn): where = ["read_at IS NULL"] params = [] if body.user_id is not None: where.append("user_id = %s"); params.append(body.user_id) if body.channel: where.append("channel = %s"); params.append(body.channel) with _conn() as conn, conn.cursor() as cur: cur.execute(f"""UPDATE pgz_sport.notifications SET read_at = now(), status = 'sent' WHERE {' AND '.join(where)} RETURNING id""", params) ids = [r["id"] for r in cur.fetchall()] conn.commit() return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}