Aligner regles metier et decisions

This commit is contained in:
Daniel Allaire 2026-05-26 09:05:58 -04:00
parent 56b69b1bb8
commit 4a3df15fa1
26 changed files with 811 additions and 84 deletions

View file

@ -5,6 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.database import async_session
from app.core.sanitization import safe_query_params
from app.models.journal_action import JournalAction
from app.models.membre import Membre
from app.services.notifications import notify_group_change
@ -16,7 +17,6 @@ SKIPPED_PREFIXES = (
"/api/health",
)
def _token_payload(request):
header = request.headers.get("authorization") or ""
if not header.lower().startswith("bearer "):
@ -52,7 +52,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
return response
details = {
"query": dict(request.query_params),
"query": safe_query_params(request.query_params),
"impersonification": bool(payload.get("adm")),
}
db.add(JournalAction(

View file

@ -0,0 +1,19 @@
"""Pure helpers for decision type derivation."""
PREFIXES_DECISION = {
"resolution": "RES",
"nomination": "NOM",
"contribution": "DON",
}
TYPES_PAR_PREFIXE = {prefixe: type_decision for type_decision, prefixe in PREFIXES_DECISION.items()}
def type_decision_depuis_proposition(proposition) -> str:
"""Retourne le type metier sans dependre d'une colonne dediee."""
numero = getattr(proposition, "numero_resolution", None) or ""
prefixe = numero.split("-", 1)[0] if "-" in numero else ""
if prefixe in TYPES_PAR_PREFIXE:
return TYPES_PAR_PREFIXE[prefixe]
if getattr(proposition, "candidature_id", None):
return "nomination"
return "proposition"

View file

@ -2,14 +2,9 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.decision_types import PREFIXES_DECISION
from app.models.proposition import Proposition
PREFIXES_DECISION = {
"resolution": "RES",
"nomination": "NOM",
"contribution": "DON",
}
async def prochain_numero_decision(groupe_id, type_decision: str, annee: int, db: AsyncSession) -> str:
prefixe = PREFIXES_DECISION.get(type_decision, "RES")

View file

@ -0,0 +1,18 @@
"""Pure value objects for financial rules."""
from dataclasses import dataclass
@dataclass(frozen=True)
class PositionTresorerie:
solde_banque: float
encaisse: float
total_reserves: float
engagements_non_debites: float
@property
def disponible_bancaire(self) -> float:
return self.solde_banque - self.total_reserves - self.engagements_non_debites
@property
def disponible_total(self) -> float:
return self.disponible_bancaire + self.encaisse

View file

@ -7,6 +7,7 @@ from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.poste_module import PosteModule
async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSession) -> bool:
@ -24,11 +25,33 @@ async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSes
return result.scalars().first() is not None
async def membre_a_module(membre_id, module_code: str, db: AsyncSession) -> bool:
from app.models.membre import Membre
membre = await db.get(Membre, membre_id)
if membre and is_sysadmin(membre):
return True
result = await db.execute(
select(Affectation).join(Poste).join(PosteModule, PosteModule.poste_id == Poste.id).where(
Affectation.membre_id == membre_id,
Affectation.statut == "actif",
PosteModule.module_code == module_code,
)
)
return result.scalars().first() is not None
async def membre_a_poste_ou_module(membre_id, libelle_poste: str, module_code: str, db: AsyncSession) -> bool:
return (
await membre_a_poste_ou_executif(membre_id, libelle_poste, db)
or await membre_a_module(membre_id, module_code, db)
)
async def require_tresorier(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
if not await membre_a_poste_ou_module(membre.id, "trésorier", "S1T", db):
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
return membre
@ -37,7 +60,10 @@ async def require_secretaire(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "secrétaire", db):
if not (
await membre_a_poste_ou_module(membre.id, "secrétaire", "M1", db)
or await membre_a_module(membre.id, "M2", db)
):
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
return membre
@ -46,6 +72,6 @@ async def require_rsg(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "RSG", db):
if not await membre_a_poste_ou_module(membre.id, "RSG", "M3", db):
raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif")
return membre

View file

@ -0,0 +1,10 @@
"""Helpers for redacting sensitive request data."""
SENSITIVE_QUERY_KEYS = {"pin", "ancien_pin", "nouveau_pin", "token", "access_token", "password", "mot_de_passe"}
def safe_query_params(query_params):
return {
key: ("***" if key.lower() in SENSITIVE_QUERY_KEYS else value)
for key, value in query_params.items()
}

View file

@ -39,6 +39,7 @@ async def lifespan(app: FastAPI):
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))
await conn.execute(text("ALTER TABLE propositions DROP COLUMN IF EXISTS type_proposition"))
await conn.execute(text(
"ALTER TABLE envois_contributions "
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"

View file

@ -12,9 +12,6 @@ class Proposition(Base):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True)
type_proposition: Mapped[str] = mapped_column(
String(20), default="proposition", # proposition, resolution, nomination, contribution
)
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
proposeur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)

View file

