2026-03-04 14:01:00 -05:00
|
|
|
"""Routes S1 — Collecte 7e Tradition."""
|
2026-05-25 17:39:38 -04:00
|
|
|
from datetime import date, datetime, time as dt_time, timedelta, timezone
|
2026-03-04 14:01:00 -05:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-04-17 04:04:04 -04:00
|
|
|
from sqlalchemy import select, desc, extract, func
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
2026-05-25 17:39:38 -04:00
|
|
|
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
|
2026-03-04 14:01:00 -05:00
|
|
|
from app.models.collecte import Collecte
|
2026-04-17 04:04:04 -04:00
|
|
|
from app.models.groupe import Groupe
|
2026-03-04 14:01:00 -05:00
|
|
|
from app.models.membre import Membre
|
2026-04-17 04:04:04 -04:00
|
|
|
from app.models.depense import Depense
|
|
|
|
|
from app.models.envoi_contribution import EnvoiContribution
|
|
|
|
|
from app.models.operation_bancaire import OperationBancaire
|
2026-05-15 16:41:17 -04:00
|
|
|
from app.models.reserve import Reserve
|
2026-05-25 17:39:38 -04:00
|
|
|
from app.models.mouvement_reserve import MouvementReserve
|
2026-04-17 04:04:04 -04:00
|
|
|
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
|
2026-05-25 17:39:38 -04:00
|
|
|
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
|
|
|
|
|
"""Vérifie si un membre est trésorier ou exécutif."""
|
2026-05-25 17:39:38 -04:00
|
|
|
return await membre_a_poste_ou_executif(membre_id, "trésorier", db)
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
@router.post("/", response_model=CollecteOut)
|
|
|
|
|
async def saisir_collecte(
|
|
|
|
|
req: CollecteCreate,
|
2026-05-25 17:39:38 -04:00
|
|
|
membre=Depends(require_executif),
|
2026-03-04 14:01:00 -05:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-05-25 17:39:38 -04:00
|
|
|
"""S1 — Un membre exécutif saisit la collecte."""
|
2026-04-17 04:04:04 -04:00
|
|
|
groupe = await db.get(Groupe, membre.groupe_id)
|
|
|
|
|
aujourdhui = date.today()
|
2026-05-25 17:39:38 -04:00
|
|
|
retro_sysadmin = is_sysadmin(membre)
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
# Validation : pas dans le futur
|
2026-05-25 17:39:38 -04:00
|
|
|
if req.date_collecte > aujourdhui and not retro_sysadmin:
|
2026-04-17 04:04:04 -04:00
|
|
|
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())
|
2026-05-25 17:39:38 -04:00
|
|
|
if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu and not retro_sysadmin:
|
2026-04-17 04:04:04 -04:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}",
|
|
|
|
|
)
|
2026-05-25 17:39:38 -04:00
|
|
|
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",
|
|
|
|
|
)
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
# Validation : si c'est aujourd'hui, pas avant l'heure de la réunion
|
2026-05-25 17:39:38 -04:00
|
|
|
if req.date_collecte == aujourdhui and groupe.heure_reunion and not retro_sysadmin:
|
2026-04-17 04:04:04 -04:00
|
|
|
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}",
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-04 14:01:00 -05:00
|
|
|
collecte = Collecte(
|
|
|
|
|
groupe_id=membre.groupe_id,
|
|
|
|
|
date_collecte=req.date_collecte,
|
|
|
|
|
montant=req.montant,
|
|
|
|
|
reunion_id=req.reunion_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
detenteur_id=req.detenteur_id,
|
|
|
|
|
statut="en_attente",
|
2026-03-04 14:01:00 -05:00
|
|
|
saisi_par=membre.id,
|
|
|
|
|
)
|
|
|
|
|
db.add(collecte)
|
|
|
|
|
await db.flush()
|
2026-04-17 04:04:04 -04:00
|
|
|
return await _to_out(collecte, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/{collecte_id}", response_model=CollecteOut)
|
|
|
|
|
async def ajuster_collecte(
|
|
|
|
|
collecte_id: str,
|
|
|
|
|
req: CollecteUpdate,
|
2026-05-25 17:39:38 -04:00
|
|
|
membre=Depends(require_executif),
|
2026-04-17 04:04:04 -04:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-05-25 17:39:38 -04:00
|
|
|
"""Ajuster le montant avant confirmation."""
|
2026-04-17 04:04:04 -04:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-25 17:39:38 -04:00
|
|
|
membre=Depends(require_executif),
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
|
|
|
|
)
|
2026-03-04 14:01:00 -05:00
|
|
|
)
|
2026-04-17 04:04:04 -04:00
|
|
|
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()
|
2026-05-25 17:39:38 -04:00
|
|
|
db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-25 17:39:38 -04:00
|
|
|
membre=Depends(require_tresorier),
|
2026-04-17 04:04:04 -04:00
|
|
|
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()
|
2026-05-25 17:39:38 -04:00
|
|
|
db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
|
2026-04-17 04:04:04 -04:00
|
|
|
await db.flush()
|
|
|
|
|
return await _to_out(collecte, db)
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_model=list[CollecteOut])
|
|
|
|
|
async def liste_collectes(
|
2026-04-17 04:04:04 -04:00
|
|
|
statut: str | None = None,
|
|
|
|
|
mois: int | None = None,
|
|
|
|
|
annee: int | None = None,
|
2026-03-04 14:01:00 -05:00
|
|
|
limite: int = 20,
|
2026-05-25 17:39:38 -04:00
|
|
|
membre=Depends(require_executif),
|
2026-03-04 14:01:00 -05:00
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-04-17 04:04:04 -04:00
|
|
|
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
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
from app.models.vente_jeton import VenteJeton
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-05-25 17:39:38 -04:00
|
|
|
solde_banque_actuel = await solde_banque(gid, db)
|
|
|
|
|
encaisse_actuelle = await encaisse(gid, db)
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
# ── 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())
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
# ── 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
|
2026-04-17 04:04:04 -04:00
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Reserve).where(
|
|
|
|
|
Reserve.groupe_id == gid,
|
|
|
|
|
Reserve.active == True,
|
|
|
|
|
).order_by(Reserve.nom)
|
|
|
|
|
)
|
|
|
|
|
reserves = result.scalars().all()
|
2026-05-25 17:39:38 -04:00
|
|
|
reserves_details = []
|
|
|
|
|
for r in reserves:
|
|
|
|
|
montant = await solde_reserve(r.id, db)
|
|
|
|
|
r.montant = montant
|
|
|
|
|
reserves_details.append({
|
2026-05-15 16:41:17 -04:00
|
|
|
"id": str(r.id),
|
|
|
|
|
"nom": r.nom,
|
2026-05-25 17:39:38 -04:00
|
|
|
"montant": round(montant, 2),
|
2026-05-15 16:41:17 -04:00
|
|
|
"note": r.note,
|
2026-05-25 17:39:38 -04:00
|
|
|
})
|
|
|
|
|
total_reserves_reelles = sum(r["montant"] for r in reserves_details)
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
# ── Somme disponible ──
|
2026-05-25 17:39:38 -04:00
|
|
|
disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles
|
2026-04-17 04:04:04 -04:00
|
|
|
# ── 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({
|
2026-05-25 17:39:38 -04:00
|
|
|
"id": str(c.id),
|
2026-04-17 04:04:04 -04:00
|
|
|
"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 "?",
|
|
|
|
|
})
|
|
|
|
|
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
return {
|
2026-05-25 17:39:38 -04:00
|
|
|
"solde_banque": round(solde_banque_actuel, 2),
|
2026-04-17 04:04:04 -04:00
|
|
|
"date_releve": date_releve,
|
2026-05-25 17:39:38 -04:00
|
|
|
"encaisse": round(encaisse_actuelle, 2),
|
2026-04-17 04:04:04 -04:00
|
|
|
"disponible": round(disponible, 2),
|
|
|
|
|
"engagements_non_debites": round(engagements_non_debites, 2),
|
2026-05-15 16:41:17 -04:00
|
|
|
"reserve_prudente": 0,
|
|
|
|
|
"reserve_anniversaire": 0,
|
2026-05-25 17:39:38 -04:00
|
|
|
"total_reserves": round(total_reserves_reelles, 2),
|
|
|
|
|
"total_reserves_reelles": round(total_reserves_reelles, 2),
|
2026-05-15 16:41:17 -04:00
|
|
|
"reserves_details": reserves_details,
|
2026-04-17 04:04:04 -04:00
|
|
|
"en_possession": round(total_en_circulation, 2),
|
|
|
|
|
"en_possession_details": details_circulation,
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
"ventes_non_remises": round(total_ventes_non_remises + total_ventes_jetons_non_remises, 2),
|
|
|
|
|
"ventes_non_remises_details": details_ventes + details_ventes_jetons,
|
2026-04-17 04:04:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/registre")
|
|
|
|
|
async def registre_transactions(
|
|
|
|
|
mois: int | None = None,
|
|
|
|
|
annee: int | None = None,
|
2026-05-15 16:41:17 -04:00
|
|
|
type_transaction: str | None = None,
|
2026-04-17 04:04:04 -04:00
|
|
|
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
|
2026-05-25 17:39:38 -04:00
|
|
|
from app.models.transaction_comptable import TransactionComptable
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
from app.models.vente_jeton import VenteJeton
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
gid = membre.groupe_id
|
|
|
|
|
transactions = []
|
2026-05-25 17:39:38 -04:00
|
|
|
dons_operation_ids = set()
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
def ajouter_transaction(transaction):
|
|
|
|
|
if type_transaction and transaction["type"] != type_transaction:
|
|
|
|
|
return
|
|
|
|
|
transactions.append(transaction)
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
# 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
|
2026-05-15 16:41:17 -04:00
|
|
|
ajouter_transaction({
|
2026-04-17 04:04:04 -04:00
|
|
|
"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)
|
2026-05-15 16:41:17 -04:00
|
|
|
ajouter_transaction({
|
2026-04-17 04:04:04 -04:00
|
|
|
"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",
|
|
|
|
|
})
|
|
|
|
|
|
Intégrer rencontres et assemblées pilotées par tuiles
Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions.
Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté.
Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus.
Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
2026-05-31 09:02:02 -04:00
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
# 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)
|
2026-05-15 16:41:17 -04:00
|
|
|
ajouter_transaction({
|
2026-04-17 04:04:04 -04:00
|
|
|
"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(
|
2026-05-15 16:41:17 -04:00
|
|
|
select(EnvoiContribution).where(
|
|
|
|
|
EnvoiContribution.groupe_id == gid,
|
|
|
|
|
EnvoiContribution.debitee == True,
|
|
|
|
|
)
|
2026-04-17 04:04:04 -04:00
|
|
|
.order_by(EnvoiContribution.date_envoi)
|
|
|
|
|
)
|
|
|
|
|
for e in result.scalars().all():
|
|
|
|
|
if filtre_mois(e.date_envoi):
|
2026-05-15 16:41:17 -04:00
|
|
|
ajouter_transaction({
|
2026-04-17 04:04:04 -04:00
|
|
|
"date": str(e.date_envoi),
|
|
|
|
|
"type": "contribution",
|
|
|
|
|
"description": f"Contribution {e.destination} ({e.methode})",
|
|
|
|
|
"entree": 0,
|
|
|
|
|
"sortie": float(e.montant),
|
|
|
|
|
"par": "Trésorier",
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 17:39:38 -04:00
|
|
|
# 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)
|
2026-04-17 04:04:04 -04:00
|
|
|
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():
|
2026-05-25 17:39:38 -04:00
|
|
|
if op.id in dons_operation_ids:
|
|
|
|
|
continue
|
2026-04-17 04:04:04 -04:00
|
|
|
if filtre_mois(op.date_operation):
|
|
|
|
|
m = await db.get(Membre, op.effectue_par)
|
|
|
|
|
is_depot = op.type == "depot"
|
2026-05-15 16:41:17 -04:00
|
|
|
ajouter_transaction({
|
2026-04-17 04:04:04 -04:00
|
|
|
"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 "?",
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 17:39:38 -04:00
|
|
|
# 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)
|
2026-04-17 04:04:04 -04:00
|
|
|
return transactions
|