groupe-meditation/backend/app/routers/rapports.py

558 lines
23 KiB
Python
Raw Normal View History

2026-03-04 14:01:00 -05:00
"""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
2026-04-17 04:04:04 -04:00
from app.models.proposition import Proposition
2026-03-04 14:01:00 -05:00
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; }
.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,
EnvoiContribution.debitee == True,
)
2026-03-04 14:01:00 -05:00
.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)
2026-04-17 04:04:04 -04:00
@router.get("/resolutions/annees")
async def annees_resolutions(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Années pour lesquelles des résolutions existent."""
result = await db.execute(
select(extract("year", Proposition.cree_le).label("annee"))
.where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut.in_(["adoptee", "rejetee"]),
)
.group_by("annee")
.order_by(extract("year", Proposition.cree_le).desc())
)
return [{"annee": int(r.annee)} for r in result.all()]
2026-03-04 14:01:00 -05:00
@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,
EnvoiContribution.debitee == True,
2026-03-04 14:01:00 -05:00
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),
}
2026-04-17 04:04:04 -04:00
@router.get("/resolutions/{annee}")
async def rapport_resolutions(
annee: int,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C1 — Registre des résolutions adoptées 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(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut.in_(["adoptee", "rejetee"]),
extract("year", Proposition.cree_le) == annee,
).order_by(Proposition.numero_resolution, Proposition.cree_le)
)
propositions = result.scalars().all()
rows = ""
for p in propositions:
proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
badge = '<span class="badge-paye">✓ Adoptée</span>' if p.statut == "adoptee" else '<span class="badge-impaye">✗ Rejetée</span>'
rows += f"""<tr>
<td>{p.numero_resolution or '—'}</td>
<td>{p.date_vote or '—'}</td>
<td>{p.sujet}</td>
<td>{proposeur.prenom if proposeur else '—'}</td>
<td>{secondeur.prenom if secondeur else '—'}</td>
<td>{badge}</td>
</tr>"""
nb_adoptees = sum(1 for p in propositions if p.statut == "adoptee")
nb_rejetees = sum(1 for p in propositions if p.statut == "rejetee")
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>Registre des résolutions — {annee}</h1>
<table>
<tr><th>N°</th><th>Date</th><th>Sujet</th><th>Proposeur</th><th>Secondeur</th><th>Résultat</th></tr>
{rows if rows else '<tr><td colspan="6">Aucune résolution pour cette année</td></tr>'}
</table>
<p class="total">{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)</p>
<div class="footer">
Registre 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"registre_resolutions_{groupe.nom.replace(' ', '_')}_{annee}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@router.get("/etat-financier")
async def etat_financier_temps_reel(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""État des résultats et transactions en temps réel (JSON)."""
from sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
2026-04-17 04:04:04 -04:00
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
gid = membre.groupe_id
groupe = await db.get(Groupe, gid)
# ── Dernier relevé bancaire ──
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type == "releve",
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
# ── Revenus ──
result = await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == gid, Collecte.statut == "recu"))
total_collectes = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
total_ventes = float(result.scalar())
revenus = total_collectes + total_ventes
# ── Dépenses ──
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True))
total_depenses_payees = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == False, Depense.confirme_par.isnot(None)))
total_engagements = float(result.scalar())
# ── Contributions ──
result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
))
2026-04-17 04:04:04 -04:00
total_contributions = float(result.scalar())
# ── Encaisse ──
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
total_depots = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
total_retraits = float(result.scalar())
2026-04-17 04:04:04 -04:00
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
depenses_cash = float(result.scalar())
encaisse = total_collectes + total_ventes + total_retraits - total_depots - depenses_cash
2026-04-17 04:04:04 -04:00
# ── Solde banque ──
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")
q_depenses_bancaires = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
q_contributions = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
2026-04-17 04:04:04 -04:00
if filtre_depuis:
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
q_depenses_bancaires = q_depenses_bancaires.where(Depense.date_confirmation > filtre_depuis)
q_contributions = q_contributions.where(EnvoiContribution.date_envoi > filtre_depuis)
2026-04-17 04:04:04 -04:00
depots_depuis = float((await db.execute(q_depots)).scalar())
retraits_depuis = float((await db.execute(q_retraits)).scalar())
depenses_bancaires_depuis = float((await db.execute(q_depenses_bancaires)).scalar())
contributions_depuis = float((await db.execute(q_contributions)).scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis - depenses_bancaires_depuis - contributions_depuis
2026-04-17 04:04:04 -04:00
# ── Réserves ──
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
Reserve.active == True,
).order_by(Reserve.nom)
)
reserves_details = [
{"id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note}
for r in result.scalars().all()
]
total_reserves = sum(r["montant"] for r in reserves_details)
2026-04-17 04:04:04 -04:00
disponible = solde_banque - total_engagements - total_reserves
2026-04-17 04:04:04 -04:00
# ── Dernières transactions ──
result = await db.execute(
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
.order_by(desc(Collecte.date_collecte)).limit(10))
dernieres_collectes = [{
"date": str(c.date_collecte), "montant": float(c.montant),
} for c in result.scalars().all()]
result = await db.execute(
select(Depense).where(Depense.groupe_id == gid)
.order_by(desc(Depense.date_depense)).limit(10))
dernieres_depenses = [{
"date": str(d.date_depense), "montant": float(d.montant),
"quoi": d.quoi, "paye": d.paye, "methode": d.methode,
} for d in result.scalars().all()]
return {
"groupe": groupe.nom if groupe else "?",
"date_releve": str(dernier_releve.date_operation) if dernier_releve else None,
"solde_releve": round(solde_releve, 2),
"solde_banque": round(solde_banque, 2),
"encaisse": round(encaisse, 2),
2026-04-17 04:04:04 -04:00
"disponible": round(disponible, 2),
"revenus": {
"collectes": round(total_collectes, 2),
"ventes_litterature": round(total_ventes, 2),
"total": round(revenus, 2),
},
"sorties": {
"depenses_payees": round(total_depenses_payees, 2),
"engagements": round(total_engagements, 2),
"contributions": round(total_contributions, 2),
"total": round(total_depenses_payees + total_contributions, 2),
},
"reserves": {
"details": reserves_details,
"total": round(total_reserves, 2),
2026-04-17 04:04:04 -04:00
},
"resultat_net": round(revenus - total_depenses_payees - total_contributions, 2),
"dernieres_collectes": dernieres_collectes,
"dernieres_depenses": dernieres_depenses,
}