groupe-meditation/backend/app/routers/contributions.py

342 lines
13 KiB
Python

"""Routes M4 — Envoi de contributions aux organismes AA."""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.roles import require_tresorier
from app.core.security import is_sysadmin
from app.core.security import get_current_membre
from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import position_tresorerie, transaction
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION,
DestinataireContributionCreate, DestinataireContributionUpdate, DestinataireContributionOut,
)
router = APIRouter(prefix="/contributions", tags=["M4 — Donner au suivant"])
# Répartition par défaut selon le Guide du groupe
DEFAUT_REPARTITION = {
"district": 30.0,
"region": 30.0,
"bsg": 30.0,
"intergroupe": 10.0,
}
async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
"""Récupère la répartition active ou utilise les défauts."""
result = await db.execute(
select(ConfigRepartition)
.where(ConfigRepartition.groupe_id == groupe_id)
.order_by(desc(ConfigRepartition.actif_depuis))
)
configs = result.scalars().all()
# Garder la plus récente par destination
repartition = {}
for c in configs:
if c.destination not in repartition:
repartition[c.destination] = float(c.pourcentage)
# Compléter avec les défauts
for dest in DESTINATIONS:
if dest not in repartition:
repartition[dest] = DEFAUT_REPARTITION[dest]
return repartition
async def _ensure_destinataire_defaut(groupe_id, db: AsyncSession) -> DestinataireContribution:
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == "district 87-16",
)
)
dest = result.scalar_one_or_none()
if dest:
if not dest.protege or not dest.actif:
dest.protege = True
dest.actif = True
await db.flush()
return dest
dest = DestinataireContribution(groupe_id=groupe_id, nom="District 87-16", protege=True, actif=True)
db.add(dest)
await db.flush()
return dest
async def _get_destinataire(groupe_id, nom: str, db: AsyncSession) -> DestinataireContribution:
nom = (nom or "").strip()
if not nom:
raise HTTPException(status_code=400, detail="Destinataire requis")
await _ensure_destinataire_defaut(groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
DestinataireContribution.actif == True,
)
)
dest = result.scalar_one_or_none()
if not dest:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
return dest
@router.get("/destinataires", response_model=list[DestinataireContributionOut])
async def liste_destinataires(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
await _ensure_destinataire_defaut(membre.groupe_id, db)
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
DestinataireContribution.actif == True,
).order_by(DestinataireContribution.protege.desc(), DestinataireContribution.nom)
)
return [DestinataireContributionOut.model_validate(d) for d in result.scalars().all()]
@router.post("/destinataires", response_model=DestinataireContributionOut)
async def creer_destinataire(
req: DestinataireContributionCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
result = await db.execute(
select(DestinataireContribution).where(
DestinataireContribution.groupe_id == membre.groupe_id,
func.lower(DestinataireContribution.nom) == nom.lower(),
)
)
existant = result.scalar_one_or_none()
if existant:
existant.actif = True
await db.flush()
return DestinataireContributionOut.model_validate(existant)
dest = DestinataireContribution(groupe_id=membre.groupe_id, nom=nom, protege=nom.lower() == "district 87-16")
db.add(dest)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.patch("/destinataires/{destinataire_id}", response_model=DestinataireContributionOut)
async def modifier_destinataire(
destinataire_id: str,
req: DestinataireContributionUpdate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
dest.nom = nom
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@router.delete("/destinataires/{destinataire_id}")
async def supprimer_destinataire(
destinataire_id: str,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
dest.actif = False
await db.flush()
return {"message": "Destinataire supprimé"}
@router.post("/calculer", response_model=list[CalculRepartitionOut])
async def calculer_repartition(
req: CalculRepartitionIn,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Calculer la répartition d'un montant selon les % configurés."""
if req.montant_total <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
repartition = await _get_repartition(membre.groupe_id, db)
return [
CalculRepartitionOut(
destination=dest,
destination_label=DESTINATIONS_LABELS[dest],
pourcentage=pct,
montant=round(req.montant_total * pct / 100, 2),
)
for dest, pct in repartition.items()
]
@router.post("/", response_model=EnvoiContributionOut)
async def enregistrer_envoi(
req: EnvoiContributionCreate,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""M4 — Enregistrer un envoi de contribution et créer sa résolution."""
from app.models.proposition import Proposition
destination = await _get_destinataire(membre.groupe_id, req.destination, db)
if req.methode.upper() not in METHODES_CONTRIBUTION:
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}")
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")
date_decision = req.date_envoi if is_sysadmin(membre) else date.today()
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
description=(
f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. "
f"Méthode prévue : {req.methode.upper()}."
),
proposeur_id=req.proposeur_id,
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date_decision,
date_adoption=date_decision,
groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {destination.nom}.",
)
db.add(resolution)
await db.flush()
envoi = EnvoiContribution(
groupe_id=membre.groupe_id,
date_envoi=req.date_envoi,
destination=destination.nom,
montant=req.montant,
methode=req.methode.upper(),
resolution_id=resolution.id,
engagee=True,
debitee=False,
saisi_par=membre.id,
)
db.add(envoi)
await db.flush()
return EnvoiContributionOut(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,
engagee=envoi.engagee,
debitee=envoi.debitee,
saisi_par_prenom=membre.prenom,
cree_le=envoi.cree_le,
)
@router.post("/{envoi_id}/confirmer-debit", response_model=EnvoiContributionOut)
async def confirmer_debit_contribution(
envoi_id: str,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que la contribution a été débitée du compte."""
result = await db.execute(
select(EnvoiContribution).where(
EnvoiContribution.id == envoi_id,
EnvoiContribution.groupe_id == membre.groupe_id,
)
)
envoi = result.scalar_one_or_none()
if not envoi:
raise HTTPException(status_code=404, detail="Envoi introuvable")
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
position = await position_tresorerie(membre.groupe_id, db)
if float(envoi.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
if position.solde_banque - float(envoi.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True
envoi.engagee = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None
return EnvoiContributionOut(
id=envoi.id,
date_envoi=envoi.date_envoi,
destination=envoi.destination,
destination_label=DESTINATIONS_LABELS.get(envoi.destination, envoi.destination),
montant=float(envoi.montant),
methode=envoi.methode,
resolution_id=envoi.resolution_id,
engagee=envoi.engagee,
debitee=envoi.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=envoi.cree_le,
)
@router.get("/", response_model=list[EnvoiContributionOut])
async def liste_envois(
limite: int = 30,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les envois de contributions récents."""
result = await db.execute(
select(EnvoiContribution)
.where(EnvoiContribution.groupe_id == membre.groupe_id)
.order_by(desc(EnvoiContribution.date_envoi))
.limit(limite)
)
envois = result.scalars().all()
out = []
for e in envois:
saisi = await db.get(Membre, e.saisi_par) if e.saisi_par else None
out.append(EnvoiContributionOut(
id=e.id,
date_envoi=e.date_envoi,
destination=e.destination,
destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination),
montant=float(e.montant),
methode=e.methode,
resolution_id=e.resolution_id,
engagee=e.engagee,
debitee=e.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=e.cree_le,
))
return out