@ -6,6 +6,7 @@ from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.jeton import Jeton
from app.models.affectation import Affectation
from app.models.poste import Poste
@ -17,7 +18,7 @@ router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"])
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
if is_sysadmin(membre) or await membre_a_module(membre.id, "M6", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(

View file

@ -13,7 +13,7 @@ from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import solde_banque, total_reserves, transaction
from app.services.finance import position_tresorerie, transaction
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
@ -218,7 +218,6 @@ async def enregistrer_envoi(
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
type_proposition="contribution",
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
description=(
f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. "
@ -283,11 +282,10 @@ async def confirmer_debit_contribution(
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
if float(envoi.montant) > solde_banque_actuel:
position = await position_tresorerie(membre.groupe_id, db)
if float(envoi.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque_actuel - float(envoi.montant) < total_reserves_actuel:
if position.solde_banque - float(envoi.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True

View file

@ -6,12 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import membre_a_poste_ou_executif, require_tresorier
from app.core.roles import membre_a_module, membre_a_poste_ou_executif, require_tresorier
from app.core.security import get_current_membre, is_sysadmin
from app.models.depense import Depense
from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import encaisse, engagements_non_debites, solde_banque, total_reserves, transaction
from app.services.finance import position_tresorerie, transaction
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
@ -46,7 +46,10 @@ async def soumettre_depense(
"""S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
qui_id = membre.id
if req.membre_id and req.membre_id != membre.id:
if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
if not (
await membre_a_poste_ou_executif(membre.id, "trésorier", db)
or await membre_a_module(membre.id, "S5", db)
):
raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre")
beneficiaire = await db.get(Membre, req.membre_id)
if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
@ -82,6 +85,13 @@ async def telecharger_preuve(
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
peut_joindre = (
depense.saisi_par == membre.id
or depense.qui == membre.id
or await membre_a_poste_ou_executif(membre.id, "trésorier", db)
)
if not peut_joindre:
raise HTTPException(status_code=403, detail="Réservé au demandeur, au bénéficiaire ou à l'exécutif")
# Sauvegarder le fichier
os.makedirs(UPLOAD_DIR, exist_ok=True)
@ -119,15 +129,12 @@ async def confirmer_depense(
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
if methode_norm == "COLLECTE":
solde_encaisse = await encaisse(membre.groupe_id, db)
if float(depense.montant) > solde_encaisse:
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
elif methode_norm in ("CHEQUE", "VIREMENT"):
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
engagements = await engagements_non_debites(membre.groupe_id, db)
disponible = solde_banque_actuel - total_reserves_actuel - engagements
if float(depense.montant) > disponible:
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.disponible_bancaire:
raise HTTPException(
status_code=400,
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
@ -166,11 +173,10 @@ async def confirmer_debit(
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
if float(depense.montant) > solde_banque_actuel:
position = await position_tresorerie(membre.groupe_id, db)
if float(depense.montant) > position.solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque_actuel - float(depense.montant) < total_reserves_actuel:
if position.solde_banque - float(depense.montant) < position.total_reserves:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
depense.paye = True

View file

@ -5,6 +5,7 @@ from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.jeton import Jeton
from app.models.membre import Membre
from app.models.affectation import Affectation
@ -16,7 +17,7 @@ router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
if is_sysadmin(membre) or await membre_a_module(membre.id, "S2", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(

View file

@ -5,6 +5,7 @@ from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
@ -15,7 +16,7 @@ router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
if is_sysadmin(membre) or await membre_a_module(membre.id, "M5", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(

View file

@ -38,7 +38,11 @@ async def modifier_profil(
membre.telephone = telephone
if updates.courriel is not None:
courriel = updates.courriel.strip().lower()
if not courriel or "@" not in courriel:
if not courriel:
raise HTTPException(status_code=400, detail="Identifiant invalide")
if is_sysadmin(membre) and courriel != "sysadmin":
raise HTTPException(status_code=400, detail="L'identifiant Sysadmin est protégé")
if not is_sysadmin(membre) and "@" not in courriel:
raise HTTPException(status_code=400, detail="Courriel invalide")
result = await db.execute(
select(Membre).where(
@ -49,7 +53,7 @@ async def modifier_profil(
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
raise HTTPException(status_code=400, detail="Cet identifiant est déjà utilisé dans ce groupe")
membre.courriel = courriel
if updates.date_abstinence is not None:
membre.date_abstinence = updates.date_abstinence

View file

@ -12,7 +12,7 @@ from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
from app.models.membre import Membre
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.services.finance import encaisse, reserve_disponible, solde_banque, solde_reserve, total_reserves
from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
@ -73,15 +73,14 @@ async def enregistrer_operation(
raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > await encaisse(membre.groupe_id, db):
position = await position_tresorerie(membre.groupe_id, db)
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > position.encaisse:
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
if req.type == "retrait" and req.montant > solde_banque_actuel:
if req.type == "retrait" and req.montant > position.solde_banque:
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
if req.type == "retrait" and solde_banque_actuel - req.montant < total_reserves_actuel:
if req.type == "retrait" and position.solde_banque - req.montant < position.total_reserves:
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
if req.type == "releve" and req.montant < total_reserves_actuel:
if req.type == "releve" and req.montant < position.total_reserves:
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire(
@ -115,7 +114,7 @@ async def enregistrer_operation(
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit"))
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
else:
delta = req.montant - solde_banque_actuel
delta = req.montant - position.solde_banque
if delta != 0:
db.add(_ligne_comptable_montant(
membre.groupe_id, membre.id, op, "banque",

View file

@ -230,7 +230,6 @@ async def postuler(
# Créer automatiquement la proposition de nomination
prop = Proposition(
groupe_id=membre.groupe_id,
type_proposition="nomination",
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}",
description=motivation.strip() or None,
proposeur_id=membre.id,
@ -388,7 +387,6 @@ async def accepter_nomination(
prop = Proposition(
groupe_id=cand.groupe_id,
type_proposition="nomination",
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom if poste else '?'}",
description=f"Nominé par {nomme_par.prenom if nomme_par else '?'}. {cand.motivation or ''}".strip(),
proposeur_id=cand.nomme_par or membre.id,
@ -484,8 +482,45 @@ async def traiter_candidature(
cand.statut = statut
# Si acceptée, créer l'affectation automatiquement
# Si acceptée, créer l'affectation et la décision de nomination automatiquement.
if statut == "acceptee":
if str(executif.id) == str(cand.membre_id):
raise HTTPException(
status_code=400,
detail="Un second membre est requis pour accepter cette candidature comme nomination",
)
membre_cible = await db.get(Membre, cand.membre_id)
poste = await db.get(Poste, cand.poste_id)
if not membre_cible or not membre_cible.actif or membre_cible.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif")
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.poste_id == cand.poste_id,
Affectation.statut == "actif",
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", date.today().year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
sujet=f"Nomination de {membre_cible.prenom} au poste de {poste.nom}",
description=cand.motivation,
proposeur_id=executif.id,
secondeur_id=cand.membre_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date.today(),
date_adoption=date.today(),
groupe_a_decide_de=f"Nommer {membre_cible.prenom} au poste de {poste.nom}.",
candidature_id=cand.id,
)
db.add(resolution)
await db.flush()
cand.proposition_id = resolution.id
affectation = Affectation(
groupe_id=executif.groupe_id,
membre_id=cand.membre_id,
@ -577,7 +612,6 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
type_proposition="nomination",
sujet=f"Nomination de {cible.prenom} au poste de {poste.nom}",
description=f"Affectation directe au poste de {poste.nom} à partir du {req.date_debut}.",
proposeur_id=req.proposeur_id,

View file

@ -6,6 +6,7 @@ from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.decision_types import type_decision_depuis_proposition
from app.core.security import get_current_membre, require_executif
from app.core.roles import membre_a_poste_ou_executif
from app.models.proposition import Proposition
@ -26,7 +27,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
return PropositionOut(
id=p.id,
reunion_id=p.reunion_id,
type_proposition=p.type_proposition or "proposition",
type_decision=type_decision_depuis_proposition(p),
sujet=p.sujet,
description=p.description,
proposeur_prenom=proposeur.prenom if proposeur else "?",
@ -64,7 +65,6 @@ async def creer_proposition(
prop = Proposition(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
type_proposition="proposition",
sujet=req.sujet.strip(),
description=req.description,
proposeur_id=membre.id,
@ -150,16 +150,17 @@ async def voter_proposition(
prop.etant_donne_que = etant_donne_que
prop.groupe_a_decide_de = groupe_a_decide_de
prop.note = None
if prop.type_proposition not in ("nomination", "contribution"):
prop.type_proposition = "resolution"
type_decision = type_decision_depuis_proposition(prop)
if type_decision == "proposition":
type_decision = "resolution"
prop.numero_resolution = await prochain_numero_decision(
executif.groupe_id, prop.type_proposition, date.today().year, db
executif.groupe_id, type_decision, date.today().year, db
)
if req.reunion_id and not prop.reunion_id:
prop.reunion_id = req.reunion_id
# ── NOMINATION : créer l'affectation automatiquement ──
if prop.type_proposition == "nomination" and prop.candidature_id:
if type_decision == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand and cand.statut == "proposee":
cand.statut = "acceptee"
@ -182,7 +183,7 @@ async def voter_proposition(
prop.note = (req.note or "").strip() or None
# Si nomination rejetée, mettre la candidature en refusée
if prop.type_proposition == "nomination" and prop.candidature_id:
if type_decision_depuis_proposition(prop) == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand:
cand.statut = "refusee"
@ -200,7 +201,7 @@ async def voter_proposition(
async def liste_propositions(
statut: str | None = None,
reunion_id: str | None = None,
type_proposition: str | None = None,
type_decision: str | None = None,
limite: int = 50,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
@ -211,18 +212,19 @@ async def liste_propositions(
query = query.where(Proposition.statut == statut)
if reunion_id:
query = query.where(Proposition.reunion_id == reunion_id)
if type_proposition:
query = query.where(Proposition.type_proposition == type_proposition)
query = query.order_by(desc(Proposition.cree_le)).limit(limite)
result = await db.execute(query)
return [await _to_out(p, db) for p in result.scalars().all()]
propositions = result.scalars().all()
if type_decision:
propositions = [p for p in propositions if type_decision_depuis_proposition(p) == type_decision]
return [await _to_out(p, db) for p in propositions]
@router.get("/resolutions", response_model=list[PropositionOut])
async def registre_resolutions(
annee: int | None = None,
type_proposition: str | None = None,
type_decision: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
@ -233,9 +235,10 @@ async def registre_resolutions(
)
if annee:
query = query.where(extract("year", Proposition.cree_le) == annee)
if type_proposition:
query = query.where(Proposition.type_proposition == type_proposition)
query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution)
result = await db.execute(query)
return [await _to_out(p, db) for p in result.scalars().all()]
propositions = result.scalars().all()
if type_decision:
propositions = [p for p in propositions if type_decision_depuis_proposition(p) == type_decision]
return [await _to_out(p, db) for p in propositions]

View file

@ -140,15 +140,23 @@ async def adopter_pv(
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
if not req.proposeur.strip() or not req.secondeur.strip():
raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
if req.proposeur_id == req.secondeur_id:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
proposeur = await db.get(Membre, req.proposeur_id)
secondeur = await db.get(Membre, req.secondeur_id)
if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
decisions = list(pv.decisions or [])
decisions.append({
"sujet": "Adoption du procès-verbal précédent",
"decision": "Le procès-verbal précédent est adopté.",
"proposeur": req.proposeur.strip(),
"secondeur": req.secondeur.strip(),
"proposeur": proposeur.prenom,
"secondeur": secondeur.prenom,
"proposeur_id": str(proposeur.id),
"secondeur_id": str(secondeur.id),
"statut": "complete",
})
pv.decisions = decisions

View file

@ -5,6 +5,7 @@ from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.litterature import Litterature
from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre
@ -18,7 +19,7 @@ router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littératur
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
if is_sysadmin(membre) or await membre_a_module(membre.id, "S3", db) or await membre_a_module(membre.id, "M5", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
@ -154,7 +155,7 @@ async def confirmer_reception_vente(
):
"""Le trésorier confirme la réception du montant de la vente."""
# Vérifier que c'est le trésorier ou exécutif
if not is_sysadmin(membre):
if not is_sysadmin(membre) and not await membre_a_module(membre.id, "S1T", db):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,

View file

@ -72,8 +72,8 @@ class PvUpdate(BaseModel):
class PvAdoption(BaseModel):
proposeur: str
secondeur: str
proposeur_id: UUID
secondeur_id: UUID
# ── M3 Rapport RSG ──

View file

@ -14,7 +14,7 @@ class PropositionCreate(BaseModel):
class PropositionOut(BaseModel):
id: UUID
reunion_id: Optional[UUID] = None
type_proposition: str = "proposition"
type_decision: str = "proposition"
sujet: str
description: Optional[str] = None
proposeur_prenom: str

View file

@ -2,6 +2,7 @@
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.financial_position import PositionTresorerie
from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.mouvement_reserve import MouvementReserve
@ -78,10 +79,15 @@ async def total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = N
async def reserve_disponible(groupe_id, db: AsyncSession) -> float:
return (
await solde_banque(groupe_id, db)
- await total_reserves(groupe_id, db)
- await engagements_non_debites(groupe_id, db)
return (await position_tresorerie(groupe_id, db)).disponible_bancaire
async def position_tresorerie(groupe_id, db: AsyncSession) -> PositionTresorerie:
return PositionTresorerie(
solde_banque=await solde_banque(groupe_id, db),
encaisse=await encaisse(groupe_id, db),
total_reserves=await total_reserves(groupe_id, db),
engagements_non_debites=await engagements_non_debites(groupe_id, db),
)

View file

@ -0,0 +1,50 @@
import unittest
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.decision_types import type_decision_depuis_proposition
from app.core.financial_position import PositionTresorerie
from app.core.sanitization import safe_query_params
class DecisionTypeTests(unittest.TestCase):
def test_resolution_prefix(self):
prop = SimpleNamespace(numero_resolution="RES-2026-001", candidature_id=None)
self.assertEqual(type_decision_depuis_proposition(prop), "resolution")
def test_nomination_from_candidature(self):
prop = SimpleNamespace(numero_resolution=None, candidature_id="candidate-id")
self.assertEqual(type_decision_depuis_proposition(prop), "nomination")
def test_contribution_prefix(self):
prop = SimpleNamespace(numero_resolution="DON-2026-001", candidature_id=None)
self.assertEqual(type_decision_depuis_proposition(prop), "contribution")
def test_plain_proposition_without_decision_marker(self):
prop = SimpleNamespace(numero_resolution=None, candidature_id=None)
self.assertEqual(type_decision_depuis_proposition(prop), "proposition")
class PositionTresorerieTests(unittest.TestCase):
def test_available_bank_excludes_reserves_and_commitments(self):
position = PositionTresorerie(
solde_banque=100.0,
encaisse=25.0,
total_reserves=40.0,
engagements_non_debites=15.0,
)
self.assertEqual(position.disponible_bancaire, 45.0)
self.assertEqual(position.disponible_total, 70.0)
class AuditTests(unittest.TestCase):
def test_sensitive_query_params_are_masked(self):
request = SimpleNamespace(query_params={"pin": "1234", "membre_id": "abc"})
self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"})
if __name__ == "__main__":
unittest.main()

536
docs/REGLES_METIER.md Normal file
View file

@ -0,0 +1,536 @@
# Registre des regles metier
Ce document inventorie les regles metier connues de l'application en les
alignant sur le comportement reel du code. Il sert de reference pour les
developpements, les tests, les validations apres deploiement et les discussions
fonctionnelles.
Le registre ne liste pas les erreurs techniques generiques comme `objet
introuvable` quand elles ne portent pas une intention metier particuliere. Il
liste toutefois les droits d'acces, les contraintes de saisie, les transitions
d'etat, les invariants financiers et les protections de donnees.
Quand le comportement actuel ne correspond pas clairement a l'intention metier,
la regle est formulee selon le code et l'ecart est liste dans la section
`Ecarts connus et decisions requises`.
## Acces et identite
- RM-ACC-001 - Le compte `Sysadmin` est cree au deploiement initial avec le PIN
`0000`.
- RM-ACC-002 - Le compte `Sysadmin` a acces a tous les modules.
- RM-ACC-003 - Le compte `Sysadmin` ne peut pas etre desactive ni supprime.
- RM-ACC-004 - Seul `Sysadmin` peut impersonifier un autre membre.
- RM-ACC-005 - Une session impersonifiee conserve l'identite du sysadmin dans le
jeton.
- RM-ACC-006 - Un membre inactif ne peut pas ouvrir de session.
- RM-ACC-007 - Un token invalide, expire ou associe a un autre groupe est
refuse.
- RM-ACC-008 - Un PIN doit etre compose de quatre chiffres.
- RM-ACC-009 - L'identifiant de connexion, stocke dans la colonne `courriel`,
est unique dans un groupe.
- RM-ACC-010 - La connexion cherche l'identifiant sans tenir compte de la casse.
- RM-ACC-011 - Un jeton de session dure 30 jours par defaut.
- RM-ACC-012 - Un jeton de session dure 90 jours lorsque `se souvenir de moi`
est choisi.
- RM-ACC-013 - Les operations d'administration courante sont reservees aux
membres occupant un poste executif, sauf exception explicite.
- RM-ACC-014 - `Sysadmin` satisfait toutes les verifications de role.
## Invitations et inscriptions
- RM-INV-001 - Seul un executif peut generer une invitation.
- RM-INV-002 - Un code d'invitation est unique et comporte 12 caracteres
hexadecimaux.
- RM-INV-003 - Un code d'invitation expire selon la configuration serveur.
- RM-INV-004 - Un code d'invitation ne peut etre utilise qu'une seule fois.
- RM-INV-005 - Une inscription cree le membre dans le groupe lie a l'invitation.
- RM-INV-006 - Le prenom et le nom saisis a l'inscription sont normalises en
casse titre.
- RM-INV-007 - Le courriel saisi a l'inscription est normalise en minuscules.
- RM-INV-008 - Une inscription ouvre immediatement une session pour le nouveau
membre.
## Membres et postes
- RM-MEM-001 - Le prenom et le telephone d'un membre sont obligatoires.
- RM-MEM-002 - Un membre ne peut pas supprimer son propre compte par le droit a
l'oubli.
- RM-MEM-003 - Un membre peut modifier son profil personnel.
- RM-MEM-004 - Un changement de courriel doit conserver l'unicite dans le
groupe.
- RM-MEM-005 - Un changement de PIN exige l'ancien PIN.
- RM-MEM-006 - Un executif peut consulter les membres actifs du groupe.
- RM-MEM-007 - La desactivation d'un membre conserve son historique.
- RM-MEM-008 - Le droit a l'oubli anonymise le membre au lieu d'effacer les
donnees relationnelles.
- RM-MEM-009 - Le droit a l'oubli retire le prenom, le nom, le telephone, la date
d'abstinence et remplace le courriel par une adresse locale anonyme.
- RM-POS-001 - Les postes appartiennent a une categorie valide:
`executif`, `service` ou `physique`.
- RM-POS-002 - Une affectation active lie un membre, un poste et une date de
debut.
- RM-POS-003 - Une affectation directe creee par le module d'affectation produit
une decision adoptee de type `Nomination`.
- RM-POS-004 - Une nomination exige un proposeur et un secondeur actifs et
distincts.
- RM-POS-005 - Un poste deja affecte ne peut pas etre affecte une seconde fois
tant que l'affectation active existe.
- RM-POS-006 - Un poste doit etre aboli avant d'etre supprime definitivement.
- RM-POS-007 - Les permissions de modules sont associees aux postes.
- RM-POS-008 - Seul un executif peut creer, modifier, abolir, reactiver ou
supprimer un poste.
- RM-POS-009 - Un poste aboli n'apparait pas dans les listes ordinaires, sauf si
l'affichage des postes abolis est demande.
- RM-POS-010 - Le nom d'un poste est obligatoire.
- RM-POS-011 - Un membre peut postuler lui-meme sur un poste disponible.
- RM-POS-012 - Un membre ne peut pas postuler deux fois pour le meme poste.
- RM-POS-013 - Un executif peut proposer un autre membre pour un poste.
- RM-POS-014 - La proposition d'un autre membre ne peut pas viser le membre qui
fait la proposition; il doit alors utiliser sa propre candidature.
- RM-POS-015 - Une candidature ou nomination deja traitee ne peut plus etre
traitee de nouveau.
- RM-POS-016 - Le traitement direct d'une candidature acceptee cree une
affectation active.
- RM-POS-017 - Une candidature refusee conserve son statut `refusee`.
- RM-POS-018 - Une nomination refusee marque la candidature comme refusee.
- RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et
conserve la date de fin, la raison et le membre qui l'a terminee.
- RM-POS-020 - Les modules consultables par un membre dependent des permissions
associees a ses postes, avec acces complet pour `Sysadmin`.
- RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues
par les verifications API des responsabilites specialisees.
## Reunions et dates
- RM-REU-001 - Les dates de reunion doivent correspondre au jour configure dans
les parametres du groupe.
- RM-REU-002 - Une reunion reguliere ne peut pas etre creee deux fois pour la
meme semaine ISO.
- RM-REU-003 - L'assemblee d'affaires mensuelle doit correspondre a l'ordre
configure: premiere, deuxieme, troisieme, quatrieme ou derniere reunion du
mois.
- RM-REU-004 - Les rapports de rencontre et d'assemblee sont consultables par
tous les membres.
- RM-REU-005 - La creation et la saisie des rapports de rencontre et
d'assemblee sont reservees aux executifs.
- RM-REU-006 - `Sysadmin` peut saisir des informations a posteriori en
specifiant la date.
- RM-REU-007 - Le jour de reunion configure dans le groupe pilote les dates
permises pour les collectes, rencontres et assemblees.
- RM-REU-008 - L'heure de reunion configuree bloque la saisie de collecte le
jour meme avant l'heure prevue, sauf pour `Sysadmin`.
- RM-REU-009 - Une rencontre hebdomadaire est de type `reguliere`.
- RM-REU-010 - Une assemblee d'affaires est de type `affaires`.
- RM-REU-011 - Une seule assemblee d'affaires peut exister pour un mois donne.
- RM-REU-012 - Les presences d'une assemblee peuvent etre resaisies; la nouvelle
saisie remplace les presences precedentes.
- RM-REU-013 - La saisie des presences d'assemblee est reservee au secretaire ou
a l'executif.
- RM-REU-014 - Le proces-verbal d'une assemblee est rattache a une reunion du
groupe.
- RM-REU-015 - La creation et la modification d'un proces-verbal sont reservees
au secretaire ou a l'executif.
- RM-REU-016 - L'adoption d'un proces-verbal exige un proposeur et un secondeur.
- RM-REU-017 - L'adoption d'un proces-verbal ajoute une decision d'adoption au
proces-verbal.
- RM-REU-018 - Un rapport RSG est reserve au RSG, au RSG substitut, a l'executif
ou a `Sysadmin`.
- RM-REU-019 - Les rotations futures et leur historique sont consultables par
les membres.
- RM-REU-020 - La planification ou la suppression d'une rotation est reservee aux
executifs.
## Collectes
- RM-COL-001 - La saisie d'une collecte est reservee aux executifs.
- RM-COL-002 - Une collecte ne peut pas etre saisie dans le futur, sauf par
`Sysadmin`.
- RM-COL-003 - La date d'une collecte doit etre une date de reunion, sauf par
`Sysadmin`.
- RM-COL-004 - La date de saisie proposee doit faire partie des 52 dernieres
rencontres sans collecte deja enregistree.
- RM-COL-005 - Il ne peut y avoir qu'une collecte par date.
- RM-COL-006 - Une collecte en attente peut etre modifiee; une collecte
confirmee ou recue ne le peut plus.
- RM-COL-007 - Le membre qui saisit une collecte ne peut pas confirmer son
propre montant.
- RM-COL-008 - Si le tresorier saisit ou confirme une collecte, la reception est
automatique.
- RM-COL-009 - Quand le tresorier recoit une collecte, la transaction comptable
utilise la date de la collecte.
- RM-COL-010 - Une collecte recue debite l'encaisse.
- RM-COL-011 - Une collecte saisie puis confirmee par un non-tresorier reste au
statut `confirmee` jusqu'a reception par le tresorier.
- RM-COL-012 - Le registre de tresorerie inclut les collectes recues.
- RM-COL-013 - Le module de collecte peut filtrer par mois, annee et statut.
- RM-COL-014 - Tous les montants de collecte participent aux soldes seulement
lorsqu'ils atteignent l'etat comptable approprie.
## Tresorerie
- RM-TRE-001 - Les soldes de tresorerie sont calcules a partir du registre
comptable.
- RM-TRE-002 - Le solde bancaire disponible est le solde en banque moins les
reserves actives et les engagements non debites.
- RM-TRE-003 - L'encaisse est consideree comme disponible.
- RM-TRE-004 - Un depot peut provenir de l'encaisse ou d'un don direct au compte
bancaire.
- RM-TRE-005 - Un depot depuis l'encaisse ne peut pas depasser l'encaisse
disponible.
- RM-TRE-006 - Un retrait bancaire ne peut pas depasser le solde en banque.
- RM-TRE-007 - Un retrait bancaire ne peut pas faire depasser le total des
reserves par rapport au solde en banque.
- RM-TRE-008 - Un releve bancaire ne peut pas etre inferieur au total des
reserves.
- RM-TRE-009 - Les transactions du registre sont affichees de la plus recente a
la plus ancienne.
- RM-TRE-010 - Le rapport PDF de tresorerie utilise l'etat des resultats du mois
precedent.
- RM-TRE-011 - Les positions de tresorerie du rapport PDF sont celles du dernier
jour du mois precedent.
- RM-TRE-012 - Les actions qui modifient la tresorerie sont reservees au
tresorier ou a l'executif.
- RM-TRE-013 - Les membres peuvent voir les elements a traiter, mais seuls les
executifs peuvent les confirmer.
- RM-TRE-014 - Une operation bancaire doit avoir un type parmi `depot`,
`retrait` ou `releve`.
- RM-TRE-015 - Une operation bancaire doit avoir un montant positif.
- RM-TRE-016 - Un depot direct de don debite la banque sans crediter l'encaisse.
- RM-TRE-017 - Un depot depuis l'encaisse debite la banque et credite l'encaisse.
- RM-TRE-018 - Un retrait credite la banque et debite l'encaisse.
- RM-TRE-019 - Un releve bancaire cree une correction de banque egale a l'ecart
entre le releve saisi et le solde bancaire courant.
- RM-TRE-020 - Le registre comptable conserve la source de chaque transaction
quand elle provient d'une table applicative.
- RM-TRE-021 - Le filtre du registre permet de restreindre par mois et par type
de transaction.
- RM-TRE-022 - Les ventes de litterature remises au tresorier debitent
l'encaisse.
- RM-TRE-023 - Les depenses payees creditent le compte utilise: banque ou
encaisse.
- RM-TRE-024 - Les contributions debitees creditent la banque.
## Reserves
- RM-RES-001 - Une reserve se cree avec un solde initial de zero.
- RM-RES-002 - Le montant d'une reserve ne se modifie pas directement.
- RM-RES-003 - Les variations de reserve passent par un virement de reserve.
- RM-RES-004 - Les virements de reserve sont historises.
- RM-RES-005 - Un virement ne peut pas avoir la meme source et la meme
destination.
- RM-RES-006 - Le solde disponible peut etre utilise comme source ou destination
de virement.
- RM-RES-007 - Un virement depuis le disponible ne peut pas depasser le
disponible bancaire.
- RM-RES-008 - Un virement depuis une reserve ne peut pas depasser le solde de
cette reserve.
- RM-RES-009 - Une reserve ne peut etre supprimee que si son solde est nul.
- RM-RES-010 - Une reserve supprimee est desactivee, pas effacee du registre.
- RM-RES-011 - La liste des reserves n'affiche que les reserves actives.
- RM-RES-012 - Le solde d'une reserve est calcule a partir des mouvements de
reserve, pas a partir d'une saisie manuelle.
- RM-RES-013 - Les mouvements de reserve apparaissent au registre.
- RM-RES-014 - Les mouvements de reserve conservent source, destination, montant,
type, note, createur et date de creation.
- RM-RES-015 - Le total des reserves actives ne doit jamais exceder le solde
bancaire.
- RM-RES-016 - Le disponible n'est pas une reserve; il est calcule comme solde
bancaire moins reserves et engagements.
## Depenses
- RM-DEP-001 - Un membre ordinaire ne peut soumettre une depense qu'en son nom.
- RM-DEP-002 - Un executif peut soumettre une depense au nom d'un autre membre
actif.
- RM-DEP-003 - Seul `Sysadmin` peut saisir une date de depense a posteriori.
- RM-DEP-004 - Une depense soumise doit etre traitee par le tresorier ou un
executif.
- RM-DEP-005 - Une depense par encaisse est payee immediatement si l'encaisse le
permet.
- RM-DEP-006 - Une depense par cheque ou virement devient un engagement en
attente de debit.
- RM-DEP-007 - Une depense bancaire ne peut pas etre engagee si le disponible
bancaire est insuffisant.
- RM-DEP-008 - Le debit final d'une depense bancaire ne peut pas faire depasser
les reserves par rapport au solde en banque.
- RM-DEP-009 - Une depense soumise non traitee peut etre rejetee.
- RM-DEP-010 - Une depense deja traitee ne peut plus etre rejetee.
- RM-DEP-011 - Les modes valides de traitement d'une depense sont `COLLECTE`,
`CHEQUE` et `VIREMENT`.
- RM-DEP-012 - Une depense deja traitee ne peut pas etre traitee de nouveau.
- RM-DEP-013 - Une depense deja payee ne peut pas faire l'objet d'une
confirmation de debit.
- RM-DEP-014 - Seules les depenses par cheque ou virement necessitent une
confirmation de debit bancaire.
- RM-DEP-015 - Une preuve de depense peut etre jointe et conservee dans le
repertoire des preuves.
- RM-DEP-016 - Rejeter une depense supprime aussi son fichier de preuve s'il
existe.
- RM-DEP-017 - La liste des depenses peut etre filtree selon payee/non payee et
confirmee/non confirmee.
- RM-DEP-018 - Modifier une depense est reserve au tresorier ou a l'executif.
- RM-DEP-019 - Modifier la methode d'une depense exige une methode valide.
## Contributions
- RM-CON-001 - Une contribution est une decision de groupe de type
`Contribution`.
- RM-CON-002 - Une contribution exige un proposeur et un secondeur actifs et
distincts.
- RM-CON-003 - Le destinataire `District 87-16` existe au deploiement initial.
- RM-CON-004 - Le destinataire `District 87-16` est protege contre la
modification et la suppression.
- RM-CON-005 - Une contribution devient un engagement tant que le debit bancaire
n'est pas confirme.
- RM-CON-006 - Le debit final d'une contribution ne peut pas depasser le solde en
banque.
- RM-CON-007 - Le debit final d'une contribution ne peut pas faire depasser les
reserves par rapport au solde en banque.
- RM-CON-008 - Une contribution doit avoir un montant positif.
- RM-CON-009 - Les modes valides de contribution sont `DU`, `VIREMENT` et
`CHEQUE`.
- RM-CON-010 - La destination d'une contribution doit etre un destinataire actif
du groupe.
- RM-CON-011 - Un destinataire de contribution est unique par nom dans un groupe.
- RM-CON-012 - Un destinataire supprime est desactive, pas efface.
- RM-CON-013 - Le calcul de repartition utilise la configuration active du
groupe ou la repartition par defaut.
- RM-CON-014 - Les destinations de repartition manquantes sont completees a
zero.
- RM-CON-015 - La configuration de repartition est reservee aux executifs.
- RM-CON-016 - Le total d'une repartition doit etre exactement 100 %, avec une
tolerance de 0,01.
- RM-CON-017 - Les destinations de repartition valides sont `district`,
`region`, `bsg` et `intergroupe`.
- RM-CON-018 - Un pourcentage de repartition ne peut pas etre negatif.
- RM-CON-019 - Une contribution cree automatiquement une decision adoptee de
type `Contribution`.
- RM-CON-020 - `Sysadmin` peut utiliser la date d'envoi comme date de decision;
les autres membres utilisent la date du jour.
## Gouvernance
- RM-GOU-001 - Une proposition ne stocke pas de type metier dedie.
- RM-GOU-002 - Une proposition exige un objet.
- RM-GOU-003 - Une proposition peut etre secondee par un autre membre.
- RM-GOU-004 - Un membre ne peut pas seconder sa propre proposition.
- RM-GOU-005 - Un executif peut seconder une proposition au nom d'un autre
membre actif.
- RM-GOU-006 - Seule une proposition secondee peut etre transformee en decision.
- RM-GOU-007 - Transformer une proposition en resolution exige les sections
`ETANT DONNE QUE` et `LE GROUPE A DECIDE DE`.
- RM-GOU-008 - Une resolution adoptee recoit un numero avec le prefixe `RES`.
- RM-GOU-009 - Une nomination adoptee recoit le prefixe `NOM`.
- RM-GOU-010 - Une contribution adoptee recoit le prefixe `DON`.
- RM-GOU-011 - Une proposition rejetee est conservee.
- RM-GOU-012 - Rejeter une proposition exige la section `ETANT DONNE QUE`.
- RM-GOU-013 - Le rapport des resolutions affiche les decisions prises en
groupe.
- RM-GOU-014 - Une proposition peut etre rattachee a une reunion existante du
groupe.
- RM-GOU-015 - Une proposition a l'etat initial `proposee`.
- RM-GOU-016 - Seule une proposition a l'etat `proposee` peut etre secondee.
- RM-GOU-017 - Seules les propositions aux statuts `proposee` ou `secondee`
peuvent etre rejetees.
- RM-GOU-018 - Une proposition adoptee conserve la date d'adoption.
- RM-GOU-019 - Le type d'une decision est derive du prefixe de son numero
(`RES`, `NOM`, `DON`) ou de son lien metier.
- RM-GOU-020 - Une nomination adoptee peut accepter automatiquement la
candidature liee.
- RM-GOU-021 - Une nomination rejetee peut refuser automatiquement la
candidature liee.
- RM-GOU-022 - L'historique des resolutions peut etre filtre par annee et par
type.
- RM-GOU-023 - Les decisions adoptees sont ordonnees par date de vote/adoption
decroissante dans l'historique.
## Rapports PDF et rapports de consultation
- RM-RAP-001 - La generation PDF exige que le moteur PDF serveur soit installe.
- RM-RAP-002 - Le mois d'un rapport financier doit etre entre 1 et 12.
- RM-RAP-003 - Le rapport des resolutions n'affiche pas de colonne de resultat.
- RM-RAP-004 - La description du rapport des resolutions est `Decisions prises
en Groupe`.
- RM-RAP-005 - Dans le rapport des resolutions, la colonne `Resolution` affiche
`LE GROUPE A DECIDE DE` lorsque ce champ est renseigne.
- RM-RAP-006 - Le sommaire final du rapport des resolutions est retire.
- RM-RAP-007 - Le rapport de tresorerie presente un etat des resultats mensuel,
les positions de tresorerie et les reserves.
- RM-RAP-008 - Les rapports de rencontre et d'assemblee sont concus pour etre
imprimables en format lettre.
- RM-RAP-009 - Les rapports consultables ne doivent pas exiger un poste
executif, sauf pour leur creation ou modification.
## Litterature et jetons
- RM-LIT-001 - La gestion du catalogue de litterature est reservee au
responsable de la litterature ou a l'executif.
- RM-LIT-002 - Une vente de litterature exige une quantite positive.
- RM-LIT-003 - Une vente ne peut pas depasser le stock disponible.
- RM-LIT-004 - Le stock de litterature ne peut pas devenir negatif.
- RM-LIT-005 - L'inventaire initial de litterature est charge depuis
`data/import/Liste-de-prix-rev.-Fevrier-2026.csv`.
- RM-LIT-006 - Les categories valides de litterature sont `livre`, `brochure`,
`depliant` et `pamphlet`.
- RM-LIT-007 - Ajouter ou modifier un titre exige une categorie valide.
- RM-LIT-008 - L'inventaire est visible aux membres connectes.
- RM-LIT-009 - Une vente de litterature non remise reste a recevoir par le
tresorier.
- RM-LIT-010 - Une vente de litterature deja remise ne peut pas etre recue une
seconde fois.
- RM-JET-001 - La gestion des jetons est reservee au responsable des jetons ou a
l'executif.
- RM-JET-002 - Un type de jeton doit faire partie des types permis.
- RM-JET-003 - Un jeton `multiple` exige un nombre d'annees d'au moins 2.
- RM-JET-004 - Un jeton peut etre rattache a un membre actif ou etre enregistre
sans membre.
- RM-JET-005 - Le stock de jetons est estime a partir des remises des 30 derniers
jours.
- RM-JET-006 - Une suggestion de commande est emise si au moins deux jetons d'un
type ont ete remis dans les 30 derniers jours.
- RM-JET-007 - Le jeton de desir est a surveiller si au moins quatre ont ete
remis dans les 30 derniers jours.
## Evenements et calendrier
- RM-EVE-001 - Les evenements affiches incluent les anniversaires de sobriete et
les evenements ad hoc.
- RM-EVE-002 - Les anniversaires de sobriete sont calcules pour tous les membres
actifs du district.
- RM-EVE-003 - Un anniversaire du 29 fevrier est ramene au 1er mars pour les
annees non bissextiles.
- RM-EVE-004 - Les evenements passes et futurs peuvent etre retournes selon les
bornes demandees.
- RM-EVE-005 - Les evenements ad hoc sont visibles par les membres du district.
- RM-EVE-006 - La creation et la suppression d'un evenement ad hoc sont reservees
aux executifs.
- RM-EVE-007 - Les evenements sont tries par date croissante.
## Journal et notifications
- RM-JOU-001 - Les requetes authentifiees qui modifient des donnees sont
journalisees.
- RM-JOU-002 - Lorsqu'un sysadmin impersonifie un membre, le journal conserve
l'identite du membre agissant et de l'admin.
- RM-NOT-001 - Les notifications push sont optionnelles et dependent de la
configuration VAPID.
- RM-NOT-002 - Un abonnement push invalide ou expire peut etre retire
automatiquement.
- RM-NOT-003 - Un membre peut enregistrer, consulter et supprimer son propre
abonnement push.
- RM-NOT-004 - Les notifications ne peuvent pas etre activees si le serveur n'a
pas de configuration VAPID.
- RM-JOU-003 - Le journal est reserve aux executifs.
- RM-JOU-004 - Les requetes `GET`, `HEAD` et `OPTIONS` ne sont pas journalisees
comme actions modificatrices.
- RM-JOU-005 - Le journal n'enregistre pas le corps des requetes; il conserve la
methode, le chemin, le statut HTTP, l'adresse IP, le user-agent et les
parametres de requete non sensibles.
## Parametres et donnees initiales
- RM-PAR-001 - La modification des parametres du groupe est reservee aux
executifs.
- RM-PAR-002 - Les ordres valides d'assemblee d'affaires sont `premiere`,
`deuxieme`, `troisieme`, `quatrieme` et `derniere`.
- RM-PAR-003 - Une heure de reunion invalide est ignoree et retire l'heure
configuree.
- RM-PAR-004 - Le deploiement initial cree le groupe `Groupe Meditation` avec le
district `87-16` et la region `87`.
- RM-PAR-005 - Le deploiement initial cree les postes de base du groupe.
- RM-PAR-006 - Le deploiement initial cree une invitation pour les prochains
membres.
- RM-PAR-007 - Le deploiement initial cree le destinataire de contribution
protege `District 87-16`.
- RM-PAR-008 - Le deploiement initial charge le catalogue de litterature depuis
`data/import` quand le fichier normalise est present.
## Transitions d'etat
| Domaine | Etat initial | Transition | Etat final | Condition |
| --- | --- | --- | --- | --- |
| Proposition | `proposee` | Seconder | `secondee` | Secondeur distinct du proposeur |
| Proposition | `secondee` | Adopter | `adoptee` | Executif, sections decisionnelles remplies |
| Proposition | `proposee` ou `secondee` | Rejeter | `rejetee` | Executif, section `ETANT DONNE QUE` remplie |
| Collecte | `en_attente` | Confirmer | `confirmee` | Confirmateur distinct du saisisseur |
| Collecte | `en_attente` ou `confirmee` | Recevoir | `recu` | Tresorier ou executif |
| Depense | Soumise | Traiter en encaisse | Payee | Encaisse suffisante |
| Depense | Soumise | Traiter par cheque/virement | Engagee | Disponible bancaire suffisant |
| Depense | Engagee | Confirmer debit | Payee | Banque suffisante et reserves preservees |
| Depense | Soumise | Rejeter | Supprimee | Non traitee |
| Contribution | Creee | Enregistrer | Engagee | Decision `DON` creee |
| Contribution | Engagee | Confirmer debit | Debitee | Banque suffisante et reserves preservees |
| Candidature | `proposee` | Retirer | `retiree` | Par le membre candidat |
| Candidature | `proposee` | Refuser | `refusee` | Executif ou membre nomine |
| Candidature | `proposee` | Accepter | `acceptee` | Decision `NOM` et affectation creees |
| Affectation | `actif` | Terminer | `termine` | Executif |
## Matrice des permissions par module
| Module | Consultation | Creation / saisie | Modification / confirmation | Suppression |
| --- | --- | --- | --- | --- |
| Accueil | Membre connecte | - | - | - |
| Profil | Membre connecte | Membre connecte | Son propre profil | - |
| Gestion des membres | Executif | Executif | Executif | Executif, sauf Sysadmin et soi-meme |
| Parametres groupe | Membre connecte | - | Executif | - |
| Journal | Executif | Automatique | - | - |
| Gouvernance | Membre connecte | Membre connecte | Executif pour adopter/rejeter | - |
| Postes a pourvoir | Membre connecte | Membre connecte | Membre concerne / executif selon action | Retrait par candidat |
| Gestion des postes | Membre connecte pour lister | Executif | Executif | Executif |
| Gestion des mandats | Membre connecte pour ses mandats | Executif | Executif | - |
| Rencontre hebdomadaire | Membre connecte | Executif | Executif | - |
| Assemblee d'affaires | Membre connecte | Secretaire ou executif | Secretaire ou executif | - |
| PV | Membre connecte | Secretaire ou executif | Secretaire ou executif | - |
| Rapport RSG | Membre connecte | RSG, RSG substitut ou executif | RSG, RSG substitut ou executif | - |
| Collecte 7e | Executif | Executif | Executif / tresorier selon etape | - |
| Tresorerie | Membre connecte | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif selon action |
| Depense | Membre connecte | Membre connecte | Tresorier ou executif | Tresorier ou executif si non traitee |
| Donner au suivant | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif pour destinataires non proteges |
| Litterature | Membre connecte | Responsable litterature ou executif | Responsable litterature ou executif | - |
| Vente litterature | Membre connecte | Responsable litterature ou executif | Tresorier ou executif pour reception | - |
| Jetons | Membre connecte | Responsable jetons ou executif | Responsable jetons ou executif | - |
| Evenements | Membre connecte | Executif | Executif | Executif |
| Rapports PDF | Membre connecte | Serveur | - | - |
`Sysadmin` a tous les acces.
## Glossaire financier
- Banque - Solde du compte bancaire selon le registre comptable.
- Encaisse - Argent liquide detenu par le groupe.
- Reserve - Portion du solde bancaire affectee a un usage precis.
- Engagement non debite - Montant approuve mais pas encore sorti du compte
bancaire.
- Disponible bancaire - Banque moins reserves actives moins engagements non
debites.
- Disponible total - Disponible bancaire plus encaisse.
- Transaction comptable - Ligne du registre qui modifie un compte financier.
- Mouvement de reserve - Virement virtuel entre le disponible et une reserve, ou
entre deux reserves.
- Etat des resultats - Revenus moins charges pour un mois donne.
- Position de tresorerie - Situation banque, encaisse, reserves et engagements a
une date donnee.
## Ecarts connus et decisions requises
Cette section liste les zones ou la regle metier souhaitee, la documentation et
le comportement reel ne sont pas encore parfaitement alignes. Ces points doivent
etre corriges dans le code ou clarifies comme decisions fonctionnelles.
- ECART-001 - La colonne `membres.courriel` sert maintenant aussi
d'identifiant de connexion, notamment pour `sysadmin`. Le profil protege
maintenant l'identifiant `sysadmin`, mais certains ecrans parlent toujours de
courriel. Decision requise: conserver un vrai courriel ou renommer/modeliser
un identifiant distinct.
- ECART-002 - L'adoption d'un proces-verbal exige actuellement un proposeur et
un secondeur en texte libre, pas des `membre_id`. Decision requise: garder une
mention textuelle historique ou exiger des membres actifs comme pour les
decisions.
- ECART-003 - Certaines contraintes metier sont uniquement appliquees dans le
code applicatif et pas par des contraintes SQL, par exemple les montants
positifs, le stock non negatif et plusieurs transitions d'etat. C'est
acceptable pour l'instant, mais les tests doivent couvrir ces invariants.
- ECART-004 - Les rapports PDF et certaines listes utilisent encore des logiques
de date dispersees. Les regles mensuelles sont documentees, mais elles
devraient etre couvertes par des tests pour eviter les divergences.

View file

@ -24,7 +24,7 @@ export default function AssembleeAffaires() {
const [contenu, setContenu] = useState('');
const [pvPrecedent, setPvPrecedent] = useState(null);
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
const [adoption, setAdoption] = useState({ proposeur: '', secondeur: '' });
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@ -109,6 +109,10 @@ export default function AssembleeAffaires() {
const adopterPvPrecedent = async () => {
if (!pvPrecedent) return;
if (!adoption.proposeur_id || !adoption.secondeur_id) {
setError('Proposeur et secondeur requis');
return;
}
setError('');
try {
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption);
@ -160,10 +164,19 @@ export default function AssembleeAffaires() {
{estExecutif && !pvPrecedent.approuve && (
<div className="space-y-2 pt-2 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2">
<input value={adoption.proposeur} onChange={(e) => setAdoption({ ...adoption, proposeur: e.target.value })} placeholder="Proposeur" className={INPUT} />
<input value={adoption.secondeur} onChange={(e) => setAdoption({ ...adoption, secondeur: e.target.value })} placeholder="Secondeur" className={INPUT} />
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
<option value="">Proposeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
<select value={adoption.secondeur_id} onChange={(e) => setAdoption({ ...adoption, secondeur_id: e.target.value })} className={INPUT}>
<option value="">Secondeur</option>
{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}
</select>
</div>
<button onClick={adopterPvPrecedent} className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100">Adopter le PV précédent</button>
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id}
className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100 disabled:opacity-40">
Adopter le PV précédent
</button>
</div>
)}
</Card>

View file

@ -324,7 +324,7 @@ export default function Decisions() {
<>
{decision.description && <p className="text-sm text-gray-500 mt-3">{decision.description}</p>}
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Type : {TYPES[decision.type_proposition] || decision.type_proposition}</p>
<p>Type : {TYPES[decision.type_decision] || decision.type_decision}</p>
<p>Date : {decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || '—'}</p>
<p>Proposée par {decision.proposeur_prenom}</p>
{decision.secondeur_prenom && <p>Secondée par {decision.secondeur_prenom}</p>}