228 lines
7.9 KiB
Python
228 lines
7.9 KiB
Python
"""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
|
|
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"}
|
|
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
|
|
|
|
|
|
async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool:
|
|
result = await db.execute(
|
|
select(Affectation).join(Poste).where(
|
|
Affectation.membre_id == membre_id,
|
|
Affectation.statut == "actif",
|
|
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
|
)
|
|
)
|
|
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")
|
|
return membre
|
|
|
|
|
|
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 — N'importe quel membre soumet une dépense courante."""
|
|
depense = Depense(
|
|
groupe_id=membre.groupe_id,
|
|
date_depense=req.date_depense,
|
|
montant=req.montant,
|
|
qui=membre.id, # le membre qui soumet est celui qui a payé
|
|
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")
|
|
|
|
# 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."""
|
|
if methode.upper() 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")
|
|
|
|
depense.methode = methode.upper()
|
|
depense.confirme_par = membre.id
|
|
depense.date_confirmation = datetime.now(timezone.utc)
|
|
|
|
# Si cash (COLLECTE) → payé immédiatement
|
|
if methode.upper() == "COLLECTE":
|
|
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")
|
|
|
|
depense.paye = True
|
|
await db.flush()
|
|
return await _to_out(depense, db)
|
|
|
|
|
|
@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)
|