Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions. Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté. Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus. Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
965 lines
38 KiB
Python
965 lines
38 KiB
Python
"""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"<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."""
|
||
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}"'},
|
||
)
|