diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py
index d21191c..26fd6f1 100644
--- a/backend/app/routers/collectes.py
+++ b/backend/app/routers/collectes.py
@@ -21,6 +21,7 @@ from app.models.reunion import Reunion
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
+from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
@@ -102,6 +103,9 @@ async def saisir_collecte(
raise HTTPException(status_code=404, detail="Détenteur introuvable ou inactif")
if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
+ )
result = await db.execute(
select(Collecte).where(
@@ -147,6 +151,10 @@ async def ajuster_collecte(
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
+ if collecte.reunion_id:
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, collecte.reunion_id, "rencontre", "Cette rencontre est clôturée"
+ )
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py
index 5e5af14..4998017 100644
--- a/backend/app/routers/jetons.py
+++ b/backend/app/routers/jetons.py
@@ -15,6 +15,7 @@ from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable
from app.models.reunion import Reunion
from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate
+from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
@@ -67,6 +68,9 @@ async def remettre_jeton(
membre_prenom = cible.prenom
if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
+ )
jeton = Jeton(
groupe_id=membre.groupe_id,
@@ -111,6 +115,9 @@ async def enregistrer_vente_jeton(
raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
+ )
vente = VenteJeton(
groupe_id=membre.groupe_id,
diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py
index 47fb196..0437791 100644
--- a/backend/app/routers/presences.py
+++ b/backend/app/routers/presences.py
@@ -5,6 +5,7 @@ from html import escape
from datetime import datetime, 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, desc, extract
from zoneinfo import ZoneInfo
@@ -22,13 +23,20 @@ from app.models.pv_reunion import PvReunion
from app.models.proposition import Proposition
from app.models.poste import Poste
from app.models.affectation import Affectation
+from app.models.rapport_adopte import RapportAdopte
from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
)
+from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
+class RapportAssembleeAdoption(BaseModel):
+ proposeur_id: str
+ secondeur_id: str
+
+
def _date_civile_reunion(valeur):
if getattr(valeur, "tzinfo", None):
return valeur.astimezone(ZoneInfo("America/Toronto")).date()
@@ -112,6 +120,129 @@ async def _assemblee_publique(reunion: Reunion, db: AsyncSession):
}
+async def _html_rapport_assemblee(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
+ groupe = await db.get(Groupe, membre.groupe_id)
+ jour = _date_civile_reunion(reunion.date_reunion)
+
+ presences = (await db.execute(
+ select(Presence).where(Presence.reunion_id == reunion.id)
+ )).scalars().all()
+ presences_rows = []
+ for presence in presences:
+ mbr = await db.get(Membre, presence.membre_id)
+ if presence.present:
+ presences_rows.append(f"
{escape(mbr.prenom if mbr else '?')}")
+
+ pv = (await db.execute(
+ select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
+ )).scalar_one_or_none()
+
+ propositions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == membre.groupe_id,
+ Proposition.statut == "secondee",
+ ).order_by(Proposition.cree_le)
+ )).scalars().all()
+ propositions_rows = []
+ for prop in propositions:
+ proposeur = await db.get(Membre, prop.proposeur_id) if prop.proposeur_id else None
+ secondeur = await db.get(Membre, prop.secondeur_id) if prop.secondeur_id else None
+ propositions_rows.append(
+ f"{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})"
+ )
+
+ resolutions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == membre.groupe_id,
+ Proposition.statut == "adoptee",
+ Proposition.date_vote == jour,
+ ).order_by(Proposition.numero_resolution)
+ )).scalars().all()
+ resolutions_rows = [
+ f"{escape(r.numero_resolution or '')} {escape(r.sujet)}".strip()
+ for r in resolutions
+ ]
+
+ postes_rows = [f"{escape(p.nom)}" for p in await _postes_vacants(membre.groupe_id, db)]
+ mois_precedent = jour.month - 1
+ annee_precedente = jour.year
+ if mois_precedent == 0:
+ mois_precedent = 12
+ annee_precedente -= 1
+ rapport_tresorier = (await db.execute(
+ select(RapportAdopte).where(
+ RapportAdopte.groupe_id == membre.groupe_id,
+ RapportAdopte.type_rapport == "tresorerie",
+ RapportAdopte.annee == annee_precedente,
+ RapportAdopte.mois == mois_precedent,
+ ).limit(1)
+ )).scalar_one_or_none()
+ tresorier_html = (
+ f"Rapport adopté: {escape(rapport_tresorier.titre)}
"
+ if rapport_tresorier else "Rapport du trésorier à adopter avant les contributions.
"
+ )
+
+ pv_contenu = pv.contenu if pv else "\n".join([
+ "Ouverture de l'assemblée.",
+ "",
+ "Lecture du procès-verbal précédent.",
+ "",
+ "Réflexions en cours:",
+ *[f"- {p.sujet}" for p in propositions],
+ "",
+ "Décisions prises pendant cette assemblée:",
+ *[f"- {r.numero_resolution or ''} {r.sujet}".strip() for r in resolutions],
+ "",
+ "Postes disponibles:",
+ *[f"- {p.nom}" for p in await _postes_vacants(membre.groupe_id, db)],
+ ])
+
+ css = """
+ body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
+ .header { border-bottom: 3px solid #3f3c8f; padding-bottom: 12pt; margin-bottom: 18pt; }
+ .groupe { color: #3f3c8f; font-size: 20pt; font-weight: 800; }
+ .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
+ h1 { font-size: 16pt; color: #3f3c8f; margin: 0 0 12pt; }
+ h2 { font-size: 12pt; color: #3f3c8f; margin: 16pt 0 6pt; border-bottom: 1px solid #dedcf4; padding-bottom: 3pt; }
+ .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
+ .box { border: 1px solid #dedcf4; border-radius: 6pt; padding: 8pt; background: #fafaff; }
+ .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
+ .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
+ ul { margin-top: 4pt; }
+ li { margin-bottom: 3pt; }
+ .pv { white-space: pre-wrap; border: 1px solid #e5e7eb; border-radius: 6pt; padding: 8pt; background: #fcfcfd; }
+ .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
+ """
+ groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
+ coords = " · ".join([
+ item for item in [
+ groupe.adresse_local if groupe else None,
+ f"District {groupe.district}" if groupe and groupe.district else None,
+ ] if item
+ ])
+ html = f"""
+
+ Rapport d'assemblée d'affaires
+
+ Présences
{"".join(presences_rows) or "- Présences à cocher sur papier.
"}
+ Réflexions en cours
{"".join(propositions_rows) or "- Aucune proposition secondée non résolue.
"}
+ Rapport du trésorier
{tresorier_html}
+ Décisions prises
{"".join(resolutions_rows) or "- Aucune décision enregistrée pour cette date.
"}
+ Postes disponibles
{"".join(postes_rows) or "- Aucun poste vacant.
"}
+ Procès-verbal
{escape(pv_contenu)}
+
+ """
+ return html, jour
+
+
@router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires(
req: ReunionAffairesCreate,
@@ -190,8 +321,14 @@ async def liste_reunions_affaires(
membre_prenom=mbr.prenom if mbr else "?",
present=p.present,
))
+ rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, r.id, "assemblee")
out.append(ReunionAffairesOut(
- id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out,
+ id=r.id,
+ date_reunion=r.date_reunion,
+ note=r.note,
+ modifie_le=r.modifie_le,
+ rapport_adopte_id=rapport_adopte.id if rapport_adopte else None,
+ presences=pres_out,
))
return out
@@ -271,109 +408,16 @@ async def rapport_assemblee_pdf(
)).scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Assemblée introuvable")
-
- groupe = await db.get(Groupe, membre.groupe_id)
- jour = _date_civile_reunion(reunion.date_reunion)
-
- presences = (await db.execute(
- select(Presence).where(Presence.reunion_id == reunion.id)
- )).scalars().all()
- presences_rows = []
- for presence in presences:
- mbr = await db.get(Membre, presence.membre_id)
- if presence.present:
- presences_rows.append(f"{escape(mbr.prenom if mbr else '?')}")
-
- pv = (await db.execute(
- select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
- )).scalar_one_or_none()
-
- propositions = (await db.execute(
- select(Proposition).where(
- Proposition.groupe_id == membre.groupe_id,
- Proposition.statut == "secondee",
- ).order_by(Proposition.cree_le)
- )).scalars().all()
- propositions_rows = []
- for prop in propositions:
- proposeur = await db.get(Membre, prop.proposeur_id) if prop.proposeur_id else None
- secondeur = await db.get(Membre, prop.secondeur_id) if prop.secondeur_id else None
- propositions_rows.append(
- f"{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})"
+ adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee")
+ if adopte:
+ pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
+ filename = f"rapport_assemblee_{reunion.date_reunion.date().isoformat()}.pdf"
+ return StreamingResponse(
+ io.BytesIO(pdf_bytes),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
-
- resolutions = (await db.execute(
- select(Proposition).where(
- Proposition.groupe_id == membre.groupe_id,
- Proposition.statut == "adoptee",
- Proposition.date_vote == jour,
- ).order_by(Proposition.numero_resolution)
- )).scalars().all()
- resolutions_rows = [
- f"{escape(r.numero_resolution or '')} {escape(r.sujet)}".strip()
- for r in resolutions
- ]
-
- postes_rows = [f"{escape(p.nom)}" for p in await _postes_vacants(membre.groupe_id, db)]
-
- pv_contenu = pv.contenu if pv else "\n".join([
- "Ouverture de l'assemblée.",
- "",
- "Lecture du procès-verbal précédent.",
- "",
- "Réflexions en cours:",
- *[f"- {p.sujet}" for p in propositions],
- "",
- "Décisions prises pendant cette assemblée:",
- *[f"- {r.numero_resolution or ''} {r.sujet}".strip() for r in resolutions],
- "",
- "Postes disponibles:",
- *[f"- {p.nom}" for p in await _postes_vacants(membre.groupe_id, db)],
- ])
-
- css = """
- body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
- .header { border-bottom: 3px solid #3f3c8f; padding-bottom: 12pt; margin-bottom: 18pt; }
- .groupe { color: #3f3c8f; font-size: 20pt; font-weight: 800; }
- .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
- h1 { font-size: 16pt; color: #3f3c8f; margin: 0 0 12pt; }
- h2 { font-size: 12pt; color: #3f3c8f; margin: 16pt 0 6pt; border-bottom: 1px solid #dedcf4; padding-bottom: 3pt; }
- .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
- .box { border: 1px solid #dedcf4; border-radius: 6pt; padding: 8pt; background: #fafaff; }
- .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
- .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
- ul { margin-top: 4pt; }
- li { margin-bottom: 3pt; }
- .pv { white-space: pre-wrap; border: 1px solid #e5e7eb; border-radius: 6pt; padding: 8pt; background: #fcfcfd; }
- .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
- """
- groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
- coords = " · ".join([
- item for item in [
- groupe.adresse_local if groupe else None,
- f"District {groupe.district}" if groupe and groupe.district else None,
- ] if item
- ])
- html = f"""
-
- Rapport d'assemblée d'affaires
-
- Présences
{"".join(presences_rows) or "- Présences à cocher sur papier.
"}
- Réflexions en cours
{"".join(propositions_rows) or "- Aucune proposition secondée non résolue.
"}
- Décisions prises
{"".join(resolutions_rows) or "- Aucune décision enregistrée pour cette date.
"}
- Postes disponibles
{"".join(postes_rows) or "- Aucun poste vacant.
"}
- Procès-verbal
{escape(pv_contenu)}
-
- """
-
+ html, jour = await _html_rapport_assemblee(reunion, membre, db)
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
return StreamingResponse(
@@ -382,6 +426,55 @@ async def rapport_assemblee_pdf(
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
+@router.post("/reunions/{reunion_id}/adopter")
+async def adopter_rapport_assemblee(
+ reunion_id: str,
+ req: RapportAssembleeAdoption,
+ membre=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ """Cristalliser le rapport d'assemblée adopté par le groupe."""
+ 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")
+ reunion = (await db.execute(
+ select(Reunion).where(
+ Reunion.id == reunion_id,
+ Reunion.groupe_id == membre.groupe_id,
+ Reunion.type == "affaires",
+ )
+ )).scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Assemblée introuvable")
+ if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee"):
+ raise HTTPException(status_code=400, detail="Cette assemblée est déjà adoptée")
+ html, jour = await _html_rapport_assemblee(reunion, membre, db)
+ rapport = RapportAdopte(
+ groupe_id=membre.groupe_id,
+ adopte_par_id=membre.id,
+ type_rapport="assemblee",
+ titre=f"Rapport d'assemblée — {jour.isoformat()}",
+ annee=jour.year,
+ mois=jour.month,
+ contenu_html=html,
+ donnees={
+ "reunion_id": str(reunion.id),
+ "date": jour.isoformat(),
+ "proposeur_id": str(proposeur.id),
+ "secondeur_id": str(secondeur.id),
+ "proposeur_prenom": proposeur.prenom,
+ "secondeur_prenom": secondeur.prenom,
+ },
+ )
+ db.add(rapport)
+ await db.flush()
+ return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
+
@router.post("/", response_model=list[PresenceOut])
async def enregistrer_presences(
@@ -400,6 +493,9 @@ async def enregistrer_presences(
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
+ )
# Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir)
existing = await db.execute(
diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py
index f48993f..d2e646a 100644
--- a/backend/app/routers/pv.py
+++ b/backend/app/routers/pv.py
@@ -11,6 +11,7 @@ from app.models.reunion import Reunion
from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
+from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
@@ -83,6 +84,9 @@ async def creer_pv(
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
+ )
decisions_data = None
if req.decisions:
@@ -117,6 +121,11 @@ async def modifier_pv(
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
+ reunion = await db.get(Reunion, pv.reunion_id)
+ if reunion:
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
+ )
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
@@ -157,6 +166,11 @@ async def adopter_pv(
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
+ reunion = await db.get(Reunion, pv.reunion_id)
+ if reunion:
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
+ )
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
if pv.approuve:
diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py
index b616e7b..636dbb1 100644
--- a/backend/app/routers/reunions.py
+++ b/backend/app/routers/reunions.py
@@ -19,8 +19,10 @@ from app.models.jeton import Jeton
from app.models.litterature import Litterature
from app.models.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature
+from app.models.rapport_adopte import RapportAdopte
from app.schemas.phase1 import ReunionCreate, ReunionOut
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
+from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
@@ -31,6 +33,139 @@ def _date_civile_reunion(valeur: datetime):
return valeur.date()
+async def _html_rapport_rencontre(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
+ groupe = await db.get(Groupe, membre.groupe_id)
+ animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
+ jour = reunion.date_reunion.date()
+
+ collecte = (await db.execute(
+ select(Collecte).where(
+ Collecte.groupe_id == membre.groupe_id,
+ (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
+ ).limit(1)
+ )).scalar_one_or_none()
+ collecte_html = "Aucune collecte saisie.
"
+ if collecte:
+ detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
+ collecte_html = (
+ ""
+ f"| Montant | {float(collecte.montant):.2f} $ |
"
+ f"| Détenteur | {escape(detenteur.prenom if detenteur else '—')} |
"
+ f"| Statut | {escape(collecte.statut)} |
"
+ "
"
+ )
+
+ ventes_lit_rows = []
+ ventes_lit_result = await db.execute(
+ select(VenteLitterature).where(
+ VenteLitterature.groupe_id == membre.groupe_id,
+ (VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
+ )
+ )
+ for vente in ventes_lit_result.scalars().all():
+ item = await db.get(Litterature, vente.litterature_id)
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_lit_rows.append(
+ f"| {escape(item.titre if item else '?')} | {vente.quantite} | "
+ f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else '—')} | "
+ f"{'Reçue' if vente.remis_tresorier else 'À recevoir'} |
"
+ )
+
+ ventes_jetons_rows = []
+ ventes_jetons_result = await db.execute(
+ select(VenteJeton).where(
+ VenteJeton.groupe_id == membre.groupe_id,
+ (VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
+ )
+ )
+ for vente in ventes_jetons_result.scalars().all():
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_jetons_rows.append(
+ f"| {escape(vente.type_jeton)} | {vente.quantite} | "
+ f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else '—')} | "
+ f"{'Reçue' if vente.remis_tresorier else 'À recevoir'} |
"
+ )
+
+ jetons_rows = []
+ jetons_result = await db.execute(
+ select(Jeton).where(
+ Jeton.groupe_id == membre.groupe_id,
+ (Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
+ )
+ )
+ for jeton in jetons_result.scalars().all():
+ cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
+ detail = f"{jeton.annees} ans" if jeton.annees else ""
+ jetons_rows.append(
+ f"| {escape(jeton.type_jeton)} | {escape(cible.prenom if cible else 'Anonyme')} | {escape(detail)} |
"
+ )
+
+ annonces = reunion.annonces or []
+ annonces_html = "".join(f"{escape(a)}" for a in annonces) or "Aucune annonce."
+ css = """
+ body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
+ .header { border-bottom: 3px solid #1a5276; padding-bottom: 12pt; margin-bottom: 18pt; }
+ .groupe { color: #1a5276; font-size: 20pt; font-weight: 800; }
+ .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
+ h1 { font-size: 16pt; color: #1a5276; margin: 0 0 12pt; }
+ h2 { font-size: 12pt; color: #1a5276; margin: 16pt 0 6pt; border-bottom: 1px solid #d8e2ea; padding-bottom: 3pt; }
+ .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
+ .box { border: 1px solid #d8e2ea; border-radius: 6pt; padding: 8pt; background: #f8fafc; }
+ .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
+ .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
+ table { width: 100%; border-collapse: collapse; margin-top: 5pt; }
+ th { text-align: left; background: #1a5276; color: white; padding: 5pt; font-size: 8.5pt; }
+ td { border-bottom: 1px solid #e5e9ef; padding: 5pt; vertical-align: top; }
+ .montant { text-align: right; white-space: nowrap; }
+ .note { white-space: pre-wrap; }
+ .page-break { break-before: page; page-break-before: always; }
+ .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
+ """
+ groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
+ coords = " · ".join([
+ item for item in [
+ groupe.adresse_local if groupe else None,
+ f"District {groupe.district}" if groupe and groupe.district else None,
+ ] if item
+ ])
+ ventes_lit_html = (
+ "| Titre | Qté | Montant | Vendu par | Statut |
"
+ + "".join(ventes_lit_rows) + "
"
+ if ventes_lit_rows else "Aucune vente de littérature.
"
+ )
+ ventes_jetons_html = (
+ "| Type | Qté | Montant | Vendu par | Statut |
"
+ + "".join(ventes_jetons_rows) + "
"
+ if ventes_jetons_rows else "Aucune vente de jetons.
"
+ )
+ jetons_html = (
+ "| Type | Membre | Détail |
" + "".join(jetons_rows) + "
"
+ if jetons_rows else "Aucun jeton/gâteau comptabilisé.
"
+ )
+ html = f"""
+
+ Rapport de rencontre
+
+ Annonces
+ Notes
{escape(reunion.note or 'Aucune note.')}
+ Collecte
{collecte_html}
+
+ Ventes de littérature
{ventes_lit_html}
+ Ventes de jetons
{ventes_jetons_html}
+ Jetons/Gâteaux
{jetons_html}
+
+ """
+ return html, jour
+
+
@router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion(
req: ReunionCreate,
@@ -216,6 +351,7 @@ async def liste_rencontres_details(
"annees": jeton.annees,
})
+ rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
rencontres.append({
"id": str(reunion.id),
"type": reunion.type,
@@ -229,6 +365,7 @@ async def liste_rencontres_details(
"ventes_litterature": ventes_litterature,
"ventes_jetons": ventes_jetons,
"jetons": jetons,
+ "rapport_adopte_id": str(rapport_adopte.id) if rapport_adopte else None,
})
return rencontres
@@ -247,6 +384,9 @@ async def modifier_reunion(
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, reunion.id, "rencontre", "Cette rencontre est clôturée"
+ )
await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions")
avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note")
animateur = None
@@ -301,137 +441,16 @@ async def rapport_reunion_pdf(
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
-
- groupe = await db.get(Groupe, membre.groupe_id)
- animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
- jour = reunion.date_reunion.date()
-
- collecte = (await db.execute(
- select(Collecte).where(
- Collecte.groupe_id == membre.groupe_id,
- (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
- ).limit(1)
- )).scalar_one_or_none()
- collecte_html = "Aucune collecte saisie.
"
- if collecte:
- detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
- collecte_html = (
- ""
- f"| Montant | {float(collecte.montant):.2f} $ |
"
- f"| Détenteur | {escape(detenteur.prenom if detenteur else '—')} |
"
- f"| Statut | {escape(collecte.statut)} |
"
- "
"
+ adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
+ if adopte:
+ pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
+ filename = f"rapport_rencontre_{reunion.date_reunion.date().isoformat()}.pdf"
+ return StreamingResponse(
+ io.BytesIO(pdf_bytes),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
-
- ventes_lit_rows = []
- ventes_lit_result = await db.execute(
- select(VenteLitterature).where(
- VenteLitterature.groupe_id == membre.groupe_id,
- (VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
- )
- )
- for vente in ventes_lit_result.scalars().all():
- item = await db.get(Litterature, vente.litterature_id)
- vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
- ventes_lit_rows.append(
- f"| {escape(item.titre if item else '?')} | {vente.quantite} | "
- f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else '—')} | "
- f"{'Reçue' if vente.remis_tresorier else 'À recevoir'} |
"
- )
-
- ventes_jetons_rows = []
- ventes_jetons_result = await db.execute(
- select(VenteJeton).where(
- VenteJeton.groupe_id == membre.groupe_id,
- (VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
- )
- )
- for vente in ventes_jetons_result.scalars().all():
- vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
- ventes_jetons_rows.append(
- f"| {escape(vente.type_jeton)} | {vente.quantite} | "
- f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else '—')} | "
- f"{'Reçue' if vente.remis_tresorier else 'À recevoir'} |
"
- )
-
- jetons_rows = []
- jetons_result = await db.execute(
- select(Jeton).where(
- Jeton.groupe_id == membre.groupe_id,
- (Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
- )
- )
- for jeton in jetons_result.scalars().all():
- cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
- detail = f"{jeton.annees} ans" if jeton.annees else ""
- jetons_rows.append(
- f"| {escape(jeton.type_jeton)} | {escape(cible.prenom if cible else 'Anonyme')} | {escape(detail)} |
"
- )
-
- annonces = reunion.annonces or []
- annonces_html = "".join(f"{escape(a)}" for a in annonces) or "Aucune annonce."
- css = """
- body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
- .header { border-bottom: 3px solid #1a5276; padding-bottom: 12pt; margin-bottom: 18pt; }
- .groupe { color: #1a5276; font-size: 20pt; font-weight: 800; }
- .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
- h1 { font-size: 16pt; color: #1a5276; margin: 0 0 12pt; }
- h2 { font-size: 12pt; color: #1a5276; margin: 16pt 0 6pt; border-bottom: 1px solid #d8e2ea; padding-bottom: 3pt; }
- .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
- .box { border: 1px solid #d8e2ea; border-radius: 6pt; padding: 8pt; background: #f8fafc; }
- .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
- .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
- table { width: 100%; border-collapse: collapse; margin-top: 5pt; }
- th { text-align: left; background: #1a5276; color: white; padding: 5pt; font-size: 8.5pt; }
- td { border-bottom: 1px solid #e5e9ef; padding: 5pt; vertical-align: top; }
- .montant { text-align: right; white-space: nowrap; }
- .note { white-space: pre-wrap; }
- .page-break { break-before: page; page-break-before: always; }
- .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
- """
- groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
- coords = " · ".join([
- item for item in [
- groupe.adresse_local if groupe else None,
- f"District {groupe.district}" if groupe and groupe.district else None,
- ] if item
- ])
- ventes_lit_html = (
- "| Titre | Qté | Montant | Vendu par | Statut |
"
- + "".join(ventes_lit_rows) + "
"
- if ventes_lit_rows else "Aucune vente de littérature.
"
- )
- ventes_jetons_html = (
- "| Type | Qté | Montant | Vendu par | Statut |
"
- + "".join(ventes_jetons_rows) + "
"
- if ventes_jetons_rows else "Aucune vente de jetons.
"
- )
- jetons_html = (
- "| Type | Membre | Détail |
" + "".join(jetons_rows) + "
"
- if jetons_rows else "Aucun jeton/gâteau comptabilisé.
"
- )
- html = f"""
-
- Rapport de rencontre
-
- Annonces
- Notes
{escape(reunion.note or 'Aucune note.')}
- Collecte
{collecte_html}
-
- Ventes de littérature
{ventes_lit_html}
- Ventes de jetons
{ventes_jetons_html}
- Jetons/Gâteaux
{jetons_html}
-
- """
-
+ html, jour = await _html_rapport_rencontre(reunion, membre, db)
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
return StreamingResponse(
@@ -441,6 +460,40 @@ async def rapport_reunion_pdf(
)
+@router.post("/{reunion_id}/cloturer")
+async def cloturer_rencontre(
+ reunion_id: str,
+ membre=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ """Cristalliser le rapport d'une rencontre régulière."""
+ reunion = (await db.execute(
+ select(Reunion).where(
+ Reunion.id == reunion_id,
+ Reunion.groupe_id == membre.groupe_id,
+ Reunion.type == "reguliere",
+ )
+ )).scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Rencontre introuvable")
+ if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre"):
+ raise HTTPException(status_code=400, detail="Cette rencontre est déjà clôturée")
+ html, jour = await _html_rapport_rencontre(reunion, membre, db)
+ rapport = RapportAdopte(
+ groupe_id=membre.groupe_id,
+ adopte_par_id=membre.id,
+ type_rapport="rencontre",
+ titre=f"Rapport de rencontre — {jour.isoformat()}",
+ annee=jour.year,
+ mois=jour.month,
+ contenu_html=html,
+ donnees={"reunion_id": str(reunion.id), "date": jour.isoformat()},
+ )
+ db.add(rapport)
+ await db.flush()
+ return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
+
+
@router.get("/{reunion_id}", response_model=ReunionOut)
async def detail_reunion(
reunion_id: str,
diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py
index 094012c..51d048b 100644
--- a/backend/app/routers/ventes_litterature.py
+++ b/backend/app/routers/ventes_litterature.py
@@ -15,6 +15,7 @@ from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable
from app.models.reunion import Reunion
from app.schemas.phase6 import VenteCreate, VenteOut
+from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"])
@@ -73,6 +74,9 @@ async def enregistrer_vente(
raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
+ await verifier_reunion_non_cristallisee(
+ db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
+ )
# Créer la vente
vente = VenteLitterature(
diff --git a/backend/app/schemas/phase4.py b/backend/app/schemas/phase4.py
index 2479ca8..ec68d9f 100644
--- a/backend/app/schemas/phase4.py
+++ b/backend/app/schemas/phase4.py
@@ -33,6 +33,7 @@ class ReunionAffairesOut(BaseModel):
date_reunion: datetime
note: Optional[str] = None
modifie_le: Optional[datetime] = None
+ rapport_adopte_id: Optional[UUID] = None
presences: list[PresenceOut] = []
model_config = {"from_attributes": True}
diff --git a/backend/app/services/rapports_adoptes.py b/backend/app/services/rapports_adoptes.py
new file mode 100644
index 0000000..aa24564
--- /dev/null
+++ b/backend/app/services/rapports_adoptes.py
@@ -0,0 +1,37 @@
+"""Aides pour les rapports cristallisés."""
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.rapport_adopte import RapportAdopte
+
+
+async def rapport_adopte_pour_reunion(
+ db: AsyncSession,
+ groupe_id,
+ reunion_id,
+ type_rapport: str,
+) -> RapportAdopte | None:
+ result = await db.execute(
+ select(RapportAdopte).where(
+ RapportAdopte.groupe_id == groupe_id,
+ RapportAdopte.type_rapport == type_rapport,
+ )
+ )
+ reunion_id_str = str(reunion_id)
+ for rapport in result.scalars().all():
+ if (rapport.donnees or {}).get("reunion_id") == reunion_id_str:
+ return rapport
+ return None
+
+
+async def verifier_reunion_non_cristallisee(
+ db: AsyncSession,
+ groupe_id,
+ reunion_id,
+ type_rapport: str,
+ detail: str,
+) -> None:
+ from fastapi import HTTPException
+
+ if await rapport_adopte_pour_reunion(db, groupe_id, reunion_id, type_rapport):
+ raise HTTPException(status_code=400, detail=detail)
diff --git a/docs/10_MANUEL_UTILISATEUR.md b/docs/10_MANUEL_UTILISATEUR.md
index 56cf80c..4e6910b 100644
--- a/docs/10_MANUEL_UTILISATEUR.md
+++ b/docs/10_MANUEL_UTILISATEUR.md
@@ -82,10 +82,13 @@ Une tuile ouverte donne accès aux actions de la rencontre:
- `Collecte`;
- `Ventes`;
- `Jetons/Gâteaux`;
+- `Clôturer`;
- `Rapport PDF`.
Les exécutifs peuvent planifier et modifier une rencontre.
+Quand une rencontre est clôturée, son rapport PDF est cristallisé. Les données de cette rencontre ne peuvent plus être changées depuis les modules de saisie.
+
## 7. Collecte
Une rencontre a une collecte. Pour saisir une collecte:
@@ -124,10 +127,13 @@ Une assemblée peut contenir:
- décisions;
- contributions;
- postes à pourvoir;
+- adoption du rapport;
- rapport PDF.
La page de l'assemblée sert d'ordre du jour. Le rapport PDF sert de procès-verbal imprimable.
+Un exécutif peut adopter le rapport d'assemblée avec un proposeur et un secondeur. Après adoption, le rapport PDF est cristallisé et les données de cette assemblée ne peuvent plus être changées.
+
## 10. Présences
Dans une assemblée, ouvrir `Présences`, cocher les membres présents et enregistrer. Les présences alimentent le rapport PDF.
@@ -157,6 +163,8 @@ Avant adoption, le rapport peut être corrigé. Après adoption, il est cristall
Le rapport du trésorier doit être adopté avant de décider du montant des contributions.
+Les rapports cristallisés sont téléchargés depuis l'icône de rapport sur les tuiles concernées.
+
## 13. Gouvernance
Le module `Gouvernance` rassemble les propositions, réflexions en cours, rejets et décisions.
diff --git a/frontend/src/pages/AssembleeAffaires.jsx b/frontend/src/pages/AssembleeAffaires.jsx
index eff4037..ea2a18c 100644
--- a/frontend/src/pages/AssembleeAffaires.jsx
+++ b/frontend/src/pages/AssembleeAffaires.jsx
@@ -33,6 +33,7 @@ export default function AssembleeAffaires({ contexte = false }) {
const [pvPrecedent, setPvPrecedent] = useState(null);
const [pvCorrection, setPvCorrection] = useState('');
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
+ const [adoptionAssemblee, setAdoptionAssemblee] = useState({ proposeur_id: '', secondeur_id: '' });
const [don, setDon] = useState({ destination: 'District 87-16', montant: '', methode: 'VIREMENT', proposeur_id: '', secondeur_id: '' });
const [adoptionTresorier, setAdoptionTresorier] = useState({ proposeur_id: '', secondeur_id: '', note: '' });
const [invitationTexte, setInvitationTexte] = useState('');
@@ -85,6 +86,11 @@ export default function AssembleeAffaires({ contexte = false }) {
proposeur_id: f.proposeur_id || m[0]?.id || '',
secondeur_id: f.secondeur_id || m[1]?.id || '',
}));
+ setAdoptionAssemblee((f) => ({
+ ...f,
+ proposeur_id: f.proposeur_id || m[0]?.id || '',
+ secondeur_id: f.secondeur_id || m[1]?.id || '',
+ }));
if (!membreProposeId && m[0]) setMembreProposeId(m[0].id);
} catch (e) {
setError(e.message);
@@ -318,6 +324,25 @@ export default function AssembleeAffaires({ contexte = false }) {
}
};
+ const adopterAssemblee = async () => {
+ if (!reunionOuverte) return;
+ if (!adoptionAssemblee.proposeur_id || !adoptionAssemblee.secondeur_id) {
+ setError('Proposeur et secondeur requis');
+ return;
+ }
+ setLoading(true);
+ setError('');
+ try {
+ await api.post(`/presences/reunions/${reunionOuverte.id}/adopter`, adoptionAssemblee);
+ setMsg('Rapport d’assemblée adopté');
+ setAction('');
+ await charger();
+ } catch (e) {
+ setError(e.message);
+ }
+ setLoading(false);
+ };
+
return (
@@ -357,7 +382,7 @@ export default function AssembleeAffaires({ contexte = false }) {
{labelDateReunion(date)} · {heure(r.date_reunion)}
-
{pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)
+
{r.rapport_adopte_id ? 'Rapport adopté' : pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)
{ouvert ? '⌃' : '⌄'}
@@ -372,15 +397,33 @@ export default function AssembleeAffaires({ contexte = false }) {
{ouvert && (
-
-
- {pvPrecedent && }
- {estExecutif && }
- {estExecutif && }
-
-
-
-
+ {r.rapport_adopte_id ? (
+
+ Rapport adopté. Les données de cette assemblée sont cristallisées.
+
+ ) : (
+
+
+ {pvPrecedent && }
+ {estExecutif && }
+ {estExecutif && }
+
+
+
+ {estExecutif && }
+
+ )}
+
+ {action === 'Adopter' && estExecutif && !r.rapport_adopte_id && (
+
+ Le rapport PDF sera cristallisé et ne pourra plus être modifié.
+
+ setAdoptionAssemblee({ ...adoptionAssemblee, proposeur_id: v })} membres={membres} />
+ setAdoptionAssemblee({ ...adoptionAssemblee, secondeur_id: v })} membres={membres} />
+
+
+
+ )}
{action === 'PV précédent' && pvPrecedent && (
diff --git a/frontend/src/pages/ReunionHebdomadaire.jsx b/frontend/src/pages/ReunionHebdomadaire.jsx
index 201820f..2148347 100644
--- a/frontend/src/pages/ReunionHebdomadaire.jsx
+++ b/frontend/src/pages/ReunionHebdomadaire.jsx
@@ -234,6 +234,22 @@ export default function ReunionHebdomadaire({ contexte = false }) {
}
};
+ const cloturerRencontre = async () => {
+ if (!rencontreOuverte) return;
+ setLoading(true);
+ setError('');
+ try {
+ await api.post(`/reunions/${rencontreOuverte.id}/cloturer`);
+ flash('Rencontre clôturée');
+ setAction('');
+ await charger();
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
const heure = (dateIso) => heureIsoSansFuseau(dateIso);
return (
@@ -268,7 +284,7 @@ export default function ReunionHebdomadaire({ contexte = false }) {
{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}
-
Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}
+
Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}{r.rapport_adopte_id ? ' · Clôturée' : ''}
{ouverteId === r.id ? '⌃' : '⌄'}
@@ -284,20 +300,43 @@ export default function ReunionHebdomadaire({ contexte = false }) {
{ouverteId === r.id && (
-
- {['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
-
+
+ )}
{action === 'Collecte' && (