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`) | | 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 veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
| Vœu « je voudrais ce poste » | `VolunteerPositionWish` (N-N `Volunteer` ↔ `Position`) | | 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` | | Compte (utilisateur connecté) | `Account` |
| Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) | | Découpage de journée (paramétrable)| `DayPart` (`code`, `label`, `start_time`, `end_time`, `order`) |
| Lien magique | `MagicLinkToken` | | 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 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), 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, `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, 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` ; `None`/absent = exposées par la fiche (`artist_ids`, `wished_position_ids`,
inchangé, `[]` = vider). `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La 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 - Vœux de postes : `volunteer_position_wish` (« postes que le bénévole
voudrait tenir »), distinct de `Assignment` (décision) et de `Position. voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
lead_account_id` (référent). 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`), - **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`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 - Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`, `edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
`artist`, `account`. La suppression complète d'un bénévole (RGPD) efface `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`. `Account`.
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur + - Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
`tzdata`). `app.models.local_now()` pour les horodatages. `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 - 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 veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui
ferait manquer le passage ; 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 - léger malus pour les bénévoles déjà bien chargés (répartition de la
charge). charge).
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la - **Pas de plafond de cumul journalier côté solveur** : contrairement à la
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un 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 bénévole qui dépasserait 8 h sur la journée — seule la validation le
signale, à la confirmation. signale, à la confirmation.
- Simplification assumée : repos et chaînage ne sont vérifiés que contre - Simplification assumée : repos, chaînage et compagnons ne sont vérifiés
les affectations **déjà décidées**, pas entre deux suggestions du même que contre les affectations **déjà décidées**, pas entre deux suggestions
calcul — la validation finale à la confirmation reste seule juge de ce du même calcul (deux compagnons suggérés ensemble dans le même calcul ne
cas limite. 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` **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 (`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 poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
de cumul 8 h / repos, déjà couverts par les badges de validation). C'est ce 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 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 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 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+7 -2
View File
@@ -49,9 +49,11 @@ class VolunteerCreate(ApiModel):
email: str | None = None email: str | None = None
phone: str | None = None phone: str | None = None
comment: str | None = None comment: str | None = None
# Vœux du bénévole : artistes à voir, postes souhaités. # Vœux du bénévole : artistes à voir, postes souhaités, bénévoles avec
# qui il voudrait être affecté (à sens unique, 0 à N).
artist_ids: list[uuid.UUID] = Field(default_factory=list) artist_ids: list[uuid.UUID] = Field(default_factory=list)
wished_position_ids: list[uuid.UUID] = Field(default_factory=list) wished_position_ids: list[uuid.UUID] = Field(default_factory=list)
wished_companion_ids: list[uuid.UUID] = Field(default_factory=list)
class VolunteerUpdate(ApiModel): class VolunteerUpdate(ApiModel):
@@ -60,9 +62,11 @@ class VolunteerUpdate(ApiModel):
email: str | None = None email: str | None = None
phone: str | None = None phone: str | None = None
comment: str | None = None comment: str | None = None
# None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids. # None = inchangé ; [] = vider. Idem pour artist_ids / wished_position_ids
# / wished_companion_ids.
artist_ids: list[uuid.UUID] | None = None artist_ids: list[uuid.UUID] | None = None
wished_position_ids: list[uuid.UUID] | None = None wished_position_ids: list[uuid.UUID] | None = None
wished_companion_ids: list[uuid.UUID] | None = None
class VolunteerRead(ApiModel): class VolunteerRead(ApiModel):
@@ -75,6 +79,7 @@ class VolunteerRead(ApiModel):
comment: str | None comment: str | None
artist_ids: list[uuid.UUID] artist_ids: list[uuid.UUID]
wished_position_ids: list[uuid.UUID] wished_position_ids: list[uuid.UUID]
wished_companion_ids: list[uuid.UUID]
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
+44 -1
View File
@@ -25,6 +25,7 @@ from app.models import (
Role, Role,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish, VolunteerPositionWish,
) )
@@ -68,11 +69,26 @@ def _wished_position_ids(
) )
def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]:
return list(
session.exec(
select(Volunteer.id)
.join(
VolunteerCompanionWish,
VolunteerCompanionWish.companion_id == Volunteer.id,
)
.where(VolunteerCompanionWish.volunteer_id == volunteer_id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead: def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return VolunteerRead( return VolunteerRead(
**volunteer.model_dump(), **volunteer.model_dump(),
artist_ids=_artist_ids(session, volunteer.id), artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_ids(session, volunteer.id),
) )
@@ -133,6 +149,27 @@ def _set_wished_positions(
) )
def _set_companions(
session: Session, volunteer: Volunteer, companion_ids: list[uuid.UUID]
) -> None:
ids = _resolve_owned_ids(
session, Volunteer, volunteer.edition_id, companion_ids, "volunteer.unknown"
)
if volunteer.id in ids:
raise HTTPException(
status_code=422, detail={"code": "volunteer.cannot_wish_self"}
)
replace_links(
session,
VolunteerCompanionWish,
filter_column=VolunteerCompanionWish.volunteer_id,
filter_value=volunteer.id,
pairs=[
{"volunteer_id": volunteer.id, "companion_id": cid} for cid in ids
],
)
@router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED) @router.post("", response_model=VolunteerRead, status_code=status.HTTP_201_CREATED)
def create_volunteer( def create_volunteer(
payload: VolunteerCreate, payload: VolunteerCreate,
@@ -142,12 +179,15 @@ def create_volunteer(
) -> VolunteerRead: ) -> VolunteerRead:
volunteer = Volunteer( volunteer = Volunteer(
edition_id=edition.id, edition_id=edition.id,
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}), **payload.model_dump(
exclude={"artist_ids", "wished_position_ids", "wished_companion_ids"}
),
) )
session.add(volunteer) session.add(volunteer)
session.flush() session.flush()
_set_artists(session, volunteer, payload.artist_ids) _set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids) _set_wished_positions(session, volunteer, payload.wished_position_ids)
_set_companions(session, volunteer, payload.wished_companion_ids)
session.commit() session.commit()
session.refresh(volunteer) session.refresh(volunteer)
return _read(session, volunteer) return _read(session, volunteer)
@@ -191,6 +231,7 @@ def update_volunteer(
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
artist_ids = changes.pop("artist_ids", None) artist_ids = changes.pop("artist_ids", None)
wished_position_ids = changes.pop("wished_position_ids", None) wished_position_ids = changes.pop("wished_position_ids", None)
wished_companion_ids = changes.pop("wished_companion_ids", None)
for field, value in changes.items(): for field, value in changes.items():
setattr(volunteer, field, value) setattr(volunteer, field, value)
session.flush() session.flush()
@@ -198,6 +239,8 @@ def update_volunteer(
_set_artists(session, volunteer, artist_ids) _set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None: if wished_position_ids is not None:
_set_wished_positions(session, volunteer, wished_position_ids) _set_wished_positions(session, volunteer, wished_position_ids)
if wished_companion_ids is not None:
_set_companions(session, volunteer, wished_companion_ids)
session.commit() session.commit()
session.refresh(volunteer) session.refresh(volunteer)
return _read(session, volunteer) return _read(session, volunteer)
+2
View File
@@ -14,6 +14,8 @@
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.", "artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.", "artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
"position.unknown": "Un des postes choisis n'appartient pas à cette édition.", "position.unknown": "Un des postes choisis n'appartient pas à cette édition.",
"volunteer.unknown": "Un des bénévoles choisis n'appartient pas à cette édition.",
"volunteer.cannot_wish_self": "Un bénévole ne peut pas se souhaiter lui-même comme compagnon.",
"assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.", "assignment.wrong_edition": "Le bénévole ou le créneau n'appartient pas à cette édition.",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.", "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau.",
"auth.invalid_token": "Lien de connexion invalide ou expiré.", "auth.invalid_token": "Lien de connexion invalide ou expiré.",
@@ -0,0 +1,39 @@
"""Vœu de bénévole : être affecté avec tel(s) autre(s) bénévole(s)
Revision ID: 0010_companion_wish
Revises: 0009_volunteer_email_not_unique
Create Date: 2026-09-15
Table `volunteer_companion_wish` : de 0 à N vœux par bénévole, à sens unique
(A peut souhaiter B sans que B ne le souhaite en retour). Le solveur (section
4 de CLAUDE.md) favorise un candidat dont l'un des compagnons souhaités est
déjà affecté au créneau.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0010_companion_wish"
down_revision: str | None = "0009_volunteer_email_not_unique"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"volunteer_companion_wish",
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
sa.Column("companion_id", sa.Uuid(), nullable=False),
sa.CheckConstraint(
"volunteer_id <> companion_id", name="ck_companion_wish_not_self"
),
sa.ForeignKeyConstraint(["companion_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("volunteer_id", "companion_id"),
)
def downgrade() -> None:
op.drop_table("volunteer_companion_wish")
+2 -1
View File
@@ -17,6 +17,7 @@ from app.models.domain import (
Shift, Shift,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.models.enums import ( from app.models.enums import (
@@ -41,7 +42,7 @@ __all__ = [
"TimestampMixin", "TimestampMixin",
"Volunteer", "Volunteer",
"VolunteerArtist", "VolunteerArtist",
"VolunteerCompanionWish",
"VolunteerPositionWish", "VolunteerPositionWish",
"VolunteerSkill",
"local_now", "local_now",
] ]
+23 -1
View File
@@ -9,7 +9,7 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
import datetime as dt import datetime as dt
import uuid import uuid
from sqlalchemy import Boolean, Column, Integer, Time from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
from sqlalchemy import Enum as SAEnum from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
@@ -52,6 +52,28 @@ class VolunteerPositionWish(SQLModel, table=True):
) )
class VolunteerCompanionWish(SQLModel, table=True):
"""Vœu d'un bénévole : « je voudrais être affecté avec ce bénévole ».
De 0 à N par bénévole, à sens unique (A peut souhaiter B sans que B ne le
souhaite en retour) — le solveur (section 4 de CLAUDE.md) le lit dans ce
seul sens."""
__tablename__ = "volunteer_companion_wish"
__table_args__ = (
CheckConstraint(
"volunteer_id <> companion_id", name="ck_companion_wish_not_self"
),
)
volunteer_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
companion_id: uuid.UUID = Field(
foreign_key="volunteer.id", primary_key=True, ondelete="CASCADE"
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Édition et découpage des journées # # Édition et découpage des journées #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+20 -2
View File
@@ -31,6 +31,7 @@ from app.models import (
Shift, Shift,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.validation import ( from app.validation import (
@@ -47,8 +48,8 @@ SEED = 20260709
DEMO_EDITION_NAME = "Festival des Lucioles" DEMO_EDITION_NAME = "Festival des Lucioles"
DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026" DEMO_COORDINATION_PASSWORD = "coordination-lucioles-2026"
DEMO_YEAR = 2026 DEMO_YEAR = 2026
START = dt.date(2026, 7, 9) START = dt.date(2026, 10, 30)
END = dt.date(2026, 7, 12) END = dt.date(2026, 10, 31)
FIRST_NAMES = [ FIRST_NAMES = [
"Camille", "Camille",
@@ -214,6 +215,7 @@ class Summary:
artists: int = 0 artists: int = 0
position_wishes: int = 0 position_wishes: int = 0
artist_wishes: int = 0 artist_wishes: int = 0
companion_wishes: int = 0
warnings: Counter = field(default_factory=Counter) warnings: Counter = field(default_factory=Counter)
blocking: int = 0 blocking: int = 0
@@ -229,6 +231,7 @@ class Summary:
f" Artistes programmés : {self.artists}", f" Artistes programmés : {self.artists}",
f" Vœux de poste déclarés : {self.position_wishes}", f" Vœux de poste déclarés : {self.position_wishes}",
f" Vœux d'artiste déclarés : {self.artist_wishes}", f" Vœux d'artiste déclarés : {self.artist_wishes}",
f" Vœux de compagnon déclarés : {self.companion_wishes}",
f" Avertissements actifs : {sum(self.warnings.values())}", f" Avertissements actifs : {sum(self.warnings.values())}",
] ]
for code, count in sorted(self.warnings.items()): for code, count in sorted(self.warnings.items()):
@@ -398,6 +401,20 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
VolunteerArtist(volunteer_id=volunteer.id, artist_id=artist.id) VolunteerArtist(volunteer_id=volunteer.id, artist_id=artist.id)
) )
n_artist_wishes += 1 n_artist_wishes += 1
# Vœux de compagnon (couple, amis) : à sens unique, choisis parmi les
# autres bénévoles déjà créés.
n_companion_wishes = 0
for volunteer in volunteers:
others = [v for v in volunteers if v.id != volunteer.id]
roll = rng.random()
n_wishes = 2 if roll < 0.03 else (1 if roll < 0.23 else 0)
for companion in rng.sample(others, k=min(n_wishes, len(others))):
session.add(
VolunteerCompanionWish(
volunteer_id=volunteer.id, companion_id=companion.id
)
)
n_companion_wishes += 1
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une # Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain). # large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0)) _FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
@@ -519,6 +536,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
artists=len(artists), artists=len(artists),
position_wishes=n_position_wishes, position_wishes=n_position_wishes,
artist_wishes=n_artist_wishes, artist_wishes=n_artist_wishes,
companion_wishes=n_companion_wishes,
) )
_verify(session, edition, summary) _verify(session, edition, summary)
return summary return summary
+14 -8
View File
@@ -7,10 +7,10 @@ contente de faire remonter ces candidats en tête de liste).
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos, `SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
chaînage entre créneaux consécutifs, vœu de poste, vœu d'artiste, équité de chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
charge) est un terme de l'objectif, pas une contrainte — comme le moteur de d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
validation, qui reste seul juge au moment où la coordination confirme une comme le moteur de validation, qui reste seul juge au moment où la
affectation. coordination confirme une affectation.
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED` Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
reste vérifiée par la validation, à la confirmation. reste vérifiée par la validation, à la confirmation.
""" """
@@ -49,6 +49,8 @@ _ARTIST_CONFLICT_PENALTY = 80
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de # n'importe quel poste) : limite les enchaînements de shifts plutôt que de
# faire tourner un poste en particulier. # faire tourner un poste en particulier.
_CHAIN_PENALTY = 40 _CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole).
_COMPANION_BONUS_PER_MATCH = 25
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -85,10 +87,12 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat. """Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées), Purement additif (aucune interaction entre deux affectations proposées),
ce qui garde le modèle CP-SAT linéaire simple. Repos et chaînage ne sont ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
vérifiés que contre les affectations déjà décidées, pas entre deux compagnons ne sont vérifiés que contre les affectations déjà décidées,
suggestions du même calcul : la validation finale (à la confirmation) pas entre deux suggestions du même calcul : la validation finale (à la
reste seule juge en cas de cas limite entre deux suggestions. confirmation) reste seule juge en cas de cas limite entre deux
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
se renforcent pas mutuellement, faute d'interaction entre variables).
""" """
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
if shift.position_id in volunteer.wished_position_ids: if shift.position_id in volunteer.wished_position_ids:
@@ -99,6 +103,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
score -= _REST_PENALTY score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift): if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY score -= _CHAIN_PENALTY
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
score += _COMPANION_BONUS_PER_MATCH * companions_present
if any( if any(
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows _overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
): ):
+13
View File
@@ -14,6 +14,7 @@ from app.models import (
Shift, Shift,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish,
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.solver.models import SolverInput, SolverShift, SolverVolunteer from app.solver.models import SolverInput, SolverShift, SolverVolunteer
@@ -61,8 +62,10 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
assigned_count: dict = defaultdict(int) assigned_count: dict = defaultdict(int)
shifts_by_volunteer: dict = defaultdict(list) shifts_by_volunteer: dict = defaultdict(list)
assigned_volunteers_by_shift: dict = defaultdict(set)
for a in assignments: for a in assignments:
assigned_count[a.shift_id] += 1 assigned_count[a.shift_id] += 1
assigned_volunteers_by_shift[a.shift_id].add(str(a.volunteer_id))
shift = shifts_by_id.get(a.shift_id) shift = shifts_by_id.get(a.shift_id)
if shift is not None: if shift is not None:
shifts_by_volunteer[a.volunteer_id].append(shift) shifts_by_volunteer[a.volunteer_id].append(shift)
@@ -77,6 +80,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
ideal_staff=s.ideal_staff, ideal_staff=s.ideal_staff,
already_assigned=assigned_count.get(s.id, 0), already_assigned=assigned_count.get(s.id, 0),
predecessor_shift_ids=predecessor_ids.get(s.id, frozenset()), predecessor_shift_ids=predecessor_ids.get(s.id, frozenset()),
assigned_volunteer_ids=frozenset(assigned_volunteers_by_shift.get(s.id, ())),
) )
for s in shifts for s in shifts
if assigned_count.get(s.id, 0) < s.ideal_staff if assigned_count.get(s.id, 0) < s.ideal_staff
@@ -98,6 +102,14 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
): ):
wishes[w.volunteer_id].add(str(w.position_id)) wishes[w.volunteer_id].add(str(w.position_id))
companion_wishes: dict = defaultdict(set)
for w in session.exec(
select(VolunteerCompanionWish).join(
Volunteer, Volunteer.id == VolunteerCompanionWish.volunteer_id
).where(Volunteer.edition_id == edition.id)
):
companion_wishes[w.volunteer_id].add(str(w.companion_id))
artists_by_id = { artists_by_id = {
a.id: a a.id: a
for a in session.exec(select(Artist).where(Artist.edition_id == edition.id)) for a in session.exec(select(Artist).where(Artist.edition_id == edition.id))
@@ -120,6 +132,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
id=str(v.id), id=str(v.id),
availability=tuple(availabilities.get(v.id, ())), availability=tuple(availabilities.get(v.id, ())),
wished_position_ids=frozenset(wishes.get(v.id, ())), wished_position_ids=frozenset(wishes.get(v.id, ())),
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
wished_artist_windows=tuple(artist_wishes.get(v.id, ())), wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
existing_shifts=tuple( existing_shifts=tuple(
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at) ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
+5
View File
@@ -27,6 +27,9 @@ class SolverShift:
# chaînage (voir `engine._score`), pour limiter les enchaînements de # chaînage (voir `engine._score`), pour limiter les enchaînements de
# shifts plutôt que cibler la rotation d'un poste en particulier. # shifts plutôt que cibler la rotation d'un poste en particulier.
predecessor_shift_ids: frozenset[str] = frozenset() predecessor_shift_ids: frozenset[str] = frozenset()
# Bénévoles déjà affectés à ce créneau (décisions déjà prises) : sert au
# bonus de compagnon de route (voir `engine._score`).
assigned_volunteer_ids: frozenset[str] = frozenset()
@property @property
def shift_info(self) -> ShiftInfo: def shift_info(self) -> ShiftInfo:
@@ -38,6 +41,8 @@ class SolverVolunteer:
id: str id: str
availability: tuple[AvailabilityWindow, ...] = () availability: tuple[AvailabilityWindow, ...] = ()
wished_position_ids: frozenset[str] = frozenset() wished_position_ids: frozenset[str] = frozenset()
# Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique.
wished_companion_ids: frozenset[str] = frozenset()
# Passages des artistes que le bénévole veut voir (heure de passage), # Passages des artistes que le bénévole veut voir (heure de passage),
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles # sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
# lui ferait manquer le passage. # lui ferait manquer le passage.
+31
View File
@@ -71,6 +71,37 @@ def test_avoids_volunteer_whose_wished_artist_would_be_missed(client, edition, s
assert suggestion["volunteer_ids"] == [str(indiff.id)] assert suggestion["volunteer_ids"] == [str(indiff.id)]
def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift(
client, edition, session
):
from app.models import Assignment
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=2,
)
copain = make_volunteer(session, edition, email="copain@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=copain.id, shift_id=shift.id)
)
session.commit()
buddy = make_volunteer(session, edition, email="buddy@ex.fr")
assert client.patch(
f"/editions/{edition.id}/volunteers/{buddy.id}",
json={"wished_companion_ids": [str(copain.id)]},
).status_code == 200
stranger = make_volunteer(session, edition, email="stranger@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
suggestion = next(
s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id)
)
assert suggestion["volunteer_ids"] == [str(buddy.id)]
assert str(stranger.id) not in suggestion["volunteer_ids"]
def test_chain_penalty_applies_across_different_positions(client, edition, session): def test_chain_penalty_applies_across_different_positions(client, edition, session):
from app.models import Assignment, PositionCategory from app.models import Assignment, PositionCategory
+55
View File
@@ -110,6 +110,61 @@ def test_unknown_artist_wish_is_422(client, edition):
assert resp.json()["detail"]["code"] == "artist.unknown" assert resp.json()["detail"]["code"] == "artist.unknown"
def test_create_with_companion_wish(client, edition, session):
friend = make_volunteer(session, edition, email="friend@ex.fr")
resp = client.post(
_url(edition),
json={
"first_name": "Alex",
"last_name": "Martin",
"wished_companion_ids": [str(friend.id)],
},
)
assert resp.status_code == 201
assert resp.json()["wished_companion_ids"] == [str(friend.id)]
def test_unknown_companion_wish_is_422(client, edition):
resp = client.post(
_url(edition),
json={
"first_name": "A",
"last_name": "B",
"wished_companion_ids": [str(uuid.uuid4())],
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "volunteer.unknown"
def test_cannot_wish_oneself_as_companion(client, edition, session):
volunteer = make_volunteer(session, edition, email="solo@ex.fr")
resp = client.patch(
_url(edition, f"/{volunteer.id}"),
json={"wished_companion_ids": [str(volunteer.id)]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "volunteer.cannot_wish_self"
def test_companion_wish_is_one_directional(client, edition, session):
# A souhaite B ne veut pas dire que B souhaite A.
a = make_volunteer(session, edition, email="a@ex.fr")
b = make_volunteer(session, edition, email="b@ex.fr")
client.patch(_url(edition, f"/{a.id}"), json={"wished_companion_ids": [str(b.id)]})
assert client.get(_url(edition, f"/{a.id}")).json()["wished_companion_ids"] == [str(b.id)]
assert client.get(_url(edition, f"/{b.id}")).json()["wished_companion_ids"] == []
def test_deleting_a_wished_companion_clears_the_wish(client, edition, session):
# RGPD : effacement complet, y compris les vœux dont on est la cible.
a = make_volunteer(session, edition, email="a@ex.fr")
b = make_volunteer(session, edition, email="b@ex.fr")
client.patch(_url(edition, f"/{a.id}"), json={"wished_companion_ids": [str(b.id)]})
assert client.delete(_url(edition, f"/{b.id}")).status_code == 204
assert client.get(_url(edition, f"/{a.id}")).json()["wished_companion_ids"] == []
def test_patch_replaces_and_clears_wishes(client, edition, session): def test_patch_replaces_and_clears_wishes(client, edition, session):
a1 = make_artist(session, edition, name="A1") a1 = make_artist(session, edition, name="A1")
a2 = make_artist(session, edition, name="A2") a2 = make_artist(session, edition, name="A2")
+1
View File
@@ -20,6 +20,7 @@ EXPECTED_TABLES = {
"volunteer", "volunteer",
"volunteer_artist", "volunteer_artist",
"volunteer_position_wish", "volunteer_position_wish",
"volunteer_companion_wish",
"account", "account",
"magic_link_token", "magic_link_token",
"position", "position",
+2
View File
@@ -20,6 +20,7 @@ def test_build_creates_a_realistic_dataset(session):
assert summary.artists > 0 assert summary.artists > 0
assert summary.position_wishes > 0 assert summary.position_wishes > 0
assert summary.artist_wishes > 0 assert summary.artist_wishes > 0
assert summary.companion_wishes > 0
edition = session.exec( edition = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME) select(Edition).where(Edition.name == DEMO_EDITION_NAME)
@@ -45,6 +46,7 @@ def test_build_is_deterministic_and_idempotent(session):
assert second.artists == first.artists assert second.artists == first.artists
assert second.position_wishes == first.position_wishes assert second.position_wishes == first.position_wishes
assert second.artist_wishes == first.artist_wishes assert second.artist_wishes == first.artist_wishes
assert second.companion_wishes == first.companion_wishes
editions = session.exec( editions = session.exec(
select(Edition).where(Edition.name == DEMO_EDITION_NAME) select(Edition).where(Edition.name == DEMO_EDITION_NAME)
+40
View File
@@ -21,6 +21,7 @@ def _shift(
ideal_staff=1, ideal_staff=1,
already_assigned=0, already_assigned=0,
predecessor_shift_ids=frozenset(), predecessor_shift_ids=frozenset(),
assigned_volunteer_ids=frozenset(),
) -> SolverShift: ) -> SolverShift:
return SolverShift( return SolverShift(
id=sid, id=sid,
@@ -31,6 +32,7 @@ def _shift(
ideal_staff=ideal_staff, ideal_staff=ideal_staff,
already_assigned=already_assigned, already_assigned=already_assigned,
predecessor_shift_ids=frozenset(predecessor_shift_ids), predecessor_shift_ids=frozenset(predecessor_shift_ids),
assigned_volunteer_ids=frozenset(assigned_volunteer_ids),
) )
@@ -39,6 +41,7 @@ def _volunteer(
*, *,
availability=(), availability=(),
wished_position_ids=frozenset(), wished_position_ids=frozenset(),
wished_companion_ids=frozenset(),
wished_artist_windows=(), wished_artist_windows=(),
existing_shifts=(), existing_shifts=(),
existing_minutes=0, existing_minutes=0,
@@ -47,6 +50,7 @@ def _volunteer(
id=vid, id=vid,
availability=tuple(availability), availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids), wished_position_ids=frozenset(wished_position_ids),
wished_companion_ids=frozenset(wished_companion_ids),
wished_artist_windows=tuple(wished_artist_windows), wished_artist_windows=tuple(wished_artist_windows),
existing_shifts=tuple(existing_shifts), existing_shifts=tuple(existing_shifts),
existing_minutes=existing_minutes, existing_minutes=existing_minutes,
@@ -266,3 +270,39 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
) )
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,))) solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
assert _by_shift(solution, "s1") == ("fan",) assert _by_shift(solution, "s1") == ("fan",)
def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
# « buddy » a souhaité être avec « copain », déjà affecté au créneau :
# préféré à « stranger », qui n'a pas ce lien.
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
buddy = _volunteer(
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
)
stranger = _volunteer("stranger", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
assert _by_shift(solution, "s1") == ("buddy",)
def test_companion_bonus_scales_with_number_of_matches():
# Deux compagnons souhaités déjà présents valent mieux qu'un seul.
shift = _shift(
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
)
two_friends = _volunteer(
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
)
one_friend = _volunteer(
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
def test_wished_companion_absent_from_the_shift_has_no_effect():
shift = _shift()
volunteer = _volunteer(
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert _by_shift(solution, "s1") == ("v",)
+5
View File
@@ -233,6 +233,10 @@ export function buildMockState(): MockState {
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id); const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids = const wished_position_ids =
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : []; rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
// Vœu de compagnon (couple, amis) : un autre bénévole de la liste, jamais
// soi-même (décalage d'au moins un cran).
const wished_companion_ids =
rnd() < 0.2 ? [`v${(i + 1 + Math.floor(rnd() * 5)) % 64}`] : [];
volunteers.push({ volunteers.push({
id, id,
edition_id, edition_id,
@@ -243,6 +247,7 @@ export function buildMockState(): MockState {
comment: null, comment: null,
artist_ids, artist_ids,
wished_position_ids, wished_position_ids,
wished_companion_ids,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
+39 -3
View File
@@ -120,6 +120,15 @@ function resolveWishes<T extends { id: string }>(
return wanted; return wanted;
} }
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
if (selfId != null && resolved.includes(selfId)) {
throw new ApiError(422, 'volunteer.cannot_wish_self');
}
return resolved;
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] { function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day; const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`]; return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -392,9 +401,12 @@ const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_REST_PENALTY = 60; const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80; const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2; const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) : // Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
// favorise la rotation plutôt que d'enchaîner toujours la même personne. // n'importe quel poste) : limite les enchaînements de shifts plutôt que de
// faire tourner un poste en particulier.
const SOLVER_CHAIN_PENALTY = 40; const SOLVER_CHAIN_PENALTY = 40;
// Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole).
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
type TimeWindow = { starts_at: string; ends_at: string }; type TimeWindow = { starts_at: string; ends_at: string };
@@ -408,6 +420,14 @@ function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
.map((a) => shiftById(a.shift_id)); .map((a) => shiftById(a.shift_id));
} }
/** Bénévoles déjà affectés (décisions déjà prises) à un créneau — sert au
* bonus de compagnon de route (voir `solverScore`). */
function assignedVolunteerIds(shiftId: string): Set<string> {
return new Set(
state.assignments.filter((a) => a.shift_id === shiftId).map((a) => a.volunteer_id),
);
}
/** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de /** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de
* chaque créneau, le même jour (indexé par id de créneau) — sert au malus de * chaque créneau, le même jour (indexé par id de créneau) — sert au malus de
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts * chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
@@ -459,6 +479,7 @@ function solverScore(
existing: ShiftRead[], existing: ShiftRead[],
wishedArtists: TimeWindow[], wishedArtists: TimeWindow[],
chainedFromPredecessor: boolean, chainedFromPredecessor: boolean,
alreadyAssignedIds: Set<string>,
): number { ): number {
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE; let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS; if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
@@ -466,6 +487,10 @@ function solverScore(
score -= SOLVER_ARTIST_CONFLICT_PENALTY; score -= SOLVER_ARTIST_CONFLICT_PENALTY;
} }
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY; if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
const companionsPresent = volunteer.wished_companion_ids.filter((id) =>
alreadyAssignedIds.has(id),
).length;
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
const windows = state.availability.get(volunteer.id) ?? []; const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at); const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
@@ -523,6 +548,7 @@ function runSolver(): SolverRunResult {
for (const shift of ordered) { for (const shift of ordered) {
const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0); const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0);
if (gap <= 0) continue; if (gap <= 0) continue;
const alreadyAssignedIds = assignedVolunteerIds(shift.id);
const picked = state.volunteers const picked = state.volunteers
.filter((v) => { .filter((v) => {
const busy = [ const busy = [
@@ -544,6 +570,7 @@ function runSolver(): SolverRunResult {
existing, existing,
artistWindowsByVolunteer.get(v.id) ?? [], artistWindowsByVolunteer.get(v.id) ?? [],
chained, chained,
alreadyAssignedIds,
), ),
}; };
}) })
@@ -729,6 +756,7 @@ export function createMockApi(): PlanningApi {
body.wished_position_ids ?? [], body.wished_position_ids ?? [],
'position.unknown', 'position.unknown',
), ),
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -756,6 +784,9 @@ export function createMockApi(): PlanningApi {
), ),
} }
: {}), : {}),
...(body.wished_companion_ids != null
? { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids, v.id) }
: {}),
updated_at: touch(), updated_at: touch(),
}); });
return delay(v); return delay(v);
@@ -764,8 +795,12 @@ export function createMockApi(): PlanningApi {
state.volunteers = state.volunteers.filter((x) => x.id !== id); state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id); state.availability.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel. // RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible.
state.accounts = state.accounts.filter((a) => a.volunteer_id !== id); state.accounts = state.accounts.filter((a) => a.volunteer_id !== id);
state.volunteers.forEach((v) => {
v.wished_companion_ids = v.wished_companion_ids.filter((cid) => cid !== id);
});
return delay(undefined); return delay(undefined);
}, },
}, },
@@ -998,6 +1033,7 @@ export function createMockApi(): PlanningApi {
comment: null, comment: null,
artist_ids: [], artist_ids: [],
wished_position_ids: [], wished_position_ids: [],
wished_companion_ids: [],
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
+4
View File
@@ -106,6 +106,8 @@ export interface VolunteerRead {
artist_ids: string[]; artist_ids: string[];
/** Vœux : postes que le bénévole voudrait tenir. */ /** Vœux : postes que le bénévole voudrait tenir. */
wished_position_ids: string[]; wished_position_ids: string[];
/** Vœux : bénévoles avec qui il voudrait être affecté (0 à N, à sens unique). */
wished_companion_ids: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -118,6 +120,7 @@ export interface VolunteerCreate {
comment?: string | null; comment?: string | null;
artist_ids?: string[]; artist_ids?: string[];
wished_position_ids?: string[]; wished_position_ids?: string[];
wished_companion_ids?: string[];
} }
export interface VolunteerUpdate { export interface VolunteerUpdate {
@@ -129,6 +132,7 @@ export interface VolunteerUpdate {
/** null / absent = inchangé ; [] = vider. */ /** null / absent = inchangé ; [] = vider. */
artist_ids?: string[] | null; artist_ids?: string[] | null;
wished_position_ids?: string[] | null; wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+6 -1
View File
@@ -288,10 +288,14 @@
"config.artists.namePlaceholder": "Nom de l'artiste", "config.artists.namePlaceholder": "Nom de l'artiste",
"config.vol.field.artists": "Artistes à voir", "config.vol.field.artists": "Artistes à voir",
"config.vol.field.positions": "Postes souhaités", "config.vol.field.positions": "Postes souhaités",
"config.vol.field.companions": "Bénévoles souhaités",
"config.vol.companionsHint": "Bénévoles avec qui il voudrait être affecté (à sens unique). Le solveur privilégie un candidat dont l'un des compagnons souhaités est déjà sur le créneau.",
"config.vol.noArtists": "Aucun artiste choisi", "config.vol.noArtists": "Aucun artiste choisi",
"config.vol.noPositions": "Aucun poste choisi", "config.vol.noPositions": "Aucun poste choisi",
"config.vol.noCompanions": "Aucun bénévole choisi",
"config.vol.pickArtist": "Ajouter un artiste…", "config.vol.pickArtist": "Ajouter un artiste…",
"config.vol.pickPosition": "Ajouter un poste…", "config.vol.pickPosition": "Ajouter un poste…",
"config.vol.pickCompanion": "Ajouter un bénévole…",
"config.vol.colWishes": "VŒUX", "config.vol.colWishes": "VŒUX",
"config.vol.wishesCount": "{count} vœu(x)", "config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.", "config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
@@ -312,5 +316,6 @@
"config.artists.startTime": "Heure de début du passage", "config.artists.startTime": "Heure de début du passage",
"config.artists.endTime": "Heure de fin du passage", "config.artists.endTime": "Heure de fin du passage",
"grille.panel.wishesPosition": "Vœu de poste", "grille.panel.wishesPosition": "Vœu de poste",
"grille.panel.artistConflict": "Manquerait un artiste souhaité" "grille.panel.artistConflict": "Manquerait un artiste souhaité",
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)"
} }
+7 -3
View File
@@ -116,23 +116,27 @@ const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5; const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80; const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2; const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
/** /**
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité * Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
* (y compris le conflit avec un artiste qu'il veut voir), charge déjà * (y compris le conflit avec un artiste qu'il veut voir), compagnons
* assignée. Plus haut = meilleur candidat. Sert uniquement à trier la liste * souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
* affichée ; la validation (blocage / avertissements) reste seule juge. * meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge.
*/ */
export function candidateChanceScore(params: { export function candidateChanceScore(params: {
wishesPosition: boolean; wishesPosition: boolean;
uncoveredMinutes: number; uncoveredMinutes: number;
conflictsWishedArtist: boolean; conflictsWishedArtist: boolean;
companionMatches: number;
assignedHours: number; assignedHours: number;
}): number { }): number {
let score = 0; let score = 0;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS; if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR; score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY; if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours; score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
return score; return score;
} }
+56 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { import {
@@ -26,6 +26,7 @@ import type {
ImportReport, ImportReport,
PositionRead, PositionRead,
VolunteerCreate, VolunteerCreate,
VolunteerRead,
} from '@/api/schema'; } from '@/api/schema';
import { FieldLabel, HelpTip, TextInput } from '@/components/ui'; import { FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
@@ -71,7 +72,21 @@ function positionOptions(positions: PositionRead[] | undefined): WishOption[] {
.map((p) => ({ id: p.id, label: p.name, hint: p.location ?? undefined })); .map((p) => ({ id: p.id, label: p.name, hint: p.location ?? undefined }));
} }
/** Sélecteur de vœux : liste déroulante + puces retirables. */ /** Les autres bénévoles, pour le vœu de compagnon (jamais soi-même). */
function volunteerOptions(
volunteers: VolunteerRead[] | undefined,
excludeId?: string,
): WishOption[] {
return [...(volunteers ?? [])]
.filter((v) => v.id !== excludeId)
.sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') ||
a.first_name.localeCompare(b.first_name, 'fr'),
)
.map((v) => ({ id: v.id, label: `${v.first_name} ${v.last_name}` }));
}
/** /**
* Accès de connexion (lien magique) du bénévole. Ajouter un email sur sa * Accès de connexion (lien magique) du bénévole. Ajouter un email sur sa
* fiche ne crée aucun compte : sans ce geste explicite, `auth/magic-link` ne * fiche ne crée aucun compte : sans ce geste explicite, `auth/magic-link` ne
@@ -144,7 +159,7 @@ function WishPicker({
emptyLabel, emptyLabel,
addLabel, addLabel,
}: { }: {
label: string; label: ReactNode;
options: WishOption[]; options: WishOption[];
selected: string[]; selected: string[];
onChange: (ids: string[]) => void; onChange: (ids: string[]) => void;
@@ -214,6 +229,10 @@ export function ConfigVolunteers() {
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]); const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]); const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
const companionOpts = useMemo(
() => volunteerOptions(volunteers.data, selectedId ?? undefined),
[volunteers.data, selectedId],
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const needle = search.trim().toLowerCase(); const needle = search.trim().toLowerCase();
@@ -338,7 +357,10 @@ export function ConfigVolunteers() {
</span> </span>
<span className="truncate font-mono text-[10px] opacity-70"> <span className="truncate font-mono text-[10px] opacity-70">
{t('config.vol.wishesCount', { {t('config.vol.wishesCount', {
count: volunteer.artist_ids.length + volunteer.wished_position_ids.length, count:
volunteer.artist_ids.length +
volunteer.wished_position_ids.length +
volunteer.wished_companion_ids.length,
})} })}
</span> </span>
</button> </button>
@@ -427,6 +449,19 @@ export function ConfigVolunteers() {
emptyLabel={t('config.vol.noPositions')} emptyLabel={t('config.vol.noPositions')}
addLabel={t('config.vol.pickPosition')} addLabel={t('config.vol.pickPosition')}
/> />
<WishPicker
label={
<>
{t('config.vol.field.companions')}{' '}
<HelpTip text={t('config.vol.companionsHint')} />
</>
}
options={companionOpts}
selected={selected.wished_companion_ids}
onChange={(ids) => patch({ wished_companion_ids: ids })}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
/>
<AvailabilityEditor volunteerId={selected.id} /> <AvailabilityEditor volunteerId={selected.id} />
@@ -456,13 +491,16 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const createVolunteer = useCreateVolunteer(); const createVolunteer = useCreateVolunteer();
const artists = useArtists(); const artists = useArtists();
const positions = usePositions(); const positions = usePositions();
const volunteers = useVolunteers();
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' }); const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
const [artistIds, setArtistIds] = useState<string[]>([]); const [artistIds, setArtistIds] = useState<string[]>([]);
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]); const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
const [wishedCompanionIds, setWishedCompanionIds] = useState<string[]>([]);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]); const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]); const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
const companionOpts = useMemo(() => volunteerOptions(volunteers.data), [volunteers.data]);
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) => const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
setForm((f) => ({ ...f, [key]: value })); setForm((f) => ({ ...f, [key]: value }));
@@ -482,6 +520,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
comment: form.comment?.trim() || null, comment: form.comment?.trim() || null,
artist_ids: artistIds, artist_ids: artistIds,
wished_position_ids: wishedPositionIds, wished_position_ids: wishedPositionIds,
wished_companion_ids: wishedCompanionIds,
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -558,6 +597,19 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
emptyLabel={t('config.vol.noPositions')} emptyLabel={t('config.vol.noPositions')}
addLabel={t('config.vol.pickPosition')} addLabel={t('config.vol.pickPosition')}
/> />
<WishPicker
label={
<>
{t('config.vol.field.companions')}{' '}
<HelpTip text={t('config.vol.companionsHint')} />
</>
}
options={companionOpts}
selected={wishedCompanionIds}
onChange={setWishedCompanionIds}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
/>
{localError ? ( {localError ? (
<div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold"> <div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold">
+30 -6
View File
@@ -127,9 +127,14 @@ export function AssignmentPanel({
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]); const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste // Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
// qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée). // qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée),
// compagnons souhaités déjà affectés à ce créneau.
const detailsByVolunteer = useMemo(() => { const detailsByVolunteer = useMemo(() => {
const map = new Map<string, { wishesPosition: boolean; conflictsWishedArtist: boolean }>(); const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
const map = new Map<
string,
{ wishesPosition: boolean; conflictsWishedArtist: boolean; companionMatches: number }
>();
for (const v of volunteers.data ?? []) { for (const v of volunteers.data ?? []) {
const conflictsWishedArtist = v.artist_ids.some((id) => { const conflictsWishedArtist = v.artist_ids.some((id) => {
const artist = artistsById.get(id); const artist = artistsById.get(id);
@@ -138,14 +143,16 @@ export function AssignmentPanel({
map.set(v.id, { map.set(v.id, {
wishesPosition: v.wished_position_ids.includes(positionId), wishesPosition: v.wished_position_ids.includes(positionId),
conflictsWishedArtist, conflictsWishedArtist,
companionMatches: v.wished_companion_ids.filter((id) => assignedIds.has(id)).length,
}); });
} }
return map; return map;
}, [volunteers.data, artistsById, shift, positionId]); }, [volunteers.data, artistsById, shift, positionId, assigned]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste, // Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), charge déjà // disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
// assignée — c'est ce qui classe la liste, pas juste « le moins chargé ». // souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// liste, pas juste « le moins chargé ».
const scoreByVolunteer = useMemo(() => { const scoreByVolunteer = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const v of volunteers.data ?? []) { for (const v of volunteers.data ?? []) {
@@ -160,6 +167,7 @@ export function AssignmentPanel({
shift.ends_at, shift.ends_at,
), ),
conflictsWishedArtist: details?.conflictsWishedArtist ?? false, conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
companionMatches: details?.companionMatches ?? 0,
assignedHours: hours.get(v.id) ?? 0, assignedHours: hours.get(v.id) ?? 0,
}), }),
); );
@@ -280,6 +288,7 @@ export function AssignmentPanel({
conflictsWishedArtist={ conflictsWishedArtist={
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
} }
companionMatches={detailsByVolunteer.get(volunteer.id)?.companionMatches ?? 0}
suggested={suggestedIds.includes(volunteer.id)} suggested={suggestedIds.includes(volunteer.id)}
resolvePosition={resolvePosition} resolvePosition={resolvePosition}
/> />
@@ -303,6 +312,7 @@ function CandidateRow({
coverage, coverage,
wishesPosition, wishesPosition,
conflictsWishedArtist, conflictsWishedArtist,
companionMatches,
suggested, suggested,
resolvePosition, resolvePosition,
}: { }: {
@@ -313,6 +323,7 @@ function CandidateRow({
coverage: Coverage; coverage: Coverage;
wishesPosition: boolean; wishesPosition: boolean;
conflictsWishedArtist: boolean; conflictsWishedArtist: boolean;
companionMatches: number;
suggested: boolean; suggested: boolean;
resolvePosition: (shiftId: string) => string | undefined; resolvePosition: (shiftId: string) => string | undefined;
}) { }) {
@@ -379,9 +390,14 @@ function CandidateRow({
{t('grille.panel.artistConflict')} {t('grille.panel.artistConflict')}
</span> </span>
) : null} ) : null}
{companionMatches > 0 ? (
<span className="inline-block border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('grille.panel.withCompanion', { count: companionMatches })}
</span>
) : null}
</div> </div>
{blocking ? ( {/* {blocking ? (
<div className="flex items-stretch border-t-2 border-ink"> <div className="flex items-stretch border-t-2 border-ink">
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold"> <span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
⛔ ⛔
@@ -390,6 +406,14 @@ function CandidateRow({
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })} {t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
</span> </span>
</div> </div>
) : null} */}
{blocking ? (
<div className="border-t-2 border-dashed border-ink bg-hatch-orange px-2 py-[6px]">
<div className="font-display text-[8.5px] font-extrabold uppercase tracking-widest text-white">
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
</div>
</div>
) : null} ) : null}
{!isBlocked && warnings.length > 0 ? ( {!isBlocked && warnings.length > 0 ? (