groupe-meditation/backend/app/routers/collectes.py
Daniel Allaire 6677b0f57b Ajouter historique métier et protection concurrente
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.
2026-05-31 10:59:41 -04:00

615 lines
24 KiB
Python

"""Routes S1 — Collecte 7e Tradition."""
from datetime import date, datetime, time as dt_time, timedelta, timezone
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func
from app.core.database import get_db
from app.core.roles import membre_a_poste_ou_executif, require_tresorier
from app.core.reunions import JOURS_SEMAINE
from app.core.security import get_current_membre, require_executif, is_sysadmin
from app.models.collecte import Collecte
from app.models.groupe import Groupe
from app.models.membre import Membre
from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
"""Vérifie si un membre est trésorier ou exécutif."""
return await membre_a_poste_ou_executif(membre_id, "trésorier", db)
async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
detenteur = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
confirme = await db.get(Membre, c.confirme_par) if c.confirme_par else None
recu = await db.get(Membre, c.recu_par) if c.recu_par else None
return CollecteOut(
id=c.id,
date_collecte=c.date_collecte,
montant=float(c.montant),
detenteur_prenom=detenteur.prenom if detenteur else None,
statut=c.statut,
saisi_par_prenom=saisi.prenom if saisi else None,
confirme_par_prenom=confirme.prenom if confirme else None,
date_confirmation=c.date_confirmation,
recu_par_prenom=recu.prenom if recu else None,
date_reception=c.date_reception,
cree_le=c.cree_le,
modifie_le=c.modifie_le,
)
@router.post("/", response_model=CollecteOut)
async def saisir_collecte(
req: CollecteCreate,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""S1 — Un membre exécutif saisit la collecte."""
groupe = await db.get(Groupe, membre.groupe_id)
aujourdhui = date.today()
retro_sysadmin = is_sysadmin(membre)
# Validation : pas dans le futur
if req.date_collecte > aujourdhui and not retro_sysadmin:
raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur")
# Validation : la date doit tomber un jour de réunion
if groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu and not retro_sysadmin:
raise HTTPException(
status_code=400,
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}",
)
if jour_attendu is not None and not retro_sysadmin:
dernieres_reunions = []
d = aujourdhui
while len(dernieres_reunions) < 52:
if d.weekday() == jour_attendu:
dernieres_reunions.append(d)
d -= timedelta(days=1)
if req.date_collecte not in dernieres_reunions:
raise HTTPException(
status_code=400,
detail="La date doit faire partie des 52 dernières rencontres du groupe",
)
# Validation : si c'est aujourd'hui, pas avant l'heure de la réunion
if req.date_collecte == aujourdhui and groupe.heure_reunion and not retro_sysadmin:
now = datetime.now(timezone.utc)
if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion:
raise HTTPException(
status_code=400,
detail=f"La saisie n'est possible qu'à partir de {groupe.heure_reunion.strftime('%H:%M')}",
)
# Validation : une seule collecte par date de réunion
result = await db.execute(
select(Collecte).where(
Collecte.groupe_id == membre.groupe_id,
Collecte.date_collecte == req.date_collecte,
)
)
if result.scalars().first():
raise HTTPException(
status_code=400,
detail=f"Il y a déjà une collecte pour le {req.date_collecte}",
)
collecte = Collecte(
groupe_id=membre.groupe_id,
date_collecte=req.date_collecte,
montant=req.montant,
reunion_id=req.reunion_id,
detenteur_id=req.detenteur_id,
statut="en_attente",
saisi_par=membre.id,
)
db.add(collecte)
await db.flush()
return await _to_out(collecte, db)
@router.patch("/{collecte_id}", response_model=CollecteOut)
async def ajuster_collecte(
collecte_id: str,
req: CollecteUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Ajuster le montant avant confirmation."""
result = await db.execute(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
if req.montant is not None:
collecte.montant = req.montant
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.modifiee",
raison="Montant de collecte ajusté avant confirmation.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
async def confirmer_collecte(
collecte_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Un second membre confirme le montant. Si le trésorier est impliqué → réception auto."""
result = await db.execute(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été confirmée")
if collecte.saisi_par == membre.id:
raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre")
collecte.confirme_par = membre.id
collecte.date_confirmation = datetime.now(timezone.utc)
# Si le trésorier est celui qui confirme OU celui qui a saisi → réception auto
confirmateur_est_tres = await _est_tresorier(membre.id, membre.groupe_id, db)
saisisseur_est_tres = await _est_tresorier(collecte.saisi_par, collecte.groupe_id, db)
if confirmateur_est_tres or saisisseur_est_tres:
tresorier_id = membre.id if confirmateur_est_tres else collecte.saisi_par
collecte.statut = "recu"
collecte.recu_par = tresorier_id
collecte.date_reception = date.today()
db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
else:
collecte.statut = "confirmee"
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.confirmee" if collecte.statut == "confirmee" else "collecte.recue",
raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@router.post("/{collecte_id}/reception", response_model=CollecteOut)
async def accuser_reception(
collecte_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier accuse réception (pour les cas sans trésorier impliqué)."""
result = await db.execute(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut == "recu":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue")
# Si en_attente, confirme aussi
if collecte.statut == "en_attente":
collecte.confirme_par = membre.id
collecte.date_confirmation = datetime.now(timezone.utc)
collecte.statut = "recu"
collecte.recu_par = membre.id
collecte.date_reception = date.today()
db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.recue",
raison="Collecte reçue par le trésorier.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@router.get("/", response_model=list[CollecteOut])
async def liste_collectes(
statut: str | None = None,
mois: int | None = None,
annee: int | None = None,
limite: int = 20,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id)
if statut:
query = query.where(Collecte.statut == statut)
if mois and annee:
query = query.where(
extract("month", Collecte.date_collecte) == mois,
extract("year", Collecte.date_collecte) == annee,
)
elif annee:
query = query.where(extract("year", Collecte.date_collecte) == annee)
query = query.order_by(desc(Collecte.date_collecte)).limit(limite)
result = await db.execute(query)
return [await _to_out(c, db) for c in result.scalars().all()]
@router.get("/soldes")
async def soldes_tresorerie(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Soldes détaillés : relevé bancaire, sommes engagées, réserves, disponible."""
from app.models.vente_litterature import VenteLitterature
from app.models.vente_jeton import VenteJeton
gid = membre.groupe_id
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type == "releve",
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
date_releve = str(dernier_releve.date_operation) if dernier_releve else None
solde_banque_actuel = await solde_banque(gid, db)
encaisse_actuelle = await encaisse(gid, db)
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == False,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
)
engagements_non_debites = float(result.scalar())
# ── Contributions engagées non débitées ──
result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.engagee == True,
EnvoiContribution.debitee == False,
)
)
contributions_non_debitees = float(result.scalar())
engagements_non_debites += contributions_non_debitees
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
Reserve.active == True,
).order_by(Reserve.nom)
)
reserves = result.scalars().all()
reserves_details = []
for r in reserves:
montant = await solde_reserve(r.id, db)
r.montant = montant
reserves_details.append({
"id": str(r.id),
"nom": r.nom,
"montant": round(montant, 2),
"note": r.note,
"modifie_le": r.modifie_le.isoformat(),
})
total_reserves_reelles = sum(r["montant"] for r in reserves_details)
# ── Somme disponible ──
disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles
# ── Argent en circulation (collectes non remises au trésorier) ──
result = await db.execute(
select(Collecte).where(
Collecte.groupe_id == gid, Collecte.statut.in_(["en_attente", "confirmee"])))
en_circulation = result.scalars().all()
details_circulation = []
total_en_circulation = 0.0
for c in en_circulation:
det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
total_en_circulation += float(c.montant)
details_circulation.append({
"id": str(c.id),
"date": str(c.date_collecte),
"montant": float(c.montant),
"detenteur": det.prenom if det else "?",
"statut": c.statut,
"modifie_le": c.modifie_le.isoformat(),
})
# ── Ventes de littérature non remises ──
result = await db.execute(
select(VenteLitterature).where(
VenteLitterature.groupe_id == gid,
VenteLitterature.remis_tresorier == False,
)
)
from app.models.litterature import Litterature
ventes_non_remises = result.scalars().all()
details_ventes = []
total_ventes_non_remises = 0.0
for v in ventes_non_remises:
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
item = await db.get(Litterature, v.litterature_id) if v.litterature_id else None
total_ventes_non_remises += float(v.montant)
details_ventes.append({
"id": str(v.id),
"date": str(v.date_vente),
"montant": float(v.montant),
"titre": item.titre if item else "?",
"vendu_par": vendeur.prenom if vendeur else "?",
})
result = await db.execute(
select(VenteJeton).where(
VenteJeton.groupe_id == gid,
VenteJeton.remis_tresorier == False,
)
)
ventes_jetons_non_remises = result.scalars().all()
details_ventes_jetons = []
total_ventes_jetons_non_remises = 0.0
for v in ventes_jetons_non_remises:
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
total_ventes_jetons_non_remises += float(v.montant)
details_ventes_jetons.append({
"id": str(v.id),
"date": str(v.date_vente),
"montant": float(v.montant),
"titre": f"Jetons {v.type_jeton} (x{v.quantite})",
"vendu_par": vendeur.prenom if vendeur else "?",
"type": "jeton",
})
return {
"solde_banque": round(solde_banque_actuel, 2),
"date_releve": date_releve,
"encaisse": round(encaisse_actuelle, 2),
"disponible": round(disponible, 2),
"engagements_non_debites": round(engagements_non_debites, 2),
"reserve_prudente": 0,
"reserve_anniversaire": 0,
"total_reserves": round(total_reserves_reelles, 2),
"total_reserves_reelles": round(total_reserves_reelles, 2),
"reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation,
"ventes_non_remises": round(total_ventes_non_remises + total_ventes_jetons_non_remises, 2),
"ventes_non_remises_details": details_ventes + details_ventes_jetons,
}
@router.get("/registre")
async def registre_transactions(
mois: int | None = None,
annee: int | None = None,
type_transaction: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Registre détaillé de toutes les transactions financières du groupe."""
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_jeton import VenteJeton
gid = membre.groupe_id
transactions = []
dons_operation_ids = set()
# Filtres date
def filtre_mois(d):
if mois and annee:
return d.month == mois and d.year == annee
if annee:
return d.year == annee
return True
def ajouter_transaction(transaction):
if type_transaction and transaction["type"] != type_transaction:
return
transactions.append(transaction)
# 1. Collectes reçues
result = await db.execute(
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
.order_by(Collecte.date_collecte)
)
for c in result.scalars().all():
if filtre_mois(c.date_collecte):
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
ajouter_transaction({
"date": str(c.date_collecte),
"type": "collecte",
"description": f"Collecte 7e Tradition",
"entree": float(c.montant),
"sortie": 0,
"par": saisi.prenom if saisi else "?",
})
# 2. Ventes de littérature remises
result = await db.execute(
select(VenteLitterature).where(
VenteLitterature.groupe_id == gid,
VenteLitterature.remis_tresorier == True,
)
)
for v in result.scalars().all():
if filtre_mois(v.date_vente):
item = await db.get(Litterature, v.litterature_id)
ajouter_transaction({
"date": str(v.date_vente),
"type": "vente_litterature",
"description": f"Vente : {item.titre if item else '?'} (x{v.quantite})",
"entree": float(v.montant),
"sortie": 0,
"par": "Littérature",
})
result = await db.execute(
select(VenteJeton).where(
VenteJeton.groupe_id == gid,
VenteJeton.remis_tresorier == True,
)
)
for v in result.scalars().all():
if filtre_mois(v.date_vente):
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
ajouter_transaction({
"date": str(v.date_vente),
"type": "vente_jeton",
"description": f"Vente de jetons : {v.type_jeton} (x{v.quantite})",
"entree": float(v.montant),
"sortie": 0,
"par": vendeur.prenom if vendeur else "Jetons",
})
# 3. Dépenses payées
result = await db.execute(
select(Depense).where(Depense.groupe_id == gid, Depense.paye == True)
.order_by(Depense.date_depense)
)
for d in result.scalars().all():
if filtre_mois(d.date_depense):
qui = await db.get(Membre, d.qui)
ajouter_transaction({
"date": str(d.date_depense),
"type": "depense",
"description": f"{d.quoi} ({d.methode})",
"entree": 0,
"sortie": float(d.montant),
"par": qui.prenom if qui else "?",
})
# 4. Envois de contributions
result = await db.execute(
select(EnvoiContribution).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
.order_by(EnvoiContribution.date_envoi)
)
for e in result.scalars().all():
if filtre_mois(e.date_envoi):
ajouter_transaction({
"date": str(e.date_envoi),
"type": "contribution",
"description": f"Contribution {e.destination} ({e.methode})",
"entree": 0,
"sortie": float(e.montant),
"par": "Trésorier",
})
# 5. Dons déposés directement à la banque
result = await db.execute(
select(TransactionComptable).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.type == "don",
TransactionComptable.compte == "banque",
TransactionComptable.sens == "debit",
).order_by(TransactionComptable.date_transaction)
)
for don in result.scalars().all():
if filtre_mois(don.date_transaction):
m = await db.get(Membre, don.cree_par) if don.cree_par else None
if don.source_id:
dons_operation_ids.add(don.source_id)
ajouter_transaction({
"date": str(don.date_transaction),
"type": "don",
"description": f"Don déposé directement{(' — ' + don.note) if don.note else ''}",
"entree": float(don.montant),
"sortie": 0,
"par": m.prenom if m else "?",
})
# 6. Opérations bancaires (dépôts/retraits)
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type.in_(["depot", "retrait"]),
).order_by(OperationBancaire.date_operation)
)
for op in result.scalars().all():
if op.id in dons_operation_ids:
continue
if filtre_mois(op.date_operation):
m = await db.get(Membre, op.effectue_par)
is_depot = op.type == "depot"
ajouter_transaction({
"date": str(op.date_operation),
"type": f"banque_{op.type}",
"description": f"{'Dépôt' if is_depot else 'Retrait'} bancaire{(' — ' + op.note) if op.note else ''}",
"entree": float(op.montant) if is_depot else 0,
"sortie": float(op.montant) if not is_depot else 0,
"par": m.prenom if m else "?",
})
# 7. Mouvements virtuels entre le disponible et les réserves
result = await db.execute(
select(MouvementReserve).where(MouvementReserve.groupe_id == gid)
.order_by(MouvementReserve.cree_le)
)
for mv in result.scalars().all():
date_mouvement = mv.cree_le.date()
if filtre_mois(date_mouvement):
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
source_nom = source.nom if source else "Disponible"
destination_nom = destination.nom if destination else "Disponible"
ajouter_transaction({
"date": str(date_mouvement),
"type": "reserve_virement",
"description": f"Virement de réserve : {source_nom} → {destination_nom} ({float(mv.montant):.2f} $)",
"entree": 0,
"sortie": 0,
"par": createur.prenom if createur else "?",
})
transactions.sort(key=lambda t: t["date"], reverse=True)
return transactions