"""Routes S1 — Collecte 7e Tradition.""" from datetime import date, datetime, time as dt_time, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract, func from app.core.database import get_db from app.core.security import get_current_membre, require_executif 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.affectation import Affectation from app.models.poste import Poste from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) JOURS_SEMAINE = { "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3, "vendredi": 4, "samedi": 5, "dimanche": 6, } async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool: """Vérifie si un membre est trésorier ou exécutif.""" 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(membre.id, membre.groupe_id, db): raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") return membre 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, ) @router.post("/", response_model=CollecteOut) async def saisir_collecte( req: CollecteCreate, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion.""" groupe = await db.get(Groupe, membre.groupe_id) aujourdhui = date.today() # Validation : pas dans le futur if req.date_collecte > aujourdhui: 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: raise HTTPException( status_code=400, detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}", ) # Validation : si c'est aujourd'hui, pas avant l'heure de la réunion if req.date_collecte == aujourdhui and groupe.heure_reunion: 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, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): """Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation).""" result = await db.execute( select(Collecte).where( Collecte.id == collecte_id, Collecte.groupe_id == membre.groupe_id, ) ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") if collecte.statut != "en_attente": raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") # Vérifier : exécutif ou saisi_par est_exec = await _est_tresorier(membre.id, membre.groupe_id, db) if collecte.saisi_par != membre.id and not est_exec: raise HTTPException(status_code=403, detail="Seul un exécutif ou la personne qui a saisi peut modifier") if req.montant is not None: collecte.montant = req.montant await db.flush() return await _to_out(collecte, db) @router.post("/{collecte_id}/confirmer", response_model=CollecteOut) async def confirmer_collecte( collecte_id: str, membre=Depends(get_current_membre), 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, ) ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") 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() else: collecte.statut = "confirmee" await db.flush() return await _to_out(collecte, db) @router.post("/{collecte_id}/reception", response_model=CollecteOut) async def accuser_reception( collecte_id: str, 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, ) ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") 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() 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(get_current_membre), 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 gid = membre.groupe_id # ── Dernier relevé bancaire ── 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() solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0 date_releve = str(dernier_releve.date_operation) if dernier_releve else None # ── Dépôts et retraits depuis le dernier relevé ── filtre_depuis = dernier_releve.date_operation if dernier_releve else None # Dépôts après relevé q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot", ) if filtre_depuis: q = q.where(OperationBancaire.date_operation > filtre_depuis) result = await db.execute(q) depots_depuis = float(result.scalar()) # Retraits après relevé q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait", ) if filtre_depuis: q = q.where(OperationBancaire.date_operation > filtre_depuis) result = await db.execute(q) retraits_depuis = float(result.scalar()) # Contributions débitées après relevé q = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( EnvoiContribution.groupe_id == gid, EnvoiContribution.debitee == True, ) if filtre_depuis: q = q.where(EnvoiContribution.date_envoi > filtre_depuis) result = await db.execute(q) contributions_debitees_depuis = float(result.scalar()) # Dépenses bancaires débitées après relevé q = select(func.coalesce(func.sum(Depense.montant), 0)).where( Depense.groupe_id == gid, Depense.paye == True, Depense.methode.in_(["VIREMENT", "CHEQUE"]), ) if filtre_depuis: q = q.where(Depense.date_confirmation > filtre_depuis) result = await db.execute(q) depenses_bancaires_depuis = float(result.scalar()) solde_banque = solde_releve + depots_depuis - retraits_depuis - contributions_debitees_depuis - depenses_bancaires_depuis # ── 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 # ── Réserves ── result = await db.execute( select(Reserve).where( Reserve.groupe_id == gid, Reserve.active == True, ).order_by(Reserve.nom) ) reserves = result.scalars().all() reserves_details = [ { "id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note, } for r in reserves ] total_reserves = sum(r["montant"] for r in reserves_details) # ── Somme disponible ── disponible = solde_banque - engagements_non_debites - total_reserves # ── Encaisse (cash reçu par le trésorier, pas déposé) ── result = await db.execute( select(func.coalesce(func.sum(Collecte.montant), 0)).where( Collecte.groupe_id == gid, Collecte.statut == "recu")) total_collectes_recues = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")) total_depots = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")) total_retraits = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE")) depenses_cash = float(result.scalar()) # Ventes de littérature remises au trésorier result = await db.execute( select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True)) ventes_recues = float(result.scalar()) encaisse = total_collectes_recues + ventes_recues + total_retraits - total_depots - depenses_cash # ── 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({ "date": str(c.date_collecte), "montant": float(c.montant), "detenteur": det.prenom if det else "?", "statut": c.statut, }) # ── 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 "?", }) return { "solde_banque": round(solde_banque, 2), "date_releve": date_releve, "encaisse": round(encaisse, 2), "disponible": round(disponible, 2), "engagements_non_debites": round(engagements_non_debites, 2), "reserve_prudente": 0, "reserve_anniversaire": 0, "total_reserves": round(total_reserves, 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, 2), "ventes_non_remises_details": details_ventes, } @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 gid = membre.groupe_id transactions = [] # 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", }) # 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. 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 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 "?", }) transactions.sort(key=lambda t: t["date"]) return transactions