Deux postes en même temps possibles, malus dans le solveur
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 27s

Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il
remonte en avertissement et l'affectation est enregistrée. Le solveur ne
l'exclut plus mais applique un fort malus au bénévole déjà affecté
ailleurs au même moment (proposé seulement sous l'effectif minimal, faute
de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà.
Même malus dans le classement par défaut du panneau et dans le mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-25 10:38:52 +01:00
co-authored by Claude Opus 5.5
parent d0fa1e109c
commit 0f904f5e3d
11 changed files with 114 additions and 37 deletions
+15 -5
View File
@@ -5,8 +5,10 @@ effectif idéal, une liste ordonnée de bénévoles candidats. C'est la
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
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
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (chaînage entre
Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
même moment reste proposable (la validation ne fait qu'avertir,
`SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
par journée ni de temps de repos : ni le solveur ni la validation n'en
@@ -42,6 +44,11 @@ _CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
_COMPANION_BONUS_PER_MATCH = 25
# Le bénévole est déjà affecté (affectation décidée) à un créneau qui
# chevauche celui-ci. Supérieur à tous les bonus cumulables et à la base
# normale : il n'est proposé que pour un créneau sous son minimum, et
# seulement faute de mieux.
_ALREADY_BUSY_PENALTY = 300
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -78,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
):
score -= _ARTIST_CONFLICT_PENALTY
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
score -= _ALREADY_BUSY_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score
@@ -98,7 +107,8 @@ def solve(
if gap <= 0:
continue
for vi, volunteer in enumerate(data.volunteers):
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
# Déjà affecté à ce créneau : rien à suggérer.
if any(other.id == shift.id for other in volunteer.existing_shifts):
continue
key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
@@ -115,8 +125,8 @@ def solve(
if shift_vars and gap > 0:
model.Add(sum(shift_vars) <= gap)
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même
# bénévole (règle `SHIFT_OVERLAP`, bloquante).
# Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
# un même calcul (contre les affectations décidées : malus, cf. `_score`).
for vi in range(len(data.volunteers)):
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
for a in range(len(my_shifts)):
+1 -1
View File
@@ -26,7 +26,7 @@ def has_blocking(violations: list[Violation]) -> bool:
# --------------------------------------------------------------------------- #
# SHIFT_OVERLAP (bloquante) #
# SHIFT_OVERLAP (avertissement) #
# --------------------------------------------------------------------------- #
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
+3 -1
View File
@@ -21,7 +21,9 @@ class ViolationCode(StrEnum):
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
# Avertissement seulement : un bénévole peut tenir deux postes à la fois
# (le solveur l'évite par un malus, cf. `app.solver.engine`).
ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
}
+10 -7
View File
@@ -51,7 +51,11 @@ def test_long_day_without_rest_raises_no_warning(client, edition, session, day_p
assert resp.json()["warnings"] == []
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
def test_create_assignment_overlap_is_allowed_with_warning(
client, edition, session, day_parts
):
# Un bénévole peut tenir deux postes en même temps : l'affectation est
# enregistrée, le chevauchement remonte en avertissement.
volunteer, shift = _setup(session, edition)
assert client.post(
_url(edition),
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
"shift_id": str(overlapping.id),
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "error.validation_failed"
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"])
# Rien n'a été enregistré pour ce créneau.
assert resp.status_code == 201
warnings = resp.json()["warnings"]
assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
assert warnings[0]["severity"] == "WARNING"
listed = client.get(
_url(edition), params={"shift_id": str(overlapping.id)}
).json()
assert listed == []
assert len(listed) == 1
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
+34
View File
@@ -74,6 +74,40 @@ def test_fully_staffed_shift_gets_no_suggestion():
assert solution.suggestions == ()
def test_busy_volunteer_is_still_suggested_for_an_urgent_gap_as_last_resort():
# Déjà affecté ailleurs au même moment : malus, mais seul candidat pour un
# créneau sous son minimum ⇒ proposé quand même.
shift = _shift(min_staff=1, ideal_staff=1)
busy = _volunteer(
"busy",
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ("busy",)
def test_volunteer_already_on_the_shift_is_never_suggested_again():
shift = _shift(min_staff=2, ideal_staff=2, already_assigned=1)
on_it = _volunteer(
"on_it",
existing_shifts=[ShiftInfo(id="s1", starts_at=_dt(10, 14), ends_at=_dt(10, 18))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(on_it,)))
assert _by_shift(solution, "s1") == ()
def test_busy_volunteer_is_not_suggested_above_the_minimum():
# Minimum déjà atteint : le malus l'emporte, personne n'est proposé.
shift = _shift(min_staff=1, ideal_staff=2, already_assigned=1)
busy = _volunteer(
"busy",
wished_position_ids={"p1"},
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ()
def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift()
busy = _volunteer(
+4 -3
View File
@@ -43,16 +43,17 @@ def test_overlap_pass_when_shifts_are_adjacent():
assert validate(data) == []
def test_overlap_triggers_and_is_blocking():
def test_overlap_triggers_as_a_warning_only():
data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
)
violations = validate(data)
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
assert violations[0].severity is Severity.BLOCKING
# Deux postes en même temps restent possibles : avertissement seulement.
assert violations[0].severity is Severity.WARNING
assert violations[0].params["other_shift_id"] == "e"
assert has_blocking(violations) is True
assert has_blocking(violations) is False
# --------------------------------------------------------------------------- #