diff --git a/crm/__init__.py b/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crm/payments.py b/crm/payments.py new file mode 100644 index 0000000..05c6dbe --- /dev/null +++ b/crm/payments.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════ +# Fajl: crm/payments.py | v1.0.0 | 04.05.2026 +# Autor: Damir Radulić / 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}", + } diff --git a/pgz_sport_api.py b/pgz_sport_api.py index b097224..597b05b 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -1400,7 +1400,27 @@ try: 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}') diff --git a/routers/clanarine_router.py b/routers/clanarine_router.py new file mode 100644 index 0000000..d06a6f3 --- /dev/null +++ b/routers/clanarine_router.py @@ -0,0 +1,524 @@ +#!/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() + 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), + }