Ajouter administration sysadmin et rapports adoptes

This commit is contained in:
Daniel Allaire 2026-05-26 13:21:16 -04:00
parent 4a3df15fa1
commit 69171c4a1d
15 changed files with 1133 additions and 384 deletions

View file

@ -17,6 +17,23 @@ SKIPPED_PREFIXES = (
"/api/health", "/api/health",
) )
def _notification_category(path: str) -> str:
mappings = (
(("/api/propositions", "/api/pv"), "gouvernance"),
(("/api/collectes", "/api/depenses", "/api/contributions", "/api/banque"), "tresorerie"),
(("/api/membres", "/api/invitations"), "membres"),
(("/api/postes", "/api/rotations"), "postes"),
(("/api/reunions", "/api/presences"), "rencontres"),
(("/api/litterature", "/api/ventes-litterature"), "litterature"),
(("/api/evenements", "/api/calendrier"), "evenements"),
)
for prefixes, category in mappings:
if any(path.startswith(prefix) for prefix in prefixes):
return category
return "systeme"
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 "):
@ -73,6 +90,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
title="Changement dans l'application", title="Changement dans l'application",
body=f"{membre.prenom} a enregistré une modification.", body=f"{membre.prenom} a enregistré une modification.",
url="/", url="/",
category=_notification_category(path),
) )
await db.commit() await db.commit()
return response return response

View file

@ -11,7 +11,7 @@ from sqlalchemy import text
from app.core.config import settings from app.core.config import settings
from app.core.audit import AuditMiddleware from app.core.audit import AuditMiddleware
from app.core.database import engine, Base from app.core.database import engine, Base
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal from app.routers import admin, auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal
# Import models pour que create_all crée toutes les tables # Import models pour que create_all crée toutes les tables
from app.models.sync_log import SyncLog # noqa: F401 from app.models.sync_log import SyncLog # noqa: F401
@ -26,6 +26,7 @@ from app.models.mouvement_reserve import MouvementReserve # noqa: F401
from app.models.journal_action import JournalAction # noqa: F401 from app.models.journal_action import JournalAction # noqa: F401
from app.models.push_subscription import PushSubscription # noqa: F401 from app.models.push_subscription import PushSubscription # noqa: F401
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401 from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
from app.models.rapport_adopte import RapportAdopte # noqa: F401
@asynccontextmanager @asynccontextmanager
@ -40,6 +41,7 @@ async def lifespan(app: FastAPI):
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("ALTER TABLE propositions DROP COLUMN IF EXISTS type_proposition"))
await conn.execute(text("ALTER TABLE membres ADD COLUMN IF NOT EXISTS notification_preferences JSON"))
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"
@ -79,6 +81,7 @@ app.add_middleware(AuditMiddleware)
# Montage des routes # Montage des routes
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(membres.router, prefix="/api") app.include_router(membres.router, prefix="/api")
app.include_router(postes.router, prefix="/api") app.include_router(postes.router, prefix="/api")
app.include_router(accueil.router, prefix="/api") app.include_router(accueil.router, prefix="/api")

View file

