Ajouter historique métier et protection concurrente

Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409.

Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur.

Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
This commit is contained in:
Daniel Allaire 2026-05-31 10:59:41 -04:00
parent a8afaffc0e
commit 6677b0f57b
38 changed files with 1346 additions and 377 deletions

View file

@ -0,0 +1,27 @@
"""Protection contre les modifications concurrentes."""
from datetime import datetime
from fastapi import HTTPException
def version_iso(obj) -> str | None:
valeur = getattr(obj, "modifie_le", None) or getattr(obj, "cree_le", None)
return valeur.isoformat() if valeur else None
def _normaliser(valeur: datetime) -> str:
return valeur.isoformat().replace("+00:00", "Z")
def verifier_version(obj, version_attendue: str | None = None):
"""Refuse l'écriture si le client a ouvert une ancienne version de la donnée."""
if not version_attendue:
return
actuelle = getattr(obj, "modifie_le", None) or getattr(obj, "cree_le", None)
if not actuelle:
return
if _normaliser(actuelle) != version_attendue.replace("+00:00", "Z"):
raise HTTPException(
status_code=409,
detail="Cette information a été modifiée depuis son affichage. Rechargez les données avant d'enregistrer.",
)

View file

@ -28,6 +28,7 @@ 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
from app.models.vente_jeton import VenteJeton # noqa: F401
from app.models.historique_modification import HistoriqueModification # noqa: F401
@asynccontextmanager
@ -47,6 +48,21 @@ async def lifespan(app: FastAPI):
await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)"))
await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID"))
for table in (
"propositions",
"depenses",
"collectes",
"reunions",
"pv_reunions",
"reserves",
"litterature",
"destinataires_contributions",
"envois_contributions",
):
await conn.execute(text(
f"ALTER TABLE {table} "
"ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMPTZ DEFAULT now() NOT NULL"
))
await conn.execute(text(
"ALTER TABLE envois_contributions "
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"

View file

@ -25,3 +25,4 @@ class Collecte(Base):
recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
date_reception: Mapped[date | None] = mapped_column(Date)
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))

View file

@ -26,3 +26,4 @@ class Depense(Base):
confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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))

View file

@ -18,3 +18,4 @@ class DestinataireContribution(Base):
protege: Mapped[bool] = mapped_column(Boolean, default=False)
actif: Mapped[bool] = mapped_column(Boolean, default=True)
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))

View file

@ -23,3 +23,4 @@ class EnvoiContribution(Base):
debitee: Mapped[bool] = mapped_column(Boolean, default=False) # montant débité du compte
saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
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))

View file

