From 14fa8b4174c9828db04f2457c5647d4de307d0e9 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 1 Jun 2026 19:45:00 -0400 Subject: [PATCH] Cristalliser rapports de reunions et assemblees --- backend/app/routers/collectes.py | 8 + backend/app/routers/jetons.py | 7 + backend/app/routers/presences.py | 302 +++++++++++++------- backend/app/routers/pv.py | 14 + backend/app/routers/reunions.py | 313 ++++++++++++--------- backend/app/routers/ventes_litterature.py | 4 + backend/app/schemas/phase4.py | 1 + backend/app/services/rapports_adoptes.py | 37 +++ docs/10_MANUEL_UTILISATEUR.md | 8 + frontend/src/pages/AssembleeAffaires.jsx | 63 ++++- frontend/src/pages/ReunionHebdomadaire.jsx | 67 ++++- 11 files changed, 567 insertions(+), 257 deletions(-) create mode 100644 backend/app/services/rapports_adoptes.py 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""" +
    +
    {escape(groupe_nom)}
    +
    {escape(coords)}
    +
    +

    Rapport d'assemblée d'affaires

    +
    +
    Date
    {jour.isoformat()}
    +
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    +
    Présences
    {len(presences_rows)}
    +
    PV
    {'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}
    +
    +

    Présences

    +

    Réflexions en cours

    +

    Rapport du trésorier

    {tresorier_html} +

    Décisions prises

    +

    Postes disponibles

    +

    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""" -
    -
    {escape(groupe_nom)}
    -
    {escape(coords)}
    -
    -

    Rapport d'assemblée d'affaires

    -
    -
    Date
    {jour.isoformat()}
    -
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    -
    Présences
    {len(presences_rows)}
    -
    PV
    {'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}
    -
    -

    Présences

    -

    Réflexions en cours

    -

    Décisions prises

    -

    Postes disponibles

    -

    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"" + f"" + f"" + "
    Montant{float(collecte.montant):.2f} $
    Détenteur{escape(detenteur.prenom if detenteur else '—')}
    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 = ( + "" + + "".join(ventes_lit_rows) + "
    TitreQtéMontantVendu parStatut
    " + if ventes_lit_rows else "

    Aucune vente de littérature.

    " + ) + ventes_jetons_html = ( + "" + + "".join(ventes_jetons_rows) + "
    TypeQtéMontantVendu parStatut
    " + if ventes_jetons_rows else "

    Aucune vente de jetons.

    " + ) + jetons_html = ( + "" + "".join(jetons_rows) + "
    TypeMembreDétail
    " + if jetons_rows else "

    Aucun jeton/gâteau comptabilisé.

    " + ) + html = f""" +
    +
    {escape(groupe_nom)}
    +
    {escape(coords)}
    +
    +

    Rapport de rencontre

    +
    +
    Date
    {reunion.date_reunion.date().isoformat()}
    +
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    +
    Sujet
    {escape(reunion.theme or 'Rencontre hebdomadaire')}
    +
    Animé par
    {escape(animateur.prenom if animateur else '—')}
    +
    +

    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"" - f"" - f"" - "
    Montant{float(collecte.montant):.2f} $
    Détenteur{escape(detenteur.prenom if detenteur else '—')}
    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 = ( - "" - + "".join(ventes_lit_rows) + "
    TitreQtéMontantVendu parStatut
    " - if ventes_lit_rows else "

    Aucune vente de littérature.

    " - ) - ventes_jetons_html = ( - "" - + "".join(ventes_jetons_rows) + "
    TypeQtéMontantVendu parStatut
    " - if ventes_jetons_rows else "

    Aucune vente de jetons.

    " - ) - jetons_html = ( - "" + "".join(jetons_rows) + "
    TypeMembreDétail
    " - if jetons_rows else "

    Aucun jeton/gâteau comptabilisé.

    " - ) - html = f""" -
    -
    {escape(groupe_nom)}
    -
    {escape(coords)}
    -
    -

    Rapport de rencontre

    -
    -
    Date
    {reunion.date_reunion.date().isoformat()}
    -
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    -
    Sujet
    {escape(reunion.theme or 'Rencontre hebdomadaire')}
    -
    Animé par
    {escape(animateur.prenom if animateur else '—')}
    -
    -

    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) => ( - + ))} + {estExecutif && ( + <> + + + + )} +
    + )} + + {action === 'Clôturer' && !r.rapport_adopte_id && ( + +

    + Le rapport PDF sera cristallisé. Les saisies de cette rencontre ne pourront plus être modifiées. +

    + - ))} - {estExecutif && ( - - )} -
    +
    + )} {action === 'Collecte' && (