@ -22,6 +22,7 @@ class Membre(Base):
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
actif: Mapped[bool] = mapped_column(Boolean, default=True) actif: Mapped[bool] = mapped_column(Boolean, default=True)
push_sub: Mapped[dict | None] = mapped_column(JSON) push_sub: Mapped[dict | None] = mapped_column(JSON)
notification_preferences: Mapped[dict | None] = mapped_column(JSON)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,23 @@
"""Rapports cristallisés après adoption par le groupe."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class RapportAdopte(Base):
__tablename__ = "rapports_adoptes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False, index=True)
adopte_par_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
type_rapport: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
titre: Mapped[str] = mapped_column(String(180), nullable=False)
annee: Mapped[int | None] = mapped_column(Integer)
mois: Mapped[int | None] = mapped_column(Integer)
contenu_html: Mapped[str] = mapped_column(Text, nullable=False)
donnees: Mapped[dict | None] = mapped_column(JSON)
adopte_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,31 @@
"""Routes d'administration reservees au sysadmin."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.services.admin_database import initialiser_demo, remettre_a_zero
router = APIRouter(prefix="/admin", tags=["Administration"])
async def require_sysadmin(membre=Depends(get_current_membre)):
if not is_sysadmin(membre):
raise HTTPException(status_code=403, detail="Réservé au sysadmin")
return membre
@router.post("/reset")
async def reset_database(
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
return await remettre_a_zero(db)
@router.post("/demo")
async def demo_database(
_sysadmin=Depends(require_sysadmin),
db: AsyncSession = Depends(get_db),
):
return await initialiser_demo(db)

View file

@ -8,7 +8,12 @@ from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_membre from app.core.security import get_current_membre
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.services.notifications import notifications_configured, send_to_subscriptions from app.services.notifications import (
NOTIFICATION_TYPES,
normalized_preferences,
notifications_configured,
send_to_subscriptions,
)
router = APIRouter(prefix="/notifications", tags=["Notifications"]) router = APIRouter(prefix="/notifications", tags=["Notifications"])
@ -19,6 +24,10 @@ class PushSubscriptionIn(BaseModel):
keys: dict keys: dict
class NotificationPreferencesIn(BaseModel):
preferences: dict[str, bool]
@router.get("/configuration") @router.get("/configuration")
async def configuration_notifications(): async def configuration_notifications():
return { return {
@ -27,6 +36,31 @@ async def configuration_notifications():
} }
@router.get("/preferences")
async def lire_preferences_notifications(membre=Depends(get_current_membre)):
return {
"types": NOTIFICATION_TYPES,
"preferences": normalized_preferences(membre.notification_preferences),
}
@router.patch("/preferences")
async def modifier_preferences_notifications(
req: NotificationPreferencesIn,
membre=Depends(get_current_membre),
):
current = normalized_preferences(membre.notification_preferences)
for item in NOTIFICATION_TYPES:
code = item["code"]
if code in req.preferences:
current[code] = bool(req.preferences[code])
membre.notification_preferences = current
return {
"types": NOTIFICATION_TYPES,
"preferences": current,
}
@router.post("/abonnement") @router.post("/abonnement")
async def enregistrer_abonnement( async def enregistrer_abonnement(
abonnement: PushSubscriptionIn, abonnement: PushSubscriptionIn,

View file

@ -140,6 +140,8 @@ 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 pv.approuve:
raise HTTPException(status_code=400, detail="PV déjà adopté")
if req.proposeur_id == req.secondeur_id: if req.proposeur_id == req.secondeur_id:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents") raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
proposeur = await db.get(Membre, req.proposeur_id) proposeur = await db.get(Membre, req.proposeur_id)
@ -149,6 +151,12 @@ async def adopter_pv(
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif: if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
if req.contenu is not None:
contenu = req.contenu.strip()
if not contenu:
raise HTTPException(status_code=400, detail="Le contenu du PV ne peut pas être vide")
pv.contenu = contenu
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",

View file

@ -4,10 +4,12 @@ from html import escape
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, extract from sqlalchemy import select, extract
from app.core.database import get_db from app.core.database import get_db
from app.core.roles import require_tresorier
from app.core.security import get_current_membre from app.core.security import get_current_membre
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.collecte import Collecte from app.models.collecte import Collecte
@ -16,6 +18,7 @@ from app.models.membre import Membre
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.models.envoi_contribution import EnvoiContribution from app.models.envoi_contribution import EnvoiContribution
from app.models.proposition import Proposition from app.models.proposition import Proposition
from app.models.rapport_adopte import RapportAdopte
router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"]) router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"])
@ -25,6 +28,10 @@ MOIS_FR = [
] ]
class RapportTresorerieAdoption(BaseModel):
note: str | None = None
def _texte_decision_resolution(p: Proposition) -> str: def _texte_decision_resolution(p: Proposition) -> str:
"""Retourne le texte décisionnel si la résolution utilise le format structuré.""" """Retourne le texte décisionnel si la résolution utilise le format structuré."""
if p.groupe_a_decide_de: if p.groupe_a_decide_de:
@ -582,19 +589,13 @@ async def etat_financier_temps_reel(
} }
@router.get("/etat-resultats/{annee}/{mois}") async def _html_etat_resultats(
async def rapport_etat_resultats(
annee: int, annee: int,
mois: int, mois: int,
membre=Depends(get_current_membre), membre,
db: AsyncSession = Depends(get_db), db: AsyncSession,
note_adoption: str | None = None,
): ):
"""PDF mensuel: état des résultats et position de trésorerie."""
try:
from weasyprint import HTML
except ImportError:
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
if mois < 1 or mois > 12: if mois < 1 or mois > 12:
raise HTTPException(status_code=400, detail="Mois invalide") raise HTTPException(status_code=400, detail="Mois invalide")
@ -739,6 +740,12 @@ async def rapport_etat_resultats(
f"<tr><td>{escape(nom)}</td><td class=\"montant\">{montant:.2f} $</td></tr>" f"<tr><td>{escape(nom)}</td><td class=\"montant\">{montant:.2f} $</td></tr>"
for nom, montant in reserves for nom, montant in reserves
) or "<tr><td colspan=\"2\">Aucune réserve enregistrée</td></tr>" ) or "<tr><td colspan=\"2\">Aucune réserve enregistrée</td></tr>"
note_html = ""
if note_adoption and note_adoption.strip():
note_html = f"""
<h2>Correction ou note d'adoption</h2>
<p>{escape(note_adoption.strip()).replace(chr(10), '<br>')}</p>
"""
css = _css() + """ css = _css() + """
.montant { text-align: right; white-space: nowrap; } .montant { text-align: right; white-space: nowrap; }
@ -792,14 +799,144 @@ async def rapport_etat_resultats(
{rows_reserves} {rows_reserves}
<tr><td><strong>Total des réserves</strong></td><td class="montant"><strong>{total_reserves:.2f} $</strong></td></tr> <tr><td><strong>Total des réserves</strong></td><td class="montant"><strong>{total_reserves:.2f} $</strong></td></tr>
</table> </table>
{note_html}
<div class="footer"> <div class="footer">
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}
</div> </div>
</body></html>""" </body></html>"""
pdf_bytes = HTML(string=html_content).write_pdf()
filename = f"etat_resultats_{groupe_nom.replace(' ', '_')}_{annee}_{mois:02d}.pdf" filename = f"etat_resultats_{groupe_nom.replace(' ', '_')}_{annee}_{mois:02d}.pdf"
donnees = {
"annee": annee,
"mois": mois,
"fin_mois": fin_mois.isoformat(),
"revenus": round(revenus, 2),
"charges": round(charges, 2),
"resultat": round(resultat, 2),
"banque": round(banque, 2),
"encaisse": round(caisse, 2),
"engagements": round(engagements, 2),
"total_reserves": round(total_reserves, 2),
"disponible": round(disponible, 2),
"reserves": [{"nom": nom, "montant": round(montant, 2)} for nom, montant in reserves],
"note_adoption": note_adoption.strip() if note_adoption else None,
}
return html_content, filename, donnees
@router.get("/etat-resultats/{annee}/{mois}")
async def rapport_etat_resultats(
annee: int,
mois: int,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""PDF mensuel: état des résultats et position de trésorerie."""
try:
from weasyprint import HTML
except ImportError:
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
html_content, filename, _ = await _html_etat_resultats(annee, mois, membre, db)
pdf_bytes = HTML(string=html_content).write_pdf()
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
@router.post("/etat-resultats/{annee}/{mois}/adopter")
async def adopter_rapport_tresorerie(
annee: int,
mois: int,
req: RapportTresorerieAdoption,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport du trésorier adopté par le groupe."""
result = await db.execute(
select(RapportAdopte).where(
RapportAdopte.groupe_id == membre.groupe_id,
RapportAdopte.type_rapport == "tresorerie",
RapportAdopte.annee == annee,
RapportAdopte.mois == mois,
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce rapport est déjà adopté")
html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, req.note)
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
type_rapport="tresorerie",
titre=f"Rapport du trésorier — {MOIS_FR[mois]} {annee}",
annee=annee,
mois=mois,
contenu_html=html_content,
donnees=donnees,
)
db.add(rapport)
await db.flush()
return {
"id": rapport.id,
"type_rapport": rapport.type_rapport,
"titre": rapport.titre,
"annee": rapport.annee,
"mois": rapport.mois,
"adopte_le": rapport.adopte_le,
}
@router.get("/adoptes")
async def liste_rapports_adoptes(
type_rapport: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import desc
query = select(RapportAdopte).where(RapportAdopte.groupe_id == membre.groupe_id)
if type_rapport:
query = query.where(RapportAdopte.type_rapport == type_rapport)
result = await db.execute(query.order_by(desc(RapportAdopte.adopte_le)).limit(60))
return [
{
"id": r.id,
"type_rapport": r.type_rapport,
"titre": r.titre,
"annee": r.annee,
"mois": r.mois,
"adopte_le": r.adopte_le,
}
for r in result.scalars().all()
]
@router.get("/adoptes/{rapport_id}.pdf")
async def imprimer_rapport_adopte(
rapport_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
try:
from weasyprint import HTML
except ImportError:
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
result = await db.execute(
select(RapportAdopte).where(
RapportAdopte.id == rapport_id,
RapportAdopte.groupe_id == membre.groupe_id,
)
)
rapport = result.scalar_one_or_none()
if not rapport:
raise HTTPException(status_code=404, detail="Rapport introuvable")
pdf_bytes = HTML(string=rapport.contenu_html).write_pdf()
filename = f"rapport_adopte_{rapport.type_rapport}_{rapport.annee or 'sans'}_{rapport.mois or 'periode'}.pdf"
return StreamingResponse( return StreamingResponse(
io.BytesIO(pdf_bytes), io.BytesIO(pdf_bytes),
media_type="application/pdf", media_type="application/pdf",

View file

@ -74,6 +74,7 @@ class PvUpdate(BaseModel):
class PvAdoption(BaseModel): class PvAdoption(BaseModel):
proposeur_id: UUID proposeur_id: UUID
secondeur_id: UUID secondeur_id: UUID
contenu: Optional[str] = None
# ── M3 Rapport RSG ── # ── M3 Rapport RSG ──

View file

@ -0,0 +1,432 @@
"""Operations sysadmin de remise a zero et de donnees de demonstration."""
from __future__ import annotations
import csv
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
from app.core.security import hash_pin
from app.models.affectation import Affectation
from app.models.candidature import Candidature
from app.models.collecte import Collecte
from app.models.config_repartition import ConfigRepartition
from app.models.depense import Depense
from app.models.destinataire_contribution import DestinataireContribution
from app.models.envoi_contribution import EnvoiContribution
from app.models.evenement import Evenement
from app.models.groupe import Groupe
from app.models.jeton import Jeton
from app.models.litterature import Litterature
from app.models.membre import Membre
from app.models.mouvement_reserve import MouvementReserve
from app.models.operation_bancaire import OperationBancaire
from app.models.poste import Poste
from app.models.poste_module import PosteModule
from app.models.presence import Presence
from app.models.proposition import Proposition
from app.models.pv_reunion import PvReunion
from app.models.rapport_adopte import RapportAdopte
from app.models.rapport_rsg import RapportRsg
from app.models.reserve import Reserve
from app.models.reunion import Reunion
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature
POSTES = [
("RSG", "executif", "Représente le groupe auprès du district et rapporte les informations de service.", "2 ans de stabilité", 1, ["M3", "C2", "C5"]),
("RSG Substitut(e)", "executif", "Appuie le RSG et assure la relève lorsque nécessaire.", "1 an de stabilité", 2, ["M3", "C2", "C5"]),
("Trésorier(ière)", "executif", "Tient les registres, reçoit les sommes et présente les rapports mensuels.", "1 an de stabilité", 3, ["S1T", "S5", "M4", "G8"]),
("Secrétaire", "executif", "Prépare les procès-verbaux, les présences et la correspondance.", "6 mois de stabilité", 4, ["M0", "M1", "M2", "S0"]),
("Animateur(trice)", "service", "Anime les rencontres hebdomadaires.", "Disponibilité régulière", 5, ["S0"]),
("Responsable accueil", "physique", "Accueille les nouveaux et veille à l'atmosphère de la salle.", "Fiabilité", 6, ["C9"]),
("Responsable café", "physique", "Prépare le café et remet les petites dépenses au trésorier.", "Aucun", 7, ["S5"]),
("Responsable littérature", "service", "Maintient l'inventaire et remet les ventes au trésorier.", "Connaissance de la littérature", 8, ["S3", "C4", "M5"]),
("Responsable jetons", "service", "Prépare les jetons et les anniversaires.", "Aucun", 9, ["S2", "M6"]),
("Responsable clés", "physique", "Ouvre et ferme le local.", "Fiabilité", 10, []),
]
MEMBRES_DEMO = [
("Sysadmin", None, "sysadmin", "0000", "514-555-0000"),
("Claire", "B.", "claire.demo@example.test", "1111", "514-555-0101"),
("Marc", "L.", "marc.demo@example.test", "2222", "514-555-0102"),
("Nadia", "R.", "nadia.demo@example.test", "3333", "514-555-0103"),
("Simon", "T.", "simon.demo@example.test", "4444", "514-555-0104"),
("Julie", "P.", "julie.demo@example.test", "5555", "514-555-0105"),
("Karim", "A.", "karim.demo@example.test", "6666", "514-555-0106"),
("Mélanie", "S.", "melanie.demo@example.test", "7777", "514-555-0107"),
("Robert", "G.", "robert.demo@example.test", "8888", "514-555-0108"),
("Anne", "C.", "anne.demo@example.test", "9999", "514-555-0109"),
("Luc", "D.", "luc.demo@example.test", "1212", "514-555-0110"),
("Sophie", "M.", "sophie.demo@example.test", "3434", "514-555-0111"),
]
def _catalogue_litterature() -> list[dict]:
chemin = Path(__file__).resolve().parents[3] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv"
if chemin.exists():
with chemin.open(newline="", encoding="utf-8") as fichier:
return [
{
"code_bsg": row["code_bsg"].strip() or None,
"titre": row["titre"].strip(),
"categorie": row["categorie"].strip() or "brochure",
"prix_coutant": float(row["prix_coutant"]) if row["prix_coutant"].strip() else 0,
}
for row in csv.DictReader(fichier)
if row.get("titre", "").strip()
]
return [
{"code_bsg": "B-1", "titre": "Les Alcooliques anonymes", "categorie": "livre", "prix_coutant": 12.0},
{"code_bsg": "B-2", "titre": "Les Douze Étapes et les Douze Traditions", "categorie": "livre", "prix_coutant": 11.0},
{"code_bsg": "P-3", "titre": "Les AA sont-ils pour vous?", "categorie": "brochure", "prix_coutant": 0.0},
]
async def remettre_a_zero(db: AsyncSession) -> dict:
tables = ", ".join(table.name for table in reversed(Base.metadata.sorted_tables))
if tables:
await db.execute(text(f"TRUNCATE TABLE {tables} RESTART IDENTITY CASCADE"))
groupe, sysadmin, postes, litterature = await _creer_base(db)
await db.flush()
return {
"message": "Base remise à zéro",
"groupe_id": groupe.id,
"sysadmin_id": sysadmin.id,
"postes": len(postes),
"litterature": len(litterature),
}
async def initialiser_demo(db: AsyncSession) -> dict:
await remettre_a_zero(db)
groupe = await _premier_groupe(db)
membres = await _membres_par_courriel(db)
postes = await _postes_par_nom(db)
litterature = await _litterature(db)
await _ajouter_membres_demo(db, groupe, membres, postes)
membres = await _membres_par_courriel(db)
await _ajouter_historique_demo(db, groupe, membres, postes, litterature)
await db.flush()
return {
"message": "Base remise à zéro et données de démonstration installées",
"groupe_id": groupe.id,
"membres": len(membres),
"periode_mois": 12,
}
async def _creer_base(db: AsyncSession):
groupe = Groupe(
nom="Groupe Méditation",
district="87-16",
region="87",
adresse_local="Salle communautaire, Montréal",
jour_reunion="mardi",
heure_reunion=time(19, 30),
assemblee_affaires_ordre="premiere",
format_reunion="Discussion ouverte",
lien_aa87="https://aa87.org",
)
db.add(groupe)
await db.flush()
postes = {}
for nom, categorie, description, prerequis, ordre, modules in POSTES:
poste = Poste(
nom=nom,
categorie=categorie,
description=description,
prerequis=prerequis,
ordre_affichage=ordre,
actif=True,
)
db.add(poste)
await db.flush()
postes[nom] = poste
for module_code in modules:
db.add(PosteModule(poste_id=poste.id, module_code=module_code))
sysadmin = Membre(
groupe_id=groupe.id,
prenom="Sysadmin",
nom=None,
telephone="514-555-0000",
courriel="sysadmin",
pin_hash=hash_pin("0000"),
actif=True,
)
db.add(sysadmin)
await db.flush()
litterature = []
for item in _catalogue_litterature():
lit = Litterature(
groupe_id=groupe.id,
titre=item["titre"][:200],
categorie=item["categorie"] if item["categorie"] in {"livre", "brochure", "depliant", "pamphlet"} else "brochure",
prix_coutant=item["prix_coutant"],
quantite_stock=8,
seuil_commande=2,
code_bsg=item["code_bsg"],
)
db.add(lit)
litterature.append(lit)
db.add(DestinataireContribution(groupe_id=groupe.id, nom="District 87-16", protege=True, actif=True))
db.add(DestinataireContribution(groupe_id=groupe.id, nom="Région 87", protege=False, actif=True))
db.add(DestinataireContribution(groupe_id=groupe.id, nom="Bureau des services généraux", protege=False, actif=True))
for destination, pct in [("district", 50), ("region", 20), ("bsg", 20), ("intergroupe", 10)]:
db.add(ConfigRepartition(groupe_id=groupe.id, destination=destination, pourcentage=pct, actif_depuis=date.today().replace(day=1)))
return groupe, sysadmin, postes, litterature
async def _ajouter_membres_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict) -> None:
for prenom, nom, courriel, pin, telephone in MEMBRES_DEMO[1:]:
membre = Membre(
groupe_id=groupe.id,
prenom=prenom,
nom=nom,
telephone=telephone,
courriel=courriel,
date_abstinence=date.today() - timedelta(days=365 * (2 + len(membres) % 12)),
pin_hash=hash_pin(pin),
actif=True,
)
db.add(membre)
await db.flush()
membres[courriel] = membre
affectations = [
("claire.demo@example.test", "RSG"),
("marc.demo@example.test", "RSG Substitut(e)"),
("nadia.demo@example.test", "Trésorier(ière)"),
("simon.demo@example.test", "Secrétaire"),
("julie.demo@example.test", "Animateur(trice)"),
("karim.demo@example.test", "Responsable littérature"),
("melanie.demo@example.test", "Responsable jetons"),
("robert.demo@example.test", "Responsable accueil"),
("anne.demo@example.test", "Responsable café"),
]
debut = date.today() - timedelta(days=240)
for courriel, poste_nom in affectations:
membre = membres.get(courriel)
if membre and poste_nom in postes:
db.add(Affectation(groupe_id=groupe.id, membre_id=membre.id, poste_id=postes[poste_nom].id, date_debut=debut, statut="actif"))
async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict, litterature: list[Litterature]) -> None:
tresorier = membres["nadia.demo@example.test"]
secretaire = membres["simon.demo@example.test"]
rsg = membres["claire.demo@example.test"]
membres_actifs = [m for m in membres.values() if m.courriel != "sysadmin"]
reserves = {
"Loyer": Reserve(groupe_id=groupe.id, nom="Loyer", montant=0, note="Prévoir trois mois de salle"),
"Littérature": Reserve(groupe_id=groupe.id, nom="Littérature", montant=0, note="Réapprovisionnement"),
"Prudentielle": Reserve(groupe_id=groupe.id, nom="Prudentielle", montant=0, note="Réserve de sécurité"),
}
for reserve in reserves.values():
db.add(reserve)
await db.flush()
debut = _premier_mardi(date.today() - timedelta(days=365))
dates_reunions = [debut + timedelta(days=7 * i) for i in range(52)]
mois_vus = set()
banque = 0.0
encaisse = 0.0
compteurs = {"RES": 0, "NOM": 0, "DON": 0}
for index, jour in enumerate(dates_reunions):
type_reunion = "affaires" if jour.month not in mois_vus else "reguliere"
mois_vus.add(jour.month)
reunion = Reunion(
groupe_id=groupe.id,
type=type_reunion,
date_reunion=datetime.combine(jour, groupe.heure_reunion or time(19, 30)).replace(tzinfo=timezone.utc),
animateur_id=membres_actifs[index % len(membres_actifs)].id,
theme=["Acceptation", "Service", "Gratitude", "Honnêteté"][index % 4],
annonces={"district": index % 5 == 0, "anniversaire": index % 8 == 0},
note="Réunion de démonstration réaliste.",
)
db.add(reunion)
await db.flush()
for membre in membres_actifs:
db.add(Presence(reunion_id=reunion.id, membre_id=membre.id, present=(index + len(membre.prenom)) % 7 != 0))
montant_collecte = round(82 + (index % 6) * 9 + (index % 3) * 4, 2)
collecte = Collecte(
groupe_id=groupe.id,
date_collecte=jour,
montant=montant_collecte,
reunion_id=reunion.id,
detenteur_id=membres_actifs[index % len(membres_actifs)].id,
statut="recu",
saisi_par=secretaire.id,
confirme_par=secretaire.id,
date_confirmation=datetime.combine(jour, time(21, 15)).replace(tzinfo=timezone.utc),
recu_par=tresorier.id,
date_reception=jour,
)
db.add(collecte)
await db.flush()
encaisse += montant_collecte
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="collecte", compte="encaisse", montant=montant_collecte, sens="debit", source_table="collectes", source_id=collecte.id, note="Collecte 7e Tradition", cree_par=tresorier.id))
if index % 4 == 0:
don = 40 + (index % 5) * 10
banque += don
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="don", compte="banque", montant=don, sens="debit", note="Don direct au compte bancaire", cree_par=tresorier.id))
if index % 4 == 3:
depot = round(encaisse - 25, 2) if encaisse > 50 else round(encaisse, 2)
if depot > 0:
encaisse -= depot
banque += depot
db.add(OperationBancaire(groupe_id=groupe.id, type="depot", montant=depot, date_operation=jour, effectue_par=tresorier.id, note="Dépôt mensuel de l'encaisse"))
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="banque_depot", compte="banque", montant=depot, sens="debit", note="Dépôt de l'encaisse", cree_par=tresorier.id))
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="banque_depot", compte="encaisse", montant=depot, sens="credit", note="Dépôt de l'encaisse", cree_par=tresorier.id))
if type_reunion == "affaires":
await _decision(db, groupe, reunion, compteurs, "RES", jour, secretaire, rsg, "Adopter l'ordre du jour", "L'assemblée doit traiter les affaires courantes.", "L'ordre du jour est adopté tel que présenté.")
await _decision(db, groupe, reunion, compteurs, "RES", jour, rsg, secretaire, f"Orientation du mois {jour.month:02d}", "Le groupe souhaite garder une conscience de groupe active.", "Le groupe retient les priorités présentées pour le mois.")
pv = PvReunion(
reunion_id=reunion.id,
redacteur_id=secretaire.id,
contenu=f"Procès-verbal de l'assemblée du {jour.isoformat()}.\nPrésences vérifiées. Rapports reçus. Décisions consignées.",
decisions=[{"sujet": "Adoption du procès-verbal précédent", "decision": "Adopté", "proposeur": rsg.prenom, "secondeur": secretaire.prenom, "statut": "complete"}],
approuve=index > 4,
)
db.add(pv)
db.add(RapportAdopte(
groupe_id=groupe.id,
adopte_par_id=tresorier.id,
type_rapport="tresorerie",
titre=f"Rapport du trésorier — {jour.strftime('%Y-%m')}",
annee=jour.year,
mois=jour.month,
contenu_html=f"<html><body><h1>Rapport du trésorier</h1><p>Rapport adopté pour {jour.strftime('%Y-%m')}.</p><p>Banque: {banque:.2f} $</p><p>Encaisse: {encaisse:.2f} $</p></body></html>",
donnees={"banque": round(banque, 2), "encaisse": round(encaisse, 2)},
))
if index % 6 == 1:
montant = 32 + (index % 4) * 6
methode = "COLLECTE" if encaisse >= montant else "VIREMENT"
if methode == "COLLECTE":
encaisse -= montant
compte = "encaisse"
else:
banque -= montant
compte = "banque"
depense = Depense(groupe_id=groupe.id, date_depense=jour, montant=montant, qui=membres_actifs[index % len(membres_actifs)].id, quoi=["Café et lait", "Papeterie", "Location de salle", "Fournitures accueil"][index % 4], paye=True, methode=methode, saisi_par=membres_actifs[index % len(membres_actifs)].id, confirme_par=tresorier.id, date_confirmation=datetime.combine(jour, time(20, 45)).replace(tzinfo=timezone.utc))
db.add(depense)
await db.flush()
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="depense", compte=compte, montant=montant, sens="credit", source_table="depenses", source_id=depense.id, note=depense.quoi, cree_par=tresorier.id))
if index % 8 == 2 and litterature:
item = litterature[index % len(litterature)]
montant = float(item.prix_coutant or 2) * 2
item.quantite_stock = max(0, item.quantite_stock - 2)
vente = VenteLitterature(groupe_id=groupe.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
db.add(vente)
await db.flush()
encaisse += montant
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_litterature", compte="encaisse", montant=montant, sens="debit", source_table="ventes_litterature", source_id=vente.id, note="Vente de littérature", cree_par=tresorier.id))
if index % 13 == 4:
montant = 125
if banque >= montant:
banque -= montant
decision = await _decision(db, groupe, reunion, compteurs, "DON", jour, rsg, secretaire, "Contribution au district", "La conscience de groupe prévoit soutenir les services.", "Le groupe décide d'envoyer une contribution au District 87-16.")
contribution = EnvoiContribution(groupe_id=groupe.id, date_envoi=jour, destination="District 87-16", montant=montant, methode="VIREMENT", resolution_id=decision.id, engagee=True, debitee=True, saisi_par=tresorier.id)
db.add(contribution)
await db.flush()
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="contribution", compte="banque", montant=montant, sens="credit", source_table="envois_contributions", source_id=contribution.id, note="Contribution au district", cree_par=tresorier.id))
if index in {9, 22, 35}:
candidat = membres_actifs[index % len(membres_actifs)]
poste = postes[["Responsable accueil", "Responsable café", "Responsable clés"][index % 3]]
candidature = Candidature(groupe_id=groupe.id, poste_id=poste.id, membre_id=candidat.id, type="nomination", statut="acceptee", motivation="Disponibilité confirmée.", nomme_par=rsg.id, acceptee_par_membre=True)
db.add(candidature)
await db.flush()
decision = await _decision(db, groupe, reunion, compteurs, "NOM", jour, rsg, secretaire, f"Nomination de {candidat.prenom}", "Un poste doit être pourvu pour soutenir le groupe.", f"Le groupe nomme {candidat.prenom} au poste de {poste.nom}.")
decision.candidature_id = candidature.id
candidature.proposition_id = decision.id
if index % 10 == 5:
db.add(Jeton(groupe_id=groupe.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
if index % 17 == 7:
reserve = reserves[["Loyer", "Littérature", "Prudentielle"][index % 3]]
montant = 75
if banque >= montant:
reserve.montant = float(reserve.montant or 0) + montant
db.add(MouvementReserve(groupe_id=groupe.id, source_id=None, destination_id=reserve.id, montant=montant, type="reserve_virement", note="Affectation au solde réservé", cree_par=tresorier.id, cree_le=datetime.combine(jour, time(21, 0)).replace(tzinfo=timezone.utc)))
db.add(Collecte(groupe_id=groupe.id, date_collecte=dates_reunions[-1] + timedelta(days=7), montant=96, detenteur_id=membres["luc.demo@example.test"].id, statut="en_attente", saisi_par=membres["luc.demo@example.test"].id))
db.add(Depense(groupe_id=groupe.id, date_depense=date.today(), montant=48, qui=membres["anne.demo@example.test"].id, quoi="Fournitures à confirmer", paye=False, methode=None, saisi_par=membres["anne.demo@example.test"].id))
db.add(Proposition(groupe_id=groupe.id, sujet="Réviser le format de discussion", description="Proposition laissée en réflexion pour la prochaine assemblée.", proposeur_id=membres["julie.demo@example.test"].id, secondeur_id=membres["marc.demo@example.test"].id, statut="secondee"))
db.add(Proposition(groupe_id=groupe.id, sujet="Acheter un projecteur", description="La dépense n'est pas jugée prioritaire.", proposeur_id=membres["luc.demo@example.test"].id, secondeur_id=membres["sophie.demo@example.test"].id, statut="rejetee", etant_donne_que="La réserve prudente doit d'abord être consolidée."))
db.add(Evenement(groupe_id=groupe.id, titre="Atelier Étapes et Traditions", description="Atelier régional ouvert aux membres.", date_evenement=date.today() + timedelta(days=24), lieu="Centre communautaire", cree_par=rsg.id))
db.add(Evenement(groupe_id=groupe.id, titre="Anniversaire du groupe", description="Souper partage et réunion ouverte.", date_evenement=date.today() - timedelta(days=40), lieu="Local habituel", cree_par=secretaire.id))
db.add(RapportRsg(groupe_id=groupe.id, reunion_dist=date.today() - timedelta(days=18), redacteur_id=rsg.id, decisions=["Le district prépare l'inventaire annuel.", "Besoin de bénévoles pour l'accueil."], infos_region="La région sollicite les groupes pour les ateliers.", sujets_groupe="Questions transmises par la conscience de groupe."))
async def _decision(db, groupe, reunion, compteurs, prefixe, jour, proposeur, secondeur, sujet, attendu, decide):
compteurs[prefixe] += 1
numero = f"{prefixe}-{jour.year}-{compteurs[prefixe]:03d}"
proposition = Proposition(
groupe_id=groupe.id,
reunion_id=reunion.id if reunion else None,
sujet=sujet,
description=attendu,
proposeur_id=proposeur.id,
secondeur_id=secondeur.id,
statut="adoptee",
numero_resolution=numero,
date_vote=jour,
date_adoption=jour,
etant_donne_que=attendu,
groupe_a_decide_de=decide,
)
db.add(proposition)
await db.flush()
return proposition
def _premier_mardi(jour: date) -> date:
delta = (1 - jour.weekday()) % 7
return jour + timedelta(days=delta)
async def _premier_groupe(db: AsyncSession) -> Groupe:
from sqlalchemy import select
result = await db.execute(select(Groupe).limit(1))
return result.scalar_one()
async def _membres_par_courriel(db: AsyncSession) -> dict[str, Membre]:
from sqlalchemy import select
result = await db.execute(select(Membre))
return {m.courriel: m for m in result.scalars().all()}
async def _postes_par_nom(db: AsyncSession) -> dict[str, Poste]:
from sqlalchemy import select
result = await db.execute(select(Poste))
return {p.nom: p for p in result.scalars().all()}
async def _litterature(db: AsyncSession) -> list[Litterature]:
from sqlalchemy import select
result = await db.execute(select(Litterature))
return result.scalars().all()

View file

@ -10,9 +10,33 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.models.membre import Membre
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
NOTIFICATION_TYPES = [
{"code": "gouvernance", "label": "Gouvernance"},
{"code": "tresorerie", "label": "Trésorerie"},
{"code": "membres", "label": "Membres"},
{"code": "postes", "label": "Postes et mandats"},
{"code": "rencontres", "label": "Rencontres et présences"},
{"code": "litterature", "label": "Littérature"},
{"code": "evenements", "label": "Événements"},
{"code": "systeme", "label": "Système"},
]
DEFAULT_NOTIFICATION_PREFERENCES = {item["code"]: True for item in NOTIFICATION_TYPES}
def normalized_preferences(raw: dict | None) -> dict:
prefs = DEFAULT_NOTIFICATION_PREFERENCES.copy()
if isinstance(raw, dict):
for code in prefs:
if code in raw:
prefs[code] = bool(raw[code])
return prefs
def notifications_configured() -> bool: def notifications_configured() -> bool:
return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64) return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64)
@ -60,17 +84,29 @@ async def notify_group_change(
title: str, title: str,
body: str, body: str,
url: str = "/", url: str = "/",
category: str = "systeme",
) -> int: ) -> int:
if not notifications_configured() or not groupe_id: if not notifications_configured() or not groupe_id:
return 0 return 0
result = await db.execute( subscriptions_result = await db.execute(
select(PushSubscription).where( select(PushSubscription).where(
PushSubscription.groupe_id == groupe_id, PushSubscription.groupe_id == groupe_id,
PushSubscription.membre_id != actor_id, PushSubscription.membre_id != actor_id,
) )
) )
return await send_to_subscriptions(db, result.scalars().all(), { subscriptions = subscriptions_result.scalars().all()
member_ids = {sub.membre_id for sub in subscriptions}
if not member_ids:
return 0
membres_result = await db.execute(select(Membre).where(Membre.id.in_(member_ids)))
membres = {m.id: m for m in membres_result.scalars().all()}
filtered = [
sub for sub in subscriptions
if normalized_preferences(getattr(membres.get(sub.membre_id), "notification_preferences", None)).get(category, True)
]
return await send_to_subscriptions(db, filtered, {
"title": title, "title": title,
"body": body, "body": body,
"url": url, "url": url,
"category": category,
}) })

