"""Routes C1 — Rapports PDF (finances, PV, rapport RSG).""" import io from html import escape from datetime import date, datetime, time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, extract from app.core.database import get_db from app.core.roles import require_tresorier 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 from app.models.rapport_adopte import RapportAdopte 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", ] class RapportTresorerieAdoption(BaseModel): note: str | None = None proposeur_id: str | None = None secondeur_id: str | None = None def _texte_decision_resolution(p: Proposition) -> str: """Retourne le texte décisionnel si la résolution utilise le format structuré.""" if p.groupe_a_decide_de: return p.groupe_a_decide_de if not p.note: return p.sujet marqueur = "LE GROUPE A DÉCIDÉ DE" if marqueur not in p.note: return p.sujet decision = p.note.split(marqueur, 1)[1].strip() return decision or p.sujet 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'}

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é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, EnvoiContribution.debitee == True, ) .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, EnvoiContribution.debitee == True, 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 numero_date = f"{escape(p.numero_resolution or '—')}
{escape(str(p.date_adoption or p.date_vote or '—'))}" resolution = escape(_texte_decision_resolution(p)).replace("\n", "
") proposeurs = f"{escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else '—')}" rows += f""" {numero_date} {resolution} {proposeurs} """ html_content = f"""
{groupe.nom if groupe else 'Groupe'}
District {groupe.district if groupe else '87-16'}

Registre des résolutions — {annee}

Décisions prises en Groupe

