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:
co-authored by
Claude Sonnet 5
parent
64e2f86647
commit
b97b95c606
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user