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