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

966 lines
38 KiB
Python
Raw Normal View History

2026-03-04 14:01:00 -05:00
"""Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
import io
2026-05-25 17:39:38 -04:00
from html import escape
from datetime import date, datetime, time, timedelta, timezone
2026-03-04 14:01:00 -05:00
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
2026-03-04 14:01:00 -05:00
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
2026-03-04 14:01:00 -05:00
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
from app.models.rapport_adopte import RapportAdopte
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",
]
class RapportTresorerieAdoption(BaseModel):
note: str | None = None
proposeur_id: str | None = None
secondeur_id: str | None = None
2026-05-25 17:39:38 -04:00
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
2026-03-04 14:01:00 -05:00
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
2026-05-25 17:39:38 -04:00
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 '—')}"
2026-04-17 04:04:04 -04:00
rows += f"""<tr>
2026-05-25 17:39:38 -04:00
<td>{numero_date}</td>
<td>{resolution}</td>
<td>{proposeurs}</td>
2026-04-17 04:04:04 -04:00
</tr>"""
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
2026-05-25 17:39:38 -04:00
<style>.muted {{ color: #777; font-size: 8pt; }}</style>
2026-04-17 04:04:04 -04:00
<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>
2026-05-25 17:39:38 -04:00
<p>Décisions prises en Groupe</p>
2026-04-17 04:04:04 -04:00
<table>
2026-05-25 17:39:38 -04:00
<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>'}
2026-04-17 04:04:04 -04:00
</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(
2026-05-25 17:39:38 -04:00
annee: int | None = None,
mois: int | None = None,
2026-04-17 04:04:04 -04:00
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
2026-05-25 17:39:38 -04:00
"""État des résultats par période et soldes courants (JSON)."""
2026-04-17 04:04:04 -04:00
from sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
2026-05-25 17:39:38 -04:00
from app.models.transaction_comptable import TransactionComptable
2026-04-17 04:04:04 -04:00
from app.models.vente_litterature import VenteLitterature
2026-05-25 17:39:38 -04:00
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
2026-04-17 04:04:04 -04:00
gid = membre.groupe_id
groupe = await db.get(Groupe, gid)
2026-05-25 17:39:38 -04:00
aujourd_hui = date.today()
annee = annee or aujourd_hui.year
mois = mois or aujourd_hui.month
2026-04-17 04:04:04 -04:00
# ── 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
2026-05-25 17:39:38 -04:00
# ── État des résultats : période demandée ──
2026-04-17 04:04:04 -04:00
result = await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
2026-05-25 17:39:38 -04:00
Collecte.groupe_id == gid,
Collecte.statut == "recu",
extract("year", Collecte.date_reception) == annee,
extract("month", Collecte.date_reception) == mois,
)
)
2026-04-17 04:04:04 -04:00
total_collectes = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
2026-05-25 17:39:38 -04:00
VenteLitterature.groupe_id == gid,
VenteLitterature.remis_tresorier == True,
extract("year", VenteLitterature.date_remise) == annee,
extract("month", VenteLitterature.date_remise) == mois,
)
)
2026-04-17 04:04:04 -04:00
total_ventes = float(result.scalar())
2026-05-25 17:39:38 -04:00
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
2026-04-17 04:04:04 -04:00
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
2026-05-25 17:39:38 -04:00
Depense.groupe_id == gid,
Depense.paye == True,
extract("year", Depense.date_confirmation) == annee,
extract("month", Depense.date_confirmation) == mois,
)
)
2026-04-17 04:04:04 -04:00
total_depenses_payees = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
2026-05-25 17:39:38 -04:00
Depense.groupe_id == gid,
Depense.paye == False,
Depense.confirme_par.isnot(None),
)
)
2026-04-17 04:04:04 -04:00
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,
2026-05-25 17:39:38 -04:00
extract("year", EnvoiContribution.date_envoi) == annee,
extract("month", EnvoiContribution.date_envoi) == mois,
))
2026-04-17 04:04:04 -04:00
total_contributions = float(result.scalar())
2026-05-25 17:39:38 -04:00
encaisse = await solde_encaisse(gid, db)
solde_banque = await solde_banque_actuel(gid, db)
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)
)
2026-05-25 17:39:38 -04:00
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)
2026-04-17 04:04:04 -04:00
2026-05-25 17:39:38 -04:00
engagements_courants = await engagements_non_debites(gid, db)
disponible = solde_banque + encaisse - engagements_courants - 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 "?",
2026-05-25 17:39:38 -04:00
"periode": {
"annee": annee,
"mois": mois,
"libelle": f"{MOIS_FR[mois]} {annee}",
},
2026-04-17 04:04:04 -04:00
"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),
2026-05-25 17:39:38 -04:00
"dons": round(total_dons, 2),
2026-04-17 04:04:04 -04:00
"total": round(revenus, 2),
},
"sorties": {
"depenses_payees": round(total_depenses_payees, 2),
2026-05-25 17:39:38 -04:00
"engagements": round(engagements_courants, 2),
2026-04-17 04:04:04 -04:00
"contributions": round(total_contributions, 2),
"total": round(total_depenses_payees + total_contributions, 2),
},
"reserves": {
"details": reserves_details,
"total": round(total_reserves, 2),
2026-05-25 17:39:38 -04:00
"total_reelles": 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,
}
2026-05-25 17:39:38 -04:00
async def _html_etat_resultats(
2026-05-25 17:39:38 -04:00
annee: int,
mois: int,
membre,
db: AsyncSession,
note_adoption: str | None = None,
2026-05-25 17:39:38 -04:00
):
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>
"""
2026-05-25 17:39:38 -04:00
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}
2026-05-25 17:39:38 -04:00
<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."""
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"
2026-05-25 17:39:38 -04:00
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)