Ajouter administration sysadmin et rapports adoptes
This commit is contained in:
parent
4a3df15fa1
commit
69171c4a1d
15 changed files with 1133 additions and 384 deletions
|
|
@ -17,6 +17,23 @@ SKIPPED_PREFIXES = (
|
|||
"/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):
|
||||
header = request.headers.get("authorization") or ""
|
||||
if not header.lower().startswith("bearer "):
|
||||
|
|
@ -73,6 +90,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||
title="Changement dans l'application",
|
||||
body=f"{membre.prenom} a enregistré une modification.",
|
||||
url="/",
|
||||
category=_notification_category(path),
|
||||
)
|
||||
await db.commit()
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from sqlalchemy import text
|
|||
from app.core.config import settings
|
||||
from app.core.audit import AuditMiddleware
|
||||
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
|
||||
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.push_subscription import PushSubscription # noqa: F401
|
||||
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
|
||||
from app.models.rapport_adopte import RapportAdopte # noqa: F401
|
||||
|
||||
|
||||
@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 groupe_a_decide_de TEXT"))
|
||||
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(
|
||||
"ALTER TABLE envois_contributions "
|
||||
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
|
||||
|
|
@ -79,6 +81,7 @@ app.add_middleware(AuditMiddleware)
|
|||
|
||||
# Montage des routes
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(membres.router, prefix="/api")
|
||||
app.include_router(postes.router, prefix="/api")
|
||||
app.include_router(accueil.router, prefix="/api")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class Membre(Base):
|
|||
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
actif: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
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))
|
||||
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
|
|
|||
23
backend/app/models/rapport_adopte.py
Normal file
23
backend/app/models/rapport_adopte.py
Normal 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))
|
||||
31
backend/app/routers/admin.py
Normal file
31
backend/app/routers/admin.py
Normal 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)
|
||||
|
|
@ -8,7 +8,12 @@ from app.core.config import settings
|
|||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre
|
||||
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"])
|
||||
|
||||
|
|
@ -19,6 +24,10 @@ class PushSubscriptionIn(BaseModel):
|
|||
keys: dict
|
||||
|
||||
|
||||
class NotificationPreferencesIn(BaseModel):
|
||||
preferences: dict[str, bool]
|
||||
|
||||
|
||||
@router.get("/configuration")
|
||||
async def configuration_notifications():
|
||||
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")
|
||||
async def enregistrer_abonnement(
|
||||
abonnement: PushSubscriptionIn,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@ async def adopter_pv(
|
|||
pv = result.scalar_one_or_none()
|
||||
if not pv:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
|
||||
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:
|
||||
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.append({
|
||||
"sujet": "Adoption du procès-verbal précédent",
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ from html import escape
|
|||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, extract
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.roles import require_tresorier
|
||||
from app.core.security import get_current_membre
|
||||
from app.models.groupe import Groupe
|
||||
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.envoi_contribution import EnvoiContribution
|
||||
from app.models.proposition import Proposition
|
||||
from app.models.rapport_adopte import RapportAdopte
|
||||
|
||||
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:
|
||||
"""Retourne le texte décisionnel si la résolution utilise le format structuré."""
|
||||
if p.groupe_a_decide_de:
|
||||
|
|
@ -582,19 +589,13 @@ async def etat_financier_temps_reel(
|
|||
}
|
||||
|
||||
|
||||
@router.get("/etat-resultats/{annee}/{mois}")
|
||||
async def rapport_etat_resultats(
|
||||
async def _html_etat_resultats(
|
||||
annee: int,
|
||||
mois: int,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
membre,
|
||||
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:
|
||||
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>"
|
||||
for nom, montant in reserves
|
||||
) 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() + """
|
||||
.montant { text-align: right; white-space: nowrap; }
|
||||
|
|
@ -792,14 +799,144 @@ async def rapport_etat_resultats(
|
|||
{rows_reserves}
|
||||
<tr><td><strong>Total des réserves</strong></td><td class="montant"><strong>{total_reserves:.2f} $</strong></td></tr>
|
||||
</table>
|
||||
{note_html}
|
||||
|
||||
<div class="footer">
|
||||
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_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(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class PvUpdate(BaseModel):
|
|||
class PvAdoption(BaseModel):
|
||||
proposeur_id: UUID
|
||||
secondeur_id: UUID
|
||||
contenu: Optional[str] = None
|
||||
|
||||
|
||||
# ── M3 Rapport RSG ──
|
||||
|
|
|
|||
432
backend/app/services/admin_database.py
Normal file
432
backend/app/services/admin_database.py
Normal 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()
|
||||
|
|
@ -10,9 +10,33 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.membre import Membre
|
||||
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:
|
||||
return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64)
|
||||
|
||||
|
|
@ -60,17 +84,29 @@ async def notify_group_change(
|
|||
title: str,
|
||||
body: str,
|
||||
url: str = "/",
|
||||
category: str = "systeme",
|
||||
) -> int:
|
||||
if not notifications_configured() or not groupe_id:
|
||||
return 0
|
||||
result = await db.execute(
|
||||
subscriptions_result = await db.execute(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.groupe_id == groupe_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,
|
||||
"body": body,
|
||||
"url": url,
|
||||
"category": category,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
fonctionnelles.
|
||||
|
||||
Le registre ne liste pas les erreurs techniques generiques comme `objet
|
||||
introuvable` quand elles ne portent pas une intention metier particuliere. Il
|
||||
Le registre ne liste pas les erreurs techniques generiques comme `objet introuvable` quand elles ne portent pas une intention metier particuliere. Il
|
||||
liste toutefois les droits d'acces, les contraintes de saisie, les transitions
|
||||
d'etat, les invariants financiers et les protections de donnees.
|
||||
|
||||
|
|
@ -16,199 +15,130 @@ la regle est formulee selon le code et l'ecart est liste dans la section
|
|||
|
||||
## Acces et identite
|
||||
|
||||
- RM-ACC-001 - Le compte `Sysadmin` est cree au deploiement initial avec le PIN
|
||||
`0000`.
|
||||
- RM-ACC-001 - Le compte `Sysadmin` est cree au deploiement initial avec le PIN `0000`.
|
||||
- RM-ACC-002 - Le compte `Sysadmin` a acces a tous les modules.
|
||||
- RM-ACC-003 - Le compte `Sysadmin` ne peut pas etre desactive ni supprime.
|
||||
- RM-ACC-004 - Seul `Sysadmin` peut impersonifier un autre membre.
|
||||
- RM-ACC-005 - Une session impersonifiee conserve l'identite du sysadmin dans le
|
||||
jeton.
|
||||
- RM-ACC-005 - Une session impersonifiee conserve l'identite du sysadmin dans le jeton.
|
||||
- RM-ACC-006 - Un membre inactif ne peut pas ouvrir de session.
|
||||
- RM-ACC-007 - Un token invalide, expire ou associe a un autre groupe est
|
||||
refuse.
|
||||
- RM-ACC-007 - Un token invalide, expire ou associe a un autre groupe est refuse.
|
||||
- RM-ACC-008 - Un PIN doit etre compose de quatre chiffres.
|
||||
- RM-ACC-009 - L'identifiant de connexion, stocke dans la colonne `courriel`,
|
||||
est unique dans un groupe.
|
||||
- RM-ACC-009 - L'identifiant de connexion, stocke dans la colonne `courriel`, est unique dans un groupe.
|
||||
- RM-ACC-010 - La connexion cherche l'identifiant sans tenir compte de la casse.
|
||||
- RM-ACC-011 - Un jeton de session dure 30 jours par defaut.
|
||||
- RM-ACC-012 - Un jeton de session dure 90 jours lorsque `se souvenir de moi`
|
||||
est choisi.
|
||||
- RM-ACC-013 - Les operations d'administration courante sont reservees aux
|
||||
membres occupant un poste executif, sauf exception explicite.
|
||||
- RM-ACC-012 - Un jeton de session dure 90 jours lorsque `se souvenir de moi` est choisi.
|
||||
- RM-ACC-013 - Les operations d'administration courante sont reservees aux membres occupant un poste executif, sauf exception explicite.
|
||||
- RM-ACC-014 - `Sysadmin` satisfait toutes les verifications de role.
|
||||
|
||||
## Invitations et inscriptions
|
||||
|
||||
- RM-INV-001 - Seul un executif peut generer une invitation.
|
||||
- RM-INV-002 - Un code d'invitation est unique et comporte 12 caracteres
|
||||
hexadecimaux.
|
||||
- RM-INV-002 - Un code d'invitation est unique et comporte 12 caracteres hexadecimaux.
|
||||
- RM-INV-003 - Un code d'invitation expire selon la configuration serveur.
|
||||
- RM-INV-004 - Un code d'invitation ne peut etre utilise qu'une seule fois.
|
||||
- RM-INV-005 - Une inscription cree le membre dans le groupe lie a l'invitation.
|
||||
- RM-INV-006 - Le prenom et le nom saisis a l'inscription sont normalises en
|
||||
casse titre.
|
||||
- RM-INV-006 - Le prenom et le nom saisis a l'inscription sont normalises en casse titre.
|
||||
- RM-INV-007 - Le courriel saisi a l'inscription est normalise en minuscules.
|
||||
- RM-INV-008 - Une inscription ouvre immediatement une session pour le nouveau
|
||||
membre.
|
||||
- RM-INV-008 - Une inscription ouvre immediatement une session pour le nouveau membre.
|
||||
|
||||
## Membres et postes
|
||||
|
||||
- RM-MEM-001 - Le prenom et le telephone d'un membre sont obligatoires.
|
||||
- RM-MEM-002 - Un membre ne peut pas supprimer son propre compte par le droit a
|
||||
l'oubli.
|
||||
- RM-MEM-002 - Un membre ne peut pas supprimer son propre compte par le droit a l'oubli.
|
||||
- RM-MEM-003 - Un membre peut modifier son profil personnel.
|
||||
- RM-MEM-004 - Un changement de courriel doit conserver l'unicite dans le
|
||||
groupe.
|
||||
- RM-MEM-004 - Un changement de courriel doit conserver l'unicite dans le groupe.
|
||||
- RM-MEM-005 - Un changement de PIN exige l'ancien PIN.
|
||||
- RM-MEM-006 - Un executif peut consulter les membres actifs du groupe.
|
||||
- RM-MEM-007 - La desactivation d'un membre conserve son historique.
|
||||
- RM-MEM-008 - Le droit a l'oubli anonymise le membre au lieu d'effacer les
|
||||
donnees relationnelles.
|
||||
- RM-MEM-009 - Le droit a l'oubli retire le prenom, le nom, le telephone, la date
|
||||
d'abstinence et remplace le courriel par une adresse locale anonyme.
|
||||
- RM-POS-001 - Les postes appartiennent a une categorie valide:
|
||||
`executif`, `service` ou `physique`.
|
||||
- RM-POS-002 - Une affectation active lie un membre, un poste et une date de
|
||||
debut.
|
||||
- RM-POS-003 - Une affectation directe creee par le module d'affectation produit
|
||||
une decision adoptee de type `Nomination`.
|
||||
- RM-POS-004 - Une nomination exige un proposeur et un secondeur actifs et
|
||||
distincts.
|
||||
- RM-POS-005 - Un poste deja affecte ne peut pas etre affecte une seconde fois
|
||||
tant que l'affectation active existe.
|
||||
- RM-MEM-008 - Le droit a l'oubli anonymise le membre au lieu d'effacer les donnees relationnelles.
|
||||
- RM-MEM-009 - Le droit a l'oubli retire le prenom, le nom, le telephone, la date d'abstinence et remplace le courriel par une adresse locale anonyme.
|
||||
- RM-POS-001 - Les postes appartiennent a une categorie valide: `executif`, `service` ou `physique`.
|
||||
- RM-POS-002 - Une affectation active lie un membre, un poste et une date de debut.
|
||||
- RM-POS-003 - Une affectation directe creee par le module d'affectation produit une decision adoptee de type `Nomination`.
|
||||
- RM-POS-004 - Une nomination exige un proposeur et un secondeur actifs et distincts.
|
||||
- RM-POS-005 - Un poste deja affecte ne peut pas etre affecte une seconde fois tant que l'affectation active existe.
|
||||
- RM-POS-006 - Un poste doit etre aboli avant d'etre supprime definitivement.
|
||||
- RM-POS-007 - Les permissions de modules sont associees aux postes.
|
||||
- RM-POS-008 - Seul un executif peut creer, modifier, abolir, reactiver ou
|
||||
supprimer un poste.
|
||||
- RM-POS-009 - Un poste aboli n'apparait pas dans les listes ordinaires, sauf si
|
||||
l'affichage des postes abolis est demande.
|
||||
- RM-POS-008 - Seul un executif peut creer, modifier, abolir, reactiver ou supprimer un poste.
|
||||
- RM-POS-009 - Un poste aboli n'apparait pas dans les listes ordinaires, sauf si l'affichage des postes abolis est demande.
|
||||
- RM-POS-010 - Le nom d'un poste est obligatoire.
|
||||
- RM-POS-011 - Un membre peut postuler lui-meme sur un poste disponible.
|
||||
- RM-POS-012 - Un membre ne peut pas postuler deux fois pour le meme poste.
|
||||
- RM-POS-013 - Un executif peut proposer un autre membre pour un poste.
|
||||
- RM-POS-014 - La proposition d'un autre membre ne peut pas viser le membre qui
|
||||
fait la proposition; il doit alors utiliser sa propre candidature.
|
||||
- RM-POS-015 - Une candidature ou nomination deja traitee ne peut plus etre
|
||||
traitee de nouveau.
|
||||
- RM-POS-016 - Le traitement direct d'une candidature acceptee cree une
|
||||
affectation active.
|
||||
- RM-POS-014 - La proposition d'un autre membre ne peut pas viser le membre qui fait la proposition; il doit alors utiliser sa propre candidature.
|
||||
- RM-POS-015 - Une candidature ou nomination deja traitee ne peut plus etre traitee de nouveau.
|
||||
- RM-POS-016 - Le traitement direct d'une candidature acceptee cree une affectation active.
|
||||
- RM-POS-017 - Une candidature refusee conserve son statut `refusee`.
|
||||
- RM-POS-018 - Une nomination refusee marque la candidature comme refusee.
|
||||
- RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et
|
||||
conserve la date de fin, la raison et le membre qui l'a terminee.
|
||||
- RM-POS-020 - Les modules consultables par un membre dependent des permissions
|
||||
associees a ses postes, avec acces complet pour `Sysadmin`.
|
||||
- RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues
|
||||
par les verifications API des responsabilites specialisees.
|
||||
- RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et conserve la date de fin, la raison et le membre qui l'a terminee.
|
||||
- RM-POS-020 - Les modules consultables par un membre dependent des permissions associees a ses postes, avec acces complet pour `Sysadmin`.
|
||||
- RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues par les verifications API des responsabilites specialisees.
|
||||
|
||||
## Reunions et dates
|
||||
|
||||
- RM-REU-001 - Les dates de reunion doivent correspondre au jour configure dans
|
||||
les parametres du groupe.
|
||||
- RM-REU-002 - Une reunion reguliere ne peut pas etre creee deux fois pour la
|
||||
meme semaine ISO.
|
||||
- RM-REU-003 - L'assemblee d'affaires mensuelle doit correspondre a l'ordre
|
||||
configure: premiere, deuxieme, troisieme, quatrieme ou derniere reunion du
|
||||
mois.
|
||||
- RM-REU-004 - Les rapports de rencontre et d'assemblee sont consultables par
|
||||
tous les membres.
|
||||
- RM-REU-005 - La creation et la saisie des rapports de rencontre et
|
||||
d'assemblee sont reservees aux executifs.
|
||||
- RM-REU-006 - `Sysadmin` peut saisir des informations a posteriori en
|
||||
specifiant la date.
|
||||
- RM-REU-007 - Le jour de reunion configure dans le groupe pilote les dates
|
||||
permises pour les collectes, rencontres et assemblees.
|
||||
- RM-REU-008 - L'heure de reunion configuree bloque la saisie de collecte le
|
||||
jour meme avant l'heure prevue, sauf pour `Sysadmin`.
|
||||
- RM-REU-001 - Les dates de reunion doivent correspondre au jour configure dans les parametres du groupe.
|
||||
- RM-REU-002 - Une reunion reguliere ne peut pas etre creee deux fois pour la meme semaine ISO.
|
||||
- RM-REU-003 - L'assemblee d'affaires mensuelle doit correspondre a l'ordre configure: premiere, deuxieme, troisieme, quatrieme ou derniere reunion du mois.
|
||||
- RM-REU-004 - Les rapports de rencontre et d'assemblee sont consultables par tous les membres.
|
||||
- RM-REU-005 - La creation et la saisie des rapports de rencontre et d'assemblee sont reservees aux executifs.
|
||||
- RM-REU-006 - `Sysadmin` peut saisir des informations a posteriori en specifiant la date.
|
||||
- RM-REU-007 - Le jour de reunion configure dans le groupe pilote les dates permises pour les collectes, rencontres et assemblees.
|
||||
- RM-REU-008 - L'heure de reunion configuree bloque la saisie de collecte le jour meme avant l'heure prevue, sauf pour `Sysadmin`.
|
||||
- RM-REU-009 - Une rencontre hebdomadaire est de type `reguliere`.
|
||||
- RM-REU-010 - Une assemblee d'affaires est de type `affaires`.
|
||||
- RM-REU-011 - Une seule assemblee d'affaires peut exister pour un mois donne.
|
||||
- RM-REU-012 - Les presences d'une assemblee peuvent etre resaisies; la nouvelle
|
||||
saisie remplace les presences precedentes.
|
||||
- RM-REU-013 - La saisie des presences d'assemblee est reservee au secretaire ou
|
||||
a l'executif.
|
||||
- RM-REU-014 - Le proces-verbal d'une assemblee est rattache a une reunion du
|
||||
groupe.
|
||||
- RM-REU-015 - La creation et la modification d'un proces-verbal sont reservees
|
||||
au secretaire ou a l'executif.
|
||||
- RM-REU-012 - Les presences d'une assemblee peuvent etre resaisies; la nouvelle saisie remplace les presences precedentes.
|
||||
- RM-REU-013 - La saisie des presences d'assemblee est reservee au secretaire ou a l'executif.
|
||||
- RM-REU-014 - Le proces-verbal d'une assemblee est rattache a une reunion du groupe.
|
||||
- RM-REU-015 - La creation et la modification d'un proces-verbal sont reservees au secretaire ou a l'executif.
|
||||
- RM-REU-016 - L'adoption d'un proces-verbal exige un proposeur et un secondeur.
|
||||
- RM-REU-017 - L'adoption d'un proces-verbal ajoute une decision d'adoption au
|
||||
proces-verbal.
|
||||
- RM-REU-018 - Un rapport RSG est reserve au RSG, au RSG substitut, a l'executif
|
||||
ou a `Sysadmin`.
|
||||
- RM-REU-019 - Les rotations futures et leur historique sont consultables par
|
||||
les membres.
|
||||
- RM-REU-020 - La planification ou la suppression d'une rotation est reservee aux
|
||||
executifs.
|
||||
- RM-REU-017 - L'adoption d'un proces-verbal ajoute une decision d'adoption au proces-verbal.
|
||||
- RM-REU-018 - Un rapport RSG est reserve au RSG, au RSG substitut, a l'executif ou a `Sysadmin`.
|
||||
- RM-REU-019 - Les rotations futures et leur historique sont consultables par les membres.
|
||||
- RM-REU-020 - La planification ou la suppression d'une rotation est reservee aux executifs.
|
||||
|
||||
## Collectes
|
||||
|
||||
- RM-COL-001 - La saisie d'une collecte est reservee aux executifs.
|
||||
- RM-COL-002 - Une collecte ne peut pas etre saisie dans le futur, sauf par
|
||||
`Sysadmin`.
|
||||
- RM-COL-003 - La date d'une collecte doit etre une date de reunion, sauf par
|
||||
`Sysadmin`.
|
||||
- RM-COL-004 - La date de saisie proposee doit faire partie des 52 dernieres
|
||||
rencontres sans collecte deja enregistree.
|
||||
- RM-COL-002 - Une collecte ne peut pas etre saisie dans le futur, sauf par `Sysadmin`.
|
||||
- RM-COL-003 - La date d'une collecte doit etre une date de reunion, sauf par `Sysadmin`.
|
||||
- RM-COL-004 - La date de saisie proposee doit faire partie des 52 dernieres rencontres sans collecte deja enregistree.
|
||||
- RM-COL-005 - Il ne peut y avoir qu'une collecte par date.
|
||||
- RM-COL-006 - Une collecte en attente peut etre modifiee; une collecte
|
||||
confirmee ou recue ne le peut plus.
|
||||
- RM-COL-007 - Le membre qui saisit une collecte ne peut pas confirmer son
|
||||
propre montant.
|
||||
- RM-COL-008 - Si le tresorier saisit ou confirme une collecte, la reception est
|
||||
automatique.
|
||||
- RM-COL-009 - Quand le tresorier recoit une collecte, la transaction comptable
|
||||
utilise la date de la collecte.
|
||||
- RM-COL-006 - Une collecte en attente peut etre modifiee; une collecte confirmee ou recue ne le peut plus.
|
||||
- RM-COL-007 - Le membre qui saisit une collecte ne peut pas confirmer son propre montant.
|
||||
- RM-COL-008 - Si le tresorier saisit ou confirme une collecte, la reception est automatique.
|
||||
- RM-COL-009 - Quand le tresorier recoit une collecte, la transaction comptable utilise la date de la collecte.
|
||||
- RM-COL-010 - Une collecte recue debite l'encaisse.
|
||||
- RM-COL-011 - Une collecte saisie puis confirmee par un non-tresorier reste au
|
||||
statut `confirmee` jusqu'a reception par le tresorier.
|
||||
- RM-COL-011 - Une collecte saisie puis confirmee par un non-tresorier reste au statut `confirmee` jusqu'a reception par le tresorier.
|
||||
- RM-COL-012 - Le registre de tresorerie inclut les collectes recues.
|
||||
- RM-COL-013 - Le module de collecte peut filtrer par mois, annee et statut.
|
||||
- RM-COL-014 - Tous les montants de collecte participent aux soldes seulement
|
||||
lorsqu'ils atteignent l'etat comptable approprie.
|
||||
- RM-COL-014 - Tous les montants de collecte participent aux soldes seulement lorsqu'ils atteignent l'etat comptable approprie.
|
||||
|
||||
## Tresorerie
|
||||
|
||||
- RM-TRE-001 - Les soldes de tresorerie sont calcules a partir du registre
|
||||
comptable.
|
||||
- RM-TRE-002 - Le solde bancaire disponible est le solde en banque moins les
|
||||
reserves actives et les engagements non debites.
|
||||
- RM-TRE-001 - Les soldes de tresorerie sont calcules a partir du registre comptable.
|
||||
- RM-TRE-002 - Le solde bancaire disponible est le solde en banque moins les reserves actives et les engagements non debites.
|
||||
- RM-TRE-003 - L'encaisse est consideree comme disponible.
|
||||
- RM-TRE-004 - Un depot peut provenir de l'encaisse ou d'un don direct au compte
|
||||
bancaire.
|
||||
- RM-TRE-005 - Un depot depuis l'encaisse ne peut pas depasser l'encaisse
|
||||
disponible.
|
||||
- RM-TRE-004 - Un depot peut provenir de l'encaisse ou d'un don direct au compte bancaire.
|
||||
- RM-TRE-005 - Un depot depuis l'encaisse ne peut pas depasser l'encaisse disponible.
|
||||
- RM-TRE-006 - Un retrait bancaire ne peut pas depasser le solde en banque.
|
||||
- RM-TRE-007 - Un retrait bancaire ne peut pas faire depasser le total des
|
||||
reserves par rapport au solde en banque.
|
||||
- RM-TRE-008 - Un releve bancaire ne peut pas etre inferieur au total des
|
||||
reserves.
|
||||
- RM-TRE-009 - Les transactions du registre sont affichees de la plus recente a
|
||||
la plus ancienne.
|
||||
- RM-TRE-010 - Le rapport PDF de tresorerie utilise l'etat des resultats du mois
|
||||
precedent.
|
||||
- RM-TRE-011 - Les positions de tresorerie du rapport PDF sont celles du dernier
|
||||
jour du mois precedent.
|
||||
- RM-TRE-012 - Les actions qui modifient la tresorerie sont reservees au
|
||||
tresorier ou a l'executif.
|
||||
- RM-TRE-013 - Les membres peuvent voir les elements a traiter, mais seuls les
|
||||
executifs peuvent les confirmer.
|
||||
- RM-TRE-014 - Une operation bancaire doit avoir un type parmi `depot`,
|
||||
`retrait` ou `releve`.
|
||||
- RM-TRE-007 - Un retrait bancaire ne peut pas faire depasser le total des reserves par rapport au solde en banque.
|
||||
- RM-TRE-008 - Un releve bancaire ne peut pas etre inferieur au total des reserves.
|
||||
- RM-TRE-009 - Les transactions du registre sont affichees de la plus recente a la plus ancienne.
|
||||
- RM-TRE-010 - Le rapport PDF de tresorerie utilise l'etat des resultats du mois precedent.
|
||||
- RM-TRE-011 - Les positions de tresorerie du rapport PDF sont celles du dernier jour du mois precedent.
|
||||
- RM-TRE-012 - Les actions qui modifient la tresorerie sont reservees au tresorier ou a l'executif.
|
||||
- RM-TRE-013 - Les membres peuvent voir les elements a traiter, mais seuls les executifs peuvent les confirmer.
|
||||
- RM-TRE-014 - Une operation bancaire doit avoir un type parmi `depot`, `retrait` ou `releve`.
|
||||
- RM-TRE-015 - Une operation bancaire doit avoir un montant positif.
|
||||
- RM-TRE-016 - Un depot direct de don debite la banque sans crediter l'encaisse.
|
||||
- RM-TRE-017 - Un depot depuis l'encaisse debite la banque et credite l'encaisse.
|
||||
- RM-TRE-018 - Un retrait credite la banque et debite l'encaisse.
|
||||
- RM-TRE-019 - Un releve bancaire cree une correction de banque egale a l'ecart
|
||||
entre le releve saisi et le solde bancaire courant.
|
||||
- RM-TRE-020 - Le registre comptable conserve la source de chaque transaction
|
||||
quand elle provient d'une table applicative.
|
||||
- RM-TRE-021 - Le filtre du registre permet de restreindre par mois et par type
|
||||
de transaction.
|
||||
- RM-TRE-022 - Les ventes de litterature remises au tresorier debitent
|
||||
l'encaisse.
|
||||
- RM-TRE-023 - Les depenses payees creditent le compte utilise: banque ou
|
||||
encaisse.
|
||||
- RM-TRE-019 - Un releve bancaire cree une correction de banque egale a l'ecart entre le releve saisi et le solde bancaire courant.
|
||||
- RM-TRE-020 - Le registre comptable conserve la source de chaque transaction quand elle provient d'une table applicative.
|
||||
- RM-TRE-021 - Le filtre du registre permet de restreindre par mois et par type de transaction.
|
||||
- RM-TRE-022 - Les ventes de litterature remises au tresorier debitent l'encaisse.
|
||||
- RM-TRE-023 - Les depenses payees creditent le compte utilise: banque ou encaisse.
|
||||
- RM-TRE-024 - Les contributions debitees creditent la banque.
|
||||
|
||||
## Reserves
|
||||
|
|
@ -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-003 - Les variations de reserve passent par un virement de reserve.
|
||||
- RM-RES-004 - Les virements de reserve sont historises.
|
||||
- RM-RES-005 - Un virement ne peut pas avoir la meme source et la meme
|
||||
destination.
|
||||
- RM-RES-006 - Le solde disponible peut etre utilise comme source ou destination
|
||||
de virement.
|
||||
- RM-RES-007 - Un virement depuis le disponible ne peut pas depasser le
|
||||
disponible bancaire.
|
||||
- RM-RES-008 - Un virement depuis une reserve ne peut pas depasser le solde de
|
||||
cette reserve.
|
||||
- RM-RES-005 - Un virement ne peut pas avoir la meme source et la meme destination.
|
||||
- RM-RES-006 - Le solde disponible peut etre utilise comme source ou destination de virement.
|
||||
- RM-RES-007 - Un virement depuis le disponible ne peut pas depasser le disponible bancaire.
|
||||
- RM-RES-008 - Un virement depuis une reserve ne peut pas depasser le solde de cette reserve.
|
||||
- RM-RES-009 - Une reserve ne peut etre supprimee que si son solde est nul.
|
||||
- RM-RES-010 - Une reserve supprimee est desactivee, pas effacee du registre.
|
||||
- RM-RES-011 - La liste des reserves n'affiche que les reserves actives.
|
||||
- RM-RES-012 - Le solde d'une reserve est calcule a partir des mouvements de
|
||||
reserve, pas a partir d'une saisie manuelle.
|
||||
- RM-RES-012 - Le solde d'une reserve est calcule a partir des mouvements de reserve, pas a partir d'une saisie manuelle.
|
||||
- RM-RES-013 - Les mouvements de reserve apparaissent au registre.
|
||||
- RM-RES-014 - Les mouvements de reserve conservent source, destination, montant,
|
||||
type, note, createur et date de creation.
|
||||
- RM-RES-015 - Le total des reserves actives ne doit jamais exceder le solde
|
||||
bancaire.
|
||||
- RM-RES-016 - Le disponible n'est pas une reserve; il est calcule comme solde
|
||||
bancaire moins reserves et engagements.
|
||||
- RM-RES-014 - Les mouvements de reserve conservent source, destination, montant, type, note, createur et date de creation.
|
||||
- RM-RES-015 - Le total des reserves actives ne doit jamais exceder le solde bancaire.
|
||||
- RM-RES-016 - Le disponible n'est pas une reserve; il est calcule comme solde bancaire moins reserves et engagements.
|
||||
|
||||
## Depenses
|
||||
|
||||
- RM-DEP-001 - Un membre ordinaire ne peut soumettre une depense qu'en son nom.
|
||||
- RM-DEP-002 - Un executif peut soumettre une depense au nom d'un autre membre
|
||||
actif.
|
||||
- RM-DEP-002 - Un executif peut soumettre une depense au nom d'un autre membre actif.
|
||||
- RM-DEP-003 - Seul `Sysadmin` peut saisir une date de depense a posteriori.
|
||||
- RM-DEP-004 - Une depense soumise doit etre traitee par le tresorier ou un
|
||||
executif.
|
||||
- RM-DEP-005 - Une depense par encaisse est payee immediatement si l'encaisse le
|
||||
permet.
|
||||
- RM-DEP-006 - Une depense par cheque ou virement devient un engagement en
|
||||
attente de debit.
|
||||
- RM-DEP-007 - Une depense bancaire ne peut pas etre engagee si le disponible
|
||||
bancaire est insuffisant.
|
||||
- RM-DEP-008 - Le debit final d'une depense bancaire ne peut pas faire depasser
|
||||
les reserves par rapport au solde en banque.
|
||||
- RM-DEP-004 - Une depense soumise doit etre traitee par le tresorier ou un executif.
|
||||
- RM-DEP-005 - Une depense par encaisse est payee immediatement si l'encaisse le permet.
|
||||
- RM-DEP-006 - Une depense par cheque ou virement devient un engagement en attente de debit.
|
||||
- RM-DEP-007 - Une depense bancaire ne peut pas etre engagee si le disponible bancaire est insuffisant.
|
||||
- RM-DEP-008 - Le debit final d'une depense bancaire ne peut pas faire depasser les reserves par rapport au solde en banque.
|
||||
- RM-DEP-009 - Une depense soumise non traitee peut etre rejetee.
|
||||
- RM-DEP-010 - Une depense deja traitee ne peut plus etre rejetee.
|
||||
- RM-DEP-011 - Les modes valides de traitement d'une depense sont `COLLECTE`,
|
||||
`CHEQUE` et `VIREMENT`.
|
||||
- RM-DEP-011 - Les modes valides de traitement d'une depense sont `COLLECTE`, `CHEQUE` et `VIREMENT`.
|
||||
- RM-DEP-012 - Une depense deja traitee ne peut pas etre traitee de nouveau.
|
||||
- RM-DEP-013 - Une depense deja payee ne peut pas faire l'objet d'une
|
||||
confirmation de debit.
|
||||
- RM-DEP-014 - Seules les depenses par cheque ou virement necessitent une
|
||||
confirmation de debit bancaire.
|
||||
- RM-DEP-015 - Une preuve de depense peut etre jointe et conservee dans le
|
||||
repertoire des preuves.
|
||||
- RM-DEP-016 - Rejeter une depense supprime aussi son fichier de preuve s'il
|
||||
existe.
|
||||
- RM-DEP-017 - La liste des depenses peut etre filtree selon payee/non payee et
|
||||
confirmee/non confirmee.
|
||||
- RM-DEP-013 - Une depense deja payee ne peut pas faire l'objet d'une confirmation de debit.
|
||||
- RM-DEP-014 - Seules les depenses par cheque ou virement necessitent une confirmation de debit bancaire.
|
||||
- RM-DEP-015 - Une preuve de depense peut etre jointe et conservee dans le repertoire des preuves.
|
||||
- RM-DEP-016 - Rejeter une depense supprime aussi son fichier de preuve s'il existe.
|
||||
- RM-DEP-017 - La liste des depenses peut etre filtree selon payee/non payee et confirmee/non confirmee.
|
||||
- RM-DEP-018 - Modifier une depense est reserve au tresorier ou a l'executif.
|
||||
- RM-DEP-019 - Modifier la methode d'une depense exige une methode valide.
|
||||
|
||||
## Contributions
|
||||
|
||||
- RM-CON-001 - Une contribution est une decision de groupe de type
|
||||
`Contribution`.
|
||||
- RM-CON-002 - Une contribution exige un proposeur et un secondeur actifs et
|
||||
distincts.
|
||||
- RM-CON-001 - Une contribution est une decision de groupe de type `Contribution`.
|
||||
- RM-CON-002 - Une contribution exige un proposeur et un secondeur actifs et distincts.
|
||||
- RM-CON-003 - Le destinataire `District 87-16` existe au deploiement initial.
|
||||
- RM-CON-004 - Le destinataire `District 87-16` est protege contre la
|
||||
modification et la suppression.
|
||||
- RM-CON-005 - Une contribution devient un engagement tant que le debit bancaire
|
||||
n'est pas confirme.
|
||||
- RM-CON-006 - Le debit final d'une contribution ne peut pas depasser le solde en
|
||||
banque.
|
||||
- RM-CON-007 - Le debit final d'une contribution ne peut pas faire depasser les
|
||||
reserves par rapport au solde en banque.
|
||||
- RM-CON-004 - Le destinataire `District 87-16` est protege contre la modification et la suppression.
|
||||
- RM-CON-005 - Une contribution devient un engagement tant que le debit bancaire n'est pas confirme.
|
||||
- RM-CON-006 - Le debit final d'une contribution ne peut pas depasser le solde en banque.
|
||||
- RM-CON-007 - Le debit final d'une contribution ne peut pas faire depasser les reserves par rapport au solde en banque.
|
||||
- RM-CON-008 - Une contribution doit avoir un montant positif.
|
||||
- RM-CON-009 - Les modes valides de contribution sont `DU`, `VIREMENT` et
|
||||
`CHEQUE`.
|
||||
- RM-CON-010 - La destination d'une contribution doit etre un destinataire actif
|
||||
du groupe.
|
||||
- RM-CON-009 - Les modes valides de contribution sont `DU`, `VIREMENT` et `CHEQUE`.
|
||||
- RM-CON-010 - La destination d'une contribution doit etre un destinataire actif du groupe.
|
||||
- RM-CON-011 - Un destinataire de contribution est unique par nom dans un groupe.
|
||||
- RM-CON-012 - Un destinataire supprime est desactive, pas efface.
|
||||
- RM-CON-013 - Le calcul de repartition utilise la configuration active du
|
||||
groupe ou la repartition par defaut.
|
||||
- RM-CON-014 - Les destinations de repartition manquantes sont completees a
|
||||
zero.
|
||||
- RM-CON-013 - Le calcul de repartition utilise la configuration active du groupe ou la repartition par defaut.
|
||||
- RM-CON-014 - Les destinations de repartition manquantes sont completees a zero.
|
||||
- RM-CON-015 - La configuration de repartition est reservee aux executifs.
|
||||
- RM-CON-016 - Le total d'une repartition doit etre exactement 100 %, avec une
|
||||
tolerance de 0,01.
|
||||
- RM-CON-017 - Les destinations de repartition valides sont `district`,
|
||||
`region`, `bsg` et `intergroupe`.
|
||||
- RM-CON-016 - Le total d'une repartition doit etre exactement 100 %, avec une tolerance de 0,01.
|
||||
- RM-CON-017 - Les destinations de repartition valides sont `district`, `region`, `bsg` et `intergroupe`.
|
||||
- RM-CON-018 - Un pourcentage de repartition ne peut pas etre negatif.
|
||||
- RM-CON-019 - Une contribution cree automatiquement une decision adoptee de
|
||||
type `Contribution`.
|
||||
- RM-CON-020 - `Sysadmin` peut utiliser la date d'envoi comme date de decision;
|
||||
les autres membres utilisent la date du jour.
|
||||
- RM-CON-019 - Une contribution cree automatiquement une decision adoptee de type `Contribution`.
|
||||
- RM-CON-020 - `Sysadmin` peut utiliser la date d'envoi comme date de decision; les autres membres utilisent la date du jour.
|
||||
|
||||
## Gouvernance
|
||||
|
||||
|
|
@ -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-003 - Une proposition peut etre secondee par un autre membre.
|
||||
- RM-GOU-004 - Un membre ne peut pas seconder sa propre proposition.
|
||||
- RM-GOU-005 - Un executif peut seconder une proposition au nom d'un autre
|
||||
membre actif.
|
||||
- RM-GOU-005 - Un executif peut seconder une proposition au nom d'un autre membre actif.
|
||||
- RM-GOU-006 - Seule une proposition secondee peut etre transformee en decision.
|
||||
- RM-GOU-007 - Transformer une proposition en resolution exige les sections
|
||||
`ETANT DONNE QUE` et `LE GROUPE A DECIDE DE`.
|
||||
- RM-GOU-007 - Transformer une proposition en resolution exige les sections `ETANT DONNE QUE` et `LE GROUPE A DECIDE DE`.
|
||||
- RM-GOU-008 - Une resolution adoptee recoit un numero avec le prefixe `RES`.
|
||||
- RM-GOU-009 - Une nomination adoptee recoit le prefixe `NOM`.
|
||||
- RM-GOU-010 - Une contribution adoptee recoit le prefixe `DON`.
|
||||
- RM-GOU-011 - Une proposition rejetee est conservee.
|
||||
- RM-GOU-012 - Rejeter une proposition exige la section `ETANT DONNE QUE`.
|
||||
- RM-GOU-013 - Le rapport des resolutions affiche les decisions prises en
|
||||
groupe.
|
||||
- RM-GOU-014 - Une proposition peut etre rattachee a une reunion existante du
|
||||
groupe.
|
||||
- RM-GOU-013 - Le rapport des resolutions affiche les decisions prises en groupe.
|
||||
- RM-GOU-014 - Une proposition peut etre rattachee a une reunion existante du groupe.
|
||||
- RM-GOU-015 - Une proposition a l'etat initial `proposee`.
|
||||
- RM-GOU-016 - Seule une proposition a l'etat `proposee` peut etre secondee.
|
||||
- RM-GOU-017 - Seules les propositions aux statuts `proposee` ou `secondee`
|
||||
peuvent etre rejetees.
|
||||
- RM-GOU-017 - Seules les propositions aux statuts `proposee` ou `secondee` peuvent etre rejetees.
|
||||
- RM-GOU-018 - Une proposition adoptee conserve la date d'adoption.
|
||||
- RM-GOU-019 - Le type d'une decision est derive du prefixe de son numero
|
||||
(`RES`, `NOM`, `DON`) ou de son lien metier.
|
||||
- RM-GOU-020 - Une nomination adoptee peut accepter automatiquement la
|
||||
candidature liee.
|
||||
- RM-GOU-021 - Une nomination rejetee peut refuser automatiquement la
|
||||
candidature liee.
|
||||
- RM-GOU-022 - L'historique des resolutions peut etre filtre par annee et par
|
||||
type.
|
||||
- RM-GOU-023 - Les decisions adoptees sont ordonnees par date de vote/adoption
|
||||
decroissante dans l'historique.
|
||||
- RM-GOU-019 - Le type d'une decision est derive du prefixe de son numero (`RES`, `NOM`, `DON`) ou de son lien metier.
|
||||
- RM-GOU-020 - Une nomination adoptee peut accepter automatiquement la candidature liee.
|
||||
- RM-GOU-021 - Une nomination rejetee peut refuser automatiquement la candidature liee.
|
||||
- RM-GOU-022 - L'historique des resolutions peut etre filtre par annee et par type.
|
||||
- RM-GOU-023 - Les decisions adoptees sont ordonnees par date de vote/adoption decroissante dans l'historique.
|
||||
|
||||
## Rapports PDF et rapports de consultation
|
||||
|
||||
- RM-RAP-001 - La generation PDF exige que le moteur PDF serveur soit installe.
|
||||
- RM-RAP-002 - Le mois d'un rapport financier doit etre entre 1 et 12.
|
||||
- RM-RAP-003 - Le rapport des resolutions n'affiche pas de colonne de resultat.
|
||||
- RM-RAP-004 - La description du rapport des resolutions est `Decisions prises
|
||||
en Groupe`.
|
||||
- RM-RAP-005 - Dans le rapport des resolutions, la colonne `Resolution` affiche
|
||||
`LE GROUPE A DECIDE DE` lorsque ce champ est renseigne.
|
||||
- RM-RAP-004 - La description du rapport des resolutions est `Decisions prises en Groupe`.
|
||||
- RM-RAP-005 - Dans le rapport des resolutions, la colonne `Resolution` affiche `LE GROUPE A DECIDE DE` lorsque ce champ est renseigne.
|
||||
- RM-RAP-006 - Le sommaire final du rapport des resolutions est retire.
|
||||
- RM-RAP-007 - Le rapport de tresorerie presente un etat des resultats mensuel,
|
||||
les positions de tresorerie et les reserves.
|
||||
- RM-RAP-008 - Les rapports de rencontre et d'assemblee sont concus pour etre
|
||||
imprimables en format lettre.
|
||||
- RM-RAP-009 - Les rapports consultables ne doivent pas exiger un poste
|
||||
executif, sauf pour leur creation ou modification.
|
||||
- RM-RAP-007 - Le rapport de tresorerie presente un etat des resultats mensuel, les positions de tresorerie et les reserves.
|
||||
- RM-RAP-008 - Les rapports de rencontre et d'assemblee sont concus pour etre imprimables en format lettre.
|
||||
- RM-RAP-009 - Les rapports consultables ne doivent pas exiger un poste executif, sauf pour leur creation ou modification.
|
||||
|
||||
## Litterature et jetons
|
||||
|
||||
- RM-LIT-001 - La gestion du catalogue de litterature est reservee au
|
||||
responsable de la litterature ou a l'executif.
|
||||
- RM-LIT-001 - La gestion du catalogue de litterature est reservee au responsable de la litterature ou a l'executif.
|
||||
- RM-LIT-002 - Une vente de litterature exige une quantite positive.
|
||||
- RM-LIT-003 - Une vente ne peut pas depasser le stock disponible.
|
||||
- RM-LIT-004 - Le stock de litterature ne peut pas devenir negatif.
|
||||
- RM-LIT-005 - L'inventaire initial de litterature est charge depuis
|
||||
`data/import/Liste-de-prix-rev.-Fevrier-2026.csv`.
|
||||
- RM-LIT-006 - Les categories valides de litterature sont `livre`, `brochure`,
|
||||
`depliant` et `pamphlet`.
|
||||
- RM-LIT-005 - L'inventaire initial de litterature est charge depuis `data/import/Liste-de-prix-rev.-Fevrier-2026.csv`.
|
||||
- RM-LIT-006 - Les categories valides de litterature sont `livre`, `brochure`, `depliant` et `pamphlet`.
|
||||
- RM-LIT-007 - Ajouter ou modifier un titre exige une categorie valide.
|
||||
- RM-LIT-008 - L'inventaire est visible aux membres connectes.
|
||||
- RM-LIT-009 - Une vente de litterature non remise reste a recevoir par le
|
||||
tresorier.
|
||||
- RM-LIT-010 - Une vente de litterature deja remise ne peut pas etre recue une
|
||||
seconde fois.
|
||||
- RM-JET-001 - La gestion des jetons est reservee au responsable des jetons ou a
|
||||
l'executif.
|
||||
- RM-LIT-009 - Une vente de litterature non remise reste a recevoir par le tresorier.
|
||||
- RM-LIT-010 - Une vente de litterature deja remise ne peut pas etre recue une seconde fois.
|
||||
- RM-JET-001 - La gestion des jetons est reservee au responsable des jetons ou a l'executif.
|
||||
- RM-JET-002 - Un type de jeton doit faire partie des types permis.
|
||||
- RM-JET-003 - Un jeton `multiple` exige un nombre d'annees d'au moins 2.
|
||||
- RM-JET-004 - Un jeton peut etre rattache a un membre actif ou etre enregistre
|
||||
sans membre.
|
||||
- RM-JET-005 - Le stock de jetons est estime a partir des remises des 30 derniers
|
||||
jours.
|
||||
- RM-JET-006 - Une suggestion de commande est emise si au moins deux jetons d'un
|
||||
type ont ete remis dans les 30 derniers jours.
|
||||
- RM-JET-007 - Le jeton de desir est a surveiller si au moins quatre ont ete
|
||||
remis dans les 30 derniers jours.
|
||||
- RM-JET-004 - Un jeton peut etre rattache a un membre actif ou etre enregistre sans membre.
|
||||
- RM-JET-005 - Le stock de jetons est estime a partir des remises des 30 derniers jours.
|
||||
- RM-JET-006 - Une suggestion de commande est emise si au moins deux jetons d'un type ont ete remis dans les 30 derniers jours.
|
||||
- RM-JET-007 - Le jeton de desir est a surveiller si au moins quatre ont ete remis dans les 30 derniers jours.
|
||||
|
||||
## Evenements et calendrier
|
||||
|
||||
- RM-EVE-001 - Les evenements affiches incluent les anniversaires de sobriete et
|
||||
les evenements ad hoc.
|
||||
- RM-EVE-002 - Les anniversaires de sobriete sont calcules pour tous les membres
|
||||
actifs du district.
|
||||
- RM-EVE-003 - Un anniversaire du 29 fevrier est ramene au 1er mars pour les
|
||||
annees non bissextiles.
|
||||
- RM-EVE-004 - Les evenements passes et futurs peuvent etre retournes selon les
|
||||
bornes demandees.
|
||||
- RM-EVE-001 - Les evenements affiches incluent les anniversaires de sobriete et les evenements ad hoc.
|
||||
- RM-EVE-002 - Les anniversaires de sobriete sont calcules pour tous les membres actifs du district.
|
||||
- RM-EVE-003 - Un anniversaire du 29 fevrier est ramene au 1er mars pour les annees non bissextiles.
|
||||
- RM-EVE-004 - Les evenements passes et futurs peuvent etre retournes selon les bornes demandees.
|
||||
- RM-EVE-005 - Les evenements ad hoc sont visibles par les membres du district.
|
||||
- RM-EVE-006 - La creation et la suppression d'un evenement ad hoc sont reservees
|
||||
aux executifs.
|
||||
- RM-EVE-006 - La creation et la suppression d'un evenement ad hoc sont reservees aux executifs.
|
||||
- RM-EVE-007 - Les evenements sont tries par date croissante.
|
||||
|
||||
## Journal et notifications
|
||||
|
||||
- RM-JOU-001 - Les requetes authentifiees qui modifient des donnees sont
|
||||
journalisees.
|
||||
- RM-JOU-002 - Lorsqu'un sysadmin impersonifie un membre, le journal conserve
|
||||
l'identite du membre agissant et de l'admin.
|
||||
- RM-NOT-001 - Les notifications push sont optionnelles et dependent de la
|
||||
configuration VAPID.
|
||||
- RM-NOT-002 - Un abonnement push invalide ou expire peut etre retire
|
||||
automatiquement.
|
||||
- RM-NOT-003 - Un membre peut enregistrer, consulter et supprimer son propre
|
||||
abonnement push.
|
||||
- RM-NOT-004 - Les notifications ne peuvent pas etre activees si le serveur n'a
|
||||
pas de configuration VAPID.
|
||||
- RM-JOU-001 - Les requetes authentifiees qui modifient des donnees sont journalisees.
|
||||
- RM-JOU-002 - Lorsqu'un sysadmin impersonifie un membre, le journal conserve l'identite du membre agissant et de l'admin.
|
||||
- RM-NOT-001 - Les notifications push sont optionnelles et dependent de la configuration VAPID.
|
||||
- RM-NOT-002 - Un abonnement push invalide ou expire peut etre retire automatiquement.
|
||||
- RM-NOT-003 - Un membre peut enregistrer, consulter et supprimer son propre abonnement push.
|
||||
- RM-NOT-004 - Les notifications ne peuvent pas etre activees si le serveur n'a pas de configuration VAPID.
|
||||
- RM-JOU-003 - Le journal est reserve aux executifs.
|
||||
- RM-JOU-004 - Les requetes `GET`, `HEAD` et `OPTIONS` ne sont pas journalisees
|
||||
comme actions modificatrices.
|
||||
- RM-JOU-005 - Le journal n'enregistre pas le corps des requetes; il conserve la
|
||||
methode, le chemin, le statut HTTP, l'adresse IP, le user-agent et les
|
||||
parametres de requete non sensibles.
|
||||
- RM-JOU-004 - Les requetes `GET`, `HEAD` et `OPTIONS` ne sont pas journalisees comme actions modificatrices.
|
||||
- RM-JOU-005 - Le journal n'enregistre pas le corps des requetes; il conserve la methode, le chemin, le statut HTTP, l'adresse IP, le user-agent et les parametres de requete non sensibles.
|
||||
|
||||
## Parametres et donnees initiales
|
||||
|
||||
- RM-PAR-001 - La modification des parametres du groupe est reservee aux
|
||||
executifs.
|
||||
- RM-PAR-002 - Les ordres valides d'assemblee d'affaires sont `premiere`,
|
||||
`deuxieme`, `troisieme`, `quatrieme` et `derniere`.
|
||||
- RM-PAR-003 - Une heure de reunion invalide est ignoree et retire l'heure
|
||||
configuree.
|
||||
- RM-PAR-004 - Le deploiement initial cree le groupe `Groupe Meditation` avec le
|
||||
district `87-16` et la region `87`.
|
||||
- RM-PAR-001 - La modification des parametres du groupe est reservee aux executifs.
|
||||
- RM-PAR-002 - Les ordres valides d'assemblee d'affaires sont `premiere`, `deuxieme`, `troisieme`, `quatrieme` et `derniere`.
|
||||
- RM-PAR-003 - Une heure de reunion invalide est ignoree et retire l'heure configuree.
|
||||
- RM-PAR-004 - Le deploiement initial cree le groupe `Groupe Meditation` avec le district `87-16` et la region `87`.
|
||||
- RM-PAR-005 - Le deploiement initial cree les postes de base du groupe.
|
||||
- RM-PAR-006 - Le deploiement initial cree une invitation pour les prochains
|
||||
membres.
|
||||
- RM-PAR-007 - Le deploiement initial cree le destinataire de contribution
|
||||
protege `District 87-16`.
|
||||
- RM-PAR-008 - Le deploiement initial charge le catalogue de litterature depuis
|
||||
`data/import` quand le fichier normalise est present.
|
||||
- RM-PAR-006 - Le deploiement initial cree une invitation pour les prochains membres.
|
||||
- RM-PAR-007 - Le deploiement initial cree le destinataire de contribution protege `District 87-16`.
|
||||
- RM-PAR-008 - Le deploiement initial charge le catalogue de litterature depuis `data/import` quand le fichier normalise est present.
|
||||
|
||||
## Transitions d'etat
|
||||
|
||||
| Domaine | Etat initial | Transition | Etat final | Condition |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Proposition | `proposee` | Seconder | `secondee` | Secondeur distinct du proposeur |
|
||||
| Proposition | `secondee` | Adopter | `adoptee` | Executif, sections decisionnelles remplies |
|
||||
| Proposition | `proposee` ou `secondee` | Rejeter | `rejetee` | Executif, section `ETANT DONNE QUE` remplie |
|
||||
| Collecte | `en_attente` | Confirmer | `confirmee` | Confirmateur distinct du saisisseur |
|
||||
| Collecte | `en_attente` ou `confirmee` | Recevoir | `recu` | Tresorier ou executif |
|
||||
| Depense | Soumise | Traiter en encaisse | Payee | Encaisse suffisante |
|
||||
| Depense | Soumise | Traiter par cheque/virement | Engagee | Disponible bancaire suffisant |
|
||||
| Depense | Engagee | Confirmer debit | Payee | Banque suffisante et reserves preservees |
|
||||
| Depense | Soumise | Rejeter | Supprimee | Non traitee |
|
||||
| Contribution | Creee | Enregistrer | Engagee | Decision `DON` creee |
|
||||
| Contribution | Engagee | Confirmer debit | Debitee | Banque suffisante et reserves preservees |
|
||||
| Candidature | `proposee` | Retirer | `retiree` | Par le membre candidat |
|
||||
| Candidature | `proposee` | Refuser | `refusee` | Executif ou membre nomine |
|
||||
| Candidature | `proposee` | Accepter | `acceptee` | Decision `NOM` et affectation creees |
|
||||
| Affectation | `actif` | Terminer | `termine` | Executif |
|
||||
| Domaine | Etat initial | Transition | Etat final | Condition |
|
||||
|--------------|-------------------------|-----------------------------|------------|--------------------------------------------|
|
||||
| Proposition | `proposee` | Seconder | `secondee` | Secondeur distinct du proposeur |
|
||||
| Proposition | `secondee` | Adopter | `adoptee` | Executif, sections decisionnelles remplies |
|
||||
| Proposition | `proposee` ou `secondee` | Rejeter | `rejetee` | Executif, section `ETANT DONNE QUE` remplie |
|
||||
| Collecte | `en_attente` | Confirmer | `confirmee` | Confirmateur distinct du saisisseur |
|
||||
| Collecte | `en_attente` ou `confirmee` | Recevoir | `recu` | Tresorier ou executif |
|
||||
| Depense | Soumise | Traiter en encaisse | Payee | Encaisse suffisante |
|
||||
| Depense | Soumise | Traiter par cheque/virement | Engagee | Disponible bancaire suffisant |
|
||||
| Depense | Engagee | Confirmer debit | Payee | Banque suffisante et reserves preservees |
|
||||
| Depense | Soumise | Rejeter | Supprimee | Non traitee |
|
||||
| Contribution | Creee | Enregistrer | Engagee | Decision `DON` creee |
|
||||
| Contribution | Engagee | Confirmer debit | Debitee | Banque suffisante et reserves preservees |
|
||||
| Candidature | `proposee` | Retirer | `retiree` | Par le membre candidat |
|
||||
| Candidature | `proposee` | Refuser | `refusee` | Executif ou membre nomine |
|
||||
| Candidature | `proposee` | Accepter | `acceptee` | Decision `NOM` et affectation creees |
|
||||
| Affectation | `actif` | Terminer | `termine` | Executif |
|
||||
|
||||
## Matrice des permissions par module
|
||||
|
||||
| Module | Consultation | Creation / saisie | Modification / confirmation | Suppression |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Accueil | Membre connecte | - | - | - |
|
||||
| Profil | Membre connecte | Membre connecte | Son propre profil | - |
|
||||
| Gestion des membres | Executif | Executif | Executif | Executif, sauf Sysadmin et soi-meme |
|
||||
| Parametres groupe | Membre connecte | - | Executif | - |
|
||||
| Journal | Executif | Automatique | - | - |
|
||||
| Gouvernance | Membre connecte | Membre connecte | Executif pour adopter/rejeter | - |
|
||||
| Postes a pourvoir | Membre connecte | Membre connecte | Membre concerne / executif selon action | Retrait par candidat |
|
||||
| Gestion des postes | Membre connecte pour lister | Executif | Executif | Executif |
|
||||
| Gestion des mandats | Membre connecte pour ses mandats | Executif | Executif | - |
|
||||
| Rencontre hebdomadaire | Membre connecte | Executif | Executif | - |
|
||||
| Assemblee d'affaires | Membre connecte | Secretaire ou executif | Secretaire ou executif | - |
|
||||
| PV | Membre connecte | Secretaire ou executif | Secretaire ou executif | - |
|
||||
| Rapport RSG | Membre connecte | RSG, RSG substitut ou executif | RSG, RSG substitut ou executif | - |
|
||||
| Collecte 7e | Executif | Executif | Executif / tresorier selon etape | - |
|
||||
| Tresorerie | Membre connecte | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif selon action |
|
||||
| Depense | Membre connecte | Membre connecte | Tresorier ou executif | Tresorier ou executif si non traitee |
|
||||
| Donner au suivant | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif pour destinataires non proteges |
|
||||
| Litterature | Membre connecte | Responsable litterature ou executif | Responsable litterature ou executif | - |
|
||||
| Vente litterature | Membre connecte | Responsable litterature ou executif | Tresorier ou executif pour reception | - |
|
||||
| Jetons | Membre connecte | Responsable jetons ou executif | Responsable jetons ou executif | - |
|
||||
| Evenements | Membre connecte | Executif | Executif | Executif |
|
||||
| Rapports PDF | Membre connecte | Serveur | - | - |
|
||||
| Module | Consultation | Creation / saisie | Modification / confirmation | Suppression |
|
||||
|------------------------|----------------------------------|-------------------------------------|-----------------------------------------|-------------------------------------------------------|
|
||||
| Accueil | Membre connecte | \- | \- | \- |
|
||||
| Profil | Membre connecte | Membre connecte | Son propre profil | \- |
|
||||
| Gestion des membres | Executif | Executif | Executif | Executif, sauf Sysadmin et soi-meme |
|
||||
| Parametres groupe | Membre connecte | \- | Executif | \- |
|
||||
| Journal | Executif | Automatique | \- | \- |
|
||||
| Gouvernance | Membre connecte | Membre connecte | Executif pour adopter/rejeter | \- |
|
||||
| Postes a pourvoir | Membre connecte | Membre connecte | Membre concerne / executif selon action | Retrait par candidat |
|
||||
| Gestion des postes | Membre connecte pour lister | Executif | Executif | Executif |
|
||||
| Gestion des mandats | Membre connecte pour ses mandats | Executif | Executif | \- |
|
||||
| Rencontre hebdomadaire | Membre connecte | Executif | Executif | \- |
|
||||
| Assemblee d'affaires | Membre connecte | Secretaire ou executif | Secretaire ou executif | \- |
|
||||
| PV | Membre connecte | Secretaire ou executif | Secretaire ou executif | \- |
|
||||
| Rapport RSG | Membre connecte | RSG, RSG substitut ou executif | RSG, RSG substitut ou executif | \- |
|
||||
| Collecte 7e | Executif | Executif | Executif / tresorier selon etape | \- |
|
||||
| Tresorerie | Membre connecte | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif selon action |
|
||||
| Depense | Membre connecte | Membre connecte | Tresorier ou executif | Tresorier ou executif si non traitee |
|
||||
| Donner au suivant | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif | Tresorier ou executif pour destinataires non proteges |
|
||||
| Litterature | Membre connecte | Responsable litterature ou executif | Responsable litterature ou executif | \- |
|
||||
| Vente litterature | Membre connecte | Responsable litterature ou executif | Tresorier ou executif pour reception | \- |
|
||||
| Jetons | Membre connecte | Responsable jetons ou executif | Responsable jetons ou executif | \- |
|
||||
| Evenements | Membre connecte | Executif | Executif | Executif |
|
||||
| Rapports PDF | Membre connecte | Serveur | \- | \- |
|
||||
|
||||
`Sysadmin` a tous les acces.
|
||||
|
||||
|
|
@ -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.
|
||||
- Encaisse - Argent liquide detenu par le groupe.
|
||||
- Reserve - Portion du solde bancaire affectee a un usage precis.
|
||||
- Engagement non debite - Montant approuve mais pas encore sorti du compte
|
||||
bancaire.
|
||||
- Disponible bancaire - Banque moins reserves actives moins engagements non
|
||||
debites.
|
||||
- Engagement non debite - Montant approuve mais pas encore sorti du compte bancaire.
|
||||
- Disponible bancaire - Banque moins reserves actives moins engagements non debites.
|
||||
- Disponible total - Disponible bancaire plus encaisse.
|
||||
- Transaction comptable - Ligne du registre qui modifie un compte financier.
|
||||
- Mouvement de reserve - Virement virtuel entre le disponible et une reserve, ou
|
||||
entre deux reserves.
|
||||
- Mouvement de reserve - Virement virtuel entre le disponible et une reserve, ou entre deux reserves.
|
||||
- Etat des resultats - Revenus moins charges pour un mois donne.
|
||||
- Position de tresorerie - Situation banque, encaisse, reserves et engagements a
|
||||
une date donnee.
|
||||
- Position de tresorerie - Situation banque, encaisse, reserves et engagements a une date donnee.
|
||||
|
||||
## 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
|
||||
etre corriges dans le code ou clarifies comme decisions fonctionnelles.
|
||||
|
||||
- ECART-001 - La colonne `membres.courriel` sert maintenant aussi
|
||||
d'identifiant de connexion, notamment pour `sysadmin`. Le profil protege
|
||||
maintenant l'identifiant `sysadmin`, mais certains ecrans parlent toujours de
|
||||
courriel. Decision requise: conserver un vrai courriel ou renommer/modeliser
|
||||
un identifiant distinct.
|
||||
- ECART-002 - L'adoption d'un proces-verbal exige actuellement un proposeur et
|
||||
un secondeur en texte libre, pas des `membre_id`. Decision requise: garder une
|
||||
mention textuelle historique ou exiger des membres actifs comme pour les
|
||||
decisions.
|
||||
- ECART-003 - Certaines contraintes metier sont uniquement appliquees dans le
|
||||
code applicatif et pas par des contraintes SQL, par exemple les montants
|
||||
positifs, le stock non negatif et plusieurs transitions d'etat. C'est
|
||||
acceptable pour l'instant, mais les tests doivent couvrir ces invariants.
|
||||
- ECART-004 - Les rapports PDF et certaines listes utilisent encore des logiques
|
||||
de date dispersees. Les regles mensuelles sont documentees, mais elles
|
||||
devraient etre couvertes par des tests pour eviter les divergences.
|
||||
- ECART-001 - La colonne `membres.courriel` sert maintenant aussi d'identifiant de connexion, notamment pour `sysadmin`. Le profil protege maintenant l'identifiant `sysadmin`, mais certains ecrans parlent toujours de courriel. Decision requise: conserver un vrai courriel ou renommer/modeliser un identifiant distinct.
|
||||
- ECART-002 - L'adoption d'un proces-verbal exige actuellement un proposeur et un secondeur en texte libre, pas des `membre_id`. Decision
|
||||
- requise: garder une mention textuelle historique ou exiger des membres actifs comme pour les decisions.
|
||||
- ECART-003 - Certaines contraintes metier sont uniquement appliquees dans le code applicatif et pas par des contraintes SQL, par exemple les montants positifs, le stock non negatif et plusieurs transitions d'etat. C'est acceptable pour l'instant, mais les tests doivent couvrir ces invariants.
|
||||
- ECART-004 - Les rapports PDF et certaines listes utilisent encore des logiques de date dispersees. Les regles mensuelles sont documentees, mais elles devraient etre couvertes par des tests pour eviter les divergences.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function AssembleeAffaires() {
|
|||
const [presencesMap, setPresencesMap] = useState({});
|
||||
const [contenu, setContenu] = useState('');
|
||||
const [pvPrecedent, setPvPrecedent] = useState(null);
|
||||
const [pvCorrection, setPvCorrection] = useState('');
|
||||
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
|
||||
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
|
||||
const [msg, setMsg] = useState('');
|
||||
|
|
@ -64,7 +65,9 @@ export default function AssembleeAffaires() {
|
|||
const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
|
||||
setReunionId(duJour?.id || '');
|
||||
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]);
|
||||
|
||||
const reunion = reunions.find((r) => r.id === reunionId);
|
||||
|
|
@ -115,7 +118,10 @@ export default function AssembleeAffaires() {
|
|||
}
|
||||
setError('');
|
||||
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é');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
|
|
@ -160,9 +166,14 @@ export default function AssembleeAffaires() {
|
|||
{showPvPrecedent && pvPrecedent && (
|
||||
<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-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
|
||||
{estExecutif && !pvPrecedent.approuve && (
|
||||
<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">
|
||||
<select value={adoption.proposeur_id} onChange={(e) => setAdoption({ ...adoption, proposeur_id: e.target.value })} className={INPUT}>
|
||||
<option value="">Proposeur</option>
|
||||
|
|
@ -175,10 +186,13 @@ export default function AssembleeAffaires() {
|
|||
</div>
|
||||
<button onClick={adopterPvPrecedent} disabled={!adoption.proposeur_id || !adoption.secondeur_id}
|
||||
className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100 disabled:opacity-40">
|
||||
Adopter le PV précédent
|
||||
Adopter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(!estExecutif || pvPrecedent.approuve) && (
|
||||
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '../utils/pushNotifications';
|
||||
|
||||
export default function Profil() {
|
||||
const { setPage } = useStore();
|
||||
const { setPage, estSysadmin, logout } = useStore();
|
||||
const [profil, setProfil] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({});
|
||||
|
|
@ -20,6 +20,10 @@ export default function Profil() {
|
|||
const [error, setError] = useState('');
|
||||
const [pushInfo, setPushInfo] = useState({ supported: pushSupported(), enabled: false, configured: 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(() => {
|
||||
api.get('/membres/moi').then((data) => {
|
||||
|
|
@ -42,6 +46,12 @@ export default function Profil() {
|
|||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
api.get('/notifications/preferences')
|
||||
.then((data) => {
|
||||
setNotificationTypes(data.types || []);
|
||||
setNotificationPrefs(data.preferences || {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
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>;
|
||||
|
||||
const Field = ({ label, value }) => (
|
||||
|
|
@ -213,7 +259,24 @@ export default function Profil() {
|
|||
</p>
|
||||
)}
|
||||
{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 ? (
|
||||
<button onClick={activerNotifications} disabled={pushBusy}
|
||||
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>
|
||||
|
||||
{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 */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
{!showPin ? (
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ export default function Tresorerie() {
|
|||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingRapport, setGeneratingRapport] = useState(false);
|
||||
const [adoptingRapport, setAdoptingRapport] = useState(false);
|
||||
const [rapportsAdoptes, setRapportsAdoptes] = useState([]);
|
||||
const [noteRapport, setNoteRapport] = useState('');
|
||||
const [registreMois, setRegistreMois] = useState('');
|
||||
const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear()));
|
||||
const [registreType, setRegistreType] = useState('');
|
||||
|
|
@ -79,6 +82,7 @@ export default function Tresorerie() {
|
|||
setContributions(contribs);
|
||||
setDepenses(deps);
|
||||
setDepensesAConfirmer(depsConf);
|
||||
setRapportsAdoptes(await api.get('/rapports/adoptes?type_rapport=tresorerie'));
|
||||
if (estExecutif) {
|
||||
const [c, o, d] = await Promise.all([
|
||||
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 contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee);
|
||||
const depensesADebiter = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
|
||||
|
|
@ -405,6 +449,50 @@ export default function Tresorerie() {
|
|||
)}
|
||||
</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">
|
||||
<h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide mb-3">Réserves</h2>
|
||||
{reserves.length > 0 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue