Cristalliser rapports de reunions et assemblees
This commit is contained in:
parent
086bdbd548
commit
14fa8b4174
11 changed files with 567 additions and 257 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"<li>{escape(mbr.prenom if mbr else '?')}</li>")
|
||||
|
||||
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"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
|
||||
)
|
||||
|
||||
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"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
|
||||
for r in resolutions
|
||||
]
|
||||
|
||||
postes_rows = [f"<li>{escape(p.nom)}</li>" 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"<p>Rapport adopté: {escape(rapport_tresorier.titre)}</p>"
|
||||
if rapport_tresorier else "<p>Rapport du trésorier à adopter avant les contributions.</p>"
|
||||
)
|
||||
|
||||
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"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport d'assemblée d'affaires</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
|
||||
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
|
||||
</div>
|
||||
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
|
||||
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
|
||||
<h2>Rapport du trésorier</h2>{tresorier_html}
|
||||
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
|
||||
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
|
||||
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></html>"""
|
||||
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"<li>{escape(mbr.prenom if mbr else '?')}</li>")
|
||||
|
||||
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"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
|
||||
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"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
|
||||
for r in resolutions
|
||||
]
|
||||
|
||||
postes_rows = [f"<li>{escape(p.nom)}</li>" 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"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport d'assemblée d'affaires</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
|
||||
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
|
||||
</div>
|
||||
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
|
||||
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
|
||||
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
|
||||
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
|
||||
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></html>"""
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = "<p>Aucune collecte saisie.</p>"
|
||||
if collecte:
|
||||
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
|
||||
collecte_html = (
|
||||
"<table>"
|
||||
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
|
||||
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
|
||||
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
|
||||
"</table>"
|
||||
)
|
||||
|
||||
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"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
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"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
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"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
|
||||
)
|
||||
|
||||
annonces = reunion.annonces or []
|
||||
annonces_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
|
||||
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 = (
|
||||
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_lit_rows) + "</table>"
|
||||
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
|
||||
)
|
||||
ventes_jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_jetons_rows) + "</table>"
|
||||
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
|
||||
)
|
||||
jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
|
||||
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
|
||||
)
|
||||
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport de rencontre</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
|
||||
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
|
||||
</div>
|
||||
<h2>Annonces</h2><ul>{annonces_html}</ul>
|
||||
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
|
||||
<h2>Collecte</h2>{collecte_html}
|
||||
<div class="page-break"></div>
|
||||
<h2>Ventes de littérature</h2>{ventes_lit_html}
|
||||
<h2>Ventes de jetons</h2>{ventes_jetons_html}
|
||||
<h2>Jetons/Gâteaux</h2>{jetons_html}
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></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 = "<p>Aucune collecte saisie.</p>"
|
||||
if collecte:
|
||||
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
|
||||
collecte_html = (
|
||||
"<table>"
|
||||
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
|
||||
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
|
||||
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
|
||||
"</table>"
|
||||
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"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
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"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
|
||||
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
|
||||
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
|
||||
)
|
||||
|
||||
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"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
|
||||
)
|
||||
|
||||
annonces = reunion.annonces or []
|
||||
annonces_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
|
||||
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 = (
|
||||
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_lit_rows) + "</table>"
|
||||
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
|
||||
)
|
||||
ventes_jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
|
||||
+ "".join(ventes_jetons_rows) + "</table>"
|
||||
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
|
||||
)
|
||||
jetons_html = (
|
||||
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
|
||||
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
|
||||
)
|
||||
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{escape(groupe_nom)}</div>
|
||||
<div class="coords">{escape(coords)}</div>
|
||||
</div>
|
||||
<h1>Rapport de rencontre</h1>
|
||||
<div class="meta">
|
||||
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().isoformat()}</div></div>
|
||||
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
|
||||
<div class="box"><div class="label">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
|
||||
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
|
||||
</div>
|
||||
<h2>Annonces</h2><ul>{annonces_html}</ul>
|
||||
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
|
||||
<h2>Collecte</h2>{collecte_html}
|
||||
<div class="page-break"></div>
|
||||
<h2>Ventes de littérature</h2>{ventes_lit_html}
|
||||
<h2>Ventes de jetons</h2>{ventes_jetons_html}
|
||||
<h2>Jetons/Gâteaux</h2>{jetons_html}
|
||||
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||
</body></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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
37
backend/app/services/rapports_adoptes.py
Normal file
37
backend/app/services/rapports_adoptes.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Assemblées" sousTitre="Assemblées d'affaires et procès-verbaux" retour={!contexte} />
|
||||
|
|
@ -357,7 +382,7 @@ export default function AssembleeAffaires({ contexte = false }) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-extrabold text-gray-900">{labelDateReunion(date)} · {heure(r.date_reunion)}</p>
|
||||
<p className="text-sm text-gray-500 truncate">{pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)</p>
|
||||
<p className="text-sm text-gray-500 truncate">{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)</p>
|
||||
</div>
|
||||
<span className="text-xl text-gray-300">{ouvert ? '⌃' : '⌄'}</span>
|
||||
</div>
|
||||
|
|
@ -372,15 +397,33 @@ export default function AssembleeAffaires({ contexte = false }) {
|
|||
|
||||
{ouvert && (
|
||||
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-2 print:hidden">
|
||||
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</button>
|
||||
{pvPrecedent && <button onClick={() => setAction(action === 'PV précédent' ? '' : 'PV précédent')} className={boutonAction(action === 'PV précédent')}>PV précédent</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Présences' ? '' : 'Présences')} className={boutonAction(action === 'Présences')}>Présences</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Trésorier' ? '' : 'Trésorier')} className={boutonAction(action === 'Trésorier')}>Rapport trésorier</button>}
|
||||
<button onClick={() => setAction(action === 'Donner' ? '' : 'Donner')} className={boutonAction(action === 'Donner')}>Donner au suivant</button>
|
||||
<button onClick={() => setAction(action === 'Postes' ? '' : 'Postes')} className={boutonAction(action === 'Postes')}>Postes à pourvoir</button>
|
||||
<button onClick={() => setAction(action === 'Décisions' ? '' : 'Décisions')} className={boutonAction(action === 'Décisions')}>Décisions</button>
|
||||
</div>
|
||||
{r.rapport_adopte_id ? (
|
||||
<div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700 print:hidden">
|
||||
Rapport adopté. Les données de cette assemblée sont cristallisées.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 print:hidden">
|
||||
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</button>
|
||||
{pvPrecedent && <button onClick={() => setAction(action === 'PV précédent' ? '' : 'PV précédent')} className={boutonAction(action === 'PV précédent')}>PV précédent</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Présences' ? '' : 'Présences')} className={boutonAction(action === 'Présences')}>Présences</button>}
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Trésorier' ? '' : 'Trésorier')} className={boutonAction(action === 'Trésorier')}>Rapport trésorier</button>}
|
||||
<button onClick={() => setAction(action === 'Donner' ? '' : 'Donner')} className={boutonAction(action === 'Donner')}>Donner au suivant</button>
|
||||
<button onClick={() => setAction(action === 'Postes' ? '' : 'Postes')} className={boutonAction(action === 'Postes')}>Postes à pourvoir</button>
|
||||
<button onClick={() => setAction(action === 'Décisions' ? '' : 'Décisions')} className={boutonAction(action === 'Décisions')}>Décisions</button>
|
||||
{estExecutif && <button onClick={() => setAction(action === 'Adopter' ? '' : 'Adopter')} className={boutonAction(action === 'Adopter')}>Adopter</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'Adopter' && estExecutif && !r.rapport_adopte_id && (
|
||||
<Card titre="Adopter le rapport d'assemblée">
|
||||
<p className="text-sm text-gray-600">Le rapport PDF sera cristallisé et ne pourra plus être modifié.</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Champ label="Proposeur"><MembreSelect label="Proposeur" value={adoptionAssemblee.proposeur_id} onChange={(v) => setAdoptionAssemblee({ ...adoptionAssemblee, proposeur_id: v })} membres={membres} /></Champ>
|
||||
<Champ label="Secondeur"><MembreSelect label="Secondeur" value={adoptionAssemblee.secondeur_id} onChange={(v) => setAdoptionAssemblee({ ...adoptionAssemblee, secondeur_id: v })} membres={membres} /></Champ>
|
||||
</div>
|
||||
<button onClick={adopterAssemblee} disabled={loading || !adoptionAssemblee.proposeur_id || !adoptionAssemblee.secondeur_id} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Adopter</button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'PV précédent' && pvPrecedent && (
|
||||
<Card titre="Procès-verbal précédent">
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-extrabold text-gray-900">{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}</p>
|
||||
<p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}</p>
|
||||
<p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}{r.rapport_adopte_id ? ' · Clôturée' : ''}</p>
|
||||
</div>
|
||||
<span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span>
|
||||
</div>
|
||||
|
|
@ -284,20 +300,43 @@ export default function ReunionHebdomadaire({ contexte = false }) {
|
|||
{ouverteId === r.id && (
|
||||
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
|
||||
<Resume rencontre={r} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
|
||||
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
{nom}
|
||||
{r.rapport_adopte_id ? (
|
||||
<div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700">
|
||||
Rapport clôturé. Les données de cette rencontre sont cristallisées.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
|
||||
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
{nom}
|
||||
</button>
|
||||
))}
|
||||
{estExecutif && (
|
||||
<>
|
||||
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
Modifier
|
||||
</button>
|
||||
<button onClick={() => setAction(action === 'Clôturer' ? '' : 'Clôturer')}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === 'Clôturer' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
Clôturer
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'Clôturer' && !r.rapport_adopte_id && (
|
||||
<Card titre="Clôturer la rencontre">
|
||||
<p className="text-sm text-gray-600">
|
||||
Le rapport PDF sera cristallisé. Les saisies de cette rencontre ne pourront plus être modifiées.
|
||||
</p>
|
||||
<button onClick={cloturerRencontre} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">
|
||||
Clôturer
|
||||
</button>
|
||||
))}
|
||||
{estExecutif && (
|
||||
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
|
||||
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
|
||||
Modifier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{action === 'Collecte' && (
|
||||
<Card titre="Collecte">
|
||||
|
|
|
|||
Loading…
Reference in a new issue