Solveur de suggestions (OR-Tools CP-SAT) — ne place jamais personne

Nouveau module `backend/app/solver/` (mêmes conventions que
`validation/` : models purs, engine pur testé, loader seul lien avec la
base). Pour chaque créneau encore sous son effectif idéal, calcule une
liste ordonnée de bénévoles candidats — c'est la coordination qui
affecte, dans l'écran habituel ; le solveur fait juste remonter ces
candidats en tête de liste (voir le câblage frontend à suivre).

- Seule contrainte dure : aucun chevauchement de créneaux pour un même
  bénévole (règle SHIFT_OVERLAP), à la fois contre l'existant et entre
  deux suggestions du même calcul. L'effectif idéal est un plafond dur.
- Tout le reste est un terme de l'objectif, pas un blocage : priorité
  forte aux créneaux sous l'effectif minimal, bonus vœu de poste, malus
  disponibilité manquante (réutilise `validation.engine.uncovered_minutes`),
  cumul de 8 h et repos (contre les affectations déjà décidées),
  répartition de la charge.
- `POST /editions/{id}/solver/suggestions` (COORDINATION/référent) :
  recalcule à la demande, ne persiste jamais d'affectation.

Dépendance `ortools`. 18 nouveaux tests (moteur + API), 172 tests au
vert, `ruff` et `alembic check` verts. Testé sur le jeu de démo (120
bénévoles, 100 créneaux) : ~0,3 s.

CLAUDE.md : nouvelle section 4 (solveur), renumérotation 5-10,
compétences retirées de « hors périmètre », journal étape 15.

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-11 08:16:17 +02:00
co-authored by Claude Sonnet 5
parent 36d108b47d
commit 803db5e9a9
12 changed files with 818 additions and 11 deletions
+68
View File
@@ -0,0 +1,68 @@
import uuid
from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _url(edition, suffix=""):
return f"/editions/{edition.id}/solver{suffix}"
def test_suggests_a_free_volunteer_for_an_understaffed_shift(client, edition, session):
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=1,
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
resp = client.post(_url(edition, "/suggestions"))
assert resp.status_code == 200
body = resp.json()
assert body["shifts_considered"] == 1
assert body["volunteers_considered"] == 1
suggestion = next(s for s in body["suggestions"] if s["shift_id"] == str(shift.id))
assert suggestion["volunteer_ids"] == [str(volunteer.id)]
def test_fully_staffed_shift_has_no_suggestion(client, edition, session):
from app.models import Assignment
position = make_position(session, edition)
shift = make_shift(
session, edition, position,
start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=1,
)
volunteer = make_volunteer(session, edition, email="v@ex.fr")
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.post(_url(edition, "/suggestions"))
assert resp.status_code == 200
shift_ids = {s["shift_id"] for s in resp.json()["suggestions"]}
assert str(shift.id) not in shift_ids
def test_unknown_edition_is_404(client):
assert client.post(f"/editions/{uuid.uuid4()}/solver/suggestions").status_code == 404
def test_volunteer_role_forbidden(edition, session, make_account, as_account):
account = make_account(Role.VOLUNTEER, email="v@e.fr")
api = as_account(account)
assert api.post(_url(edition, "/suggestions")).status_code == 403
def test_does_not_persist_any_assignment(client, edition, session):
from sqlmodel import select
from app.models import Assignment
position = make_position(session, edition)
make_shift(session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18))
make_volunteer(session, edition, email="v@ex.fr")
client.post(_url(edition, "/suggestions"))
assert session.exec(select(Assignment)).all() == []
+148
View File
@@ -0,0 +1,148 @@
"""Tests du solveur (suggestions, jamais d'affectation automatique)."""
import datetime as dt
from app.solver.engine import solve
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.models import AvailabilityWindow, ShiftInfo
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
return dt.datetime(2026, 7, day, hour, minute)
def _shift(
sid: str = "s1",
*,
position_id: str = "p1",
start: dt.datetime | None = None,
end: dt.datetime | None = None,
min_staff=1,
ideal_staff=1,
already_assigned=0,
) -> SolverShift:
return SolverShift(
id=sid,
position_id=position_id,
starts_at=start or _dt(10, 14),
ends_at=end or _dt(10, 18),
min_staff=min_staff,
ideal_staff=ideal_staff,
already_assigned=already_assigned,
)
def _volunteer(
vid: str,
*,
availability=(),
wished_position_ids=frozenset(),
existing_shifts=(),
existing_minutes=0,
) -> SolverVolunteer:
return SolverVolunteer(
id=vid,
availability=tuple(availability),
wished_position_ids=frozenset(wished_position_ids),
existing_shifts=tuple(existing_shifts),
existing_minutes=existing_minutes,
)
def _full_day(day: int) -> AvailabilityWindow:
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
for s in solution.suggestions:
if s.shift_id == shift_id:
return s.volunteer_ids
return ()
def test_no_variables_returns_empty_solution():
data = SolverInput(shifts=(), volunteers=())
assert solve(data).suggestions == ()
def test_fully_staffed_shift_gets_no_suggestion():
shift = _shift(ideal_staff=1, already_assigned=1)
volunteer = _volunteer("v1", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
assert solution.suggestions == ()
def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift()
busy = _volunteer(
"busy",
availability=[_full_day(10)],
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
free = _volunteer("free", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
assert _by_shift(solution, "s1") == ("free",)
def test_does_not_exceed_ideal_staff_cap():
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
a = _volunteer("a", availability=[_full_day(10)])
b = _volunteer("b", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
assert len(_by_shift(solution, "s1")) == 1
def test_prefers_volunteer_with_matching_position_wish():
shift = _shift(position_id="regie")
plain = _volunteer("plain", availability=[_full_day(10)])
wisher = _volunteer(
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
assert _by_shift(solution, "s1") == ("wisher",)
def test_prefers_fully_available_volunteer_over_partially_available():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full",)
def test_ranks_both_candidates_when_two_slots_are_open():
shift = _shift(ideal_staff=2)
full = _volunteer("full", availability=[_full_day(10)])
partial = _volunteer(
"partial",
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
assert _by_shift(solution, "s1") == ("full", "partial")
def test_never_double_books_a_volunteer_across_two_new_suggestions():
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
# deux : le solveur ne le propose qu'une fois.
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
only = _volunteer("only", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
picked_a = _by_shift(solution, "a")
picked_b = _by_shift(solution, "b")
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
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à
)
fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("fresh",)