Aligner regles metier et decisions
This commit is contained in:
parent
56b69b1bb8
commit
4a3df15fa1
26 changed files with 811 additions and 84 deletions
|
|
@ -5,6 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import async_session
|
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.journal_action import JournalAction
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.services.notifications import notify_group_change
|
from app.services.notifications import notify_group_change
|
||||||
|
|
@ -16,7 +17,6 @@ SKIPPED_PREFIXES = (
|
||||||
"/api/health",
|
"/api/health",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _token_payload(request):
|
def _token_payload(request):
|
||||||
header = request.headers.get("authorization") or ""
|
header = request.headers.get("authorization") or ""
|
||||||
if not header.lower().startswith("bearer "):
|
if not header.lower().startswith("bearer "):
|
||||||
|
|
@ -52,7 +52,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
details = {
|
details = {
|
||||||
"query": dict(request.query_params),
|
"query": safe_query_params(request.query_params),
|
||||||
"impersonification": bool(payload.get("adm")),
|
"impersonification": bool(payload.get("adm")),
|
||||||
}
|
}
|
||||||
db.add(JournalAction(
|
db.add(JournalAction(
|
||||||
|
|
|
||||||
19
backend/app/core/decision_types.py
Normal file
19
backend/app/core/decision_types.py
Normal 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"
|
||||||
|
|
@ -2,14 +2,9 @@
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.decision_types import PREFIXES_DECISION
|
||||||
from app.models.proposition import Proposition
|
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:
|
async def prochain_numero_decision(groupe_id, type_decision: str, annee: int, db: AsyncSession) -> str:
|
||||||
prefixe = PREFIXES_DECISION.get(type_decision, "RES")
|
prefixe = PREFIXES_DECISION.get(type_decision, "RES")
|
||||||
|
|
|
||||||
18
backend/app/core/financial_position.py
Normal file
18
backend/app/core/financial_position.py
Normal 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
|
||||||
|
|
@ -7,6 +7,7 @@ from app.core.database import get_db
|
||||||
from app.core.security import get_current_membre, is_sysadmin
|
from app.core.security import get_current_membre, is_sysadmin
|
||||||
from app.models.affectation import Affectation
|
from app.models.affectation import Affectation
|
||||||
from app.models.poste import Poste
|
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:
|
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
|
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(
|
async def require_tresorier(
|
||||||
membre=Depends(get_current_membre),
|
membre=Depends(get_current_membre),
|
||||||
db: AsyncSession = Depends(get_db),
|
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")
|
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||||
return membre
|
return membre
|
||||||
|
|
||||||
|
|
@ -37,7 +60,10 @@ async def require_secretaire(
|
||||||
membre=Depends(get_current_membre),
|
membre=Depends(get_current_membre),
|
||||||
db: AsyncSession = Depends(get_db),
|
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")
|
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
|
||||||
return membre
|
return membre
|
||||||
|
|
||||||
|
|
@ -46,6 +72,6 @@ async def require_rsg(
|
||||||
membre=Depends(get_current_membre),
|
membre=Depends(get_current_membre),
|
||||||
db: AsyncSession = Depends(get_db),
|
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")
|
raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif")
|
||||||
return membre
|
return membre
|
||||||
|
|
|
||||||
10
backend/app/core/sanitization.py
Normal file
10
backend/app/core/sanitization.py
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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 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 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 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(
|
await conn.execute(text(
|
||||||
"ALTER TABLE envois_contributions "
|
"ALTER TABLE envois_contributions "
|
||||||
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
|
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ class Proposition(Base):
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
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)
|
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)
|
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
proposeur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
proposeur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlalchemy import select, func
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_membre, is_sysadmin
|
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.jeton import Jeton
|
||||||
from app.models.affectation import Affectation
|
from app.models.affectation import Affectation
|
||||||
from app.models.poste import Poste
|
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)):
|
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
"""Vérifie que le membre est responsable des jetons."""
|
"""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
|
return membre
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Affectation).join(Poste).where(
|
select(Affectation).join(Poste).where(
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from app.models.envoi_contribution import EnvoiContribution
|
||||||
from app.models.config_repartition import ConfigRepartition
|
from app.models.config_repartition import ConfigRepartition
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.models.destinataire_contribution import DestinataireContribution
|
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 (
|
from app.schemas.phase7 import (
|
||||||
EnvoiContributionCreate, EnvoiContributionOut,
|
EnvoiContributionCreate, EnvoiContributionOut,
|
||||||
CalculRepartitionIn, CalculRepartitionOut,
|
CalculRepartitionIn, CalculRepartitionOut,
|
||||||
|
|
@ -218,7 +218,6 @@ async def enregistrer_envoi(
|
||||||
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
|
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
|
||||||
resolution = Proposition(
|
resolution = Proposition(
|
||||||
groupe_id=membre.groupe_id,
|
groupe_id=membre.groupe_id,
|
||||||
type_proposition="contribution",
|
|
||||||
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
|
sujet=f"Envoi de {req.montant:.2f} $ à {destination.nom}",
|
||||||
description=(
|
description=(
|
||||||
f"Contribution enregistrée le {req.date_envoi} par le module Donner au suivant. "
|
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:
|
if envoi.debitee:
|
||||||
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
|
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
|
||||||
|
|
||||||
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
|
position = await position_tresorerie(membre.groupe_id, db)
|
||||||
if float(envoi.montant) > solde_banque_actuel:
|
if float(envoi.montant) > position.solde_banque:
|
||||||
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
|
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 position.solde_banque - float(envoi.montant) < position.total_reserves:
|
||||||
if solde_banque_actuel - float(envoi.montant) < total_reserves_actuel:
|
|
||||||
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
|
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
|
||||||
|
|
||||||
envoi.debitee = True
|
envoi.debitee = True
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
from app.core.database import get_db
|
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.core.security import get_current_membre, is_sysadmin
|
||||||
from app.models.depense import Depense
|
from app.models.depense import Depense
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
|
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"])
|
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."""
|
"""S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
|
||||||
qui_id = membre.id
|
qui_id = membre.id
|
||||||
if req.membre_id and req.membre_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")
|
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)
|
beneficiaire = await db.get(Membre, req.membre_id)
|
||||||
if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
|
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()
|
depense = result.scalar_one_or_none()
|
||||||
if not depense:
|
if not depense:
|
||||||
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
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
|
# Sauvegarder le fichier
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
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")
|
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
|
||||||
|
|
||||||
if methode_norm == "COLLECTE":
|
if methode_norm == "COLLECTE":
|
||||||
solde_encaisse = await encaisse(membre.groupe_id, db)
|
position = await position_tresorerie(membre.groupe_id, db)
|
||||||
if float(depense.montant) > solde_encaisse:
|
if float(depense.montant) > position.encaisse:
|
||||||
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
|
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
|
||||||
elif methode_norm in ("CHEQUE", "VIREMENT"):
|
elif methode_norm in ("CHEQUE", "VIREMENT"):
|
||||||
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
|
position = await position_tresorerie(membre.groupe_id, db)
|
||||||
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
|
if float(depense.montant) > position.disponible_bancaire:
|
||||||
engagements = await engagements_non_debites(membre.groupe_id, db)
|
|
||||||
disponible = solde_banque_actuel - total_reserves_actuel - engagements
|
|
||||||
if float(depense.montant) > disponible:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
|
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"):
|
if depense.methode not in ("CHEQUE", "VIREMENT"):
|
||||||
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
|
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)
|
position = await position_tresorerie(membre.groupe_id, db)
|
||||||
if float(depense.montant) > solde_banque_actuel:
|
if float(depense.montant) > position.solde_banque:
|
||||||
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
|
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 position.solde_banque - float(depense.montant) < position.total_reserves:
|
||||||
if solde_banque_actuel - float(depense.montant) < total_reserves_actuel:
|
|
||||||
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
|
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
|
||||||
|
|
||||||
depense.paye = True
|
depense.paye = True
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from sqlalchemy import select, desc
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_membre, is_sysadmin
|
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.jeton import Jeton
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.models.affectation import Affectation
|
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)):
|
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
"""Vérifie que le membre est responsable des jetons."""
|
"""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
|
return membre
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Affectation).join(Poste).where(
|
select(Affectation).join(Poste).where(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_membre, is_sysadmin
|
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.litterature import Litterature
|
||||||
from app.models.affectation import Affectation
|
from app.models.affectation import Affectation
|
||||||
from app.models.poste import Poste
|
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)):
|
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."""
|
"""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
|
return membre
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Affectation).join(Poste).where(
|
select(Affectation).join(Poste).where(
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,11 @@ async def modifier_profil(
|
||||||
membre.telephone = telephone
|
membre.telephone = telephone
|
||||||
if updates.courriel is not None:
|
if updates.courriel is not None:
|
||||||
courriel = updates.courriel.strip().lower()
|
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")
|
raise HTTPException(status_code=400, detail="Courriel invalide")
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Membre).where(
|
select(Membre).where(
|
||||||
|
|
@ -49,7 +53,7 @@ async def modifier_profil(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if result.scalar_one_or_none():
|
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
|
membre.courriel = courriel
|
||||||
if updates.date_abstinence is not None:
|
if updates.date_abstinence is not None:
|
||||||
membre.date_abstinence = updates.date_abstinence
|
membre.date_abstinence = updates.date_abstinence
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from app.models.mouvement_reserve import MouvementReserve
|
||||||
from app.models.reserve import Reserve
|
from app.models.reserve import Reserve
|
||||||
from app.models.membre import Membre
|
from app.models.membre import Membre
|
||||||
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
|
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"])
|
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")
|
raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
|
||||||
if req.montant <= 0:
|
if req.montant <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
||||||
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
|
position = await position_tresorerie(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 > position.encaisse:
|
||||||
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > await encaisse(membre.groupe_id, db):
|
|
||||||
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
|
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")
|
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")
|
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")
|
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
|
||||||
|
|
||||||
op = OperationBancaire(
|
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, "banque", "credit"))
|
||||||
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
|
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
|
||||||
else:
|
else:
|
||||||
delta = req.montant - solde_banque_actuel
|
delta = req.montant - position.solde_banque
|
||||||
if delta != 0:
|
if delta != 0:
|
||||||
db.add(_ligne_comptable_montant(
|
db.add(_ligne_comptable_montant(
|
||||||
membre.groupe_id, membre.id, op, "banque",
|
membre.groupe_id, membre.id, op, "banque",
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,6 @@ async def postuler(
|
||||||
# Créer automatiquement la proposition de nomination
|
# Créer automatiquement la proposition de nomination
|
||||||
prop = Proposition(
|
prop = Proposition(
|
||||||
groupe_id=membre.groupe_id,
|
groupe_id=membre.groupe_id,
|
||||||
type_proposition="nomination",
|
|
||||||
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}",
|
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}",
|
||||||
description=motivation.strip() or None,
|
description=motivation.strip() or None,
|
||||||
proposeur_id=membre.id,
|
proposeur_id=membre.id,
|
||||||
|
|
@ -388,7 +387,6 @@ async def accepter_nomination(
|
||||||
|
|
||||||
prop = Proposition(
|
prop = Proposition(
|
||||||
groupe_id=cand.groupe_id,
|
groupe_id=cand.groupe_id,
|
||||||
type_proposition="nomination",
|
|
||||||
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom if poste else '?'}",
|
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(),
|
description=f"Nominé par {nomme_par.prenom if nomme_par else '?'}. {cand.motivation or ''}".strip(),
|
||||||
proposeur_id=cand.nomme_par or membre.id,
|
proposeur_id=cand.nomme_par or membre.id,
|
||||||
|
|
@ -484,8 +482,45 @@ async def traiter_candidature(
|
||||||
|
|
||||||
cand.statut = statut
|
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 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(
|
affectation = Affectation(
|
||||||
groupe_id=executif.groupe_id,
|
groupe_id=executif.groupe_id,
|
||||||
membre_id=cand.membre_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)
|
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
|
||||||
resolution = Proposition(
|
resolution = Proposition(
|
||||||
groupe_id=executif.groupe_id,
|
groupe_id=executif.groupe_id,
|
||||||
type_proposition="nomination",
|
|
||||||
sujet=f"Nomination de {cible.prenom} au poste de {poste.nom}",
|
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}.",
|
description=f"Affectation directe au poste de {poste.nom} à partir du {req.date_debut}.",
|
||||||
proposeur_id=req.proposeur_id,
|
proposeur_id=req.proposeur_id,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlalchemy import select, desc, extract
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.decisions import prochain_numero_decision
|
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.security import get_current_membre, require_executif
|
||||||
from app.core.roles import membre_a_poste_ou_executif
|
from app.core.roles import membre_a_poste_ou_executif
|
||||||
from app.models.proposition import Proposition
|
from app.models.proposition import Proposition
|
||||||
|
|
@ -26,7 +27,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
|
||||||
return PropositionOut(
|
return PropositionOut(
|
||||||
id=p.id,
|
id=p.id,
|
||||||
reunion_id=p.reunion_id,
|
reunion_id=p.reunion_id,
|
||||||
type_proposition=p.type_proposition or "proposition",
|
type_decision=type_decision_depuis_proposition(p),
|
||||||
sujet=p.sujet,
|
sujet=p.sujet,
|
||||||
description=p.description,
|
description=p.description,
|
||||||
proposeur_prenom=proposeur.prenom if proposeur else "?",
|
proposeur_prenom=proposeur.prenom if proposeur else "?",
|
||||||
|
|
@ -64,7 +65,6 @@ async def creer_proposition(
|
||||||
prop = Proposition(
|
prop = Proposition(
|
||||||
groupe_id=membre.groupe_id,
|
groupe_id=membre.groupe_id,
|
||||||
reunion_id=req.reunion_id,
|
reunion_id=req.reunion_id,
|
||||||
type_proposition="proposition",
|
|
||||||
sujet=req.sujet.strip(),
|
sujet=req.sujet.strip(),
|
||||||
description=req.description,
|
description=req.description,
|
||||||
proposeur_id=membre.id,
|
proposeur_id=membre.id,
|
||||||
|
|
@ -150,16 +150,17 @@ async def voter_proposition(
|
||||||
prop.etant_donne_que = etant_donne_que
|
prop.etant_donne_que = etant_donne_que
|
||||||
prop.groupe_a_decide_de = groupe_a_decide_de
|
prop.groupe_a_decide_de = groupe_a_decide_de
|
||||||
prop.note = None
|
prop.note = None
|
||||||
if prop.type_proposition not in ("nomination", "contribution"):
|
type_decision = type_decision_depuis_proposition(prop)
|
||||||
prop.type_proposition = "resolution"
|
if type_decision == "proposition":
|
||||||
|
type_decision = "resolution"
|
||||||
prop.numero_resolution = await prochain_numero_decision(
|
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:
|
if req.reunion_id and not prop.reunion_id:
|
||||||
prop.reunion_id = req.reunion_id
|
prop.reunion_id = req.reunion_id
|
||||||
|
|
||||||
# ── NOMINATION : créer l'affectation automatiquement ──
|
# ── 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)
|
cand = await db.get(Candidature, prop.candidature_id)
|
||||||
if cand and cand.statut == "proposee":
|
if cand and cand.statut == "proposee":
|
||||||
cand.statut = "acceptee"
|
cand.statut = "acceptee"
|
||||||
|
|
@ -182,7 +183,7 @@ async def voter_proposition(
|
||||||
prop.note = (req.note or "").strip() or None
|
prop.note = (req.note or "").strip() or None
|
||||||
|
|
||||||
# Si nomination rejetée, mettre la candidature en refusée
|
# 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)
|
cand = await db.get(Candidature, prop.candidature_id)
|
||||||
if cand:
|
if cand:
|
||||||
cand.statut = "refusee"
|
cand.statut = "refusee"
|
||||||
|
|
@ -200,7 +201,7 @@ async def voter_proposition(
|
||||||
async def liste_propositions(
|
async def liste_propositions(
|
||||||
statut: str | None = None,
|
statut: str | None = None,
|
||||||
reunion_id: str | None = None,
|
reunion_id: str | None = None,
|
||||||
type_proposition: str | None = None,
|
type_decision: str | None = None,
|
||||||
limite: int = 50,
|
limite: int = 50,
|
||||||
membre=Depends(get_current_membre),
|
membre=Depends(get_current_membre),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
@ -211,18 +212,19 @@ async def liste_propositions(
|
||||||
query = query.where(Proposition.statut == statut)
|
query = query.where(Proposition.statut == statut)
|
||||||
if reunion_id:
|
if reunion_id:
|
||||||
query = query.where(Proposition.reunion_id == 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)
|
query = query.order_by(desc(Proposition.cree_le)).limit(limite)
|
||||||
|
|
||||||
result = await db.execute(query)
|
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])
|
@router.get("/resolutions", response_model=list[PropositionOut])
|
||||||
async def registre_resolutions(
|
async def registre_resolutions(
|
||||||
annee: int | None = None,
|
annee: int | None = None,
|
||||||
type_proposition: str | None = None,
|
type_decision: str | None = None,
|
||||||
membre=Depends(get_current_membre),
|
membre=Depends(get_current_membre),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
|
@ -233,9 +235,10 @@ async def registre_resolutions(
|
||||||
)
|
)
|
||||||
if annee:
|
if annee:
|
||||||
query = query.where(extract("year", Proposition.cree_le) == 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)
|
query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution)
|
||||||
|
|
||||||
result = await db.execute(query)
|
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]
|
||||||
|
|
|
||||||
|
|
@ -140,15 +140,23 @@ async def adopter_pv(
|
||||||
pv = result.scalar_one_or_none()
|
pv = result.scalar_one_or_none()
|
||||||
if not pv:
|
if not pv:
|
||||||
raise HTTPException(status_code=404, detail="PV introuvable")
|
raise HTTPException(status_code=404, detail="PV introuvable")
|
||||||
if not req.proposeur.strip() or not req.secondeur.strip():
|
if req.proposeur_id == req.secondeur_id:
|
||||||
raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
|
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 = list(pv.decisions or [])
|
||||||
decisions.append({
|
decisions.append({
|
||||||
"sujet": "Adoption du procès-verbal précédent",
|
"sujet": "Adoption du procès-verbal précédent",
|
||||||
"decision": "Le procès-verbal précédent est adopté.",
|
"decision": "Le procès-verbal précédent est adopté.",
|
||||||
"proposeur": req.proposeur.strip(),
|
"proposeur": proposeur.prenom,
|
||||||
"secondeur": req.secondeur.strip(),
|
"secondeur": secondeur.prenom,
|
||||||
|
"proposeur_id": str(proposeur.id),
|
||||||
|
"secondeur_id": str(secondeur.id),
|
||||||
"statut": "complete",
|
"statut": "complete",
|
||||||
})
|
})
|
||||||
pv.decisions = decisions
|
pv.decisions = decisions
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from sqlalchemy import select, desc
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import get_current_membre, is_sysadmin
|
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.litterature import Litterature
|
||||||
from app.models.vente_litterature import VenteLitterature
|
from app.models.vente_litterature import VenteLitterature
|
||||||
from app.models.membre import Membre
|
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)):
|
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."""
|
"""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
|
return membre
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Affectation).join(Poste).where(
|
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."""
|
"""Le trésorier confirme la réception du montant de la vente."""
|
||||||
# Vérifier que c'est le trésorier ou exécutif
|
# 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(
|
result = await db.execute(
|
||||||
select(Affectation).join(Poste).where(
|
select(Affectation).join(Poste).where(
|
||||||
Affectation.membre_id == membre.id,
|
Affectation.membre_id == membre.id,
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ class PvUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class PvAdoption(BaseModel):
|
class PvAdoption(BaseModel):
|
||||||
proposeur: str
|
proposeur_id: UUID
|
||||||
secondeur: str
|
secondeur_id: UUID
|
||||||
|
|
||||||
|
|
||||||
# ── M3 Rapport RSG ──
|
# ── M3 Rapport RSG ──
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class PropositionCreate(BaseModel):
|
||||||
class PropositionOut(BaseModel):
|
class PropositionOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
reunion_id: Optional[UUID] = None
|
reunion_id: Optional[UUID] = None
|
||||||
type_proposition: str = "proposition"
|
type_decision: str = "proposition"
|
||||||
sujet: str
|
sujet: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
proposeur_prenom: str
|
proposeur_prenom: str
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.financial_position import PositionTresorerie
|
||||||
from app.models.depense import Depense
|
from app.models.depense import Depense
|
||||||
from app.models.envoi_contribution import EnvoiContribution
|
from app.models.envoi_contribution import EnvoiContribution
|
||||||
from app.models.mouvement_reserve import MouvementReserve
|
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:
|
async def reserve_disponible(groupe_id, db: AsyncSession) -> float:
|
||||||
return (
|
return (await position_tresorerie(groupe_id, db)).disponible_bancaire
|
||||||
await solde_banque(groupe_id, db)
|
|
||||||
- await total_reserves(groupe_id, db)
|
|
||||||
- await engagements_non_debites(groupe_id, db)
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
50
backend/tests/test_business_rules.py
Normal file
50
backend/tests/test_business_rules.py
Normal 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
536
docs/REGLES_METIER.md
Normal 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.
|
||||||
|
|
@ -24,7 +24,7 @@ export default function AssembleeAffaires() {
|
||||||
const [contenu, setContenu] = useState('');
|
const [contenu, setContenu] = useState('');
|
||||||
const [pvPrecedent, setPvPrecedent] = useState(null);
|
const [pvPrecedent, setPvPrecedent] = useState(null);
|
||||||
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
|
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
|
||||||
const [adoption, setAdoption] = useState({ proposeur: '', secondeur: '' });
|
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -109,6 +109,10 @@ export default function AssembleeAffaires() {
|
||||||
|
|
||||||
const adopterPvPrecedent = async () => {
|
const adopterPvPrecedent = async () => {
|
||||||
if (!pvPrecedent) return;
|
if (!pvPrecedent) return;
|
||||||
|
if (!adoption.proposeur_id || !adoption.secondeur_id) {
|
||||||
|
setError('Proposeur et secondeur requis');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption);
|
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption);
|
||||||
|
|
@ -160,10 +164,19 @@ export default function AssembleeAffaires() {
|
||||||
{estExecutif && !pvPrecedent.approuve && (
|
{estExecutif && !pvPrecedent.approuve && (
|
||||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<input value={adoption.proposeur} onChange={(e) => setAdoption({ ...adoption, proposeur: e.target.value })} placeholder="Proposeur" className={INPUT} />
|
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
|
||||||
<input value={adoption.secondeur} onChange={(e) => setAdoption({ ...adoption, secondeur: e.target.value })} placeholder="Secondeur" 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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ export default function Decisions() {
|
||||||
<>
|
<>
|
||||||
{decision.description && <p className="text-sm text-gray-500 mt-3">{decision.description}</p>}
|
{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">
|
<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>Date : {decision.date_adoption || decision.date_vote || decision.cree_le?.slice(0, 10) || '—'}</p>
|
||||||
<p>Proposée par {decision.proposeur_prenom}</p>
|
<p>Proposée par {decision.proposeur_prenom}</p>
|
||||||
{decision.secondeur_prenom && <p>Secondée par {decision.secondeur_prenom}</p>}
|
{decision.secondeur_prenom && <p>Secondée par {decision.secondeur_prenom}</p>}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue