Vœu de compagnon : être affecté avec 0 à N autres bénévoles

Nouveau vœu sur la fiche du bénévole — « je voudrais être affecté avec tel(s)
bénévole(s) » — pris en compte à la fois pour le rendre visible et par le
solveur.

Backend :
- `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique,
  `CHECK (volunteer_id <> companion_id)`) — migration 0010.
- API : `wished_companion_ids` sur `VolunteerCreate`/`Update`/`Read`, mêmes
  règles que les autres vœux (ids inconnus → 422 `volunteer.unknown` ; se
  souhaiter soi-même → 422 `volunteer.cannot_wish_self`).
- Solveur (`engine.py`) : bonus (+25 par correspondance) si un des compagnons
  souhaités du candidat est déjà affecté (décision déjà prise) au créneau —
  cumulable si plusieurs compagnons sont présents. Comme les autres termes de
  l'objectif, un bonus/malus, jamais une contrainte.
- Seed de démo : ~20 % des bénévoles ont un vœu de compagnon (couple, amis).

Frontend :
- Fiche bénévole (`ConfigVolunteers.tsx`) : sélecteur de compagnons (ajout et
  édition), avec une bulle d'aide expliquant le sens unique et l'effet sur le
  solveur — c'est ce qui « fait apparaître » le lien entre bénévoles.
- Panneau d'affectation : badge « avec N compagnon(s) souhaité(s) » sur un
  candidat, et bonus correspondant dans le classement par défaut
  (`candidateChanceScore`).
- Mock (`runSolver`) : même bonus, pour que la démonstration se comporte
  pareil que le vrai solveur.

CLAUDE.md à jour (modèle de domaine, section 4, journal).

Vérifié : 194 tests backend (dont 13 nouveaux), ruff, alembic check ;
typecheck/lint/build/smoke frontend ; solveur testé en direct sur le jeu de
démo reseedé (0,25 s, 47 suggestions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 21:50:30 +02:00
co-authored by Claude Sonnet 5
parent b97b95c606
commit 6a4bcf8782
23 changed files with 469 additions and 42 deletions
+24 -10
View File
@@ -25,6 +25,7 @@ colonne, route et variable utilise la colonne « Code / base ».
| 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`) |
| Vœu « je voudrais être avec ce bénévole » | `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique) |
| Compte (utilisateur connecté) | `Account` |
| Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) |
| Lien magique | `MagicLinkToken` |
@@ -69,9 +70,9 @@ les tables sauf les tables d'association.
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
`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).
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
exposées par la fiche (`artist_ids`, `wished_position_ids`,
`wished_companion_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
@@ -83,6 +84,12 @@ les tables sauf les tables d'association.
- 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).
- Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je
voudrais être affecté », 0 à N, à sens unique — A peut souhaiter B sans que
B ne le souhaite en retour). `CHECK (volunteer_id <> companion_id)` : un
bénévole ne peut pas se souhaiter lui-même. Le solveur (section 4)
favorise un candidat dont l'un des compagnons souhaités est déjà affecté
au créneau.
- **`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
@@ -116,7 +123,8 @@ Conventions techniques :
- 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
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
`Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
`tzdata`). `app.models.local_now()` pour les horodatages.
@@ -283,21 +291,26 @@ affectation n'est jamais persistée par cet endpoint.
- 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 ;
- bonus par compagnon souhaité (`VolunteerCompanionWish`) déjà affecté
(affectation décidée) au créneau — plusieurs compagnons présents
cumulent le bonus ;
- léger malus pour les bénévoles déjà bien chargés (répartition de la
charge).
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
bénévole qui dépasserait 8 h sur la journée — seule la validation le
signale, à la confirmation.
- Simplification assumée : repos et chaînage 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.
- Simplification assumée : repos, chaînage et compagnons ne sont vérifiés
que contre les affectations **déjà décidées**, pas entre deux suggestions
du même calcul (deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement) — 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
poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos /
chaînage, 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
@@ -479,6 +492,7 @@ Aucune dépendance locale requise hors Docker.
| 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 |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +