2026-03-04 14:01:00 -05:00
|
|
|
"""Routes M4 — Envoi de contributions aux organismes AA."""
|
|
|
|
|
from datetime import date
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-05-15 16:41:17 -04:00
|
|
|
from sqlalchemy import select, desc, func
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
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.affectation import Affectation
|
|
|
|
|
from app.models.poste import Poste
|
|
|
|
|
from app.schemas.phase7 import (
|
|
|
|
|
EnvoiContributionCreate, EnvoiContributionOut,
|
|
|
|
|
CalculRepartitionIn, CalculRepartitionOut,
|
|
|
|
|
DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"])
|
|
|
|
|
|
|
|
|
|
# Répartition par défaut selon le Guide du groupe
|
|
|
|
|
DEFAUT_REPARTITION = {
|
|
|
|
|
"district": 30.0,
|
|
|
|
|
"region": 30.0,
|
|
|
|
|
"bsg": 30.0,
|
|
|
|
|
"intergroupe": 10.0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
|
|
|
|
|
from app.models.proposition import Proposition
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(func.count(Proposition.id)).where(
|
|
|
|
|
Proposition.groupe_id == groupe_id,
|
|
|
|
|
Proposition.numero_resolution.isnot(None),
|
|
|
|
|
Proposition.numero_resolution.like(f"RES-{annee}-%"),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
count = result.scalar() or 0
|
|
|
|
|
return f"RES-{annee}-{count + 1:03d}"
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Affectation).join(Poste).where(
|
|
|
|
|
Affectation.membre_id == membre.id,
|
|
|
|
|
Affectation.statut == "actif",
|
|
|
|
|
Poste.nom.ilike("%trésorier%"),
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-03-13 09:53:55 -04:00
|
|
|
if not result.scalars().first():
|
2026-03-04 14:01:00 -05:00
|
|
|
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
|
|
|
|
return membre
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
):
|
2026-05-15 16:41:17 -04:00
|
|
|
"""M4 — Enregistrer un envoi de contribution et créer sa résolution."""
|
2026-04-17 04:04:04 -04:00
|
|
|
from app.models.proposition import Proposition
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
if req.destination not in DESTINATIONS:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}")
|
|
|
|
|
if req.methode.upper() not in METHODES_CONTRIBUTION:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}")
|
2026-05-15 16:41:17 -04:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
numero_resolution = await _prochain_numero_resolution(membre.groupe_id, date.today().year, db)
|
|
|
|
|
resolution = Proposition(
|
|
|
|
|
groupe_id=membre.groupe_id,
|
|
|
|
|
type_proposition="contribution",
|
|
|
|
|
sujet=f"Envoi de {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}",
|
|
|
|
|
description=(
|
|
|
|
|
f"Contribution enregistrée le {req.date_envoi} par le module Envoi contributions. "
|
|
|
|
|
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.today(),
|
2026-04-17 04:04:04 -04:00
|
|
|
)
|
2026-05-15 16:41:17 -04:00
|
|
|
db.add(resolution)
|
|
|
|
|
await db.flush()
|
2026-04-17 04:04:04 -04:00
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
envoi = EnvoiContribution(
|
|
|
|
|
groupe_id=membre.groupe_id,
|
|
|
|
|
date_envoi=req.date_envoi,
|
|
|
|
|
destination=req.destination,
|
|
|
|
|
montant=req.montant,
|
|
|
|
|
methode=req.methode.upper(),
|
2026-05-15 16:41:17 -04:00
|
|
|
resolution_id=resolution.id,
|
2026-04-17 04:04:04 -04:00
|
|
|
engagee=True,
|
2026-05-15 16:41:17 -04:00
|
|
|
debitee=False,
|
2026-03-04 14:01:00 -05:00
|
|
|
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[envoi.destination],
|
|
|
|
|
montant=float(envoi.montant),
|
|
|
|
|
methode=envoi.methode,
|
2026-04-17 04:04:04 -04:00
|
|
|
resolution_id=envoi.resolution_id,
|
|
|
|
|
engagee=envoi.engagee,
|
|
|
|
|
debitee=envoi.debitee,
|
2026-03-04 14:01:00 -05:00
|
|
|
saisi_par_prenom=membre.prenom,
|
|
|
|
|
cree_le=envoi.cree_le,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
@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")
|
|
|
|
|
|
|
|
|
|
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
|
|
|
|
|
|
|
|
|
|
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
|
|
|
|
|
if float(envoi.montant) > solde_banque:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
|
|
|
|
|
total_reserves = await _total_reserves(membre.groupe_id, db)
|
|
|
|
|
if solde_banque - float(envoi.montant) < 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
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
@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,
|
2026-04-17 04:04:04 -04:00
|
|
|
resolution_id=e.resolution_id,
|
|
|
|
|
engagee=e.engagee,
|
|
|
|
|
debitee=e.debitee,
|
2026-03-04 14:01:00 -05:00
|
|
|
saisi_par_prenom=saisi.prenom if saisi else None,
|
|
|
|
|
cree_le=e.cree_le,
|
|
|
|
|
))
|
|
|
|
|
return out
|