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

944 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
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"<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,
)
.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 '—')}<br><span class=\"muted\">{escape(str(p.date_adoption or p.date_vote or '—'))}</span>"
resolution = escape(_texte_decision_resolution(p)).replace("\n", "<br>")
proposeurs = f"{escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else '—')}"
rows += f"""<tr>
<td>{numero_date}</td>
<td>{resolution}</td>
<td>{proposeurs}</td>
</tr>"""
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
<style>.muted {{ color: #777; font-size: 8pt; }}</style>
<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>
<p>Décisions prises en Groupe</p>
<table>
<colgroup>
<col style="width: 18%;">
<col style="width: 60%;">
<col style="width: 22%;">
</colgroup>
<tr><th>N° / date</th><th>Résolution</th><th>Proposeur / secondeur</th></tr>
{rows if rows else '<tr><td colspan="3">Aucune résolution pour cette année</td></tr>'}
</table>
<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(
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"<tr><td>{escape(nom)}</td><td class=\"montant\">{montant:.2f} $</td></tr>"
for nom, montant in reserves
) or "<tr><td colspan=\"2\">Aucune réserve enregistrée</td></tr>"
note_html = ""
if note_adoption and note_adoption.strip():
note_html = f"""
<h2>Correction ou note d'adoption</h2>
<p>{escape(note_adoption.strip()).replace(chr(10), '<br>')}</p>
"""
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")
region = str(groupe.region if groupe and groupe.region else "87")
html_content = f"""<!DOCTYPE html><html><head><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="sub">District {escape(district)} · Région {escape(region)}</div>
</div>
<h1>État des résultats — {MOIS_FR[mois]} {annee}</h1>
<h2>Revenus</h2>
<table>
<tr><td>Collectes 7e Tradition</td><td class="montant">{total_collectes:.2f} $</td></tr>
<tr><td>Ventes de littérature</td><td class="montant">{total_ventes:.2f} $</td></tr>
<tr><td>Dons déposés directement</td><td class="montant">{total_dons:.2f} $</td></tr>
<tr><td><strong>Total des revenus</strong></td><td class="montant"><strong>{revenus:.2f} $</strong></td></tr>
</table>
<h2>Charges</h2>
<table>
<tr><td>Dépenses payées</td><td class="montant">{total_depenses:.2f} $</td></tr>
<tr><td>Contributions envoyées</td><td class="montant">{total_contributions:.2f} $</td></tr>
<tr><td><strong>Total des charges</strong></td><td class="montant"><strong>{charges:.2f} $</strong></td></tr>
</table>
<p class="total {classe_resultat}">{'Excédent' if resultat >= 0 else 'Déficit'} : {signe}{resultat:.2f} $</p>
<h2>Position de trésorerie</h2>
<table>
<tr><td>Solde bancaire au {fin_mois.isoformat()}</td><td class="montant">{banque:.2f} $</td></tr>
<tr><td>Encaisse au {fin_mois.isoformat()}</td><td class="montant">{caisse:.2f} $</td></tr>
<tr><td>Engagements non débités</td><td class="montant">{engagements:.2f} $</td></tr>
<tr><td>Réserves</td><td class="montant">{total_reserves:.2f} $</td></tr>
<tr><td>Disponible au {fin_mois.isoformat()}</td><td class="montant">{disponible:.2f} $</td></tr>
</table>
{f'<p class="note">Dernier relevé bancaire : {dernier_releve.date_operation}</p>' if dernier_releve else ''}
<h2>Réserves</h2>
<table>
{rows_reserves}
<tr><td><strong>Total des réserves</strong></td><td class="montant"><strong>{total_reserves:.2f} $</strong></td></tr>
</table>
{note_html}
<div class="footer">
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}
</div>
</body></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."""
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é")
html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, req.note)
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}"'},
)