# CLAUDE.md — Gestion des bénévoles Application web de gestion des bénévoles pour un festival associatif (50–200 bénévoles, 4 jours montage/démontage inclus). Interface en français, code en anglais. Ce fichier est le contrat de travail. Il est tenu à jour à chaque étape. --- ## 1. Vocabulaire du domaine (glossaire FR ↔ EN) Le métier parle français, le code et la base parlent anglais. Toute entité, colonne, route et variable utilise la colonne « Code / base ». | Métier (brief) | Code / base | |------------------------------------|-----------------------------------------------| | Édition | `Edition` | | Bénévole | `Volunteer` | | Poste | `Position` | | Référent d'un poste | `Position.lead_account_id` → `Account` | | Créneau | `Shift` | | Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) | | Affectation | `Assignment` | | Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) | | Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) | | Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) | | Compte (utilisateur connecté) | `Account` | | Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) | | Lien magique | `MagicLinkToken` | Énumérations : | Métier | Code | |------------------------------------|-----------------------------------------------| | Origine d'affectation | `SOLVER` / `MANUAL` | | Verrouillée | `Assignment.locked: bool` | | Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` | | Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` | ### Distinction critique (ne jamais confondre, ni en base ni dans l'UI) - **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour. - **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar samedi 18 h – 22 h ». Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts. --- ## 2. Modèle de domaine Défini dans `backend/app/models/` (`enums.py`, `base.py`, `domain.py`). Clés primaires `UUID`. Champs d'audit `created_at` / `updated_at` sur toutes les tables sauf les tables d'association. - **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`, `retention_days` (durée de rétention des données nominatives en jours, `NULL` = pas de purge). - **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition (nombre et bornes libres). `code`, `label`, `start_time`, `end_time` (`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`, `code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des postes (pas les disponibilités, qui sont des fenêtres libres). CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour l'écriture). - **`Volunteer`** : `email` (unique **par édition**, nullable pour les bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance, taille de t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, `[]` = vider). - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La coordination gère la liste ; elle est présentée par jour. Les bénévoles y choisissent les artistes qu'ils veulent voir (`volunteer_artist`) — le solveur (section 4) évite de les affecter pendant ce passage. CRUD `GET/POST/PATCH/DELETE /editions/{id}/artists` (lecture : tout compte de l'édition ; écriture : COORDINATION). - Vœux de postes : `volunteer_position_wish` (« postes que le bénévole voudrait tenir »), distinct de `Assignment` (décision) et de `Position. lead_account_id` (référent). - **`Position`** : `name`, `location`, `category` (`PositionCategory`), `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at` (peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un `Position`, un `Shift` à 0/0 est généré par jour de l'édition et par `DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les manquants (après ajout d'un créneau type ou extension des dates). - **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time` (`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique (`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc : `GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD / LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout). `GET /editions/{id}/availability` liste celles de toute l'édition (COORD / LEAD) — pour la grille des besoins. - **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`, défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, `shift_id`). **Pas de statut** : l'affectation est valide dès sa création (aucune étape de confirmation) ; la retirer, c'est supprimer la ligne. - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, `volunteer_id` (nullable, unique). - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, `used_at`. Conventions techniques : - Multi-édition : toute entité métier porte un `edition_id`. - Énumérations stockées en `VARCHAR` + `CHECK` (`native_enum=False`), pas de type ENUM natif PostgreSQL. - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les `edition_id` et sur les liens vers `volunteer`, `shift`, `position`, `artist`, `account`. La suppression complète d'un bénévole (RGPD) efface donc ses `Availability`, `Assignment`, vœux (artistes, postes) et son `Account`. - Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur + `tzdata`). `app.models.local_now()` pour les horodatages. ### Rôles et accès (`app/auth/`) - **`COORDINATOR`** : accès complet, construit et publie les plannings. - **`LEAD`** (référent) : lecture sur toute l'édition, écriture limitée aux postes dont il est `lead_account_id` (et à leurs créneaux et affectations). - **`VOLUNTEER`** : lecture de sa seule fiche et de ses seules affectations ; aucune écriture. Matrice appliquée par les endpoints : | Ressource | COORDINATOR | LEAD | VOLUNTEER | |-----------|-------------|------|-----------| | `volunteers` liste/écriture | ✔ | liste seule | — | | `volunteers/{id}` lecture | ✔ | ✔ | soi-même | | `positions`/`shifts` lecture | ✔ | ✔ | — | | `positions`/`shifts` écriture | ✔ | ses postes | — | | `assignments` écriture | ✔ | ses postes | — | | `assignments` lecture | ✔ | ✔ | les siennes | | `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — | | `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | **Authentification — deux systèmes selon le compte**, une même session JWT HS256 (`Authorization: Bearer `, TTL `session_ttl_hours`) à l'arrivée. Le compte porte `password_hash` : défini ⇒ mot de passe, `NULL` ⇒ lien magique (`Account.auth_method` = `"password"` / `"magic_link"`). *Mot de passe* — comptes `COORDINATOR` : 1. `POST /editions/{id}/auth/login` `{email, password}` → JWT + `AccountRead`. Échec = `401 auth.invalid_credentials` (aucune divulgation). 2. `POST /editions/{id}/auth/change-password` `{current_password, new_password}` (compte courant). Hachage **bcrypt**, longueur mini `MIN_LENGTH` (10). *Lien magique* — comptes `LEAD` / `VOLUNTEER` (sans mot de passe) : 3. `POST /editions/{id}/auth/magic-link` `{email}` → `202` **constant**. Si un compte **sans mot de passe** correspond, un `MagicLinkToken` est créé (jeton aléatoire, **haché SHA-256** en base, TTL `magic_link_ttl_minutes`). 4. **Envoi d'email stubé** : le lien est écrit dans les logs (`app.auth`, INFO, `jeton=…` — identifiants seulement, aucune donnée nominative). 5. `POST /editions/{id}/auth/verify` `{token}` → JWT + `AccountRead`. Jeton à usage unique. `GET /auth/me` → compte courant. La coordination crée les comptes via `POST /editions/{id}/accounts` : un `COORDINATOR` **exige** un `password` (≥ 10) ; un `LEAD` / `VOLUNTEER` le **refuse** (lien magique). Un bénévole sans email n'a pas de `Account`. ### Emails (`app/email/`) Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒ les emails sont seulement journalisés (`app.email`, INFO, **sans destinataire ni corps** — RGPD). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, `PUBLIC_BASE_URL` (pour les liens). Ne jamais mettre de vraies valeurs dans `config.py` : elles vivent dans `.env` (non versionné). - Remise « best effort » : une erreur SMTP (connexion, auth, rejet) est journalisée avec le message du serveur mais ne fait pas échouer l'appel HTTP. - Le **port 465** implique le SSL (SMTPS) : `open_smtp` bascule automatiquement, même si `SMTP_SSL=false` / `SMTP_STARTTLS=true`. Port 587 = STARTTLS. - Beaucoup de fournisseurs (OVH…) exigent une `From` = boîte authentifiée ou alias validé. - Diagnostic : `docker compose run --rm backend uv run python -m app.email.check ` — affiche la config effective et la cause exacte d'un échec. - **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant `PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non nominatif) reste journalisé pour le dev. - **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning` (COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body` sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`, `{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email, recipients }`. --- ## 3. Moteur de validation des affectations — cœur du produit Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**, un cas passant + un cas déclenchant par règle. Retourne une liste de violations typées `BLOCKING` ou `WARNING`. Chaque violation est un **code** + des paramètres structurés, jamais une phrase (la traduction se fait via `locales/fr.json`). | Code | Sévérité | Règle | |---------------------------|------------|-------------------------------------------------------------| | `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. | | `OUTSIDE_AVAILABILITY` | `WARNING` | Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : `date`, `uncovered_minutes`). | | `DAILY_HOURS_EXCEEDED` | `WARNING` | > 8 h 00 d'affectations cumulées sur une même journée. | | `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. | Sémantique figée : - Toutes les affectations du bénévole comptent pour chevauchement / cumul / repos (une affectation = une décision, toujours active). - `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du bénévole (converties en instants, minuit géré) ; les minutes du créneau hors de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒ créneau entièrement à découvert. - « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça). - Cumul : `> 8 h 00` strict (8 h 00 pile passe). - Repos : `< 8 h 00` strict. - Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement. - Une violation `BLOCKING` empêche l'enregistrement, sans dérogation. - Une violation `WARNING` est retournée à l'appelant mais n'empêche rien (l'équipe connaît des exceptions légitimes). --- ## 4. Solveur de suggestions (OR-Tools CP-SAT) Module isolé `backend/app/solver/` (mêmes conventions que `validation/` : `models.py` types purs, `engine.py` fonction pure `solve()`, `loader.py` seul point de contact avec la base). **Ne place jamais personne** : il calcule, pour chaque créneau encore sous son effectif idéal, une liste ordonnée de bénévoles candidats. La coordination affecte elle-même dans l'écran habituel (grille des besoins) ; l'appli fait simplement remonter ces candidats en tête de la colonne des choix quand on ouvre le détail d'un poste. Aucune affectation n'est jamais persistée par cet endpoint. - `POST /editions/{id}/solver/suggestions` (COORDINATION ou référent) : recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie `{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered, volunteers_considered }`, `volunteer_ids` du meilleur au moins bon. - **Seule contrainte dure** : aucun chevauchement de créneaux pour un même bénévole (règle `SHIFT_OVERLAP`) — à la fois contre les affectations déjà décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est un plafond dur (le solveur ne sur-staffe jamais). - Tout le reste est un terme de l'objectif (maximisation), pas une contrainte — cohérent avec le moteur de validation, qui reste seul juge au moment où la coordination confirme une affectation : - un créneau encore sous son effectif **minimal** prime largement sur tout le reste (le solveur doit presque toujours proposer quelqu'un pour un trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un avertissement) ; - bonus si le poste fait partie des vœux du bénévole (`VolunteerPositionWish`) ; - malus proportionnel aux minutes hors des fenêtres de disponibilité déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà décidée serait `< 8 h 00` ; - malus si le créneau chevauche le passage d'un artiste que le bénévole veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui ferait manquer le passage ; - léger malus pour les bénévoles déjà bien chargés (répartition de la charge). - Simplification assumée : cumul de 8 h et repos ne sont vérifiés que contre les affectations **déjà décidées**, pas entre deux suggestions du même calcul — la validation finale à la confirmation reste seule juge de ce cas limite. **Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts` (`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de poste, disponibilité y compris conflit d'artiste, charge déjà affectée ; pas de cumul 8 h / repos, déjà couverts par les badges de validation). C'est ce qui classe la colonne des candidats dans le panneau d'affectation dès l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation jointe, pas juste un score local). **Colonnes de la grille des besoins** — une colonne représente un **créneau** distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux créneaux qui démarrent en même temps mais durent différemment ont chacun leur colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`). Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec défilement horizontal — jamais de colonnes étroites par défaut. --- ## 5. Périmètre ### Premier incrément (en cours) 1. Modèle de données complet + migrations Alembic. 2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone, commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping validé. Rapport ligne par ligne. Détection des doublons : upsert sur `email` ; sans email, détection sur (`last_name`, `first_name`) signalée sans upsert automatique. Mode simulation qui ne persiste rien. 3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`. 4. Moteur de validation (section 3). 5. Endpoint matrice d'une édition : `[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`. 6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours, quelques trous d'effectif, 2–3 avertissements actifs, programmation (artistes les jours de festival) et vœux des bénévoles (postes, artistes) pour que le solveur ait matière à arbitrer. 7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours. ### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte) SMS · constructeur de formulaire · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)* --- ## 6. Arborescence ``` . ├── CLAUDE.md ├── README.md ├── docker-compose.yml # dev : db + backend (hot reload) ├── .env.example ├── .github/workflows/deploy.yml # CI/CD : build → GHCR → SSH sur le VPS ├── deploy/ │ ├── docker-compose.prod.yml │ └── README.md # secrets requis, setup VPS ├── docker/postgres-init/ # création de la base de test au 1er boot ├── backend/ │ ├── pyproject.toml # géré par uv │ ├── Dockerfile │ ├── alembic.ini │ ├── app/ │ │ ├── main.py # instance FastAPI │ │ ├── config.py # settings (pydantic-settings) │ │ ├── db.py # engine + session │ │ ├── logging_config.py # RGPD : pas de PII dans les logs │ │ ├── api/ # routers + schemas Pydantic + deps │ │ │ # health, auth, accounts, editions, │ │ │ # day_parts, imports, artists, volunteers, │ │ │ # availability, positions, shifts, │ │ │ # assignments, solver, matrix, notifications │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── importing/ # import CSV/Excel (parser, text, service) │ │ ├── models/ # tables SQLModel │ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── i18n/ │ │ │ ├── __init__.py # t(key, **params) │ │ │ └── locales/fr.json │ │ └── migrations/ # Alembic (env.py, versions/) │ └── tests/ │ ├── conftest.py # vraie base de test PostgreSQL │ └── ... └── frontend/ # React + Vite + TS + Tailwind (étape ultérieure) ``` --- ## 7. Commandes Aucune dépendance locale requise hors Docker. | Action | Commande | |-------------------------------|-------------------------------------------------------------------------| | Lancer en dev | `docker compose up --build` | | API + docs interactives | http://localhost:8000/docs | | Tests backend | `docker compose run --rm backend uv run pytest` | | Couverture | `docker compose run --rm backend uv run pytest --cov=app` | | Lint | `docker compose run --rm backend uv run ruff check .` | | Format | `docker compose run --rm backend uv run ruff format .` | | Créer une migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "..."` | | Appliquer les migrations | `docker compose run --rm backend uv run alembic upgrade head` | | Vérifier migration ↔ modèle | `docker compose run --rm backend uv run alembic check` (après `upgrade head`) | | Instance vierge (édition + 1er compte) | `docker compose run --rm backend uv run python -m app.bootstrap --name … --start … --end … --email … --password …` | | Charger le jeu de démo | `docker compose run --rm backend uv run python -m app.seed` (après `alembic upgrade head`) | --- ## 8. Conventions - **Code, base, noms de fichiers en anglais.** Le français vit dans les fichiers de traduction et les commits. - **Aucun texte visible par l'utilisateur en dur.** Backend : `app.i18n.t(key)` + `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue. - **RGPD :** - Pas de log des noms, emails, téléphones. On logue des identifiants. - `Edition.retention_days` : durée de rétention paramétrable. - Endpoint de suppression complète d'un bénévole : *hard delete* en cascade. - **Dates et heures :** stockées naïves, interprétées en heure locale Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée » sont toutes en heure locale. - **Validation TDD :** tests avant implémentation, un cas passant + un cas déclenchant par règle. - **Git :** un commit atomique par étape validée, message en français. - **Étapes courtes :** après chaque étape, montrer le résultat et attendre la validation avant de continuer. --- ## 9. Déploiement - Dépôt GitHub. VPS avec Docker. - `.github/workflows/deploy.yml` : sur chaque push `main` → build de l'image backend → push sur `ghcr.io//-backend` → SSH sur le VPS → `docker compose -f docker-compose.prod.yml pull && up -d`. - Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`, `SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`. - Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`). --- ## 10. Journal des étapes | Étape | Description | État | |-------|-------------------------------------------------------|----------| | 1 | Squelette : monorepo, Docker, CI/CD, backend qui démarre | validée | | 2 | Modèle de données complet + migration Alembic initiale | validée | | 3 | Import CSV/Excel (analyse + exécution, rapport, doublons, simulation) | validée | | 4 | Moteur de validation des affectations (pur, TDD, 5 règles) | validée | | 5 | API CRUD (Position, Shift, Volunteer, Assignment) + matrice + validation branchée | validée | | 6 | Jeu de données de démonstration (`app/seed.py`) | validée | | 7 | Authentification : lien magique + JWT + application des rôles | validée | | 8 | Second système : connexion mot de passe (bcrypt) pour la coordination | validée | | 9 | Suppression du statut d'affectation (plus d'étape de confirmation) | validée | | 10 | Envoi d'emails par SMTP : lien magique + récapitulatif de planning | validée | | 11 | `GET`/`PATCH /editions/{id}` (nom, dates) — ajouter/retirer des jours ; UX config front | validée | | 12 | Créneaux types (`DayPart` CRUD) + génération auto des créneaux 0/0 à la création d'un poste ; case « à définir » / « sureffectif » sur la grille | à valider | | 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider | | 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider | | 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + connexion en place (voir `frontend/`).