Solveur : retire le plafond de cumul journalier, ajoute un malus de chaînage

- Retiré : le solveur ne pénalise plus un bénévole qui dépasserait 8 h sur
  la journée (`_daily_overage_minutes`/`_DAILY_OVERAGE_PENALTY_PER_MIN`,
  côté back et mock). Seule la validation le signale encore, à la
  confirmation (`DAILY_HOURS_EXCEEDED`, inchangée). `SolverInput.day_parts`
  et son chargement sont retirés au passage : ils ne servaient qu'à ça.
- Ajouté : malus (-40) si le bénévole a déjà (affectation décidée) le
  créneau précédent de ce même poste, le même jour
  (`SolverShift.predecessor_shift_id`, calculé par le loader à partir de
  tous les créneaux du poste — pas seulement ceux à compléter). Favorise la
  rotation plutôt que d'enchaîner toujours la même personne sur un poste.
  Reste un malus, pas une exclusion : le seul candidat pour un créneau
  urgent est quand même proposé.
- Miroir côté mock (`runSolver`) pour que la démonstration se comporte pareil.
- CLAUDE.md à jour (§4).

Tests : remplacé le test du plafond journalier par un test de non-régression
explicite (le cumul n'est plus pénalisé) + un test isolant le repos (qui,
lui, reste vérifié) ; ajouté 3 tests pour le chaînage (préfère un bénévole
frais, reste proposé si c'est le seul candidat urgent, un créneau existant
non-prédécesseur ne déclenche pas le malus).

Vérifié : 183 tests backend, 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:12:30 +02:00
co-authored by Claude Sonnet 5
parent e405590bbc
commit 64e2f86647
6 changed files with 176 additions and 94 deletions
+50 -20
View File
@@ -389,10 +389,12 @@ const SOLVER_URGENT_BASE = 1000;
const SOLVER_NORMAL_BASE = 20;
const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) :
// favorise la rotation plutôt que d'enchaîner toujours la même personne.
const SOLVER_CHAIN_PENALTY = 40;
type TimeWindow = { starts_at: string; ends_at: string };
@@ -406,6 +408,34 @@ 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[]>();
for (const s of state.shifts) {
const list = byPosition.get(s.position_id) ?? [];
list.push(s);
byPosition.set(s.position_id, 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);
}
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);
}
}
}
return predecessor;
}
/** Fenêtres datées des passages que le bénévole veut voir (un créneau qui
* chevauche l'une d'elles lui ferait manquer le passage). */
function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] {
@@ -424,26 +454,19 @@ function solverScore(
assignedCount: number,
existing: ShiftRead[],
wishedArtists: TimeWindow[],
chainedFromPredecessor: boolean,
): number {
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) {
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
}
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
const dayKey = shift.starts_at.slice(0, 10);
let totalMin = naiveMinutes(shift.ends_at) - naiveMinutes(shift.starts_at);
for (const other of existing) {
if (other.starts_at.slice(0, 10) === dayKey) {
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
}
}
score -= Math.max(0, totalMin - EIGHT_HOURS_MIN) * SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN;
for (const other of existing) {
if (overlaps(shift, other)) continue;
let gap: number | null = null;
@@ -482,6 +505,7 @@ function runSolver(): SolverRunResult {
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
}
const predecessorByShift = buildPredecessorMap();
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
// fois un même bénévole sur des créneaux qui se chevauchent.
const takenByVolunteer = new Map<string, ShiftRead[]>();
@@ -503,16 +527,22 @@ function runSolver(): SolverRunResult {
];
return !busy.some((other) => overlaps(shift, other));
})
.map((v) => ({
volunteer: v,
score: solverScore(
v,
shift,
assignedCount.get(shift.id) ?? 0,
existingByVolunteer.get(v.id) ?? [],
artistWindowsByVolunteer.get(v.id) ?? [],
),
}))
.map((v) => {
const existing = existingByVolunteer.get(v.id) ?? [];
const predecessorId = predecessorByShift.get(shift.id);
const chained = predecessorId != null && existing.some((s) => s.id === predecessorId);
return {
volunteer: v,
score: solverScore(
v,
shift,
assignedCount.get(shift.id) ?? 0,
existing,
artistWindowsByVolunteer.get(v.id) ?? [],
chained,
),
};
})
.filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, gap);