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:
co-authored by
Claude Sonnet 5
parent
e405590bbc
commit
64e2f86647
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user