groupe-meditation/backend/app/routers/collectes.py

568 lines
21 KiB
Python

"""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