@ -0,0 +1,24 @@
"""Historique métier des modifications importantes."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class HistoriqueModification(Base):
__tablename__ = "historique_modifications"
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)
table_cible: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
objet_id: Mapped[uuid.UUID] = mapped_column(nullable=False, index=True)
action: Mapped[str] = mapped_column(String(120), nullable=False)
raison: Mapped[str] = mapped_column(Text, nullable=False)
membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
admin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
chemin_api: Mapped[str | None] = mapped_column(String(255))
avant: Mapped[dict | None] = mapped_column(JSON)
apres: Mapped[dict | None] = mapped_column(JSON)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)

View file

@ -1,6 +1,7 @@
"""Table litterature — Inventaire AA."""
import uuid
from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum
from datetime import datetime, timezone
from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@ -19,3 +20,4 @@ class Litterature(Base):
quantite_stock: Mapped[int] = mapped_column(Integer, default=0)
seuil_commande: Mapped[int] = mapped_column(Integer, default=2)
code_bsg: Mapped[str | None] = mapped_column(String(20))
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

View file

@ -31,3 +31,8 @@ class Proposition(Base):
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),
)

View file

@ -16,3 +16,4 @@ class PvReunion(Base):
decisions: Mapped[list | None] = mapped_column(JSON) # [{sujet, decision, proposeur, secondeur, statut}]
approuve: Mapped[bool] = mapped_column(Boolean, default=False)
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))

View file

@ -16,4 +16,4 @@ class Reserve(Base):
note: Mapped[str | None] = mapped_column(Text)
active: Mapped[bool] = mapped_column(Boolean, default=True)
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))

View file

@ -18,3 +18,4 @@ class Reunion(Base):
annonces: Mapped[dict | None] = mapped_column(JSON)
note: Mapped[str | None] = mapped_column(Text)
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))

View file

@ -1,6 +1,6 @@
"""Routes S1 — Collecte 7e Tradition."""
from datetime import date, datetime, time as dt_time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func
@ -18,6 +18,7 @@ from app.models.reserve import Reserve
from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
@ -44,6 +45,7 @@ async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
recu_par_prenom=recu.prenom if recu else None,
date_reception=c.date_reception,
cree_le=c.cree_le,
modifie_le=c.modifie_le,
)
@ -123,6 +125,7 @@ async def saisir_collecte(
async def ajuster_collecte(
collecte_id: str,
req: CollecteUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
@ -131,16 +134,26 @@ async def ajuster_collecte(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
)
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
if req.montant is not None:
collecte.montant = req.montant
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.modifiee",
raison="Montant de collecte ajusté avant confirmation.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@ -148,6 +161,7 @@ async def ajuster_collecte(
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
async def confirmer_collecte(
collecte_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
@ -156,11 +170,12 @@ async def confirmer_collecte(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
)
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été confirmée")
if collecte.saisi_par == membre.id:
@ -181,6 +196,15 @@ async def confirmer_collecte(
db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
else:
collecte.statut = "confirmee"
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.confirmee" if collecte.statut == "confirmee" else "collecte.recue",
raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@ -189,6 +213,7 @@ async def confirmer_collecte(
@router.post("/{collecte_id}/reception", response_model=CollecteOut)
async def accuser_reception(
collecte_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
@ -197,11 +222,12 @@ async def accuser_reception(
select(Collecte).where(
Collecte.id == collecte_id,
Collecte.groupe_id == membre.groupe_id,
)
).with_for_update()
)
collecte = result.scalar_one_or_none()
if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable")
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut == "recu":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue")
@ -214,6 +240,15 @@ async def accuser_reception(
collecte.recu_par = membre.id
collecte.date_reception = date.today()
db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="collectes",
objet_id=collecte.id,
action="collecte.recue",
raison="Collecte reçue par le trésorier.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(collecte, db)
@ -301,6 +336,7 @@ async def soldes_tresorerie(
"nom": r.nom,
"montant": round(montant, 2),
"note": r.note,
"modifie_le": r.modifie_le.isoformat(),
})
total_reserves_reelles = sum(r["montant"] for r in reserves_details)
@ -322,6 +358,7 @@ async def soldes_tresorerie(
"montant": float(c.montant),
"detenteur": det.prenom if det else "?",
"statut": c.statut,
"modifie_le": c.modifie_le.isoformat(),
})
# ── Ventes de littérature non remises ──

View file

@ -1,6 +1,6 @@
"""Routes M4 — Envoi de contributions aux organismes AA."""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
@ -14,6 +14,7 @@ from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
@ -138,18 +139,29 @@ async def creer_destinataire(
async def modifier_destinataire(
destinataire_id: str,
req: DestinataireContributionUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
nom = req.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Nom requis")
dest.nom = nom
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="destinataires_contributions",
objet_id=dest.id,
action="destinataire.modifie",
raison="Destinataire de contribution renommé.",
membre_id=membre.id,
)
await db.flush()
return DestinataireContributionOut.model_validate(dest)
@ -157,15 +169,26 @@ async def modifier_destinataire(
@router.delete("/destinataires/{destinataire_id}")
async def supprimer_destinataire(
destinataire_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
dest = await db.get(DestinataireContribution, destinataire_id)
if not dest or dest.groupe_id != membre.groupe_id or not dest.actif:
raise HTTPException(status_code=404, detail="Destinataire introuvable")
await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
dest.actif = False
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="destinataires_contributions",
objet_id=dest.id,
action="destinataire.supprime",
raison="Destinataire de contribution désactivé.",
membre_id=membre.id,
)
await db.flush()
return {"message": "Destinataire supprimé"}
@ -260,12 +283,14 @@ async def enregistrer_envoi(
debitee=envoi.debitee,
saisi_par_prenom=membre.prenom,
cree_le=envoi.cree_le,
modifie_le=envoi.modifie_le,
)
@router.post("/{envoi_id}/confirmer-debit", response_model=EnvoiContributionOut)
async def confirmer_debit_contribution(
envoi_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
@ -274,11 +299,12 @@ async def confirmer_debit_contribution(
select(EnvoiContribution).where(
EnvoiContribution.id == envoi_id,
EnvoiContribution.groupe_id == membre.groupe_id,
)
).with_for_update()
)
envoi = result.scalar_one_or_none()
if not envoi:
raise HTTPException(status_code=404, detail="Envoi introuvable")
await verifier_version_historisee(db, envoi, version_attendue, table_cible="envois_contributions")
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
@ -291,6 +317,15 @@ async def confirmer_debit_contribution(
envoi.debitee = True
envoi.engagee = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="envois_contributions",
objet_id=envoi.id,
action="contribution.debit_confirme",
raison="Débit bancaire de la contribution confirmé.",
membre_id=membre.id,
)
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None
@ -306,6 +341,7 @@ async def confirmer_debit_contribution(
debitee=envoi.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=envoi.cree_le,
modifie_le=envoi.modifie_le,
)
@ -338,5 +374,6 @@ async def liste_envois(
debitee=e.debitee,
saisi_par_prenom=saisi.prenom if saisi else None,
cree_le=e.cree_le,
modifie_le=e.modifie_le,
))
return out

View file

@ -1,7 +1,7 @@
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, Header, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@ -12,6 +12,7 @@ from app.models.depense import Depense
from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import position_tresorerie, transaction
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
@ -34,6 +35,7 @@ async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
saisi_par_prenom=saisi.prenom if saisi else None,
confirme=d.confirme_par is not None,
cree_le=d.cree_le,
modifie_le=d.modifie_le,
)
@ -80,7 +82,7 @@ async def telecharger_preuve(
):
"""Attacher une preuve (reçu, photo) à une dépense."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
@ -111,6 +113,7 @@ async def telecharger_preuve(
async def confirmer_depense(
depense_id: str,
methode: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
@ -120,11 +123,12 @@ async def confirmer_depense(
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
@ -150,6 +154,15 @@ async def confirmer_depense(
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.traitee",
raison=f"Dépense traitée par {methode_norm.lower()}.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(depense, db)
@ -158,16 +171,18 @@ async def confirmer_depense(
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit(
depense_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.paye:
raise HTTPException(status_code=400, detail="Déjà confirmée comme payée")
if depense.methode not in ("CHEQUE", "VIREMENT"):
@ -182,6 +197,15 @@ async def confirmer_debit(
depense.paye = True
depense.date_confirmation = datetime.now(timezone.utc)
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.debit_confirme",
raison="Débit bancaire de la dépense confirmé.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(depense, db)
@ -189,16 +213,18 @@ async def confirmer_debit(
@router.delete("/{depense_id}")
async def rejeter_depense(
depense_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Rejeter une dépense soumise : elle est supprimée du registre."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update()
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
@ -207,6 +233,15 @@ async def rejeter_depense(
if os.path.exists(chemin):
os.remove(chemin)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.rejetee",
raison="Dépense soumise rejetée.",
membre_id=membre.id,
)
await db.delete(depense)
await db.flush()
return {"message": "Dépense rejetée"}
@ -254,6 +289,7 @@ async def depenses_a_confirmer(
async def modifier_depense(
depense_id: str,
req: DepenseUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
@ -264,6 +300,7 @@ async def modifier_depense(
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
if req.paye is not None:
depense.paye = req.paye
@ -273,6 +310,15 @@ async def modifier_depense(
depense.methode = req.methode.upper()
if req.quoi is not None:
depense.quoi = req.quoi.strip()
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="depenses",
objet_id=depense.id,
action="depense.modifiee",
raison="Dépense modifiée par le trésorier.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(depense, db)

View file

@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_executif
from app.models.journal_action import JournalAction
from app.models.historique_modification import HistoriqueModification
from app.models.membre import Membre
router = APIRouter(prefix="/journal", tags=["Journal"])
@ -39,3 +41,37 @@ async def journal_actions(
}
for item in result.scalars().all()
]
@router.get("/historique")
async def historique_modifications(
limite: int = 100,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
limite = max(1, min(limite, 500))
result = await db.execute(
select(HistoriqueModification)
.where(HistoriqueModification.groupe_id == executif.groupe_id)
.order_by(HistoriqueModification.cree_le.desc())
.limit(limite)
)
items = result.scalars().all()
lignes = []
for item in items:
membre = await db.get(Membre, item.membre_id) if item.membre_id else None
lignes.append({
"id": str(item.id),
"table_cible": item.table_cible,
"objet_id": str(item.objet_id),
"action": item.action,
"raison": item.raison,
"membre_id": str(item.membre_id) if item.membre_id else None,
"membre_prenom": membre.prenom if membre else None,
"admin_id": str(item.admin_id) if item.admin_id else None,
"chemin_api": item.chemin_api,
"avant": item.avant,
"apres": item.apres,
"cree_le": item.cree_le.isoformat(),
})
return lignes

View file

@ -1,5 +1,5 @@
"""Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@ -10,6 +10,7 @@ from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
@ -40,6 +41,7 @@ def _to_out(item: Litterature) -> LitteratureOut:
seuil_commande=item.seuil_commande,
code_bsg=item.code_bsg,
stock_bas=item.quantite_stock <= item.seuil_commande,
modifie_le=item.modifie_le,
)
@ -86,6 +88,7 @@ async def ajouter_titre(
async def modifier_titre(
item_id: str,
req: LitteratureUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_resp_litterature),
db: AsyncSession = Depends(get_db),
):
@ -96,9 +99,19 @@ async def modifier_titre(
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature")
for field, value in req.model_dump(exclude_none=True).items():
setattr(item, field, value)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="litterature",
objet_id=item.id,
action="litterature.modifiee",
raison="Titre de littérature modifié.",
membre_id=membre.id,
)
await db.flush()
return _to_out(item)
@ -107,6 +120,7 @@ async def modifier_titre(
async def ajuster_stock(
item_id: str,
req: AjustementStock,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_resp_litterature),
db: AsyncSession = Depends(get_db),
):
@ -117,11 +131,24 @@ async def ajuster_stock(
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature")
nouveau_stock = item.quantite_stock + req.quantite
if nouveau_stock < 0:
raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif")
ancien_stock = item.quantite_stock
item.quantite_stock = nouveau_stock
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="litterature",
objet_id=item.id,
action="litterature.stock_ajuste",
raison=f"Stock de littérature ajusté de {req.quantite}.",
membre_id=membre.id,
avant={"quantite_stock": ancien_stock},
apres={"quantite_stock": nouveau_stock},
)
await db.flush()
return _to_out(item)

View file

@ -1,5 +1,5 @@
"""Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@ -13,6 +13,7 @@ from app.models.reserve import Reserve
from app.models.membre import Membre
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
@ -121,6 +122,15 @@ async def enregistrer_operation(
"debit" if delta > 0 else "credit",
abs(delta),
))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="operations_bancaires",
objet_id=op.id,
action=f"operation.{op.type}",
raison=f"Opération bancaire enregistrée : {op.type}.",
membre_id=membre.id,
)
await db.flush()
return OperationBancaireOut(
id=op.id,
@ -140,7 +150,7 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession =
.order_by(Reserve.nom)
)
return [
ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le)
ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le, modifie_le=r.modifie_le)
for r in result.scalars().all()
]
@ -186,14 +196,21 @@ async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), d
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
db.add(reserve)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le)
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
async def modifier_reserve(
reserve_id: str,
req: ReserveUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
ancien_montant = await solde_reserve(reserve.id, db)
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
@ -204,8 +221,17 @@ async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(r
if req.note is not None:
reserve.note = req.note.strip() or None
reserve.montant = await solde_reserve(reserve.id, db)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="reserves",
objet_id=reserve.id,
action="reserve.modifiee",
raison="Réserve renommée ou note modifiée.",
membre_id=membre.id,
)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le)
@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut])
@ -276,19 +302,45 @@ async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresor
source_id=source.id if source else None,
destination_id=destination.id if destination else None,
))
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="mouvements_reserves",
objet_id=source.id if source else (destination.id if destination else membre.groupe_id),
action="reserve.transfert",
raison="Virement entre réserves ou disponible enregistré.",
membre_id=membre.id,
avant={"source_id": req.source_id, "destination_id": req.destination_id},
apres={"montant": req.montant},
)
await db.flush()
return {"message": "Transfert effectué"}
@router.delete("/reserves/{reserve_id}")
async def supprimer_reserve(reserve_id: str, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
async def supprimer_reserve(
reserve_id: str,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves")
montant = await solde_reserve(reserve.id, db)
if round(montant, 2) != 0:
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
reserve.active = False
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="reserves",
objet_id=reserve.id,
action="reserve.supprimee",
raison="Réserve supprimée après retour à zéro.",
membre_id=membre.id,
)
await db.flush()
return {"message": "Réserve supprimée"}

View file

@ -153,7 +153,7 @@ async def creer_reunion_affaires(
)
db.add(reunion)
await db.flush()
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note)
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note, modifie_le=reunion.modifie_le)
@router.get("/reunions", response_model=list[ReunionAffairesOut])
@ -186,7 +186,7 @@ async def liste_reunions_affaires(
present=p.present,
))
out.append(ReunionAffairesOut(
id=r.id, date_reunion=r.date_reunion, note=r.note, presences=pres_out,
id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out,
))
return out

View file