View file

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

View file

@ -23,6 +23,7 @@ export default function AssembleeAffaires() {
const [presencesMap, setPresencesMap] = useState({}); const [presencesMap, setPresencesMap] = useState({});
const [contenu, setContenu] = useState(''); const [contenu, setContenu] = useState('');
const [pvPrecedent, setPvPrecedent] = useState(null); const [pvPrecedent, setPvPrecedent] = useState(null);
const [pvCorrection, setPvCorrection] = useState('');
const [showPvPrecedent, setShowPvPrecedent] = useState(false); const [showPvPrecedent, setShowPvPrecedent] = useState(false);
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' }); const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@ -64,7 +65,9 @@ export default function AssembleeAffaires() {
const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection); const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
setReunionId(duJour?.id || ''); setReunionId(duJour?.id || '');
const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection); const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection);
setPvPrecedent(precedents[0] || null); const precedent = precedents[0] || null;
setPvPrecedent(precedent);
setPvCorrection(precedent?.contenu || '');
}, [dateSelection, reunions, pvs]); }, [dateSelection, reunions, pvs]);
const reunion = reunions.find((r) => r.id === reunionId); const reunion = reunions.find((r) => r.id === reunionId);
@ -115,7 +118,10 @@ export default function AssembleeAffaires() {
} }
setError(''); setError('');
try { try {
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption); await api.patch(`/pv/${pvPrecedent.id}/adopter`, {
...adoption,
contenu: pvCorrection.trim(),
});
setMsg('PV précédent adopté'); setMsg('PV précédent adopté');
await charger(); await charger();
} catch (e) { setError(e.message); } } catch (e) { setError(e.message); }
@ -160,9 +166,14 @@ export default function AssembleeAffaires() {
{showPvPrecedent && pvPrecedent && ( {showPvPrecedent && pvPrecedent && (
<Card titre="Procès-verbal précédent"> <Card titre="Procès-verbal précédent">
<p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p> <p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
{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">
<textarea
value={pvCorrection}
onChange={(e) => setPvCorrection(e.target.value)}
rows={8}
className={`${INPUT} resize-none text-sm`}
/>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}> <select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
<option value="">Proposeur</option> <option value="">Proposeur</option>
@ -175,10 +186,13 @@ export default function AssembleeAffaires() {
</div> </div>
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id} <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"> 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 Adopter
</button> </button>
</div> </div>
)} )}
{(!estExecutif || pvPrecedent.approuve) && (
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
)}
</Card> </Card>
)} )}

