Solveur : retire le plafond de cumul journalier, ajoute un malus de chaînage
- Retiré : le solveur ne pénalise plus un bénévole qui dépasserait 8 h sur la journée (`_daily_overage_minutes`/`_DAILY_OVERAGE_PENALTY_PER_MIN`, côté back et mock). Seule la validation le signale encore, à la confirmation (`DAILY_HOURS_EXCEEDED`, inchangée). `SolverInput.day_parts` et son chargement sont retirés au passage : ils ne servaient qu'à ça. - Ajouté : malus (-40) si le bénévole a déjà (affectation décidée) le créneau précédent de ce même poste, le même jour (`SolverShift.predecessor_shift_id`, calculé par le loader à partir de tous les créneaux du poste — pas seulement ceux à compléter). Favorise la rotation plutôt que d'enchaîner toujours la même personne sur un poste. Reste un malus, pas une exclusion : le seul candidat pour un créneau urgent est quand même proposé. - Miroir côté mock (`runSolver`) pour que la démonstration se comporte pareil. - CLAUDE.md à jour (§4). Tests : remplacé le test du plafond journalier par un test de non-régression explicite (le cumul n'est plus pénalisé) + un test isolant le repos (qui, lui, reste vérifié) ; ajouté 3 tests pour le chaînage (préfère un bénévole frais, reste proposé si c'est le seul candidat urgent, un créneau existant non-prédécesseur ne déclenche pas le malus). Vérifié : 183 tests backend, 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
e405590bbc
commit
64e2f86647
@@ -20,6 +20,7 @@ def _shift(
|
||||
min_staff=1,
|
||||
ideal_staff=1,
|
||||
already_assigned=0,
|
||||
predecessor_shift_id: str | None = None,
|
||||
) -> SolverShift:
|
||||
return SolverShift(
|
||||
id=sid,
|
||||
@@ -29,6 +30,7 @@ def _shift(
|
||||
min_staff=min_staff,
|
||||
ideal_staff=ideal_staff,
|
||||
already_assigned=already_assigned,
|
||||
predecessor_shift_id=predecessor_shift_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -138,18 +140,77 @@ def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_who_would_exceed_eight_hours_that_day():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 12))], # 6 h déjà
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
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")
|
||||
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_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")
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
assert _by_shift(solution, "s1") == ("chained",)
|
||||
|
||||
|
||||
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")
|
||||
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]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
|
||||
def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
# Le créneau (14 h - 18 h) chevauche le passage de l'artiste (16 h - 17 h)
|
||||
# que « fan » veut voir : le solveur préfère « indiff », qui n'a pas ce
|
||||
|
||||
Reference in New Issue
Block a user