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

325 lines
13 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
2026-05-25 17:39:38 -04:00
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Header, HTTPException, UploadFile, File
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
2026-05-26 09:05:58 -04:00
from app.core.roles import membre_a_module, membre_a_poste_ou_executif, require_tresorier
2026-05-25 17:39:38 -04:00
from app.core.security import get_current_membre, is_sysadmin
2026-03-04 14:01:00 -05:00
from app.models.depense import Depense
from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
2026-05-26 09:05:58 -04:00
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
2026-03-04 14:01:00 -05:00
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
2026-05-25 17:39:38 -04:00
METHODES_VALIDES = {"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 _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,
modifie_le=d.modifie_le,
2026-04-17 04:04:04 -04:00
)
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-05-25 17:39:38 -04:00
"""S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
qui_id = membre.id
if req.membre_id and req.membre_id != membre.id:
2026-05-26 09:05:58 -04:00
if not (
await membre_a_poste_ou_executif(membre.id, "trésorier", db)
or await membre_a_module(membre.id, "S5", db)
):
2026-05-25 17:39:38 -04:00
raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre")
beneficiaire = await db.get(Membre, req.membre_id)
if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
qui_id = beneficiaire.id
2026-03-04 14:01:00 -05:00
depense = Depense(
groupe_id=membre.groupe_id,
2026-05-25 17:39:38 -04:00
date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(),
2026-03-04 14:01:00 -05:00
montant=req.montant,
2026-05-25 17:39:38 -04:00
qui=qui_id,
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).with_for_update()
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")
2026-05-26 09:05:58 -04:00
peut_joindre = (
depense.saisi_par == membre.id
or depense.qui == membre.id
or await membre_a_poste_ou_executif(membre.id, "trésorier", db)
)
if not peut_joindre:
raise HTTPException(status_code=403, detail="Réservé au demandeur, au bénéficiaire ou à l'exécutif")
2026-04-17 04:04:04 -04:00
# 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,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
2026-05-25 17:39:38 -04:00
membre=Depends(require_tresorier),
2026-04-17 04:04:04 -04:00
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).with_for_update()
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")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
2026-05-25 17:39:38 -04:00
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
2026-04-17 04:04:04 -04:00
if methode_norm == "COLLECTE":
2026-05-26 09:05:58 -04:00
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
2026-05-25 17:39:38 -04:00
elif methode_norm in ("CHEQUE", "VIREMENT"):
2026-05-26 09:05:58 -04:00
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.disponible_bancaire:
2026-05-25 17:39:38 -04:00
raise HTTPException(
status_code=400,
detail="Le solde bancaire disponible est insuffisant pour engager 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
2026-05-25 17:39:38 -04:00
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
2026-04-17 04:04:04 -04:00
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.traitee",
raison=f"Dépense traitée par {methode_norm.lower()}.",
membre_id=membre.id,
)
2026-04-17 04:04:04 -04:00
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,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
2026-05-25 17:39:38 -04:00
membre=Depends(require_tresorier),
2026-04-17 04:04:04 -04:00
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).with_for_update()
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")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
2026-04-17 04:04:04 -04:00
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")
2026-05-26 09:05:58 -04:00
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
2026-05-26 09:05:58 -04:00
if position.solde_banque - float(depense.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")
2026-04-17 04:04:04 -04:00
depense.paye = True
2026-05-25 17:39:38 -04:00
depense.date_confirmation = datetime.now(timezone.utc)
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.debit_confirme",
raison="Débit bancaire de la dépense confirmé.",
membre_id=membre.id,
)
2026-04-17 04:04:04 -04:00
await db.flush()
return await _to_out(depense, db)
2026-03-04 14:01:00 -05:00
2026-05-25 17:39:38 -04:00
@router.delete("/{depense_id}")
async def rejeter_depense(
depense_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
2026-05-25 17:39:38 -04:00
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Rejeter une dépense soumise : elle est supprimée du registre."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
2026-05-25 17:39:38 -04:00
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
2026-05-25 17:39:38 -04:00
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
if depense.preuve_nom:
chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
if os.path.exists(chemin):
os.remove(chemin)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.rejetee",
raison="Dépense soumise rejetée.",
membre_id=membre.id,
)
2026-05-25 17:39:38 -04:00
await db.delete(depense)
await db.flush()
return {"message": "Dépense rejetée"}
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(
2026-05-25 17:39:38 -04:00
membre=Depends(require_tresorier),
2026-04-17 04:04:04 -04:00
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,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
2026-05-25 17:39:38 -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")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
2026-03-04 14:01:00 -05:00
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 enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.modifiee",
raison="Dépense modifiée par le trésorier.",
membre_id=membre.id,
)
2026-03-04 14:01:00 -05:00
await db.flush()
2026-04-17 04:04:04 -04:00
return await _to_out(depense, db)