@ -1,6 +1,6 @@
"""Routes — Propositions et résolutions (réunions d'affaires)."""
from datetime import date, timezone, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
@ -16,6 +16,7 @@ from app.models.candidature import Candidature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"])
@ -41,6 +42,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
note=p.note,
candidature_id=p.candidature_id,
cree_le=p.cree_le,
modifie_le=p.modifie_le,
date_reunion=reunion.date_reunion if reunion else None,
)
@ -79,6 +81,7 @@ async def creer_proposition(
async def seconder_proposition(
proposition_id: str,
membre_id: str | None = None,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
@ -87,11 +90,12 @@ async def seconder_proposition(
select(Proposition).where(
Proposition.id == proposition_id,
Proposition.groupe_id == membre.groupe_id,
)
).with_for_update()
)
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions")
if prop.statut != "proposee":
raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée")
@ -108,6 +112,15 @@ async def seconder_proposition(
prop.secondeur_id = secondeur.id
prop.statut = "secondee"
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="propositions",
objet_id=prop.id,
action="proposition.secondee",
raison=f"Proposition secondée par {secondeur.prenom}.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(prop, db)
@ -116,6 +129,7 @@ async def seconder_proposition(
async def voter_proposition(
proposition_id: str,
req: PropositionVote,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
@ -127,11 +141,12 @@ async def voter_proposition(
select(Proposition).where(
Proposition.id == proposition_id,
Proposition.groupe_id == executif.groupe_id,
)
).with_for_update()
)
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions")
if req.statut == "adoptee" and prop.statut != "secondee":
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être transformée en résolution")
if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"):
@ -192,6 +207,15 @@ async def voter_proposition(
prop.date_vote = date.today()
if req.statut == "adoptee":
prop.date_adoption = prop.date_vote
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="propositions",
objet_id=prop.id,
action="proposition.adoptee" if req.statut == "adoptee" else "proposition.rejetee",
raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.",
membre_id=executif.id,
)
await db.flush()
return await _to_out(prop, db)

View file

@ -1,5 +1,5 @@
"""Routes M1 — Rédiger un PV de réunion d'affaires."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
@ -10,6 +10,7 @@ from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion
from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
@ -25,6 +26,7 @@ async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut:
decisions=pv.decisions,
approuve=pv.approuve,
cree_le=pv.cree_le,
modifie_le=pv.modifie_le,
date_reunion=reunion.date_reunion if reunion else None,
)
@ -102,6 +104,7 @@ async def creer_pv(
async def modifier_pv(
pv_id: str,
req: PvUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
@ -114,6 +117,7 @@ async def modifier_pv(
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
if req.contenu is not None:
pv.contenu = req.contenu.strip()
@ -121,6 +125,15 @@ async def modifier_pv(
pv.decisions = [d.model_dump() for d in req.decisions]
if req.approuve is not None:
pv.approuve = req.approuve
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="pv_reunions",
objet_id=pv.id,
action="pv.modifie",
raison="Procès-verbal modifié.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(pv, db)
@ -129,6 +142,7 @@ async def modifier_pv(
async def adopter_pv(
pv_id: str,
req: PvAdoption,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
@ -140,6 +154,7 @@ async def adopter_pv(
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
if pv.approuve:
raise HTTPException(status_code=400, detail="PV déjà adopté")
if req.proposeur_id == req.secondeur_id:
@ -169,5 +184,14 @@ async def adopter_pv(
})
pv.decisions = decisions
pv.approuve = True
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="pv_reunions",
objet_id=pv.id,
action="pv.adopte",
raison="Procès-verbal adopté.",
membre_id=membre.id,
)
await db.flush()
return await _to_out(pv, db)

View file

@ -3,7 +3,7 @@ import io
from html import escape
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
@ -20,6 +20,7 @@ from app.models.litterature import Litterature
from app.models.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature
from app.schemas.phase1 import ReunionCreate, ReunionOut
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
@ -91,6 +92,7 @@ async def creer_rapport_reunion(
annonces=reunion.annonces,
note=reunion.note,
cree_le=reunion.cree_le,
modifie_le=reunion.modifie_le,
)
@ -120,6 +122,7 @@ async def liste_reunions(
annonces=r.annonces,
note=r.note,
cree_le=r.cree_le,
modifie_le=r.modifie_le,
))
return out
@ -157,6 +160,7 @@ async def liste_rencontres_details(
"montant": float(collecte.montant),
"statut": collecte.statut,
"detenteur_prenom": detenteur.prenom if detenteur else None,
"modifie_le": collecte.modifie_le,
}
ventes_lit_result = await db.execute(
@ -220,6 +224,7 @@ async def liste_rencontres_details(
"theme": reunion.theme,
"annonces": reunion.annonces,
"note": reunion.note,
"modifie_le": reunion.modifie_le,
"collecte": collecte_out,
"ventes_litterature": ventes_litterature,
"ventes_jetons": ventes_jetons,
@ -232,6 +237,7 @@ async def liste_rencontres_details(
async def modifier_reunion(
reunion_id: str,
req: ReunionCreate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
@ -241,6 +247,7 @@ async def modifier_reunion(
reunion = result.scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions")
animateur = None
if req.animateur_id:
animateur = await db.get(Membre, req.animateur_id)
@ -251,6 +258,15 @@ async def modifier_reunion(
reunion.annonces = req.annonces
reunion.note = req.note
anim = animateur or (await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="reunions",
objet_id=reunion.id,
action="reunion.modifiee",
raison="Rencontre modifiée.",
membre_id=membre.id,
)
await db.flush()
return ReunionOut(
id=reunion.id,
@ -261,6 +277,7 @@ async def modifier_reunion(
annonces=reunion.annonces,
note=reunion.note,
cree_le=reunion.cree_le,
modifie_le=reunion.modifie_le,
)
@ -444,4 +461,5 @@ async def detail_reunion(
annonces=reunion.annonces,
note=reunion.note,
cree_le=reunion.cree_le,
modifie_le=reunion.modifie_le,
)

View file

@ -25,6 +25,7 @@ class CollecteOut(BaseModel):
recu_par_prenom: Optional[str] = None
date_reception: Optional[date] = None
cree_le: datetime
modifie_le: datetime
model_config = {"from_attributes": True}
@ -93,6 +94,7 @@ class ReserveOut(BaseModel):
note: Optional[str] = None
active: bool = True
cree_le: datetime
modifie_le: datetime
model_config = {"from_attributes": True}
@ -136,6 +138,7 @@ class ReunionOut(BaseModel):
annonces: Optional[list[str]] = None
note: Optional[str] = None
cree_le: datetime
modifie_le: datetime
model_config = {"from_attributes": True}
@ -162,6 +165,7 @@ class DepenseOut(BaseModel):
saisi_par_prenom: Optional[str] = None
confirme: bool = False
cree_le: datetime
modifie_le: datetime
model_config = {"from_attributes": True}

View file

@ -32,6 +32,7 @@ class ReunionAffairesOut(BaseModel):
id: UUID
date_reunion: datetime
note: Optional[str] = None
modifie_le: Optional[datetime] = None
presences: list[PresenceOut] = []
model_config = {"from_attributes": True}
@ -60,6 +61,7 @@ class PvOut(BaseModel):
decisions: Optional[list[dict]] = None
approuve: bool
cree_le: datetime
modifie_le: datetime
date_reunion: Optional[datetime] = None
model_config = {"from_attributes": True}

View file

@ -24,6 +24,7 @@ class LitteratureOut(BaseModel):
seuil_commande: int
code_bsg: Optional[str] = None
stock_bas: bool = False
modifie_le: datetime
model_config = {"from_attributes": True}

View file

@ -39,6 +39,7 @@ class EnvoiContributionOut(BaseModel):
debitee: bool = False
saisi_par_prenom: Optional[str] = None
cree_le: datetime
modifie_le: datetime
model_config = {"from_attributes": True}
@ -68,6 +69,7 @@ class DestinataireContributionOut(BaseModel):
nom: str
protege: bool = False
actif: bool = True
modifie_le: datetime
model_config = {"from_attributes": True}

View file

@ -28,6 +28,7 @@ class PropositionOut(BaseModel):
note: Optional[str] = None
candidature_id: Optional[UUID] = None
cree_le: datetime
modifie_le: datetime
date_reunion: Optional[datetime] = None
model_config = {"from_attributes": True}

View file

@ -0,0 +1,82 @@
"""Service d'historique métier."""
from fastapi import HTTPException
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.concurrency import version_iso
from app.models.historique_modification import HistoriqueModification
from app.models.membre import Membre
async def enregistrer_historique(
db: AsyncSession,
*,
groupe_id,
table_cible: str,
objet_id,
action: str,
raison: str,
membre_id=None,
admin_id=None,
chemin_api: str | None = None,
avant: dict | None = None,
apres: dict | None = None,
) -> HistoriqueModification:
entree = HistoriqueModification(
groupe_id=groupe_id,
table_cible=table_cible,
objet_id=objet_id,
action=action,
raison=raison,
membre_id=membre_id,
admin_id=admin_id,
chemin_api=chemin_api,
avant=avant,
apres=apres,
)
db.add(entree)
return entree
async def dernier_historique(db: AsyncSession, table_cible: str, objet_id) -> HistoriqueModification | None:
result = await db.execute(
select(HistoriqueModification)
.where(
HistoriqueModification.table_cible == table_cible,
HistoriqueModification.objet_id == objet_id,
)
.order_by(desc(HistoriqueModification.cree_le))
.limit(1)
)
return result.scalar_one_or_none()
async def verifier_version_historisee(
db: AsyncSession,
obj,
version_attendue: str | None,
*,
table_cible: str,
objet_id=None,
):
if not version_attendue:
return
actuelle = version_iso(obj)
if not actuelle or actuelle.replace("+00:00", "Z") == version_attendue.replace("+00:00", "Z"):
return
objet = objet_id or getattr(obj, "id", None)
entree = await dernier_historique(db, table_cible, objet) if objet else None
if not entree:
raise HTTPException(
status_code=409,
detail="Cette information a été modifiée depuis son affichage. Rechargez les données avant d'enregistrer.",
)
membre = await db.get(Membre, entree.membre_id) if entree.membre_id else None
par = f" par {membre.prenom}" if membre else ""
quand = entree.cree_le.astimezone().strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=409,
detail=f"Cette information a été modifiée{par} le {quand} : {entree.raison}. Rechargez les données avant d'enregistrer.",
)

View file

@ -0,0 +1,209 @@
# Présentation aux membres
## L'attrait plutôt que la réclame
Bonjour à toutes et à tous.
Cette présentation n'a pas pour but de vendre un outil, ni de convaincre qui que ce soit d'aimer l'informatique. Dans AA, nous savons que ce qui dure repose rarement sur la pression. Nous avançons mieux quand les choses sont simples, utiles, et qu'elles servent le groupe.
L'application que nous vous présentons a été pensée dans cet esprit : aider le groupe à mieux garder sa mémoire, ses décisions, ses responsabilités et sa trésorerie, sans remplacer la conscience de groupe.
Elle ne décide pas à notre place. Elle ne dirige pas le groupe. Elle ne remplace ni les traditions, ni le bon sens, ni la confiance entre nous.
Elle sert simplement à mieux soutenir ce que nous faisons déjà.
## Pourquoi cet outil existe
Dans un groupe AA, plusieurs petites choses doivent être suivies régulièrement :
- les rencontres;
- les assemblées d'affaires;
- les présences;
- les procès-verbaux;
- les propositions et décisions;
- les collectes;
- les dépenses;
- les contributions;
- les postes de service;
- la littérature;
- les événements;
- les rapports.
Quand tout repose sur quelques personnes, des cahiers, des messages texte, des souvenirs ou des fichiers dispersés, il devient facile de perdre le fil.
L'application vise à réduire cette fragilité.
Elle aide à ce que l'information du groupe reste disponible, compréhensible et transmissible, même quand les serviteurs changent.
## Ce que l'application apporte
Elle apporte surtout de l'ordre.
Un membre peut consulter les informations utiles au groupe sans avoir à demander à trois personnes différentes.
Un serviteur peut saisir ce qui concerne son service au bon endroit.
Un trésorier peut suivre les entrées, les sorties, les réserves et les rapports.
Un secrétaire peut préparer les assemblées, les présences et les procès-verbaux.
L'exécutif peut gérer les postes, les accès et les informations du groupe.
Le groupe peut retrouver ses décisions passées plus facilement.
## Ce que l'application ne fait pas
Elle ne remplace pas l'assemblée d'affaires.
Elle ne remplace pas la discussion.
Elle ne remplace pas le vote ou le consensus.
Elle ne remplace pas la responsabilité des serviteurs.
Elle ne remplace pas la rotation.
Elle ne remplace pas la confiance.
Elle donne seulement un endroit commun pour noter ce qui a été fait, ce qui reste à faire, et ce qui a été décidé ensemble.
## Pour les membres
Pour un membre, l'utilisation peut rester très simple.
On peut s'en servir pour :
- voir les événements;
- consulter la littérature;
- voir les postes à pourvoir;
- proposer sa candidature;
- soumettre une proposition;
- seconder une proposition;
- soumettre une dépense;
- consulter les décisions du groupe;
- consulter les rapports disponibles.
Personne n'a besoin de tout utiliser.
Chacun peut commencer par ce qui lui est utile.
## Pour les serviteurs
Pour les serviteurs, l'application peut alléger la tâche.
Elle aide à garder les informations au même endroit :
- rencontre hebdomadaire;
- collecte;
- ventes;
- jetons et gâteaux;
- assemblée d'affaires;
- présences;
- procès-verbal;
- rapport du trésorier;
- décisions;
- postes à pourvoir.
L'idée n'est pas d'ajouter du travail, mais de rendre le travail plus clair et plus facile à transmettre.
## Pour la trésorerie
La trésorerie est souvent un point sensible, non parce que les gens manquent de bonne volonté, mais parce que l'argent demande de la clarté.
L'application aide à suivre :
- l'encaisse;
- le solde bancaire;
- les collectes;
- les dépenses;
- les contributions;
- les réserves;
- les montants engagés;
- le registre;
- l'état des résultats;
- le rapport mensuel.
Le but est simple : que le groupe puisse comprendre où il en est, sans dépendre seulement de la mémoire d'une personne.
## Pour les assemblées d'affaires
Une assemblée d'affaires peut être mieux préparée.
L'application rassemble :
- l'ordre du jour;
- le procès-verbal précédent;
- les présences;
- le rapport du trésorier;
- les propositions en cours;
- les décisions prises;
- les contributions;
- les postes à pourvoir.
Le rapport PDF permet d'imprimer ce qui est nécessaire. Si la technologie fait défaut pendant la réunion, le groupe peut continuer avec le document papier.
## Respect de l'esprit AA
L'application a été pensée pour rester au service du groupe.
Elle favorise :
- la mémoire collective;
- la transparence;
- la rotation;
- la responsabilité;
- la simplicité;
- l'autonomie du groupe.
Elle ne cherche pas à faire entrer le groupe dans un moule. Elle doit plutôt s'adapter aux pratiques du groupe, tant que ces pratiques restent cohérentes et compréhensibles.
## Confidentialité et prudence
L'application contient des informations du groupe. Elle doit donc être utilisée avec prudence.
Quelques habitudes simples sont importantes :
- utiliser son propre compte;
- ne pas partager son PIN;
- se déconnecter sur un appareil partagé;
- éviter d'inscrire des informations inutilement personnelles;
- respecter l'anonymat;
- limiter les accès selon les responsabilités réelles.
Le principe reste le même : assez d'information pour bien servir le groupe, pas plus que nécessaire.
## Comment commencer
Il n'est pas nécessaire que tout le monde maîtrise tout dès le premier jour.
Une approche simple serait :
1. Les membres se connectent et consultent l'accueil.
2. Les serviteurs utilisent les modules liés à leur poste.
3. Le trésorier utilise la trésorerie.
4. Le secrétaire utilise les assemblées.
5. Le groupe ajuste les permissions et les habitudes au fil de l'usage.
L'outil peut grandir dans le groupe tranquillement, par l'utilité qu'il démontre.
## Ce que nous demandons aux membres
Nous ne demandons pas aux membres d'adopter une nouveauté par enthousiasme.
Nous demandons seulement de l'essayer avec ouverture.
Si l'application aide, elle trouvera naturellement sa place.
Si quelque chose complique le service au lieu de le simplifier, il faudra le corriger.
L'application doit rester un outil de service.
## Conclusion
Dans AA, nous avons appris que les choses les plus utiles sont souvent les plus simples.
Cette application ne prétend pas être importante. Le groupe est important. Les membres sont importants. Le message est important. Le service est important.
Si cet outil peut aider le groupe à mieux se souvenir, mieux se préparer, mieux rendre compte et mieux transmettre les responsabilités, alors il aura rempli son rôle.
Nous pourrons ensuite laisser l'expérience parler d'elle-même.

View file

@ -1,417 +1,655 @@
# Guide utilisateur
# Manuel de l'utilisateur
Application du Groupe Méditation pour les membres, les serviteurs et l'exécutif du groupe.
Application du Groupe Méditation, pour les membres AA, les serviteurs et les membres de l'exécutif.
Ce guide explique quoi faire dans l'application, sans détails techniques.
Ce manuel explique les processus de l'application en langage d'usage. Il ne remplace pas les traditions, les procédures du groupe ou la conscience de groupe; il décrit comment l'application aide à les appliquer.
## 1. Accéder à l'application
## 1. Principes généraux
L'application sert à garder ensemble les informations courantes du groupe :
- les rencontres hebdomadaires;
- les assemblées d'affaires;
- les propositions, décisions, nominations et contributions;
- les collectes, dépenses, réserves et rapports de trésorerie;
- les membres, postes, mandats et permissions;
- la littérature, les jetons, les événements et les rapports.
Chaque membre voit les sections auxquelles il a accès selon ses postes actifs. Un membre peut consulter plusieurs informations du groupe. Les actions qui modifient la vie du groupe ou les finances sont réservées aux membres autorisés, généralement les postes exécutifs.
Le compte `Sysadmin` sert à l'administration initiale et aux interventions exceptionnelles. Il a tous les accès.
## 2. Connexion et profil
### Se connecter
1. Ouvrir l'application dans le navigateur.
2. Choisir le groupe si plusieurs groupes sont affichés.
3. Entrer son courriel.
4. Entrer son PIN à 4 chiffres.
1. Ouvrir l'application.
2. Choisir le groupe si l'application en présente plusieurs.
3. Saisir son identifiant ou courriel.
4. Saisir son PIN à 4 chiffres.
5. Cocher `Se souvenir de moi` seulement sur un appareil personnel.
6. Appuyer sur `Connexion`.
Si `Se souvenir de moi` n'est pas coché, la session reste limitée à l'onglet ou à la session du navigateur.
### Créer son compte
Un nouveau membre doit avoir un code d'invitation.
1. Sur l'écran de connexion, choisir `J'ai un code d'invitation`.
2. Entrer le code reçu.
3. Entrer les informations demandées.
4. Choisir un PIN à 4 chiffres.
5. Valider l'inscription.
Le code d'invitation ne peut être utilisé qu'une seule fois et expire automatiquement.
Si la connexion échoue, vérifier l'identifiant, le PIN et le groupe sélectionné. En navigation privée, la session ne réutilise pas les anciens caches ou anciennes données du navigateur.
### Se déconnecter
Depuis l'accueil, appuyer sur `Déconnexion`.
Le bouton `Déconnexion` est disponible dans l'entête des pages. Sur un appareil partagé, il faut toujours se déconnecter.
## 2. Page d'accueil
### Gérer son profil
L'accueil affiche les modules disponibles pour le membre connecté.
Les modules sont regroupés en trois sections :
- `Mes tâches` : saisies et actions liées au poste du membre.
- `Consultation` : modules accessibles à tous les membres.
- `Gestion` : modules réservés à l'exécutif ou aux postes autorisés.
Les accès dépendent des postes actifs du membre. Ils peuvent être ajustés dans `Gestion des postes`.
## 3. Modules de consultation
### Rapports PDF
Permet de consulter ou produire les rapports du groupe :
- finances;
- procès-verbaux;
- résolutions;
- rapports disponibles selon les données enregistrées.
### Événements
Affiche les événements du groupe et du district :
- anniversaires de sobriété;
- événements créés par l'exécutif;
- résolutions adoptées.
Les événements passés et futurs sont affichés. Les filtres peuvent être combinés : anniversaires, événements, résolutions.
Les membres de l'exécutif peuvent créer un événement.
### Littérature
Permet de consulter l'inventaire de littérature :
- titres disponibles;
- prix;
- quantités;
- alertes de stock bas.
### Gouvernance
Permet de suivre les propositions non terminées, de soumettre une nouvelle proposition, de seconder une proposition non secondée, et de consulter l'historique des résolutions.
Une proposition n'a pas de type lors de sa soumission. Les membres de l'exécutif peuvent transformer une proposition secondée en résolution, ou rejeter une proposition en cours. L'application exige alors de remplir les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ».
### Mon profil
Permet de modifier :
Le module `Profil` permet de modifier ses informations personnelles :
- prénom;
- nom;
- téléphone;
- courriel;
- courriel ou identifiant;
- date d'abstinence;
- PIN.
Un courriel déjà utilisé dans le même groupe ne peut pas être repris.
Un membre peut aussi choisir les types de changements pour lesquels il souhaite être notifié, lorsque les notifications sont disponibles sur son appareil.
Une proposition suit normalement ce cycle :
### Notifications
1. proposée;
2. secondée;
3. transformée en résolution par l'exécutif.
Les notifications push sont optionnelles. Le membre choisit les catégories de changements qui l'intéressent depuis son profil. Les notifications dépendent aussi du navigateur, du téléphone et de l'autorisation donnée par l'utilisateur.
Une proposition adoptée devient une résolution numérotée.
## 3. Navigation
### Postes à pourvoir
L'application est organisée par contextes.
Permet de consulter les postes vacants et de proposer sa candidature.
### Accueil
Si un membre est nominé à un poste, il peut accepter ou refuser la nomination.
La page `Accueil` regroupe ce qu'un membre consulte souvent :
### Trésorerie
- choses à faire maintenant;
- état général du groupe;
- événements;
- littérature;
- profil;
- modules de consultation.
La page d'accueil du module affiche l'état des résultats : revenus, charges et excédent ou déficit pour un mois donné.
Le bouton **Télécharger le PDF** génère le rapport mensuel à présenter au groupe.
### Rencontres
L'écran affiche aussi la position courante de trésorerie :
La page `Rencontres` sert aux activités de la rencontre hebdomadaire. Les rencontres sont présentées sous forme de tuiles. Une tuile fermée montre l'essentiel : date, heure, sujet et animation. En l'ouvrant, les boutons liés à cette rencontre apparaissent.
- solde bancaire;
- encaisse;
- montant disponible;
- montants engagés;
- réserves;
- dernières collectes et dépenses.
### Assemblées
Les membres peuvent consulter l'état des résultats et le registre. Les boutons qui modifient les données de trésorerie sont réservés aux postes exécutifs.
La page `Assemblées` sert aux assemblées d'affaires. Les assemblées sont aussi présentées sous forme de tuiles. Chaque assemblée regroupe les présences, l'ordre du jour, le rapport du trésorier, les décisions, les contributions, les postes à pourvoir et le rapport PDF.
## 4. Modules de saisie
### Gestion
### Rencontre hebdomadaire
La page `Gestion` regroupe les modules d'administration :
Formulaire regroupé pour les données de la réunion régulière : thème, annonces, collecte, jeton et vente de littérature. Les sections non remplies sont ignorées.
- gestion des membres;
- gestion des postes;
- gestion des mandats;
- paramètres du groupe;
- trésorerie;
- inventaire;
- journal.
### Collecte 7e Tradition
Les boutons visibles dépendent des permissions du membre.
Ce module est réservé aux postes exécutifs.
## 4. Rencontres hebdomadaires
Permet de saisir une collecte.
### Planifier une rencontre
Règles importantes :
Un membre de l'exécutif peut planifier une rencontre.
- la collecte doit être associée à une date de réunion;
- il ne peut pas y avoir deux collectes pour la même date;
- un autre membre doit confirmer le montant;
- le trésorier doit accuser réception si la collecte n'est pas déjà reçue automatiquement.
1. Aller à `Rencontres`.
2. Appuyer sur `Planifier une rencontre`.
3. Choisir une date permise.
4. Renseigner le sujet, l'animateur et les notes utiles.
5. Créer la rencontre.
Le module offre aussi une liste des collectes existantes.
Les dates offertes respectent le jour de réunion configuré dans les paramètres du groupe. Les dates sont présentées de manière à éviter de choisir accidentellement une mauvaise année.
### Dépense
### Modifier une rencontre
Permet à un membre de soumettre une dépense en son nom. Un membre de l'exécutif peut aussi soumettre une dépense au nom d'un autre membre.
Un membre de l'exécutif peut ouvrir une tuile de rencontre et utiliser l'action de modification pour corriger les informations de la rencontre.
La date de la dépense est la date de soumission.
### Saisir la collecte
La dépense n'est pas considérée comme payée tant que le trésorier ne l'a pas confirmée.
Chaque rencontre peut avoir une seule collecte.
Le trésorier choisit la méthode :
1. Ouvrir la tuile de la rencontre.
2. Appuyer sur `Collecte`.
3. Saisir le montant.
4. Indiquer qui détient l'argent.
5. Enregistrer.
- `COLLECTE` : payé depuis l'encaisse;
- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît.
La collecte suit ensuite son processus de confirmation et de réception par le trésorier. Lorsque le trésorier accuse réception, la transaction utilise la date de la collecte.
Une dépense soumise peut aussi être rejetée. Dans ce cas, elle est supprimée.
### Saisir une vente
## 5. Trésorerie
Les ventes de littérature et les ventes de jetons sont saisies depuis la rencontre.
Module réservé au trésorier ou aux postes autorisés.
1. Ouvrir la tuile de la rencontre.
2. Appuyer sur `Ventes`.
3. Choisir le type de vente.
4. Renseigner l'article, la quantité, le montant et le membre qui détient l'argent.
5. Enregistrer.
### Soldes
L'argent d'une vente est traité comme l'argent d'une collecte : il doit être remis au trésorier avant d'entrer dans l'encaisse.
Affiche :
### Jetons et gâteaux
- solde en banque;
- encaisse;
- montant disponible;
- réserves;
- engagements non débités;
- argent encore en circulation.
Les remises de jetons et les gâteaux servent aux statistiques et à la mémoire du groupe. Elles ne retirent pas automatiquement de jetons de l'inventaire. Seules les ventes de jetons affectent l'inventaire.
### Réceptions
### Rapport PDF de rencontre
Permet de traiter :
Chaque tuile de rencontre offre un bouton `Rapport PDF`. Le rapport est destiné à être imprimé au format lettre. Il rassemble les informations associées à la rencontre.
- collectes à recevoir;
- ventes de littérature remises au trésorier;
- contributions à confirmer comme débitées.
## 5. Assemblées d'affaires
### Registre
### Planifier une assemblée
Affiche les transactions financières.
Un membre de l'exécutif peut planifier une assemblée d'affaires.
On peut filtrer par :
1. Aller à `Assemblées`.
2. Appuyer sur `Planifier une assemblée`.
3. Choisir une date permise.
4. Ajouter une note si nécessaire.
5. Créer l'assemblée.
- mois;
- année;
- type de transaction.
Les dates offertes respectent les paramètres du groupe : jour de réunion et rang de l'assemblée mensuelle, par exemple première, deuxième, troisième, quatrième ou dernière réunion du mois.
Types disponibles :
### Comprendre la tuile d'assemblée
- collectes;
- ventes de littérature;
- dépenses;
- contributions;
- dépôts bancaires;
- retraits bancaires.
Une tuile d'assemblée contient le résumé de l'assemblée. En l'ouvrant, les sections de travail apparaissent :
### Réserves et opérations
- `Invitation`;
- `PV précédent`;
- `Présences`;
- `Rapport trésorier`;
- `Donner au suivant`;
- `Postes à pourvoir`;
- `Décisions`.
Dans l'onglet **Opérations**, il est possible de créer, modifier et supprimer les réserves.
Le bouton `Rapport PDF` produit le procès-verbal et le rapport imprimable de l'assemblée. Le rapport PDF est le document officiel à consulter ou imprimer.
Règle importante : le total des réserves ne peut jamais dépasser le solde en banque.
### Invitation à l'assemblée
Permet d'enregistrer :
L'application peut préparer un texte d'invitation à copier-coller. Ce texte contient un lien invité donnant accès à la page de l'assemblée en lecture seule.
- un dépôt provenant de l'encaisse;
- un don déposé directement à la banque;
- un retrait : transfert de la banque vers l'encaisse;
- un virement entre réserves.
Le lien invité :
Un dépôt provenant de l'encaisse ne peut pas dépasser l'encaisse disponible. Un don augmente directement le solde bancaire et apparaît dans les revenus de l'état des résultats.
## 6. Envoi contributions
Module réservé au trésorier.
Permet d'enregistrer une contribution vers :
- district;
- région;
- BSG;
- intergroupe.
Le module demande :
- destination;
- montant;
- méthode;
- proposeur;
- secondeur.
Lors de l'enregistrement, l'application crée une résolution de type `contribution`.
La contribution ne modifie pas les soldes immédiatement. Elle doit être confirmée comme débitée dans `Trésorerie` pour que le solde bancaire soit mis à jour.
## 7. Procès-verbal, présences et rapport RSG
### Assemblée d'affaires
Formulaire regroupé pour créer ou sélectionner une assemblée, noter les présences et rédiger le procès-verbal.
### Rédiger PV
Permet de préparer un procès-verbal de réunion d'affaires.
Les propositions secondées et les décisions peuvent alimenter le contenu du PV.
- ne demande pas d'authentification;
- est contrôlé par une période de validité;
- ne montre pas de lien BBB permanent;
- sert à consulter l'ordre du jour ou les informations de l'assemblée.
### Présences
Permet de noter les membres présents à une réunion d'affaires.
Les présences d'assemblée sont saisies dans la section `Présences`.
### Rapport RSG
1. Ouvrir la tuile de l'assemblée.
2. Appuyer sur `Présences`.
3. Cocher les membres présents.
4. Enregistrer.
Permet au RSG ou au RSG substitut de saisir un rapport destiné au groupe.
Une nouvelle saisie remplace la précédente pour cette assemblée.
## 8. Littérature et jetons
### Procès-verbal précédent
### Vente littérature
Le procès-verbal précédent peut être consulté depuis l'assemblée courante. S'il n'est pas encore adopté, un exécutif peut le corriger puis l'adopter.
Permet d'enregistrer une vente.
L'adoption demande :
Quand une vente est enregistrée :
- le stock est ajusté;
- le montant devient à remettre au trésorier;
- le trésorier confirme ensuite la réception dans `Trésorerie`.
### Ajuster inventaire
Permet au responsable de la littérature d'ajuster les quantités ou d'ajouter des éléments.
### Remise jeton
Permet d'enregistrer une remise de jeton.
### Commander jetons
Permet de préparer ou suivre les commandes de jetons.
## 9. Gestion des membres
Module réservé à l'exécutif ou aux postes autorisés.
Permet de :
- voir les membres actifs;
- consulter une fiche membre;
- générer un code d'invitation;
- copier ou partager le code;
- désactiver un membre;
- appliquer le droit à l'oubli.
### Désactiver un membre
Le membre ne peut plus se connecter, mais son historique demeure.
### Droit à l'oubli
Les données personnelles du membre sont anonymisées. Cette action est irréversible.
## 10. Gestion des mandats
Regroupe les actions liées aux mandats :
- affecter un poste;
- terminer un mandat;
- planifier les rotations.
### Affecter un poste
Une affectation demande :
- le membre;
- le poste;
- la date de début;
- un proposeur;
- un secondeur.
- un secondeur;
- le contenu final corrigé au besoin.
Une affectation crée automatiquement une résolution adoptée de type `nomination`.
L'adoption se fait en une seule étape avec le bouton `Adopter`.
### Terminer un mandat
### Rapport du trésorier
Permet de fermer un mandat actif.
Avant de décider les contributions, le groupe doit adopter le rapport du trésorier.
La fin d'un mandat n'est pas automatique. Elle doit être enregistrée manuellement.
1. Ouvrir la tuile de l'assemblée.
2. Appuyer sur `Rapport trésorier`.
3. Choisir un proposeur.
4. Choisir un secondeur.
5. Ajouter une correction ou une note si nécessaire.
6. Appuyer sur `Adopter le rapport`.
## 11. Gestion des postes
Une fois adopté, le rapport est cristallisé : il devient l'historique consultable et imprimable de la période concernée.
Permet de :
### Donner au suivant
- créer des postes;
- abolir des postes;
- gérer les candidatures;
- contrôler les accès aux modules selon les postes.
Les contributions se décident pendant l'assemblée d'affaires.
Les accès ne sont pas accordés directement aux personnes : ils passent par les postes.
1. Le rapport du trésorier doit d'abord être adopté.
2. Ouvrir la section `Donner au suivant`.
3. Choisir le destinataire.
4. Saisir le montant.
5. Choisir la méthode, par exemple chèque ou virement.
6. Choisir le proposeur et le secondeur.
7. Créer l'envoi.
## 12. Paramètres du groupe
L'application crée alors une décision de type `Contribution`. La contribution devient un engagement financier. Elle ne modifie pas le solde bancaire tant que le trésorier ne confirme pas le débit dans la trésorerie.
Permet de modifier les informations générales :
Le destinataire `District 87-16` existe par défaut et ne peut pas être supprimé.
### Postes à pourvoir pendant l'assemblée
La section `Postes à pourvoir` affiche les postes vacants.
Pour un poste vacant, un membre peut :
- postuler lui-même;
- proposer un autre membre, si l'interface lui en donne le droit.
Une nomination exige une décision du groupe. Elle demande un proposeur et un secondeur. Une nomination adoptée reçoit un numéro de type `NOM`.
### Décisions pendant l'assemblée
La section `Décisions` permet de travailler les propositions.
Un membre peut soumettre une proposition avec un objet et des détails. Une proposition n'a pas de type au départ.
Le cycle normal est :
1. proposition soumise;
2. proposition secondée;
3. adoption ou rejet par l'exécutif selon la décision du groupe.
Pour adopter une proposition comme résolution, l'exécutif doit renseigner :
- `ÉTANT DONNÉ QUE`;
- `LE GROUPE A DÉCIDÉ DE`.
Pour rejeter une proposition, l'exécutif doit aussi renseigner `ÉTANT DONNÉ QUE`, afin de conserver la raison historique du rejet.
Une résolution adoptée reçoit un numéro de type `RES`.
## 6. Gouvernance et décisions
Le module `Gouvernance` présente les décisions du groupe.
### Soumettre une proposition
1. Aller dans `Gouvernance` ou dans la section `Décisions` d'une assemblée.
2. Saisir l'objet de la proposition.
3. Ajouter les détails nécessaires.
4. Soumettre.
L'objet est obligatoire. Les détails servent à comprendre l'intention.
### Seconder une proposition
Un membre peut appuyer une proposition avec le bouton `Je seconde`. Un exécutif peut aussi seconder pour un autre membre depuis une liste déroulante.
Un membre ne doit normalement pas seconder sa propre proposition. Le compte `Sysadmin` peut le faire pour des interventions administratives ou de démonstration.
### Transformer en décision
Une proposition secondée peut devenir une décision du groupe. Les types de décisions sont :
- `Résolution`, préfixe `RES`;
- `Nomination`, préfixe `NOM`;
- `Contribution`, préfixe `DON`.
Les nominations et contributions sont des décisions du groupe avec leurs propres processus.
### Historique
L'historique des résolutions permet de consulter les décisions adoptées et leurs détails. Les rapports PDF de résolutions présentent les décisions prises en groupe.
## 7. Trésorerie
Le module `Trésorerie` est consultable par les membres selon les permissions. Les actions de modification sont réservées au trésorier ou aux exécutifs.
### Page d'accueil de la trésorerie
La page d'accueil présente :
- positions de trésorerie;
- réserves;
- éléments à traiter.
Les positions de trésorerie incluent :
- solde bancaire;
- encaisse;
- disponible;
- engagements;
- réserves.
Le montant de l'encaisse est considéré disponible. Le solde disponible n'est pas une réserve : il correspond au solde bancaire moins les réserves et engagements.
### Résultats
La page `Résultats` présente l'état des résultats pour un mois choisi :
- revenus;
- charges;
- excédent ou déficit.
Le bouton `Rapport PDF` produit le rapport mensuel. Le rapport PDF utilise l'état des résultats du mois précédent et les positions de trésorerie en date du dernier jour du mois précédent.
### Registre
Le registre affiche les transactions, les plus récentes d'abord. Il peut être filtré par mois, année et type de transaction.
Le registre permet de suivre :
- collectes reçues;
- ventes reçues;
- dépenses payées;
- contributions débitées;
- dépôts;
- retraits;
- virements de réserves.
### Éléments à traiter
La section `À traiter` regroupe les mouvements qui attendent une action.
On peut y trouver :
- dépenses soumises;
- dépenses bancaires en attente de débit;
- contributions en attente de débit;
- collectes à recevoir;
- ventes à recevoir.
Tous les membres autorisés peuvent voir ces éléments. Seuls les exécutifs ou le trésorier peuvent confirmer les mouvements.
### Dépenses
Un membre peut soumettre une dépense en son nom.
Un exécutif peut soumettre une dépense au nom d'un autre membre.
La date normale est la date de soumission. Le compte `Sysadmin` peut saisir une date antérieure pour inscrire des informations à posteriori.
Le processus est :
1. le membre soumet la dépense;
2. le trésorier ou l'exécutif la traite;
3. si elle est payée en encaisse, l'encaisse est ajustée immédiatement;
4. si elle est payée par chèque ou virement, elle devient un engagement;
5. le débit bancaire est confirmé plus tard quand il apparaît;
6. la dépense peut être rejetée tant qu'elle n'est pas traitée.
Une dépense bancaire ne peut pas être acceptée si le solde disponible ne le permet pas.
### Collectes et ventes reçues
Quand le trésorier reçoit l'argent d'une collecte ou d'une vente, il accuse réception. L'argent entre alors dans l'encaisse.
Pour une collecte, la date de transaction est la date de la collecte.
### Dépôts et dons
Un dépôt peut provenir de deux sources :
- l'encaisse;
- un don direct au compte bancaire.
Un dépôt depuis l'encaisse déplace l'argent de l'encaisse vers la banque. Un don direct augmente la banque sans diminuer l'encaisse.
### Retraits
Un retrait déplace de l'argent de la banque vers l'encaisse. Il ne doit pas rendre les réserves impossibles à couvrir.
### Réserves
Une réserve représente une partie du solde bancaire affectée à un usage précis.
Règles importantes :
- une réserve se crée à zéro;
- son montant ne se modifie pas directement;
- les changements passent par un virement;
- une réserve ne peut être supprimée que si son solde est zéro;
- le total des réserves ne doit pas dépasser le solde bancaire.
### Virements entre réserves
La fonction `Virement` permet de déplacer un montant :
- du disponible vers une réserve;
- d'une réserve vers le disponible;
- d'une réserve vers une autre réserve.
Ces mouvements sont virtuels, mais ils apparaissent au registre pour conserver l'historique.
## 8. Postes, mandats et permissions
### Postes à pourvoir
Le module `Postes à pourvoir` affiche les postes disponibles sous forme de tuiles.
En ouvrant un poste, le membre peut voir :
- description;
- candidats;
- bouton `Postuler`;
- bouton `Proposer`.
`Postuler` sert à proposer sa propre candidature. `Proposer` sert à proposer un autre membre.
### Gestion des postes
La gestion des postes permet aux exécutifs de définir la structure de service du groupe.
Pour chaque poste, on peut gérer :
- nom;
- district;
- région;
- adresse;
- catégorie;
- description;
- attentes;
- permissions;
- abolition ou réactivation.
Le bouton `Permissions` permet d'associer ou dissocier les modules accessibles à un poste. Les permissions d'un membre viennent de ses postes actifs.
### Gestion des mandats
La gestion des mandats sert à suivre les affectations :
- qui occupe quel poste;
- depuis quelle date;
- quel mandat est terminé;
- quelles rotations sont prévues.
Terminer un mandat conserve l'historique. Une nouvelle affectation peut ensuite être créée.
## 9. Membres
### Gestion des membres
Les exécutifs peuvent gérer les membres :
- consulter les membres actifs;
- créer un compte pour un membre;
- générer des invitations;
- désactiver un membre;
- corriger certaines informations;
- utiliser les actions administratives permises.
Le compte `Sysadmin` est protégé contre la suppression.
### Invitations de membres
Une invitation permet à un nouveau membre de créer son compte. Le code d'invitation expire et ne peut servir qu'une seule fois.
### Impersonification
Le compte `Sysadmin` peut impersonifier un membre pour diagnostiquer, corriger ou démontrer une situation. Les actions restent journalisées avec l'information d'impersonification.
## 10. Littérature et jetons
### Consulter la littérature
Le module `Littérature` affiche le catalogue :
- titre;
- catégorie;
- prix;
- quantité;
- alertes de stock bas.
### Gérer l'inventaire
Le responsable de la littérature ou l'exécutif peut :
- ajouter un titre;
- modifier un titre;
- ajuster les quantités;
- suivre les stocks.
L'inventaire initial peut être chargé depuis le fichier de prix prévu au déploiement.
### Ventes de littérature
Les ventes sont saisies lors d'une rencontre. Une vente diminue l'inventaire et crée un montant à remettre au trésorier. Lorsque le trésorier reçoit l'argent, l'encaisse est mise à jour.
### Jetons
Les remises de jetons servent aux statistiques. Les ventes de jetons affectent l'inventaire. Les commandes suggérées s'appuient sur l'utilisation récente.
## 11. Événements et calendrier
Le module `Événements` affiche :
- anniversaires de sobriété;
- événements créés par le groupe;
- événements passés et futurs.
Les filtres peuvent être combinés. Les exécutifs peuvent créer ou supprimer des événements.
Le calendrier permet aussi de consulter certains éléments liés aux serviteurs et au district, selon les modules disponibles.
## 12. Rapports PDF
Les rapports PDF servent à imprimer ou archiver les informations du groupe.
Les rapports importants sont :
- rapport de rencontre;
- rapport d'assemblée d'affaires;
- rapport de trésorerie;
- registre des résolutions;
- rapports disponibles selon les modules actifs.
Le rapport d'assemblée d'affaires tient lieu de procès-verbal imprimable. Il reprend les intrants de l'assemblée.
Le rapport de trésorerie présente les résultats du mois précédent et les positions de trésorerie à la fin de ce mois.
## 13. Journal
Le journal conserve les actions modificatrices faites dans l'application.
Il aide à répondre aux questions :
- qui a fait quoi;
- quand l'action a été faite;
- depuis quel chemin de l'application;
- avec quel résultat technique.
Le journal présente deux niveaux : l'historique métier, qui explique les changements importants en langage courant, et le journal technique, qui montre les appels API enregistrés.
Les consultations simples ne sont pas journalisées comme actions modificatrices. Le journal est réservé aux exécutifs.
## 14. Modifications simultanées
Il peut arriver que deux membres exécutifs travaillent sur la même information en même temps.
Pour éviter qu'une personne écrase sans le savoir le travail d'une autre, l'application vérifie la version de certaines données sensibles au moment de l'enregistrement :
- décisions et propositions;
- procès-verbaux;
- collectes;
- dépenses;
- contributions à débiter;
- réserves;
- destinataires de contributions;
- rencontres;
- inventaire de littérature.
Si une donnée a changé pendant qu'elle était ouverte à l'écran, l'application refuse l'enregistrement et demande de recharger les données. Dans ce cas, il faut revenir à la liste ou recharger la page, relire l'information à jour, puis refaire l'action si elle est encore pertinente.
Le journal permet ensuite de voir qui a effectué les changements.
## 15. Paramètres du groupe
Les paramètres du groupe définissent notamment :
- nom du groupe;
- coordonnées;
- jour de réunion;
- heure;
- format.
- heure de réunion;
- rang de l'assemblée d'affaires mensuelle.
## 13. Bonnes pratiques
Ces paramètres influencent les dates offertes dans les rencontres, assemblées et collectes. Une mauvaise configuration peut rendre certaines dates indisponibles.
- Utiliser son propre compte, jamais celui d'un autre membre.
- Ne pas partager son PIN.
## 16. Données de démonstration et remise à zéro
Le compte `Sysadmin` dispose d'actions spéciales pour l'administration :
- remettre la base de données à zéro;
- initialiser une base fictive de démonstration.
Ces actions effacent les données courantes. Elles sont prévues pour les tests, les démonstrations ou les environnements non productifs. Elles ne doivent pas être utilisées sur une base en production sans décision explicite.
## 17. Bonnes pratiques
- Utiliser l'application sur un appareil personnel lorsque possible.
- Se déconnecter sur un appareil partagé.
- Saisir les collectes et dépenses dès que possible.
- Confirmer les débits seulement lorsque l'argent est réellement sorti du compte.
- Enregistrer les dépôts quand l'encaisse est réellement déposée à la banque.
- Garder les réserves à jour.
- Utiliser les propositions et résolutions pour conserver une trace claire des décisions.
- Vérifier la date avant de saisir une rencontre, une assemblée ou une collecte.
- Utiliser les rapports PDF pour les documents imprimés.
- Ne pas contourner la conscience de groupe : les décisions importantes doivent passer par proposition, secondeur et adoption.
- Saisir les remises d'argent dès que possible afin que l'encaisse et les rapports restent fiables.
- Garder les postes et permissions à jour pour que chaque membre voie les bons modules.
- Consulter le journal en cas de doute sur une modification.
## 14. Problèmes fréquents
## 18. Résumé des principaux processus
### Je ne peux pas me connecter
### Proposition vers résolution
Vérifier :
1. Un membre soumet une proposition.
2. Un membre la seconde.
3. L'exécutif renseigne les sections décisionnelles.
4. Le groupe adopte ou rejette.
5. L'application conserve l'historique.
- le bon groupe est sélectionné;
- le courriel est exact;
- le PIN contient 4 chiffres;
- le compte n'a pas été désactivé.
### Nomination
### Je ne vois pas un module
1. Un poste est disponible.
2. Un membre postule ou est proposé.
3. Une nomination est décidée par le groupe.
4. L'application crée la décision `NOM`.
5. Le mandat est actif ou refusé selon le traitement.
Les modules dépendent des postes et des accès configurés.
### Contribution
Demander à l'exécutif de vérifier :
1. Le rapport du trésorier est adopté.
2. Le groupe décide une contribution.
3. L'application crée une décision `DON`.
4. La contribution devient un engagement.
5. Le trésorier confirme le débit bancaire.
- vos mandats actifs;
- les accès du poste dans `Gestion des postes`.
### Collecte
### Le dépôt est refusé
1. La collecte est saisie pour une rencontre.
2. Le montant est confirmé.
3. Le trésorier reçoit l'argent.
4. L'encaisse est mise à jour.
Si le dépôt provient de l'encaisse, son montant dépasse probablement l'encaisse disponible.
### Dépense
### Une réserve est refusée
1. Un membre soumet une dépense.
2. Le trésorier ou l'exécutif la traite.
3. Elle est payée par encaisse ou engagée par banque.
4. Le débit bancaire est confirmé si nécessaire.
5. Le registre conserve la transaction.
Le total des réserves dépasserait le solde en banque.
### Réserve
### Une contribution ou dépense ne change pas le solde
Elle doit être confirmée comme débitée dans `Trésorerie`.
### Une collecte ne peut pas être saisie
Causes possibles :
- date hors jour de réunion;
- collecte déjà enregistrée pour cette date;
- date future.
## 15. Règles importantes de l'application
- Les données sont séparées par groupe.
- Un membre se connecte avec son courriel et son PIN.
- Les actions financières sensibles sont réservées au trésorier ou à l'exécutif.
- Les résolutions adoptées sont numérotées automatiquement.
- Les mandats ne se terminent pas seuls.
- Les accès aux modules sont contrôlés par les postes.
1. Une réserve est créée à zéro.
2. Un virement affecte de l'argent à la réserve.
3. Les mouvements sont historisés.
4. La réserve peut être supprimée seulement si son solde revient à zéro.

View file

@ -30,7 +30,8 @@ export default function AjusterInventaire() {
const ajusterStock = async (itemId, quantite) => {
setError('');
try {
await api.patch(`/litterature/${itemId}/stock`, { quantite });
const item = items.find((i) => i.id === itemId);
await api.patch(`/litterature/${itemId}/stock`, { quantite }, { version: item?.modifie_le });
setSuccess('Stock ajusté ✓');
setTimeout(() => { setSuccess(''); charger(); }, 1000);
} catch (e) {

View file

@ -270,8 +270,9 @@ export default function AssembleeAffaires({ contexte = false }) {
const seconder = async (id, membreId = '') => {
setError('');
try {
const proposition = propositions.find((p) => p.id === id);
const query = membreId ? `?membre_id=${membreId}` : '';
await api.post(`/propositions/${id}/seconder${query}`);
await api.post(`/propositions/${id}/seconder${query}`, null, { version: proposition?.modifie_le });
setMsg('Proposition secondée');
await charger();
} catch (e) { setError(e.message); }
@ -287,11 +288,12 @@ export default function AssembleeAffaires({ contexte = false }) {
}
setError('');
try {
const proposition = propositions.find((p) => p.id === id);
await api.patch(`/propositions/${id}/voter`, {
statut,
etant_donne_que: etantDonneQue,
groupe_a_decide_de: groupeADecideDe || null,
});
}, { version: proposition?.modifie_le });
setResolutionForm({ ...resolutionForm, [id]: {} });
setMsg(statut === 'adoptee' ? 'Décision créée' : 'Proposition rejetée');
await charger();
@ -309,7 +311,7 @@ export default function AssembleeAffaires({ contexte = false }) {
await api.patch(`/pv/${pvPrecedent.id}/adopter`, {
...adoption,
contenu: pvCorrection.trim(),
});
}, { version: pvPrecedent.modifie_le });
setMsg('PV précédent adopté');
await charger();
} catch (e) {

View file

@ -69,7 +69,8 @@ export default function Collecte() {
const confirmer = async (id) => {
setError('');
try {
await api.post(`/collectes/${id}/confirmer`);
const collecte = collectesToutes.find((c) => c.id === id) || collectesMois.find((c) => c.id === id);
await api.post(`/collectes/${id}/confirmer`, null, { version: collecte?.modifie_le });
flash('Montant confirmé ✓');
await charger();
} catch (e) { setError(e.message); }

View file

@ -74,8 +74,9 @@ export default function Decisions() {
const seconder = async (id, membreId = '') => {
setError('');
try {
const proposition = propositions.find((p) => p.id === id);
const query = membreId ? `?membre_id=${membreId}` : '';
await api.post(`/propositions/${id}/seconder${query}`);
await api.post(`/propositions/${id}/seconder${query}`, null, { version: proposition?.modifie_le });
flash('Proposition secondée');
await charger();
} catch (e) { setError(e.message); }
@ -99,11 +100,12 @@ export default function Decisions() {
}
try {
const proposition = propositions.find((p) => p.id === id);
await api.patch(`/propositions/${id}/voter`, {
statut: 'adoptee',
etant_donne_que: etantDonneQue,
groupe_a_decide_de: groupeADecideDe,
});
}, { version: proposition?.modifie_le });
setResolutionForm({ ...resolutionForm, [id]: { etant_donne_que: '', groupe_a_decide_de: '' } });
flash('Résolution créée');
await charger();
@ -119,10 +121,11 @@ export default function Decisions() {
return;
}
try {
const proposition = propositions.find((p) => p.id === id);
await api.patch(`/propositions/${id}/voter`, {
statut: 'rejetee',
etant_donne_que: etantDonneQue,
});
}, { version: proposition?.modifie_le });
flash('Proposition rejetée');
await charger();
} catch (e) { setError(e.message); }

View file

@ -5,12 +5,17 @@ import { api } from '../stores/api';
export default function JournalActions() {
const { setPage } = useStore();
const [actions, setActions] = useState([]);
const [historique, setHistorique] = useState([]);
const [tab, setTab] = useState('historique');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
api.get('/journal/actions?limite=100')
.then(setActions)
Promise.all([
api.get('/journal/historique?limite=100'),
api.get('/journal/actions?limite=100'),
])
.then(([hist, acts]) => { setHistorique(hist); setActions(acts); })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
@ -31,12 +36,40 @@ export default function JournalActions() {
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<button onClick={() => setTab('historique')} className={`p-3 rounded-xl text-sm font-bold ${tab === 'historique' ? 'bg-aa-blue text-white' : 'bg-white text-gray-600'}`}>
Historique
</button>
<button onClick={() => setTab('technique')} className={`p-3 rounded-xl text-sm font-bold ${tab === 'technique' ? 'bg-aa-blue text-white' : 'bg-white text-gray-600'}`}>
Technique
</button>
</div>
{loading && <p className="text-gray-500 text-center py-8">Chargement...</p>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-sm">{error}</div>}
{!loading && actions.length === 0 && (
{!loading && tab === 'historique' && historique.length === 0 && (
<p className="text-gray-500 text-center py-8">Aucun changement journalisé.</p>
)}
{actions.map((action) => (
{!loading && tab === 'technique' && actions.length === 0 && (
<p className="text-gray-500 text-center py-8">Aucune action technique journalisée.</p>
)}
{tab === 'historique' && historique.map((item) => (
<div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-bold text-gray-800">{item.raison}</p>
<p className="text-xs text-gray-400 mt-1">{formatDate(item.cree_le)}</p>
</div>
<span className="text-xs font-bold rounded-full bg-blue-50 text-blue-700 px-2 py-1">
{item.action}
</span>
</div>
<div className="mt-3 text-xs text-gray-500 space-y-1">
<p>Membre: {item.membre_prenom || item.membre_id || '—'}</p>
<p>Objet: {item.table_cible} · {item.objet_id}</p>
</div>
</div>
))}
{tab === 'technique' && actions.map((action) => (
<div key={action.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>

View file

@ -205,7 +205,7 @@ export default function ReunionHebdomadaire({ contexte = false }) {
theme: rapport.theme.trim() || 'Rencontre hebdomadaire',
annonces: rapport.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
note: rapport.note.trim() || null,
});
}, { version: rencontreOuverte.modifie_le });
flash('Rapport enregistré');
await charger();
} catch (e) { setError(e.message); }

View file

@ -111,10 +111,10 @@ export default function Tresorerie() {
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const accuserReception = async (id) => {
const accuserReception = async (id, version) => {
setError('');
try {
await api.post(`/collectes/${id}/reception`);
await api.post(`/collectes/${id}/reception`, null, { version });
flash('Réception confirmée ✓');
await charger();
} catch (e) { setError(e.message); }
@ -129,37 +129,37 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const confirmerContribution = async (id) => {
const confirmerContribution = async (id, version) => {
setError('');
try {
await api.post(`/contributions/${id}/confirmer-debit`);
await api.post(`/contributions/${id}/confirmer-debit`, null, { version });
flash('Contribution débitée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const confirmerDepense = async (id, methode) => {
const confirmerDepense = async (id, methode, version) => {
setError('');
try {
await api.post(`/depenses/${id}/confirmer?methode=${methode}`);
await api.post(`/depenses/${id}/confirmer?methode=${methode}`, null, { version });
flash('Dépense traitée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const rejeterDepense = async (id) => {
const rejeterDepense = async (id, version) => {
setError('');
try {
await api.delete(`/depenses/${id}`);
await api.delete(`/depenses/${id}`, { version });
flash('Dépense rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const confirmerDebitDepense = async (id) => {
const confirmerDebitDepense = async (id, version) => {
setError('');
try {
await api.post(`/depenses/${id}/confirmer-debit`);
await api.post(`/depenses/${id}/confirmer-debit`, null, { version });
flash('Débit confirmé ✓');
await charger();
} catch (e) { setError(e.message); }
@ -216,7 +216,8 @@ export default function Tresorerie() {
note: reserveNote.trim() || null,
};
if (reserveId) {
await api.patch(`/banque/reserves/${reserveId}`, payload);
const reserve = reserves.find((r) => r.id === reserveId);
await api.patch(`/banque/reserves/${reserveId}`, payload, { version: reserve?.modifie_le });
flash('Réserve modifiée ✓');
} else {
await api.post('/banque/reserves', { ...payload, montant: 0 });
@ -231,7 +232,8 @@ export default function Tresorerie() {
if (!window.confirm('Supprimer cette réserve ?')) return;
setError('');
try {
await api.delete(`/banque/reserves/${id}`);
const reserve = reserves.find((r) => r.id === id);
await api.delete(`/banque/reserves/${id}`, { version: reserve?.modifie_le });
flash('Réserve supprimée ✓');
if (reserveId === id) resetReserveForm();
await charger();
@ -266,7 +268,10 @@ export default function Tresorerie() {
if (!nom) { setError('Nom de destinataire requis'); return; }
setError('');
try {
if (destinataireId) await api.patch(`/contributions/destinataires/${destinataireId}`, { nom });
if (destinataireId) {
const destinataire = destinataires.find((d) => d.id === destinataireId);
await api.patch(`/contributions/destinataires/${destinataireId}`, { nom }, { version: destinataire?.modifie_le });
}
else await api.post('/contributions/destinataires', { nom });
resetDestinataireForm();
setDestinataires(await api.get('/contributions/destinataires'));
@ -278,7 +283,8 @@ export default function Tresorerie() {
if (!window.confirm('Supprimer ce destinataire ?')) return;
setError('');
try {
await api.delete(`/contributions/destinataires/${id}`);
const destinataire = destinataires.find((d) => d.id === id);
await api.delete(`/contributions/destinataires/${id}`, { version: destinataire?.modifie_le });
setDestinataires(await api.get('/contributions/destinataires'));
flash('Destinataire supprimé');
} catch (e) { setError(e.message); }
@ -553,7 +559,7 @@ export default function Tresorerie() {
montant={d.montant}
meta={`${d.date_depense} · ${d.qui_prenom} · ${d.methode}`}
action={estExecutif ? 'Confirmer le débit' : null}
onAction={() => confirmerDebitDepense(d.id)}
onAction={() => confirmerDebitDepense(d.id, d.modifie_le)}
/>
))}
</div>
@ -569,7 +575,7 @@ export default function Tresorerie() {
montant={c.montant}
meta={`${c.date_envoi} · ${c.methode} · Saisi par ${c.saisi_par_prenom || '—'}`}
action={estExecutif ? 'Confirmer le débit' : null}
onAction={() => confirmerContribution(c.id)}
onAction={() => confirmerContribution(c.id, c.modifie_le)}
/>
))}
</div>
@ -585,7 +591,7 @@ export default function Tresorerie() {
montant={c.montant}
meta={`Chez ${c.detenteur} · ${STATUT_COL[c.statut]?.label || c.statut}`}
action={estExecutif ? 'Accuser réception' : null}
onAction={() => accuserReception(c.id)}
onAction={() => accuserReception(c.id, c.modifie_le)}
/>
))}
</div>
@ -989,7 +995,7 @@ export default function Tresorerie() {
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
</div>
<button onClick={() => confirmerDebitDepense(d.id)}
<button onClick={() => confirmerDebitDepense(d.id, d.modifie_le)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Confirmer le débit
</button>
@ -1016,7 +1022,7 @@ export default function Tresorerie() {
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
</div>
<button onClick={() => confirmerContribution(c.id)}
<button onClick={() => confirmerContribution(c.id, c.modifie_le)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Confirmer le débit
</button>
@ -1043,7 +1049,7 @@ export default function Tresorerie() {
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
</div>
<button onClick={() => accuserReception(c.id)}
<button onClick={() => accuserReception(c.id, c.modifie_le)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Accuser réception
</button>
@ -1223,11 +1229,11 @@ function DepenseConfirmCard({ depense, soldeDisponible, onConfirm, onReject }) {
)}
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
<button onClick={() => onReject(depense.id)}
<button onClick={() => onReject(depense.id, depense.modifie_le)}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
<button onClick={() => peutTraiter && onConfirm(depense.id, methode)}
<button onClick={() => peutTraiter && onConfirm(depense.id, methode, depense.modifie_le)}
disabled={!peutTraiter}
className="py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 disabled:opacity-40 active:scale-[0.97] transition">
Traiter ({methode || '...'})

View file

@ -22,11 +22,14 @@ class ApiClient {
}
}
async request(method, path, body = null) {
async request(method, path, body = null, options = {}) {
const headers = { 'Content-Type': 'application/json' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (options.version) {
headers['X-Resource-Version'] = options.version;
}
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
@ -54,10 +57,10 @@ class ApiClient {
return res.json();
}
get(path) { return this.request('GET', path); }
post(path, body) { return this.request('POST', path, body); }
patch(path, body) { return this.request('PATCH', path, body); }
delete(path) { return this.request('DELETE', path); }
get(path, options) { return this.request('GET', path, null, options); }
post(path, body, options) { return this.request('POST', path, body, options); }
patch(path, body, options) { return this.request('PATCH', path, body, options); }
delete(path, options) { return this.request('DELETE', path, null, options); }
}
export const api = new ApiClient();