"""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, snapshot, 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") avant = snapshot(collecte, "montant", "statut") 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, avant=avant, apres=snapshot(collecte, "montant", "statut"), ) 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") avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception") 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, avant=avant, apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"), ) 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") avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception") # 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, avant=avant, apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"), ) 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