diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py index ea6b88f..9153519 100644 --- a/backend/app/core/audit.py +++ b/backend/app/core/audit.py @@ -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( diff --git a/backend/app/core/decision_types.py b/backend/app/core/decision_types.py new file mode 100644 index 0000000..20d9406 --- /dev/null +++ b/backend/app/core/decision_types.py @@ -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" diff --git a/backend/app/core/decisions.py b/backend/app/core/decisions.py index 6ba70b9..046562a 100644 --- a/backend/app/core/decisions.py +++ b/backend/app/core/decisions.py @@ -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") diff --git a/backend/app/core/financial_position.py b/backend/app/core/financial_position.py new file mode 100644 index 0000000..a7e8481 --- /dev/null +++ b/backend/app/core/financial_position.py @@ -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 diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py index 5878cd0..a426a1c 100644 --- a/backend/app/core/roles.py +++ b/backend/app/core/roles.py @@ -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 diff --git a/backend/app/core/sanitization.py b/backend/app/core/sanitization.py new file mode 100644 index 0000000..0d0245b --- /dev/null +++ b/backend/app/core/sanitization.py @@ -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() + } diff --git a/backend/app/main.py b/backend/app/main.py index 704bc3d..6729825 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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" diff --git a/backend/app/models/proposition.py b/backend/app/models/proposition.py index 0014347..465d783 100644 --- a/backend/app/models/proposition.py +++ b/backend/app/models/proposition.py @@ -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) diff --git a/backend/app/routers/commandes_jetons.py b/backend/app/routers/commandes_jetons.py index c8cadb5..0d16708 100644 --- a/backend/app/routers/commandes_jetons.py +++ b/backend/app/routers/commandes_jetons.py @@ -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( diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index c5bb9fd..6400dc2 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -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 diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py index 9d3a682..e14b235 100644 --- a/backend/app/routers/depenses.py +++ b/backend/app/routers/depenses.py @@ -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 diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py index 337c765..178537e 100644 --- a/backend/app/routers/jetons.py +++ b/backend/app/routers/jetons.py @@ -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( diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py index de4f67c..d242a97 100644 --- a/backend/app/routers/litterature.py +++ b/backend/app/routers/litterature.py @@ -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( diff --git a/backend/app/routers/membres.py b/backend/app/routers/membres.py index b79382a..be2900d 100644 --- a/backend/app/routers/membres.py +++ b/backend/app/routers/membres.py @@ -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 diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py index 79b0e25..0867c2c 100644 --- a/backend/app/routers/operations_bancaires.py +++ b/backend/app/routers/operations_bancaires.py @@ -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", diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py index 7e5c969..f014947 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -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, diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index f5ea12c..290ec0d 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -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] diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py index 5eef6bb..52dd2b0 100644 --- a/backend/app/routers/pv.py +++ b/backend/app/routers/pv.py @@ -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 diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py index d60e7b2..0ded228 100644 --- a/backend/app/routers/ventes_litterature.py +++ b/backend/app/routers/ventes_litterature.py @@ -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, diff --git a/backend/app/schemas/phase4.py b/backend/app/schemas/phase4.py index 6cfcfb8..b4d4bad 100644 --- a/backend/app/schemas/phase4.py +++ b/backend/app/schemas/phase4.py @@ -72,8 +72,8 @@ class PvUpdate(BaseModel): class PvAdoption(BaseModel): - proposeur: str - secondeur: str + proposeur_id: UUID + secondeur_id: UUID # ── M3 Rapport RSG ── diff --git a/backend/app/schemas/propositions.py b/backend/app/schemas/propositions.py index cf22116..a9cdd56 100644 --- a/backend/app/schemas/propositions.py +++ b/backend/app/schemas/propositions.py @@ -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 diff --git a/backend/app/services/finance.py b/backend/app/services/finance.py index e3a5c27..32df373 100644 --- a/backend/app/services/finance.py +++ b/backend/app/services/finance.py @@ -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), ) diff --git a/backend/tests/test_business_rules.py b/backend/tests/test_business_rules.py new file mode 100644 index 0000000..28ed892 --- /dev/null +++ b/backend/tests/test_business_rules.py @@ -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() diff --git a/docs/REGLES_METIER.md b/docs/REGLES_METIER.md new file mode 100644 index 0000000..13d02ca --- /dev/null +++ b/docs/REGLES_METIER.md @@ -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. diff --git a/frontend/src/pages/AssembleeAffaires.jsx b/frontend/src/pages/AssembleeAffaires.jsx index e2d89c3..530b588 100644 --- a/frontend/src/pages/AssembleeAffaires.jsx +++ b/frontend/src/pages/AssembleeAffaires.jsx @@ -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 && (
{decision.description}
}Type : {TYPES[decision.type_proposition] || decision.type_proposition}
+Type : {TYPES[decision.type_decision] || decision.type_decision}
Date : {decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || '—'}
Proposée par {decision.proposeur_prenom}
{decision.secondeur_prenom &&Secondée par {decision.secondeur_prenom}
}