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:
+316
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: crm/payments.py | v1.0.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / 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}",
|
||||
}
|
||||
@@ -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}')
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user