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
+24 -20
View File
@@ -408,28 +408,32 @@ function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
.map((a) => shiftById(a.shift_id));
}
/** Créneau de même poste, même jour, juste avant chaque créneau (indexé par
* id de créneau) — sert au malus de chaînage (voir `solverScore`). */
function buildPredecessorMap(): Map<string, string> {
const byPosition = new Map<string, ShiftRead[]>();
/** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de
* chaque créneau, le même jour (indexé par id de créneau) — sert au malus de
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
* plutôt que cibler la rotation d'un poste en particulier. */
function buildPredecessorMap(): Map<string, Set<string>> {
const byDay = new Map<string, ShiftRead[]>();
for (const s of state.shifts) {
const list = byPosition.get(s.position_id) ?? [];
const day = s.starts_at.slice(0, 10);
const list = byDay.get(day) ?? [];
list.push(s);
byPosition.set(s.position_id, list);
byDay.set(day, list);
}
const predecessor = new Map<string, string>();
for (const positionShifts of byPosition.values()) {
const byDay = new Map<string, ShiftRead[]>();
for (const s of positionShifts) {
const day = s.starts_at.slice(0, 10);
const list = byDay.get(day) ?? [];
list.push(s);
byDay.set(day, list);
const predecessor = new Map<string, Set<string>>();
for (const dayShifts of byDay.values()) {
const idsByEnd = new Map<string, string[]>();
for (const s of dayShifts) {
const list = idsByEnd.get(s.ends_at) ?? [];
list.push(s.id);
idsByEnd.set(s.ends_at, list);
}
for (const dayShifts of byDay.values()) {
dayShifts.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
for (let i = 1; i < dayShifts.length; i += 1) {
predecessor.set(dayShifts[i].id, dayShifts[i - 1].id);
const distinctEnds = [...idsByEnd.keys()].sort();
for (const s of dayShifts) {
const earlierEnds = distinctEnds.filter((e) => e <= s.starts_at);
if (earlierEnds.length > 0) {
const latest = earlierEnds[earlierEnds.length - 1];
predecessor.set(s.id, new Set(idsByEnd.get(latest)));
}
}
}
@@ -529,8 +533,8 @@ function runSolver(): SolverRunResult {
})
.map((v) => {
const existing = existingByVolunteer.get(v.id) ?? [];
const predecessorId = predecessorByShift.get(shift.id);
const chained = predecessorId != null && existing.some((s) => s.id === predecessorId);
const predecessorIds = predecessorByShift.get(shift.id);
const chained = predecessorIds != null && existing.some((s) => predecessorIds.has(s.id));
return {
volunteer: v,
score: solverScore(