Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409. Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur. Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
374 lines
15 KiB
Python
374 lines
15 KiB
Python
"""Routes — Opérations bancaires (dépôts, retraits)."""
|
|
from fastapi import APIRouter, Depends, Header, 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
|
|
from app.services.historique import enregistrer_historique, verifier_version_historisee
|
|
|
|
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 enregistrer_historique(
|
|
db,
|
|
groupe_id=membre.groupe_id,
|
|
table_cible="operations_bancaires",
|
|
objet_id=op.id,
|
|
action=f"operation.{op.type}",
|
|
raison=f"Opération bancaire enregistrée : {op.type}.",
|
|
membre_id=membre.id,
|
|
)
|
|
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, modifie_le=r.modifie_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, modifie_le=reserve.modifie_le)
|
|
|
|
|
|
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
|
|
async def modifier_reserve(
|
|
reserve_id: str,
|
|
req: ReserveUpdate,
|
|
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
|
|
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")
|
|
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
|
|
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 enregistrer_historique(
|
|
db,
|
|
groupe_id=membre.groupe_id,
|
|
table_cible="reserves",
|
|
objet_id=reserve.id,
|
|
action="reserve.modifiee",
|
|
raison="Réserve renommée ou note modifiée.",
|
|
membre_id=membre.id,
|
|
)
|
|
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, modifie_le=reserve.modifie_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 enregistrer_historique(
|
|
db,
|
|
groupe_id=membre.groupe_id,
|
|
table_cible="mouvements_reserves",
|
|
objet_id=source.id if source else (destination.id if destination else membre.groupe_id),
|
|
action="reserve.transfert",
|
|
raison="Virement entre réserves ou disponible enregistré.",
|
|
membre_id=membre.id,
|
|
avant={"source_id": req.source_id, "destination_id": req.destination_id},
|
|
apres={"montant": req.montant},
|
|
)
|
|
await db.flush()
|
|
return {"message": "Transfert effectué"}
|
|
|
|
|
|
@router.delete("/reserves/{reserve_id}")
|
|
async def supprimer_reserve(
|
|
reserve_id: str,
|
|
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
|
|
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")
|
|
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
|
|
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 enregistrer_historique(
|
|
db,
|
|
groupe_id=membre.groupe_id,
|
|
table_cible="reserves",
|
|
objet_id=reserve.id,
|
|
action="reserve.supprimee",
|
|
raison="Réserve supprimée après retour à zéro.",
|
|
membre_id=membre.id,
|
|
)
|
|
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
|