{rows if rows else ''}
N° / dateRésolutionProposeur / secondeur
Aucune résolution pour cette année
""" 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( annee: int | None = None, mois: int | None = None, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """État des résultats par période et soldes courants (JSON).""" from sqlalchemy import func, desc from app.models.operation_bancaire import OperationBancaire from app.models.reserve import Reserve from app.models.transaction_comptable import TransactionComptable from app.models.vente_litterature import VenteLitterature from app.services.finance import encaisse as solde_encaisse from app.services.finance import engagements_non_debites from app.services.finance import solde_banque as solde_banque_actuel from app.services.finance import solde_reserve gid = membre.groupe_id groupe = await db.get(Groupe, gid) aujourd_hui = date.today() annee = annee or aujourd_hui.year mois = mois or aujourd_hui.month # ── 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 # ── État des résultats : période demandée ── result = await db.execute( select(func.coalesce(func.sum(Collecte.montant), 0)).where( Collecte.groupe_id == gid, Collecte.statut == "recu", extract("year", Collecte.date_reception) == annee, extract("month", Collecte.date_reception) == mois, ) ) 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, extract("year", VenteLitterature.date_remise) == annee, extract("month", VenteLitterature.date_remise) == mois, ) ) total_ventes = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( TransactionComptable.groupe_id == gid, TransactionComptable.type == "don", TransactionComptable.compte == "banque", TransactionComptable.sens == "debit", extract("year", TransactionComptable.date_transaction) == annee, extract("month", TransactionComptable.date_transaction) == mois, ) ) total_dons = float(result.scalar()) revenus = total_collectes + total_ventes + total_dons result = await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( Depense.groupe_id == gid, Depense.paye == True, extract("year", Depense.date_confirmation) == annee, extract("month", Depense.date_confirmation) == mois, ) ) 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()) result = await db.execute( select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( EnvoiContribution.groupe_id == gid, EnvoiContribution.debitee == True, extract("year", EnvoiContribution.date_envoi) == annee, extract("month", EnvoiContribution.date_envoi) == mois, )) total_contributions = float(result.scalar()) encaisse = await solde_encaisse(gid, db) solde_banque = await solde_banque_actuel(gid, db) # ── Réserves ── result = await db.execute( select(Reserve).where( Reserve.groupe_id == gid, Reserve.active == True, ).order_by(Reserve.nom) ) reserves_details = [] for reserve in result.scalars().all(): reserves_details.append({ "id": str(reserve.id), "nom": reserve.nom, "montant": round(await solde_reserve(reserve.id, db), 2), "note": reserve.note, }) total_reserves = sum(r["montant"] for r in reserves_details) engagements_courants = await engagements_non_debites(gid, db) disponible = solde_banque + encaisse - engagements_courants - total_reserves # ── 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 "?", "periode": { "annee": annee, "mois": mois, "libelle": f"{MOIS_FR[mois]} {annee}", }, "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), "disponible": round(disponible, 2), "revenus": { "collectes": round(total_collectes, 2), "ventes_litterature": round(total_ventes, 2), "dons": round(total_dons, 2), "total": round(revenus, 2), }, "sorties": { "depenses_payees": round(total_depenses_payees, 2), "engagements": round(engagements_courants, 2), "contributions": round(total_contributions, 2), "total": round(total_depenses_payees + total_contributions, 2), }, "reserves": { "details": reserves_details, "total": round(total_reserves, 2), "total_reelles": round(total_reserves, 2), }, "resultat_net": round(revenus - total_depenses_payees - total_contributions, 2), "dernieres_collectes": dernieres_collectes, "dernieres_depenses": dernieres_depenses, } async def _html_etat_resultats( annee: int, mois: int, membre, db: AsyncSession, note_adoption: str | None = None, ): if mois < 1 or mois > 12: raise HTTPException(status_code=400, detail="Mois invalide") from sqlalchemy import func, desc from app.models.operation_bancaire import OperationBancaire from app.models.mouvement_reserve import MouvementReserve from app.models.reserve import Reserve from app.models.transaction_comptable import TransactionComptable from app.models.vente_litterature import VenteLitterature gid = membre.groupe_id groupe = await db.get(Groupe, gid) fin_mois = (date(annee + 1, 1, 1) if mois == 12 else date(annee, mois + 1, 1)) - timedelta(days=1) fin_mois_dt = datetime.combine(fin_mois, time.max).replace(tzinfo=timezone.utc) async def solde_compte_a_date(compte: str) -> float: debits = float((await db.execute( select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( TransactionComptable.groupe_id == gid, TransactionComptable.compte == compte, TransactionComptable.sens == "debit", TransactionComptable.date_transaction <= fin_mois, ) )).scalar()) credits = float((await db.execute( select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( TransactionComptable.groupe_id == gid, TransactionComptable.compte == compte, TransactionComptable.sens == "credit", TransactionComptable.date_transaction <= fin_mois, ) )).scalar()) return debits - credits async def solde_reserve_a_date(reserve_id) -> float: entrees = float((await db.execute( select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( MouvementReserve.destination_id == reserve_id, MouvementReserve.cree_le <= fin_mois_dt, ) )).scalar()) sorties = float((await db.execute( select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( MouvementReserve.source_id == reserve_id, MouvementReserve.cree_le <= fin_mois_dt, ) )).scalar()) return entrees - sorties async def engagements_a_date() -> float: depenses = float((await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( Depense.groupe_id == gid, Depense.paye == False, Depense.methode.in_(["VIREMENT", "CHEQUE"]), Depense.date_confirmation <= fin_mois_dt, ) )).scalar()) contributions = float((await db.execute( select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( EnvoiContribution.groupe_id == gid, EnvoiContribution.engagee == True, EnvoiContribution.debitee == False, EnvoiContribution.date_envoi <= fin_mois, ) )).scalar()) return depenses + contributions total_collectes = float((await db.execute( select(func.coalesce(func.sum(Collecte.montant), 0)).where( Collecte.groupe_id == gid, Collecte.statut == "recu", extract("year", Collecte.date_reception) == annee, extract("month", Collecte.date_reception) == mois, ) )).scalar()) total_ventes = float((await db.execute( select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True, extract("year", VenteLitterature.date_remise) == annee, extract("month", VenteLitterature.date_remise) == mois, ) )).scalar()) total_dons = float((await db.execute( select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( TransactionComptable.groupe_id == gid, TransactionComptable.type == "don", TransactionComptable.compte == "banque", TransactionComptable.sens == "debit", extract("year", TransactionComptable.date_transaction) == annee, extract("month", TransactionComptable.date_transaction) == mois, ) )).scalar()) total_depenses = float((await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( Depense.groupe_id == gid, Depense.paye == True, extract("year", Depense.date_confirmation) == annee, extract("month", Depense.date_confirmation) == mois, ) )).scalar()) total_contributions = float((await db.execute( select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( EnvoiContribution.groupe_id == gid, EnvoiContribution.debitee == True, extract("year", EnvoiContribution.date_envoi) == annee, extract("month", EnvoiContribution.date_envoi) == mois, ) )).scalar()) revenus = total_collectes + total_ventes + total_dons charges = total_depenses + total_contributions resultat = revenus - charges banque = await solde_compte_a_date("banque") caisse = await solde_compte_a_date("encaisse") engagements = await engagements_a_date() result = await db.execute( select(Reserve).where(Reserve.groupe_id == gid).order_by(Reserve.nom) ) reserves = [] total_reserves = 0.0 for reserve in result.scalars().all(): montant = await solde_reserve_a_date(reserve.id) if not reserve.active and round(montant, 2) == 0: continue total_reserves += montant reserves.append((reserve.nom, montant)) disponible = banque + caisse - engagements - total_reserves result = await db.execute( select(OperationBancaire).where( OperationBancaire.groupe_id == gid, OperationBancaire.type == "releve", OperationBancaire.date_operation <= fin_mois, ).order_by(desc(OperationBancaire.date_operation)).limit(1) ) dernier_releve = result.scalar_one_or_none() rows_reserves = "".join( f"{escape(nom)}{montant:.2f} $" for nom, montant in reserves ) or "Aucune réserve enregistrée" note_html = "" if note_adoption and note_adoption.strip(): note_html = f"""

