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:
co-authored by
Claude Sonnet 5
parent
36d108b47d
commit
803db5e9a9
@@ -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() == []
|
||||
@@ -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",)
|
||||
Reference in New Issue
Block a user