306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
|
||
import io
|
||
from datetime import date, datetime
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from fastapi.responses import StreamingResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, extract
|
||
|
||
from app.core.database import get_db
|
||
from app.core.security import get_current_membre
|
||
from app.models.groupe import Groupe
|
||
from app.models.collecte import Collecte
|
||
from app.models.depense import Depense
|
||
from app.models.membre import Membre
|
||
from app.models.reunion import Reunion
|
||
from app.models.envoi_contribution import EnvoiContribution
|
||
|
||
router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"])
|
||
|
||
MOIS_FR = [
|
||
"", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||
]
|
||
|
||
|
||
def _css():
|
||
return """
|
||
body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; }
|
||
h1 { color: #1a5276; font-size: 16pt; margin-bottom: 4pt; }
|
||
h2 { color: #1a5276; font-size: 13pt; border-bottom: 2px solid #1a5276; padding-bottom: 4pt; margin-top: 20pt; }
|
||
.header { text-align: center; margin-bottom: 20pt; }
|
||
.header .groupe { font-size: 18pt; font-weight: bold; color: #1a5276; }
|
||
.header .sub { font-size: 9pt; color: #888; }
|
||
table { width: 100%; border-collapse: collapse; margin-top: 8pt; }
|
||
th { background: #1a5276; color: white; padding: 6pt 8pt; text-align: left; font-size: 9pt; }
|
||
td { padding: 5pt 8pt; border-bottom: 1px solid #ddd; font-size: 10pt; }
|
||
tr:nth-child(even) { background: #f8f9fa; }
|
||
.total { font-weight: bold; font-size: 12pt; margin-top: 10pt; }
|
||
.footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 30pt; }
|
||
.badge-paye { color: #27ae60; }
|
||
.badge-impaye { color: #e74c3c; }
|
||
"""
|
||
|
||
|
||
@router.get("/finances/{annee}/{mois}")
|
||
async def rapport_finances(
|
||
annee: int,
|
||
mois: int,
|
||
membre=Depends(get_current_membre),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""C1 — Rapport financier mensuel en PDF."""
|
||
try:
|
||
from weasyprint import HTML
|
||
except ImportError:
|
||
raise HTTPException(status_code=500, detail="WeasyPrint non installé. Installer avec: pip install weasyprint")
|
||
|
||
groupe = await db.get(Groupe, membre.groupe_id)
|
||
|
||
# Collectes du mois
|
||
result = await db.execute(
|
||
select(Collecte).where(
|
||
Collecte.groupe_id == membre.groupe_id,
|
||
extract("year", Collecte.date_collecte) == annee,
|
||
extract("month", Collecte.date_collecte) == mois,
|
||
).order_by(Collecte.date_collecte)
|
||
)
|
||
collectes = result.scalars().all()
|
||
|
||
# Dépenses du mois
|
||
result = await db.execute(
|
||
select(Depense).where(
|
||
Depense.groupe_id == membre.groupe_id,
|
||
extract("year", Depense.date_depense) == annee,
|
||
extract("month", Depense.date_depense) == mois,
|
||
).order_by(Depense.date_depense)
|
||
)
|
||
depenses = result.scalars().all()
|
||
|
||
total_collectes = sum(float(c.montant) for c in collectes)
|
||
total_depenses = sum(float(d.montant) for d in depenses)
|
||
solde = total_collectes - total_depenses
|
||
|
||
# Construire le HTML
|
||
rows_collectes = ""
|
||
for c in collectes:
|
||
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
|
||
rows_collectes += f"<tr><td>{c.date_collecte}</td><td>{float(c.montant):.2f} $</td><td>{saisi.prenom if saisi else '-'}</td></tr>"
|
||
|
||
rows_depenses = ""
|
||
for d in depenses:
|
||
qui = await db.get(Membre, d.qui) if d.qui else None
|
||
badge = '<span class="badge-paye">✓</span>' if d.paye else '<span class="badge-impaye">✗ DÛ</span>'
|
||
rows_depenses += f"<tr><td>{d.date_depense}</td><td>{float(d.montant):.2f} $</td><td>{qui.prenom if qui else '-'}</td><td>{d.quoi}</td><td>{d.methode}</td><td>{badge}</td></tr>"
|
||
|
||
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
|
||
<div class="header">
|
||
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
|
||
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
|
||
</div>
|
||
<h1>Rapport financier — {MOIS_FR[mois]} {annee}</h1>
|
||
|
||
<h2>Collectes (7e Tradition)</h2>
|
||
<table><tr><th>Date</th><th>Montant</th><th>Saisi par</th></tr>
|
||
{rows_collectes if rows_collectes else '<tr><td colspan="3">Aucune collecte ce mois</td></tr>'}
|
||
</table>
|
||
<p class="total">Total collectes : {total_collectes:.2f} $</p>
|
||
|
||
<h2>Dépenses</h2>
|
||
<table><tr><th>Date</th><th>Montant</th><th>Qui</th><th>Quoi</th><th>Méthode</th><th>Payé</th></tr>
|
||
{rows_depenses if rows_depenses else '<tr><td colspan="6">Aucune dépense ce mois</td></tr>'}
|
||
</table>
|
||
<p class="total">Total dépenses : {total_depenses:.2f} $</p>
|
||
|
||
<h2>Sommaire</h2>
|
||
<table>
|
||
<tr><td><strong>Collectes</strong></td><td><strong>{total_collectes:.2f} $</strong></td></tr>
|
||
<tr><td><strong>Dépenses</strong></td><td><strong>−{total_depenses:.2f} $</strong></td></tr>
|
||
<tr><td><strong>Solde du mois</strong></td><td><strong>{'+'if solde>=0 else ''}{solde:.2f} $</strong></td></tr>
|
||
</table>
|
||
|
||
<div class="footer">
|
||
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre
|
||
</div>
|
||
</body></html>"""
|
||
|
||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||
filename = f"rapport_finances_{groupe.nom.replace(' ','_')}_{annee}_{mois:02d}.pdf"
|
||
|
||
return StreamingResponse(
|
||
io.BytesIO(pdf_bytes),
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||
)
|
||
|
||
|
||
@router.get("/reunions/{annee}/{mois}")
|
||
async def rapport_reunions(
|
||
annee: int,
|
||
mois: int,
|
||
membre=Depends(get_current_membre),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""C1 — Résumé des réunions du mois en PDF."""
|
||
try:
|
||
from weasyprint import HTML
|
||
except ImportError:
|
||
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
|
||
|
||
groupe = await db.get(Groupe, membre.groupe_id)
|
||
|
||
result = await db.execute(
|
||
select(Reunion).where(
|
||
Reunion.groupe_id == membre.groupe_id,
|
||
extract("year", Reunion.date_reunion) == annee,
|
||
extract("month", Reunion.date_reunion) == mois,
|
||
).order_by(Reunion.date_reunion)
|
||
)
|
||
reunions = result.scalars().all()
|
||
|
||
rows = ""
|
||
for r in reunions:
|
||
anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None
|
||
annonces_str = ", ".join(r.annonces) if r.annonces else "—"
|
||
rows += f"<tr><td>{r.date_reunion.strftime('%Y-%m-%d')}</td><td>{r.type}</td><td>{anim.prenom if anim else '-'}</td><td>{r.theme or '-'}</td><td>{annonces_str}</td></tr>"
|
||
|
||
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
|
||
<div class="header">
|
||
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
|
||
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
|
||
</div>
|
||
<h1>Réunions — {MOIS_FR[mois]} {annee}</h1>
|
||
<table><tr><th>Date</th><th>Type</th><th>Animateur</th><th>Thème</th><th>Annonces</th></tr>
|
||
{rows if rows else '<tr><td colspan="5">Aucune réunion enregistrée ce mois</td></tr>'}
|
||
</table>
|
||
<p class="total">{len(reunions)} réunion(s) enregistrée(s)</p>
|
||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||
</body></html>"""
|
||
|
||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||
filename = f"rapport_reunions_{annee}_{mois:02d}.pdf"
|
||
|
||
return StreamingResponse(
|
||
io.BytesIO(pdf_bytes),
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||
)
|
||
|
||
|
||
@router.get("/disponibles")
|
||
async def rapports_disponibles(
|
||
membre=Depends(get_current_membre),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Liste les mois pour lesquels des rapports sont disponibles."""
|
||
# Mois avec collectes
|
||
result = await db.execute(
|
||
select(
|
||
extract("year", Collecte.date_collecte).label("annee"),
|
||
extract("month", Collecte.date_collecte).label("mois"),
|
||
).where(Collecte.groupe_id == membre.groupe_id)
|
||
.group_by("annee", "mois")
|
||
.order_by(extract("year", Collecte.date_collecte).desc(), extract("month", Collecte.date_collecte).desc())
|
||
)
|
||
mois_finances = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||
|
||
# Mois avec réunions
|
||
result = await db.execute(
|
||
select(
|
||
extract("year", Reunion.date_reunion).label("annee"),
|
||
extract("month", Reunion.date_reunion).label("mois"),
|
||
).where(Reunion.groupe_id == membre.groupe_id)
|
||
.group_by("annee", "mois")
|
||
.order_by(extract("year", Reunion.date_reunion).desc(), extract("month", Reunion.date_reunion).desc())
|
||
)
|
||
mois_reunions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||
|
||
# Mois avec contributions
|
||
result = await db.execute(
|
||
select(
|
||
extract("year", EnvoiContribution.date_envoi).label("annee"),
|
||
extract("month", EnvoiContribution.date_envoi).label("mois"),
|
||
).where(EnvoiContribution.groupe_id == membre.groupe_id)
|
||
.group_by("annee", "mois")
|
||
)
|
||
mois_contributions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||
|
||
# Combiner et dédupliquer
|
||
all_mois = {}
|
||
for m in mois_finances:
|
||
key = f"{m['annee']}-{m['mois']:02d}"
|
||
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": True, "reunions": False, "contributions": False}
|
||
for m in mois_reunions:
|
||
key = f"{m['annee']}-{m['mois']:02d}"
|
||
if key in all_mois:
|
||
all_mois[key]["reunions"] = True
|
||
else:
|
||
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": True, "contributions": False}
|
||
for m in mois_contributions:
|
||
key = f"{m['annee']}-{m['mois']:02d}"
|
||
if key in all_mois:
|
||
all_mois[key]["contributions"] = True
|
||
else:
|
||
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": False, "contributions": True}
|
||
|
||
return sorted(all_mois.values(), key=lambda x: (x["annee"], x["mois"]), reverse=True)
|
||
|
||
|
||
@router.get("/financier-complet/{annee}/{mois}")
|
||
async def rapport_financier_complet(
|
||
annee: int,
|
||
mois: int,
|
||
membre=Depends(get_current_membre),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Rapport financier complet incluant collectes, dépenses et contributions."""
|
||
gid = membre.groupe_id
|
||
|
||
# Collectes
|
||
result = await db.execute(
|
||
select(Collecte).where(
|
||
Collecte.groupe_id == gid,
|
||
extract("year", Collecte.date_collecte) == annee,
|
||
extract("month", Collecte.date_collecte) == mois,
|
||
)
|
||
)
|
||
collectes = result.scalars().all()
|
||
total_collectes = sum(float(c.montant) for c in collectes)
|
||
|
||
# Dépenses
|
||
result = await db.execute(
|
||
select(Depense).where(
|
||
Depense.groupe_id == gid,
|
||
extract("year", Depense.date_depense) == annee,
|
||
extract("month", Depense.date_depense) == mois,
|
||
)
|
||
)
|
||
depenses = result.scalars().all()
|
||
total_depenses = sum(float(d.montant) for d in depenses)
|
||
depenses_impayees = sum(float(d.montant) for d in depenses if not d.paye)
|
||
|
||
# Contributions
|
||
result = await db.execute(
|
||
select(EnvoiContribution).where(
|
||
EnvoiContribution.groupe_id == gid,
|
||
extract("year", EnvoiContribution.date_envoi) == annee,
|
||
extract("month", EnvoiContribution.date_envoi) == mois,
|
||
)
|
||
)
|
||
contributions = result.scalars().all()
|
||
total_contributions = sum(float(c.montant) for c in contributions)
|
||
|
||
solde = total_collectes - total_depenses - total_contributions
|
||
|
||
return {
|
||
"mois": mois,
|
||
"annee": annee,
|
||
"total_collectes": round(total_collectes, 2),
|
||
"total_depenses": round(total_depenses, 2),
|
||
"total_contributions": round(total_contributions, 2),
|
||
"solde_mois": round(solde, 2),
|
||
"nb_collectes": len(collectes),
|
||
"nb_depenses": len(depenses),
|
||
"nb_contributions": len(contributions),
|
||
"depenses_impayees": round(depenses_impayees, 2),
|
||
}
|