322 lines
14 KiB
Python
322 lines
14 KiB
Python
"""Routes — Opérations bancaires (dépôts, retraits)."""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, desc
|
|
|
|
from app.core.database import get_db
|
|
from app.core.roles import require_tresorier
|
|
from app.core.security import get_current_membre
|
|
from app.models.operation_bancaire import OperationBancaire
|
|
from app.models.transaction_comptable import TransactionComptable
|
|
from app.models.mouvement_reserve import MouvementReserve
|
|
from app.models.reserve import Reserve
|
|
from app.models.membre import Membre
|
|
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
|
|
from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve
|
|
|
|
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
|
|
|
|
|
|
def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None):
|
|
return MouvementReserve(
|
|
groupe_id=groupe_id,
|
|
source_id=source_id,
|
|
destination_id=destination_id,
|
|
montant=montant,
|
|
type=type_mouvement,
|
|
note=note,
|
|
cree_par=membre_id,
|
|
)
|
|
|
|
|
|
def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str):
|
|
return TransactionComptable(
|
|
groupe_id=groupe_id,
|
|
date_transaction=op.date_operation,
|
|
type=op.type,
|
|
compte=compte,
|
|
montant=op.montant,
|
|
sens=sens,
|
|
source_table="operations_bancaires",
|
|
source_id=op.id,
|
|
note=op.note,
|
|
cree_par=membre_id,
|
|
)
|
|
|
|
|
|
def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float):
|
|
return TransactionComptable(
|
|
groupe_id=groupe_id,
|
|
date_transaction=op.date_operation,
|
|
type=op.type,
|
|
compte=compte,
|
|
montant=montant,
|
|
sens=sens,
|
|
source_table="operations_bancaires",
|
|
source_id=op.id,
|
|
note=op.note,
|
|
cree_par=membre_id,
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=OperationBancaireOut)
|
|
async def enregistrer_operation(
|
|
req: OperationBancaireCreate,
|
|
membre=Depends(require_tresorier),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Enregistrer un dépôt ou retrait bancaire (trésorier)."""
|
|
if req.type not in ("depot", "retrait", "releve"):
|
|
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
|
|
provenance_depot = (req.provenance_depot or "encaisse").lower()
|
|
if req.type == "depot" and provenance_depot not in ("encaisse", "don"):
|
|
raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
|
|
if req.montant <= 0:
|
|
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
|
position = await position_tresorerie(membre.groupe_id, db)
|
|
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > position.encaisse:
|
|
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
|
|
if req.type == "retrait" and req.montant > position.solde_banque:
|
|
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
|
|
if req.type == "retrait" and position.solde_banque - req.montant < position.total_reserves:
|
|
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
|
|
if req.type == "releve" and req.montant < position.total_reserves:
|
|
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
|
|
|
|
op = OperationBancaire(
|
|
groupe_id=membre.groupe_id,
|
|
type=req.type,
|
|
montant=req.montant,
|
|
date_operation=req.date_operation,
|
|
effectue_par=membre.id,
|
|
note=req.note or ("Don déposé directement au compte bancaire" if provenance_depot == "don" else None),
|
|
)
|
|
db.add(op)
|
|
await db.flush()
|
|
if op.type == "depot":
|
|
if provenance_depot == "don":
|
|
db.add(TransactionComptable(
|
|
groupe_id=membre.groupe_id,
|
|
date_transaction=op.date_operation,
|
|
type="don",
|
|
compte="banque",
|
|
montant=op.montant,
|
|
sens="debit",
|
|
source_table="operations_bancaires",
|
|
source_id=op.id,
|
|
note=op.note,
|
|
cree_par=membre.id,
|
|
))
|
|
else:
|
|
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "debit"))
|
|
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "credit"))
|
|
elif op.type == "retrait":
|
|
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit"))
|
|
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
|
|
else:
|
|
delta = req.montant - position.solde_banque
|
|
if delta != 0:
|
|
db.add(_ligne_comptable_montant(
|
|
membre.groupe_id, membre.id, op, "banque",
|
|
"debit" if delta > 0 else "credit",
|
|
abs(delta),
|
|
))
|
|
await db.flush()
|
|
return OperationBancaireOut(
|
|
id=op.id,
|
|
type=op.type,
|
|
montant=float(op.montant),
|
|
date_operation=op.date_operation,
|
|
effectue_par_prenom=membre.prenom,
|
|
note=op.note,
|
|
cree_le=op.cree_le,
|
|
)
|
|
|
|
|
|
@router.get("/reserves", response_model=list[ReserveOut])
|
|
async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(
|
|
select(Reserve).where(Reserve.groupe_id == membre.groupe_id, Reserve.active == True)
|
|
.order_by(Reserve.nom)
|
|
)
|
|
return [
|
|
ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le)
|
|
for r in result.scalars().all()
|
|
]
|
|
|
|
|
|
@router.get("/registre-comptable", response_model=list[TransactionComptableOut])
|
|
async def registre_comptable(
|
|
limite: int = 200,
|
|
membre=Depends(get_current_membre),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(TransactionComptable)
|
|
.where(TransactionComptable.groupe_id == membre.groupe_id)
|
|
.order_by(desc(TransactionComptable.date_transaction), desc(TransactionComptable.cree_le))
|
|
.limit(limite)
|
|
)
|
|
transactions = result.scalars().all()
|
|
out = []
|
|
for tx in transactions:
|
|
createur = await db.get(Membre, tx.cree_par) if tx.cree_par else None
|
|
out.append(TransactionComptableOut(
|
|
id=tx.id,
|
|
date_transaction=tx.date_transaction,
|
|
type=tx.type,
|
|
compte=tx.compte,
|
|
montant=float(tx.montant),
|
|
sens=tx.sens,
|
|
source_table=tx.source_table,
|
|
source_id=tx.source_id,
|
|
note=tx.note,
|
|
cree_par_prenom=createur.prenom if createur else None,
|
|
cree_le=tx.cree_le,
|
|
))
|
|
return out
|
|
|
|
|
|
@router.post("/reserves", response_model=ReserveOut)
|
|
async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
|
|
if not req.nom.strip():
|
|
raise HTTPException(status_code=400, detail="Nom requis")
|
|
if req.montant != 0:
|
|
raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner")
|
|
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
|
|
db.add(reserve)
|
|
await db.flush()
|
|
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
|
|
|
|
|
|
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
|
|
async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
|
|
reserve = await db.get(Reserve, reserve_id)
|
|
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
|
|
raise HTTPException(status_code=404, detail="Réserve introuvable")
|
|
ancien_montant = await solde_reserve(reserve.id, db)
|
|
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
|
|
raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
|
|
if req.nom is not None:
|
|
if not req.nom.strip():
|
|
raise HTTPException(status_code=400, detail="Nom requis")
|
|
reserve.nom = req.nom.strip()
|
|
if req.note is not None:
|
|
reserve.note = req.note.strip() or None
|
|
reserve.montant = await solde_reserve(reserve.id, db)
|
|
await db.flush()
|
|
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
|
|
|
|
|
|
@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut])
|
|
async def liste_mouvements_reserves(
|
|
limite: int = 100,
|
|
membre=Depends(get_current_membre),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(MouvementReserve)
|
|
.where(MouvementReserve.groupe_id == membre.groupe_id)
|
|
.order_by(desc(MouvementReserve.cree_le))
|
|
.limit(limite)
|
|
)
|
|
mouvements = result.scalars().all()
|
|
out = []
|
|
for mv in mouvements:
|
|
source = await db.get(Reserve, mv.source_id) if mv.source_id else None
|
|
destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
|
|
createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
|
|
out.append(ReserveMouvementOut(
|
|
id=mv.id,
|
|
source_id=mv.source_id,
|
|
source_nom=source.nom if source else "Disponible",
|
|
destination_id=mv.destination_id,
|
|
destination_nom=destination.nom if destination else "Disponible",
|
|
montant=float(mv.montant),
|
|
type=mv.type,
|
|
note=mv.note,
|
|
cree_par_prenom=createur.prenom if createur else None,
|
|
cree_le=mv.cree_le,
|
|
))
|
|
return out
|
|
|
|
|
|
@router.post("/reserves/transfert")
|
|
async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
|
|
if req.montant <= 0:
|
|
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
|
if req.source_id == req.destination_id:
|
|
raise HTTPException(status_code=400, detail="La source et la destination doivent être différentes")
|
|
|
|
source = None if req.source_id == "disponible" else await db.get(Reserve, req.source_id)
|
|
destination = None if req.destination_id == "disponible" else await db.get(Reserve, req.destination_id)
|
|
|
|
if req.source_id != "disponible" and (not source or source.groupe_id != membre.groupe_id or not source.active):
|
|
raise HTTPException(status_code=404, detail="Réserve source introuvable")
|
|
if req.destination_id != "disponible" and (not destination or destination.groupe_id != membre.groupe_id or not destination.active):
|
|
raise HTTPException(status_code=404, detail="Réserve destination introuvable")
|
|
|
|
if req.source_id == "disponible":
|
|
disponible = await reserve_disponible(membre.groupe_id, db)
|
|
if req.montant > disponible:
|
|
raise HTTPException(status_code=400, detail="La réserve disponible est insuffisante")
|
|
elif req.montant > await solde_reserve(source.id, db):
|
|
raise HTTPException(status_code=400, detail="La réserve source est insuffisante")
|
|
|
|
if source:
|
|
source.montant = await solde_reserve(source.id, db) - req.montant
|
|
if destination:
|
|
destination.montant = await solde_reserve(destination.id, db) + req.montant
|
|
|
|
db.add(_mouvement_reserve(
|
|
membre.groupe_id,
|
|
membre.id,
|
|
req.montant,
|
|
"transfert",
|
|
source_id=source.id if source else None,
|
|
destination_id=destination.id if destination else None,
|
|
))
|
|
await db.flush()
|
|
return {"message": "Transfert effectué"}
|
|
|
|
|
|
@router.delete("/reserves/{reserve_id}")
|
|
async def supprimer_reserve(reserve_id: str, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
|
|
reserve = await db.get(Reserve, reserve_id)
|
|
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
|
|
raise HTTPException(status_code=404, detail="Réserve introuvable")
|
|
montant = await solde_reserve(reserve.id, db)
|
|
if round(montant, 2) != 0:
|
|
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
|
|
reserve.active = False
|
|
await db.flush()
|
|
return {"message": "Réserve supprimée"}
|
|
|
|
|
|
@router.get("/", response_model=list[OperationBancaireOut])
|
|
async def liste_operations(
|
|
limite: int = 20,
|
|
membre=Depends(get_current_membre),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Lister les opérations bancaires du groupe."""
|
|
result = await db.execute(
|
|
select(OperationBancaire)
|
|
.where(OperationBancaire.groupe_id == membre.groupe_id)
|
|
.order_by(desc(OperationBancaire.date_operation))
|
|
.limit(limite)
|
|
)
|
|
ops = result.scalars().all()
|
|
out = []
|
|
for op in ops:
|
|
m = await db.get(Membre, op.effectue_par)
|
|
out.append(OperationBancaireOut(
|
|
id=op.id,
|
|
type=op.type,
|
|
montant=float(op.montant),
|
|
date_operation=op.date_operation,
|
|
effectue_par_prenom=m.prenom if m else "?",
|
|
note=op.note,
|
|
cree_le=op.cree_le,
|
|
))
|
|
return out
|