278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
|
|
import os
|
|
from datetime import date, datetime, timezone
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc
|
|
|
|
from app.core.database import get_db
|
|
from app.core.roles import membre_a_module, membre_a_poste_ou_executif, require_tresorier
|
|
from app.core.security import get_current_membre, is_sysadmin
|
|
from app.models.depense import Depense
|
|
from app.models.membre import Membre
|
|
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
|
|
from app.services.finance import position_tresorerie, transaction
|
|
|
|
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
|
|
|
|
METHODES_VALIDES = {"VIREMENT", "CHEQUE", "COLLECTE"}
|
|
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=DepenseOut)
|
|
async def soumettre_depense(
|
|
req: DepenseCreate,
|
|
membre=Depends(get_current_membre),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""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:
|
|
if not (
|
|
await membre_a_poste_ou_executif(membre.id, "trésorier", db)
|
|
or await membre_a_module(membre.id, "S5", db)
|
|
):
|
|
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
|
|
|
|
depense = Depense(
|
|
groupe_id=membre.groupe_id,
|
|
date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(),
|
|
montant=req.montant,
|
|
qui=qui_id,
|
|
quoi=req.quoi.strip(),
|
|
paye=False,
|
|
methode=None, # sera renseigné par le trésorier
|
|
saisi_par=membre.id,
|
|
)
|
|
db.add(depense)
|
|
await db.flush()
|
|
return await _to_out(depense, db)
|
|
|
|
|
|
@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")
|
|
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")
|
|
|
|
# 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:
|
|
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)
|
|
)
|
|
depense = result.scalar_one_or_none()
|
|
if not depense:
|
|
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
|
if depense.confirme_par is not None:
|
|
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
|
|
|
|
if methode_norm == "COLLECTE":
|
|
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")
|
|
elif methode_norm in ("CHEQUE", "VIREMENT"):
|
|
position = await position_tresorerie(membre.groupe_id, db)
|
|
if float(depense.montant) > position.disponible_bancaire:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
|
|
)
|
|
|
|
depense.methode = methode_norm
|
|
depense.confirme_par = membre.id
|
|
depense.date_confirmation = datetime.now(timezone.utc)
|
|
|
|
# Si cash (COLLECTE) → payé immédiatement
|
|
if methode_norm == "COLLECTE":
|
|
depense.paye = True
|
|
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
|
|
|
|
# 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")
|
|
|
|
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")
|
|
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")
|
|
|
|
depense.paye = True
|
|
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 db.flush()
|
|
return await _to_out(depense, db)
|
|
|
|
|
|
@router.delete("/{depense_id}")
|
|
async def rejeter_depense(
|
|
depense_id: str,
|
|
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)
|
|
)
|
|
depense = result.scalar_one_or_none()
|
|
if not depense:
|
|
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
|
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 db.delete(depense)
|
|
await db.flush()
|
|
return {"message": "Dépense rejetée"}
|
|
|
|
|
|
@router.get("/", response_model=list[DepenseOut])
|
|
async def liste_depenses(
|
|
limite: int = 30,
|
|
paye: bool | None = None,
|
|
confirmees: bool | None = None,
|
|
membre=Depends(get_current_membre),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Lister les dépenses du groupe."""
|
|
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
|
|
if paye is not None:
|
|
query = query.where(Depense.paye == paye)
|
|
if confirmees is not None:
|
|
if confirmees:
|
|
query = query.where(Depense.confirme_par.isnot(None))
|
|
else:
|
|
query = query.where(Depense.confirme_par.is_(None))
|
|
query = query.order_by(desc(Depense.date_depense)).limit(limite)
|
|
|
|
result = await db.execute(query)
|
|
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()]
|
|
|
|
|
|
@router.patch("/{depense_id}", response_model=DepenseOut)
|
|
async def modifier_depense(
|
|
depense_id: str,
|
|
req: DepenseUpdate,
|
|
membre=Depends(require_tresorier),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Modifier une dépense (trésorier)."""
|
|
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()
|
|
return await _to_out(depense, db)
|