"""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.""" 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 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 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 # 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") 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)