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.
223 lines
7.8 KiB
Python
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
|