Front : suggestions du solveur en tête de liste dans la grille

- Surface API `solver.run(editionId)` (HTTP + mock) et hooks
  `useRunSolver` (mutation, alimente le cache) / `useSolverSuggestions`
  (lecture seule du cache, ne déclenche jamais de calcul).
- Grille des besoins : bouton « Suggestions (solveur) », affiche le
  nombre de suggestions calculées.
- Panneau d'affectation d'un créneau : les bénévoles suggérés par le
  solveur passent en tête de la liste des candidats (avant le tri par
  disponibilité / charge), avec un badge « Suggéré ». Ne pré-affecte
  jamais personne — la coordination choisit et confirme comme avant.
- Mock : portage glouton (par créneau, du plus urgent au moins urgent)
  du scoring du moteur back-end, pour une démonstration cohérente sans
  CP-SAT côté navigateur.

typecheck / lint / build / smoke au vert.

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:29 +02:00
co-authored by Claude Sonnet 5
parent 803db5e9a9
commit e47c29d35b
8 changed files with 251 additions and 8 deletions
+140
View File
@@ -22,8 +22,11 @@ import type {
MatrixResponse,
PlanningNotificationRequest,
ShiftRead,
SolverRunResult,
SolverSuggestion,
TokenResponse,
Violation,
VolunteerRead,
} from '../schema';
import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types';
@@ -375,6 +378,139 @@ function buildMatrix(): MatrixResponse {
};
}
// --------------------------------------------------------------------------- //
// Solveur (suggestions) — portage glouton du moteur CP-SAT du back-end //
// (`backend/app/solver/engine.py`). Approximation : traite les créneaux les //
// plus urgents en premier et choisit, pour chacun, les meilleurs candidats //
// libres — pas une optimisation jointe comme CP-SAT, mais un ordre de //
// grandeur cohérent pour la démonstration. Ne modifie jamais `state`. //
// --------------------------------------------------------------------------- //
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_FAIRNESS_PENALTY_PER_HOUR = 2;
function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
return state.assignments
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftById(a.shift_id));
}
function solverScore(
volunteer: VolunteerRead,
shift: ShiftRead,
assignedCount: number,
existing: ShiftRead[],
): 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;
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;
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
}
if (gap !== null && gap < EIGHT_HOURS_MIN) {
score -= SOLVER_REST_PENALTY;
break;
}
}
const existingMinutes = existing.reduce(
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
0,
);
score -= Math.floor((SOLVER_FAIRNESS_PENALTY_PER_HOUR * existingMinutes) / 60);
return score;
}
function runSolver(): SolverRunResult {
const assignedCount = new Map<string, number>();
for (const a of state.assignments) {
assignedCount.set(a.shift_id, (assignedCount.get(a.shift_id) ?? 0) + 1);
}
const candidateShifts = state.shifts.filter(
(s) => (assignedCount.get(s.id) ?? 0) < s.ideal_staff,
);
const existingByVolunteer = new Map<string, ShiftRead[]>();
for (const v of state.volunteers) {
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
}
// 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[]>();
const ordered = [...candidateShifts].sort((a, b) => {
const urgent = (s: ShiftRead) => ((assignedCount.get(s.id) ?? 0) < s.min_staff ? 0 : 1);
return urgent(a) - urgent(b);
});
const suggestions: SolverSuggestion[] = [];
for (const shift of ordered) {
const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0);
if (gap <= 0) continue;
const picked = state.volunteers
.filter((v) => {
const busy = [
...(existingByVolunteer.get(v.id) ?? []),
...(takenByVolunteer.get(v.id) ?? []),
];
return !busy.some((other) => overlaps(shift, other));
})
.map((v) => ({
volunteer: v,
score: solverScore(
v,
shift,
assignedCount.get(shift.id) ?? 0,
existingByVolunteer.get(v.id) ?? [],
),
}))
.filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, gap);
if (picked.length > 0) {
suggestions.push({
shift_id: shift.id,
volunteer_ids: picked.map((c) => c.volunteer.id),
});
for (const c of picked) {
const list = takenByVolunteer.get(c.volunteer.id) ?? [];
list.push(shift);
takenByVolunteer.set(c.volunteer.id, list);
}
}
}
return {
suggestions,
shifts_considered: candidateShifts.length,
volunteers_considered: state.volunteers.length,
};
}
export function createMockApi(): PlanningApi {
return {
editions: {
@@ -926,6 +1062,10 @@ export function createMockApi(): PlanningApi {
get: () => delay(buildMatrix()),
},
solver: {
run: () => delay(runSolver()),
},
notifications: {
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
const ids = body.volunteer_ids ?? null;