"""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 from app.models.proposition import Proposition 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"{c.date_collecte}{float(c.montant):.2f} ${saisi.prenom if saisi else '-'}" rows_depenses = "" for d in depenses: qui = await db.get(Membre, d.qui) if d.qui else None badge = '✓' if d.paye else '✗ DÛ' rows_depenses += f"{d.date_depense}{float(d.montant):.2f} ${qui.prenom if qui else '-'}{d.quoi}{d.methode}{badge}" html_content = f"""
{groupe.nom if groupe else 'Groupe'}
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}

Rapport financier — {MOIS_FR[mois]} {annee}

Collectes (7e Tradition)

{rows_collectes if rows_collectes else ''}
DateMontantSaisi par
Aucune collecte ce mois

Total collectes : {total_collectes:.2f} $

Dépenses

{rows_depenses if rows_depenses else ''}
DateMontantQuiQuoiMéthodePayé
Aucune dépense ce mois

Total dépenses : {total_depenses:.2f} $

Sommaire

Collectes{total_collectes:.2f} $
Dépenses−{total_depenses:.2f} $
Solde du mois{'+'if solde>=0 else ''}{solde:.2f} $
""" 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"{r.date_reunion.strftime('%Y-%m-%d')}{r.type}{anim.prenom if anim else '-'}{r.theme or '-'}{annonces_str}" html_content = f"""
{groupe.nom if groupe else 'Groupe'}
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}

Réunions — {MOIS_FR[mois]} {annee}

{rows if rows else ''}
DateTypeAnimateurThèmeAnnonces
Aucune réunion enregistrée ce mois

{len(reunions)} réunion(s) enregistrée(s)

""" 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("/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()] @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), } @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 = '✓ Adoptée' if p.statut == "adoptee" else '✗ Rejetée' rows += f""" {p.numero_resolution or '—'} {p.date_vote or '—'} {p.sujet} {proposeur.prenom if proposeur else '—'} {secondeur.prenom if secondeur else '—'} {badge} """ 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"""
{groupe.nom if groupe else 'Groupe'}
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}

Registre des résolutions — {annee}

{rows if rows else ''}
N°DateSujetProposeurSecondeurRésultat
Aucune résolution pour cette année

{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)

""" 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.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)) 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(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_depots - depenses_cash # ── 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") if filtre_depuis: q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis) q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis) depots_depuis = float((await db.execute(q_depots)).scalar()) retraits_depuis = float((await db.execute(q_retraits)).scalar()) solde_banque = solde_releve + depots_depuis - retraits_depuis # ── Réserves ── reserve_prudente = 500.0 mois_depuis = 0 if groupe and groupe.cree_le: from datetime import date as d aujourdhui = d.today() cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le mois_depuis = max(0, (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month)) reserve_anniversaire = mois_depuis * 20.0 disponible = solde_banque - total_engagements - reserve_prudente - reserve_anniversaire # ── 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(max(0, encaisse), 2), "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": { "prudente": reserve_prudente, "anniversaire": round(reserve_anniversaire, 2), "total": round(reserve_prudente + reserve_anniversaire, 2), }, "resultat_net": round(revenus - total_depenses_payees - total_contributions, 2), "dernieres_collectes": dernieres_collectes, "dernieres_depenses": dernieres_depenses, }