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

246 lines
8.8 KiB
Python
Raw Normal View History

2026-04-17 04:04:04 -04:00
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os
import uuid as uuid_mod
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
2026-03-04 14:01:00 -05:00
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
from app.models.depense import Depense
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"}
2026-04-17 04:04:04 -04:00
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
2026-03-04 14:01:00 -05:00
2026-04-17 04:04:04 -04:00
async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool:
2026-03-04 14:01:00 -05:00
result = await db.execute(
select(Affectation).join(Poste).where(
2026-04-17 04:04:04 -04:00
Affectation.membre_id == membre_id,
2026-03-04 14:01:00 -05:00
Affectation.statut == "actif",
2026-04-17 04:04:04 -04:00
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
2026-03-04 14:01:00 -05:00
)
)
2026-04-17 04:04:04 -04:00
return result.scalars().first() is not None
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
if not await _est_tresorier_ou_executif(membre.id, db):
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
2026-03-04 14:01:00 -05:00
return membre
2026-04-17 04:04:04 -04:00
async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
qui = await db.get(Membre, d.qui)
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
return DepenseOut(
id=d.id,
date_depense=d.date_depense,
montant=float(d.montant),
qui_prenom=qui.prenom if qui else "?",
quoi=d.quoi,
paye=d.paye,
methode=d.methode,
preuve_nom=d.preuve_nom,
saisi_par_prenom=saisi.prenom if saisi else None,
confirme=d.confirme_par is not None,
cree_le=d.cree_le,
)
2026-03-04 14:01:00 -05:00
@router.post("/", response_model=DepenseOut)
2026-04-17 04:04:04 -04:00
async def soumettre_depense(
2026-03-04 14:01:00 -05:00
req: DepenseCreate,
2026-04-17 04:04:04 -04:00
membre=Depends(get_current_membre),
2026-03-04 14:01:00 -05:00
db: AsyncSession = Depends(get_db),
):
2026-04-17 04:04:04 -04:00
"""S5 — N'importe quel membre soumet une dépense courante."""
2026-03-04 14:01:00 -05:00
depense = Depense(
groupe_id=membre.groupe_id,
date_depense=req.date_depense,
montant=req.montant,
2026-04-17 04:04:04 -04:00
qui=membre.id, # le membre qui soumet est celui qui a payé
2026-03-04 14:01:00 -05:00
quoi=req.quoi.strip(),
2026-04-17 04:04:04 -04:00
paye=False,
methode=None, # sera renseigné par le trésorier
2026-03-04 14:01:00 -05:00
saisi_par=membre.id,
)
db.add(depense)
await db.flush()
2026-04-17 04:04:04 -04:00
return await _to_out(depense, db)
2026-03-04 14:01:00 -05:00
2026-04-17 04:04:04 -04:00
@router.post("/{depense_id}/preuve")
async def telecharger_preuve(
depense_id: str,
fichier: UploadFile = File(...),
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Attacher une preuve (reçu, photo) à une dépense."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
# Sauvegarder le fichier
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(fichier.filename)[1] if fichier.filename else ".jpg"
nom_fichier = f"{depense_id}{ext}"
chemin = os.path.join(UPLOAD_DIR, nom_fichier)
contenu = await fichier.read()
with open(chemin, "wb") as f:
f.write(contenu)
depense.preuve_nom = nom_fichier
await db.flush()
return {"message": "Preuve enregistrée", "fichier": nom_fichier}
@router.post("/{depense_id}/confirmer", response_model=DepenseOut)
async def confirmer_depense(
depense_id: str,
methode: str,
membre=Depends(_require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
methode_norm = methode.upper()
if methode_norm not in METHODES_VALIDES:
2026-04-17 04:04:04 -04:00
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
2026-03-04 14:01:00 -05:00
)
2026-04-17 04:04:04 -04:00
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
if methode_norm == "COLLECTE":
from app.routers.operations_bancaires import _encaisse
encaisse = await _encaisse(membre.groupe_id, db)
if float(depense.montant) > encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
depense.methode = methode_norm
2026-04-17 04:04:04 -04:00
depense.confirme_par = membre.id
depense.date_confirmation = datetime.now(timezone.utc)
# Si cash (COLLECTE) → payé immédiatement
if methode_norm == "COLLECTE":
2026-04-17 04:04:04 -04:00
depense.paye = True
# Si DU → le groupe doit rembourser, pas encore payé
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await db.flush()
return await _to_out(depense, db)
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit(
depense_id: str,
membre=Depends(_require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
if depense.paye:
raise HTTPException(status_code=400, detail="Déjà confirmée comme payée")
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(depense.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(depense.montant) < total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
2026-04-17 04:04:04 -04:00
depense.paye = True
await db.flush()
return await _to_out(depense, db)
2026-03-04 14:01:00 -05:00
@router.get("/", response_model=list[DepenseOut])
async def liste_depenses(
limite: int = 30,
paye: bool | None = None,
2026-04-17 04:04:04 -04:00
confirmees: bool | None = None,
2026-03-04 14:01:00 -05:00
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
2026-04-17 04:04:04 -04:00
"""Lister les dépenses du groupe."""
2026-03-04 14:01:00 -05:00
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
if paye is not None:
query = query.where(Depense.paye == paye)
2026-04-17 04:04:04 -04:00
if confirmees is not None:
if confirmees:
query = query.where(Depense.confirme_par.isnot(None))
else:
query = query.where(Depense.confirme_par.is_(None))
2026-03-04 14:01:00 -05:00
query = query.order_by(desc(Depense.date_depense)).limit(limite)
result = await db.execute(query)
2026-04-17 04:04:04 -04:00
return [await _to_out(d, db) for d in result.scalars().all()]
@router.get("/a-confirmer")
async def depenses_a_confirmer(
membre=Depends(_require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Dépenses soumises en attente de confirmation par le trésorier."""
result = await db.execute(
select(Depense).where(
Depense.groupe_id == membre.groupe_id,
Depense.confirme_par.is_(None),
).order_by(desc(Depense.cree_le))
)
return [await _to_out(d, db) for d in result.scalars().all()]
2026-03-04 14:01:00 -05:00
@router.patch("/{depense_id}", response_model=DepenseOut)
async def modifier_depense(
depense_id: str,
req: DepenseUpdate,
2026-04-17 04:04:04 -04:00
membre=Depends(_require_tresorier),
2026-03-04 14:01:00 -05:00
db: AsyncSession = Depends(get_db),
):
2026-04-17 04:04:04 -04:00
"""Modifier une dépense (trésorier)."""
2026-03-04 14:01:00 -05:00
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
if req.paye is not None:
depense.paye = req.paye
if req.methode is not None:
if req.methode.upper() not in METHODES_VALIDES:
raise HTTPException(status_code=400, detail="Méthode invalide")
depense.methode = req.methode.upper()
if req.quoi is not None:
depense.quoi = req.quoi.strip()
await db.flush()
2026-04-17 04:04:04 -04:00
return await _to_out(depense, db)