944 lines
37 KiB
Python
944 lines
37 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
|
||
|
||
|
||
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}"'},
|
||
)
|