View file

@ -10,7 +10,7 @@ import {
} from '../utils/pushNotifications'; } from '../utils/pushNotifications';
export default function Profil() { export default function Profil() {
const { setPage } = useStore(); const { setPage, estSysadmin, logout } = useStore();
const [profil, setProfil] = useState(null); const [profil, setProfil] = useState(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form, setForm] = useState({}); const [form, setForm] = useState({});
@ -20,6 +20,10 @@ export default function Profil() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [pushInfo, setPushInfo] = useState({ supported: pushSupported(), enabled: false, configured: false }); const [pushInfo, setPushInfo] = useState({ supported: pushSupported(), enabled: false, configured: false });
const [pushBusy, setPushBusy] = useState(false); const [pushBusy, setPushBusy] = useState(false);
const [notificationTypes, setNotificationTypes] = useState([]);
const [notificationPrefs, setNotificationPrefs] = useState({});
const [savingPrefs, setSavingPrefs] = useState(false);
const [adminBusy, setAdminBusy] = useState(false);
useEffect(() => { useEffect(() => {
api.get('/membres/moi').then((data) => { api.get('/membres/moi').then((data) => {
@ -42,6 +46,12 @@ export default function Profil() {
}); });
}) })
.catch(() => {}); .catch(() => {});
api.get('/notifications/preferences')
.then((data) => {
setNotificationTypes(data.types || []);
setNotificationPrefs(data.preferences || {});
})
.catch(() => {});
}, []); }, []);
const saveProfile = async () => { const saveProfile = async () => {
@ -114,6 +124,42 @@ export default function Profil() {
} }
}; };
const togglePreference = async (code) => {
const next = { ...notificationPrefs, [code]: !notificationPrefs[code] };
setNotificationPrefs(next);
setSavingPrefs(true);
setError('');
try {
const data = await api.patch('/notifications/preferences', { preferences: next });
setNotificationPrefs(data.preferences || next);
setMsg('Préférences enregistrées ✓');
setTimeout(() => setMsg(''), 1500);
} catch (e) {
setError(e.message);
} finally {
setSavingPrefs(false);
}
};
const operationAdminBd = async (mode) => {
const libelle = mode === 'demo' ? 'initialiser la base de démonstration' : 'remettre la base à zéro';
const confirmation = window.prompt(`Cette action va ${libelle} et effacer les données actuelles. Tapez CONFIRMER pour continuer.`);
if (confirmation !== 'CONFIRMER') return;
setAdminBusy(true);
setError('');
try {
await api.post(mode === 'demo' ? '/admin/demo' : '/admin/reset');
alert(mode === 'demo'
? 'Base de démonstration installée. Reconnectez-vous avec Sysadmin / 0000.'
: 'Base remise à zéro. Reconnectez-vous avec Sysadmin / 0000.');
logout();
} catch (e) {
setError(e.message);
} finally {
setAdminBusy(false);
}
};
if (!profil) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>; if (!profil) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>;
const Field = ({ label, value }) => ( const Field = ({ label, value }) => (
@ -213,7 +259,24 @@ export default function Profil() {
</p> </p>
)} )}
{pushInfo.supported && pushInfo.configured && ( {pushInfo.supported && pushInfo.configured && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
{notificationTypes.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wide">Me notifier pour</p>
{notificationTypes.map((type) => (
<label key={type.code} className="flex items-center gap-3 min-h-[44px] p-3 bg-gray-50 rounded-xl">
<input
type="checkbox"
checked={notificationPrefs[type.code] !== false}
disabled={savingPrefs}
onChange={() => togglePreference(type.code)}
className="w-5 h-5 text-aa-blue rounded focus:ring-aa-blue"
/>
<span className="text-sm font-medium text-gray-700">{type.label}</span>
</label>
))}
</div>
)}
{!pushInfo.enabled ? ( {!pushInfo.enabled ? (
<button onClick={activerNotifications} disabled={pushBusy} <button onClick={activerNotifications} disabled={pushBusy}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40"> className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">
@ -235,6 +298,31 @@ export default function Profil() {
)} )}
</div> </div>
{estSysadmin && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3 border border-red-100">
<div>
<h2 className="font-bold text-red-700">Administration sysadmin</h2>
<p className="text-sm text-gray-500 mt-1">
Ces actions effacent la base courante. Elles recréent le compte Sysadmin avec le PIN 0000.
</p>
</div>
<button
onClick={() => operationAdminBd('reset')}
disabled={adminBusy}
className="w-full min-h-[44px] p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold disabled:opacity-40"
>
Remettre la BD à vide
</button>
<button
onClick={() => operationAdminBd('demo')}
disabled={adminBusy}
className="w-full min-h-[44px] p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40"
>
Initialiser une BD de démonstration
</button>
</div>
)}
{/* Changer PIN */} {/* Changer PIN */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
{!showPin ? ( {!showPin ? (

View file

@ -32,6 +32,9 @@ export default function Tresorerie() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generatingRapport, setGeneratingRapport] = useState(false); const [generatingRapport, setGeneratingRapport] = useState(false);
const [adoptingRapport, setAdoptingRapport] = useState(false);
const [rapportsAdoptes, setRapportsAdoptes] = useState([]);
const [noteRapport, setNoteRapport] = useState('');
const [registreMois, setRegistreMois] = useState(''); const [registreMois, setRegistreMois] = useState('');
const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear())); const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear()));
const [registreType, setRegistreType] = useState(''); const [registreType, setRegistreType] = useState('');
@ -79,6 +82,7 @@ export default function Tresorerie() {
setContributions(contribs); setContributions(contribs);
setDepenses(deps); setDepenses(deps);
setDepensesAConfirmer(depsConf); setDepensesAConfirmer(depsConf);
setRapportsAdoptes(await api.get('/rapports/adoptes?type_rapport=tresorerie'));
if (estExecutif) { if (estExecutif) {
const [c, o, d] = await Promise.all([ const [c, o, d] = await Promise.all([
api.get('/collectes/?limite=20'), api.get('/collectes/?limite=20'),
@ -310,6 +314,46 @@ export default function Tresorerie() {
} }
}; };
const telechargerRapportAdopte = async (rapport) => {
setGeneratingRapport(true);
setError('');
try {
const token = api.getToken();
const res = await fetch(`/api/rapports/adoptes/${rapport.id}.pdf`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Erreur de génération du rapport adopté');
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rapport_adopte_tresorerie_${rapport.annee}_${String(rapport.mois).padStart(2, '0')}.pdf`;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) {
setError(e.message);
} finally {
setGeneratingRapport(false);
}
};
const adopterRapportTresorier = async () => {
const { annee, mois } = periodeRapportPrecedent();
setAdoptingRapport(true);
setError('');
try {
await api.post(`/rapports/etat-resultats/${annee}/${mois}/adopter`, {
note: noteRapport.trim() || null,
});
setNoteRapport('');
setRapportsAdoptes(await api.get('/rapports/adoptes?type_rapport=tresorerie'));
flash('Rapport adopté et cristallisé ✓');
} catch (e) {
setError(e.message);
} finally {
setAdoptingRapport(false);
}
};
const aRecevoir = soldes?.en_possession_details || []; const aRecevoir = soldes?.en_possession_details || [];
const contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee); const contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee);
const depensesADebiter = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode)); const depensesADebiter = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
@ -405,6 +449,50 @@ export default function Tresorerie() {
)} )}
</section> </section>
<section className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div>
<h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide">Rapport du trésorier</h2>
<p className="text-xs text-gray-500">
Adoption du rapport de {MOIS_NOMS[rapportPrecedent.mois]} {rapportPrecedent.annee}.
</p>
</div>
{estExecutif && (
<div className="space-y-2">
<textarea
value={noteRapport}
onChange={(e) => setNoteRapport(e.target.value)}
rows={3}
placeholder="Correction ou note d'adoption optionnelle"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm"
/>
<button
type="button"
onClick={adopterRapportTresorier}
disabled={adoptingRapport}
className="w-full min-h-[44px] rounded-xl bg-green-600 text-white text-sm font-bold disabled:opacity-40 active:scale-[0.98]"
>
{adoptingRapport ? 'Adoption...' : 'Adopter'}
</button>
</div>
)}
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Rapports adoptés</h3>
{rapportsAdoptes.length > 0 ? rapportsAdoptes.map((rapport) => (
<button
key={rapport.id}
type="button"
onClick={() => telechargerRapportAdopte(rapport)}
className="w-full flex items-center justify-between gap-3 p-3 bg-gray-50 rounded-xl text-left"
>
<span className="text-sm font-bold text-gray-800">{rapport.titre}</span>
<span className="text-xs font-bold text-aa-blue whitespace-nowrap">Imprimer</span>
</button>
)) : (
<p className="text-sm text-gray-500">Aucun rapport adopté.</p>
)}
</div>
</section>
<section className="bg-white rounded-2xl p-4 shadow-sm"> <section className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide mb-3">Réserves</h2> <h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide mb-3">Réserves</h2>
{reserves.length > 0 ? ( {reserves.length > 0 ? (