Solveur : le malus de chaînage n'est pas limité au même poste

Correction : le malus ajouté précédemment ne se déclenchait que si le
bénévole avait tenu le créneau précédent du *même* poste. L'intention était
plus générale — limiter les enchaînements de shifts d'un bénévole, quel que
soit le poste, pas seulement faire tourner un poste en particulier.

- `SolverShift.predecessor_shift_id: str | None` → `predecessor_shift_ids:
  frozenset[str]` : l'ensemble des créneaux (n'importe quel poste) du même
  jour dont la fin coïncide avec le début de celui-ci. Peut contenir
  plusieurs ids (plusieurs postes se terminant à la même heure).
- Loader : le calcul du prédécesseur se fait maintenant sur tous les
  créneaux de l'édition regroupés par jour (comparaison des heures de fin),
  plus par poste.
- Miroir côté mock (`buildPredecessorMap`, `runSolver`).
- Nouveau test d'intégration (`test_api_solver.py`) : deux postes différents,
  confirme que le chaînage s'applique bien d'un poste à l'autre.
- CLAUDE.md à jour.

Vérifié : 185 tests backend (dont le nouveau), ruff, alembic check ;
typecheck/lint/build/smoke frontend.

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:20:41 +02:00
co-authored by Claude Sonnet 5
parent 64e2f86647
commit b97b95c606
7 changed files with 127 additions and 64 deletions
+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)]
def test_chain_penalty_applies_across_different_positions(client, edition, session):
from app.models import Assignment, PositionCategory
# Deux postes différents, même jour, repos suffisant (8 h pile) entre les
# deux créneaux : seul le chaînage (n'importe quel poste) doit
# différencier les deux bénévoles pour le second créneau.
bar = make_position(session, edition, name="Bar")
entrance = make_position(session, edition, name="Entrée", category=PositionCategory.WELCOME)
morning_shift = make_shift(
session, edition, bar,
start=dt_at(10, 8), end=dt_at(10, 12), min_staff=1, ideal_staff=1,
)
evening_shift = make_shift(
session, edition, entrance,
start=dt_at(10, 20), end=dt_at(10, 23), min_staff=1, ideal_staff=1,
)
chained = make_volunteer(session, edition, email="chained@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=chained.id, shift_id=morning_shift.id)
)
session.commit()
fresh = make_volunteer(session, edition, email="fresh@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
suggestion = next(
s for s in resp.json()["suggestions"] if s["shift_id"] == str(evening_shift.id)
)
assert suggestion["volunteer_ids"] == [str(fresh.id)]
def test_unknown_edition_is_404(client):
assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404
+31 -12
View File
@@ -20,7 +20,7 @@ def _shift(
min_staff=1,
ideal_staff=1,
already_assigned=0,
predecessor_shift_id: str | None = None,
predecessor_shift_ids=frozenset(),
) -> SolverShift:
return SolverShift(
id=sid,
@@ -30,7 +30,7 @@ def _shift(
min_staff=min_staff,
ideal_staff=ideal_staff,
already_assigned=already_assigned,
predecessor_shift_id=predecessor_shift_id,
predecessor_shift_ids=frozenset(predecessor_shift_ids),
)
@@ -169,10 +169,27 @@ def test_daily_cumulative_hours_no_longer_penalized():
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
def test_prefers_volunteer_without_the_previous_shift_on_the_same_position():
# Même poste, repos suffisant (8 h pile) entre les deux créneaux : seul
# le chaînage doit différencier les deux bénévoles.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
def test_prefers_volunteer_without_the_immediately_preceding_shift():
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
# doit différencier les deux bénévoles.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)
def test_chain_penalty_applies_across_different_positions():
# Le prédécesseur n'est pas limité au même poste : le bénévole enchaîne
# ici depuis un autre poste, ça compte quand même — l'idée est de limiter
# les enchaînements de shifts, pas de faire tourner un poste précis.
shift = _shift(
position_id="bar", start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"}
)
chained = _volunteer(
"chained",
availability=[_full_day(10)],
@@ -186,7 +203,7 @@ def test_prefers_volunteer_without_the_previous_shift_on_the_same_position():
def test_still_suggests_the_only_candidate_despite_chaining():
# Le chaînage reste un avertissement, pas un blocage : personne d'autre
# pour ce créneau urgent, le solveur le propose quand même.
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
chained = _volunteer(
"chained",
availability=[_full_day(10)],
@@ -197,11 +214,13 @@ def test_still_suggests_the_only_candidate_despite_chaining():
def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
# Le bénévole a bien un créneau existant, mais ce n'est pas le
# prédécesseur désigné de ce créneau (ex. un autre poste) : pas de malus
# de chaînage (le prédécesseur est identifié par id, pas par proximité).
# Les deux candidats doivent donc être à égalité.
shift = _shift(ideal_staff=2, start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
# Le bénévole a bien un créneau existant, mais ce n'est pas l'un des
# prédécesseurs désignés de ce créneau : pas de malus de chaînage (le
# prédécesseur est identifié par id, pas par proximité). Les deux
# candidats doivent donc être à égalité.
shift = _shift(
ideal_staff=2, start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"}
)
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
almost_chained = _volunteer(
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]