groupe-meditation/backend/app/routers/jetons.py
Daniel Allaire 69309acd13 Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.

Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.

Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.

Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00

223 lines
7.8 KiB
Python

"""Routes S2 — Remise de jeton."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.jeton import Jeton
from app.models.vente_jeton import VenteJeton
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable
from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate
router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre) or await membre_a_module(membre.id, "S2", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au responsable des jetons")
return membre
@router.get("/types")
async def types_jetons():
"""Liste des types de jetons disponibles."""
return TYPES_JETONS
@router.post("/", response_model=JetonOut)
async def remettre_jeton(
req: JetonCreate,
membre=Depends(require_resp_jetons),
db: AsyncSession = Depends(get_db),
):
"""S2 — Enregistrer la remise d'un jeton."""
# Validation
types_valides = [t["value"] for t in TYPES_JETONS]
if req.type_jeton not in types_valides:
raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}")
if req.type_jeton == "multiple" and (not req.annees or req.annees < 2):
raise HTTPException(status_code=400, detail="Le nombre d'années est requis (≥ 2) pour un jeton multiple")
# Vérifier que le membre existe si spécifié
membre_prenom = None
if req.membre_id:
cible = await db.get(Membre, req.membre_id)
if not cible or not cible.actif:
raise HTTPException(status_code=404, detail="Membre introuvable")
membre_prenom = cible.prenom
jeton = Jeton(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
membre_id=req.membre_id,
type_jeton=req.type_jeton,
date_remise=req.date_remise,
annees=req.annees if req.type_jeton == "multiple" else None,
remis_par=membre.id,
)
db.add(jeton)
await db.flush()
return JetonOut(
id=jeton.id,
membre_prenom=membre_prenom,
type_jeton=jeton.type_jeton,
date_remise=jeton.date_remise,
annees=jeton.annees,
remis_par_prenom=membre.prenom,
cree_le=jeton.cree_le,
)
@router.post("/ventes")
async def enregistrer_vente_jeton(
req: VenteJetonCreate,
membre=Depends(require_resp_jetons),
db: AsyncSession = Depends(get_db),
):
"""Enregistrer une vente de jetons. Les remises ne touchent pas l'inventaire."""
types_valides = [t["value"] for t in TYPES_JETONS]
if req.type_jeton not in types_valides:
raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}")
if req.quantite <= 0:
raise HTTPException(status_code=400, detail="La quantité doit être positive")
if req.montant < 0:
raise HTTPException(status_code=400, detail="Le montant ne peut pas être négatif")
vendeur_id = req.vendu_par_id or membre.id
vendeur = await db.get(Membre, vendeur_id)
if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif:
raise HTTPException(status_code=404, detail="Vendeur introuvable")
vente = VenteJeton(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
type_jeton=req.type_jeton,
quantite=req.quantite,
montant=req.montant,
date_vente=req.date_vente,
vendu_par=vendeur_id,
)
db.add(vente)
await db.flush()
return {
"id": str(vente.id),
"type_jeton": vente.type_jeton,
"quantite": vente.quantite,
"montant": float(vente.montant),
"date_vente": str(vente.date_vente),
"vendu_par_prenom": vendeur.prenom,
"remis_tresorier": vente.remis_tresorier,
}
@router.get("/ventes/a-recevoir")
async def ventes_jetons_a_recevoir(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(VenteJeton)
.where(VenteJeton.groupe_id == membre.groupe_id, VenteJeton.remis_tresorier == False)
.order_by(desc(VenteJeton.date_vente))
)
out = []
for vente in result.scalars().all():
vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
out.append({
"id": str(vente.id),
"type_jeton": vente.type_jeton,
"quantite": vente.quantite,
"montant": float(vente.montant),
"date_vente": str(vente.date_vente),
"vendu_par_prenom": vendeur.prenom if vendeur else None,
})
return out
@router.post("/ventes/{vente_id}/recevoir")
async def confirmer_reception_vente_jeton(
vente_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not is_sysadmin(membre) and not await membre_a_module(membre.id, "S1T", db):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
vente = await db.get(VenteJeton, vente_id)
if not vente or vente.groupe_id != membre.groupe_id:
raise HTTPException(status_code=404, detail="Vente introuvable")
if vente.remis_tresorier:
raise HTTPException(status_code=400, detail="Déjà reçue")
from datetime import date as date_type
vente.remis_tresorier = True
vente.recu_par = membre.id
vente.date_remise = date_type.today()
db.add(TransactionComptable(
groupe_id=vente.groupe_id,
date_transaction=vente.date_vente,
type="vente_jeton",
compte="encaisse",
montant=vente.montant,
sens="debit",
source_table="ventes_jetons",
source_id=vente.id,
note="Vente de jetons reçue",
cree_par=membre.id,
))
await db.flush()
return {"message": "Réception confirmée"}
@router.get("/", response_model=list[JetonOut])
async def liste_jetons(
limite: int = 30,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les jetons remis récemment (tout le groupe)."""
result = await db.execute(
select(Jeton)
.where(Jeton.groupe_id == membre.groupe_id)
.order_by(desc(Jeton.date_remise))
.limit(limite)
)
jetons = result.scalars().all()
out = []
for j in jetons:
m = await db.get(Membre, j.membre_id) if j.membre_id else None
r = await db.get(Membre, j.remis_par) if j.remis_par else None
out.append(JetonOut(
id=j.id,
membre_prenom=m.prenom if m else None,
type_jeton=j.type_jeton,
date_remise=j.date_remise,
annees=j.annees,
remis_par_prenom=r.prenom if r else None,
cree_le=j.cree_le,
))
return out