Correction ou note d'adoption

{escape(note_adoption.strip()).replace(chr(10), '
')}

""" css = _css() + """ .montant { text-align: right; white-space: nowrap; } .positif { color: #1e8449; } .negatif { color: #c0392b; } .note { color: #777; font-size: 9pt; } """ signe = "+" if resultat >= 0 else "" classe_resultat = "positif" if resultat >= 0 else "negatif" groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe" district = str(groupe.district if groupe and groupe.district else "87-16") html_content = f"""
{escape(groupe_nom)}
District {escape(district)}

État des résultats — {MOIS_FR[mois]} {annee}

Revenus

Collectes 7e Tradition{total_collectes:.2f} $
Ventes de littérature{total_ventes:.2f} $
Dons déposés directement{total_dons:.2f} $
Total des revenus{revenus:.2f} $

Charges

Dépenses payées{total_depenses:.2f} $
Contributions envoyées{total_contributions:.2f} $
Total des charges{charges:.2f} $

{'Excédent' if resultat >= 0 else 'Déficit'} : {signe}{resultat:.2f} $

Position de trésorerie

Solde bancaire au {fin_mois.isoformat()}{banque:.2f} $
Encaisse au {fin_mois.isoformat()}{caisse:.2f} $
Engagements non débités{engagements:.2f} $
Réserves{total_reserves:.2f} $
Disponible au {fin_mois.isoformat()}{disponible:.2f} $
{f'

Dernier relevé bancaire : {dernier_releve.date_operation}

' if dernier_releve else ''}

Réserves

{rows_reserves}
Total des réserves{total_reserves:.2f} $
{note_html} """ filename = f"etat_resultats_{groupe_nom.replace(' ', '_')}_{annee}_{mois:02d}.pdf" donnees = { "annee": annee, "mois": mois, "fin_mois": fin_mois.isoformat(), "revenus": round(revenus, 2), "charges": round(charges, 2), "resultat": round(resultat, 2), "banque": round(banque, 2), "encaisse": round(caisse, 2), "engagements": round(engagements, 2), "total_reserves": round(total_reserves, 2), "disponible": round(disponible, 2), "reserves": [{"nom": nom, "montant": round(montant, 2)} for nom, montant in reserves], "note_adoption": note_adoption.strip() if note_adoption else None, } return html_content, filename, donnees @router.get("/etat-resultats/{annee}/{mois}") async def rapport_etat_resultats( annee: int, mois: int, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """PDF mensuel: état des résultats et position de trésorerie.""" try: from weasyprint import HTML except ImportError: raise HTTPException(status_code=500, detail="WeasyPrint non installé") html_content, filename, _ = await _html_etat_resultats(annee, mois, membre, db) pdf_bytes = HTML(string=html_content).write_pdf() return StreamingResponse( io.BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{filename}"'}, ) @router.post("/etat-resultats/{annee}/{mois}/adopter") async def adopter_rapport_tresorerie( annee: int, mois: int, req: RapportTresorerieAdoption, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Cristalliser le rapport du trésorier adopté par le groupe.""" if not req.proposeur_id or not req.secondeur_id: raise HTTPException(status_code=400, detail="Proposeur et secondeur requis") if req.proposeur_id == req.secondeur_id: raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents") proposeur = await db.get(Membre, req.proposeur_id) secondeur = await db.get(Membre, req.secondeur_id) if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif: raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif") if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif: raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") result = await db.execute( select(RapportAdopte).where( RapportAdopte.groupe_id == membre.groupe_id, RapportAdopte.type_rapport == "tresorerie", RapportAdopte.annee == annee, RapportAdopte.mois == mois, ) ) if result.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Ce rapport est déjà adopté") note = req.note or "" note_formelle = ( f"{note.strip()}\n\n" if note.strip() else "" ) + f"Rapport adopté sur proposition de {proposeur.prenom}, secondée par {secondeur.prenom}." html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, note_formelle) donnees["proposeur_id"] = str(proposeur.id) donnees["secondeur_id"] = str(secondeur.id) donnees["proposeur_prenom"] = proposeur.prenom donnees["secondeur_prenom"] = secondeur.prenom rapport = RapportAdopte( groupe_id=membre.groupe_id, adopte_par_id=membre.id, type_rapport="tresorerie", titre=f"Rapport du trésorier — {MOIS_FR[mois]} {annee}", annee=annee, mois=mois, contenu_html=html_content, donnees=donnees, ) db.add(rapport) await db.flush() return { "id": rapport.id, "type_rapport": rapport.type_rapport, "titre": rapport.titre, "annee": rapport.annee, "mois": rapport.mois, "adopte_le": rapport.adopte_le, } @router.get("/adoptes") async def liste_rapports_adoptes( type_rapport: str | None = None, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): from sqlalchemy import desc query = select(RapportAdopte).where(RapportAdopte.groupe_id == membre.groupe_id) if type_rapport: query = query.where(RapportAdopte.type_rapport == type_rapport) result = await db.execute(query.order_by(desc(RapportAdopte.adopte_le)).limit(60)) return [ { "id": r.id, "type_rapport": r.type_rapport, "titre": r.titre, "annee": r.annee, "mois": r.mois, "adopte_le": r.adopte_le, } for r in result.scalars().all() ] @router.get("/adoptes/{rapport_id}.pdf") async def imprimer_rapport_adopte( rapport_id: str, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): try: from weasyprint import HTML except ImportError: raise HTTPException(status_code=500, detail="WeasyPrint non installé") result = await db.execute( select(RapportAdopte).where( RapportAdopte.id == rapport_id, RapportAdopte.groupe_id == membre.groupe_id, ) ) rapport = result.scalar_one_or_none() if not rapport: raise HTTPException(status_code=404, detail="Rapport introuvable") pdf_bytes = HTML(string=rapport.contenu_html).write_pdf() filename = f"rapport_adopte_{rapport.type_rapport}_{rapport.annee or 'sans'}_{rapport.mois or 'periode'}.pdf" return StreamingResponse( io.BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{filename}"'}, )