diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 677f273..e81cb58 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -36,6 +36,7 @@ export const queryKeys = { assignments: (params?: { volunteerId?: string; shiftId?: string }) => ['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const, matrix: ['matrix', edition] as const, + solver: ['solver', edition] as const, validate: (volunteerId: string, shiftId: string) => ['validate', edition, volunteerId, shiftId] as const, }; @@ -115,6 +116,19 @@ export function useMatrix() { return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) }); } +/** + * Dernières suggestions du solveur (lecture seule du cache posé par + * `useRunSolver`). Ne déclenche jamais de calcul elle-même. + */ +export function useSolverSuggestions() { + return useQuery({ + queryKey: queryKeys.solver, + queryFn: () => api.solver.run(edition), + enabled: false, + staleTime: Infinity, + }); +} + export function useValidateAssignment( volunteerId: string, shiftId: string, @@ -386,6 +400,18 @@ export function useDeleteAssignment() { }); } +/** + * Lance le solveur (CP-SAT) : calcule des suggestions, n'affecte jamais + * personne. Le résultat alimente le cache lu par `useSolverSuggestions`. + */ +export function useRunSolver() { + const client = useQueryClient(); + return useMutation({ + mutationFn: () => api.solver.run(edition), + onSuccess: (data) => client.setQueryData(queryKeys.solver, data), + }); +} + export function useSendPlanningNotification() { return useMutation({ mutationFn: (body: PlanningNotificationRequest) => diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index e6d1a35..8fb1829 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -178,6 +178,10 @@ export function createHttpApi(baseUrl: string): PlanningApi { matrix: { get: (editionId) => request(`${base(editionId)}/matrix`), }, + solver: { + run: (editionId) => + request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }), + }, notifications: { sendPlanning: (editionId, body: PlanningNotificationRequest) => request(`${base(editionId)}/notifications/planning`, { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index bf8e309..bb1a8d4 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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(); + 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(); + 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(); + + 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; diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b16602f..764f3e8 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -346,6 +346,21 @@ export interface AssignmentValidateResult { violations: Violation[]; } +// --------------------------------------------------------------------------- // +// Solveur (suggestions, CP-SAT) — ne place jamais personne // +// --------------------------------------------------------------------------- // +export interface SolverSuggestion { + shift_id: string; + /** Bénévoles suggérés pour ce créneau, meilleur en tête. */ + volunteer_ids: string[]; +} + +export interface SolverRunResult { + suggestions: SolverSuggestion[]; + shifts_considered: number; + volunteers_considered: number; +} + // --------------------------------------------------------------------------- // // Matrice poste × créneau // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7c9c2a9..e4813af 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -31,6 +31,7 @@ import type { ShiftCreate, ShiftRead, ShiftUpdate, + SolverRunResult, TokenResponse, VerifyRequest, Violation, @@ -118,6 +119,10 @@ export interface PlanningApi { matrix: { get(editionId: string): Promise; }; + solver: { + /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ + run(editionId: string): Promise; + }; notifications: { /** Récapitulatif de planning par email (COORDINATION). */ sendPlanning( diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 8875f0a..c0073c6 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -293,5 +293,9 @@ "grille.panel.coverage.partial": "Partiellement dispo", "grille.panel.coverage.none": "Dispo non déclarée", "availability.date_outside_edition": "Cette date est hors des dates de l'édition.", - "availability.empty_window": "Une plage doit avoir un début et une fin différents." + "availability.empty_window": "Une plage doit avoir un début et une fin différents.", + "grille.solver.run": "SUGGESTIONS (SOLVEUR)", + "grille.solver.running": "CALCUL EN COURS…", + "grille.solver.result": "{count} suggestion(s)", + "grille.panel.suggested": "Suggéré" } diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 4fabeaa..fe2e77b 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; -import { useMatrix } from '@/api/hooks'; +import { useMatrix, useRunSolver } from '@/api/hooks'; import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import { Chip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; @@ -12,6 +12,7 @@ import { cx } from '@/lib/cx'; export function GrillePage() { const matrix = useMatrix(); + const runSolver = useRunSolver(); const [dayFilter, setDayFilter] = useState<'all' | string>('all'); const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all'); const [selectedShiftId, setSelectedShiftId] = useState(null); @@ -81,6 +82,27 @@ export function GrillePage() { ))} +
+ + {runSolver.data ? ( + + {t('grille.solver.result', { count: runSolver.data.suggestions.length })} + + ) : null} + {runSolver.isError ? ( + + {t('error.generic')} + + ) : null} +
+
{t('legend.title')} diff --git a/frontend/src/pages/grille/AssignmentPanel.tsx b/frontend/src/pages/grille/AssignmentPanel.tsx index 41cb907..74252e9 100644 --- a/frontend/src/pages/grille/AssignmentPanel.tsx +++ b/frontend/src/pages/grille/AssignmentPanel.tsx @@ -8,6 +8,7 @@ import { useEditionAvailability, usePositions, useShifts, + useSolverSuggestions, useValidateAssignment, useVolunteers, } from '@/api/hooks'; @@ -54,8 +55,16 @@ export function AssignmentPanel({ const shifts = useShifts(); const positions = usePositions(); const availability = useEditionAvailability(); + const solverSuggestions = useSolverSuggestions(); const deleteAssignment = useDeleteAssignment(); + const suggestedIds = useMemo(() => { + const entry = solverSuggestions.data?.suggestions.find( + (s) => s.shift_id === shift.shift_id, + ); + return entry?.volunteer_ids ?? []; + }, [solverSuggestions.data, shift.shift_id]); + const coverageByVolunteer = useMemo(() => { const byVol = new Map(); for (const w of availability.data ?? []) { @@ -104,15 +113,23 @@ export function AssignmentPanel({ const candidates = useMemo(() => { const assignedIds = new Set(assigned.map((a) => a.volunteer_id)); + const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i])); const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none']; return (volunteers.data ?? []) .filter((v) => !assignedIds.has(v.id)) - // D'abord les bénévoles disponibles sur le créneau, puis les moins chargés. - .sort( - (a, b) => cov(a.id) - cov(b.id) || (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0), - ) + // Le solveur d'abord (dans son ordre), puis les disponibles, puis les + // moins chargés. + .sort((a, b) => { + const ra = suggestedRank.get(a.id) ?? Infinity; + const rb = suggestedRank.get(b.id) ?? Infinity; + return ( + ra - rb || + cov(a.id) - cov(b.id) || + (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0) + ); + }) .slice(0, MAX_CANDIDATES); - }, [volunteers.data, assigned, hours, coverageByVolunteer]); + }, [volunteers.data, assigned, hours, coverageByVolunteer, suggestedIds]); if (volunteers.isLoading || shiftAssignments.isLoading) { return ; @@ -208,6 +225,7 @@ export function AssignmentPanel({ hours={round(hours.get(volunteer.id) ?? 0)} slots={slotsByVolunteer.get(volunteer.id) ?? 0} coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'} + suggested={suggestedIds.includes(volunteer.id)} resolvePosition={resolvePosition} /> ))} @@ -228,6 +246,7 @@ function CandidateRow({ hours, slots, coverage, + suggested, resolvePosition, }: { volunteer: VolunteerRead; @@ -235,6 +254,7 @@ function CandidateRow({ hours: number; slots: number; coverage: Coverage; + suggested: boolean; resolvePosition: (shiftId: string) => string | undefined; }) { const validation = useValidateAssignment(volunteer.id, shiftId); @@ -248,13 +268,20 @@ function CandidateRow({ createAssignment.error instanceof ApiError ? createAssignment.error : null; return ( -
+
{initials(volunteer)} {volunteer.first_name} {volunteer.last_name} + {suggested ? ( + + {t('grille.panel.suggested')} + + ) : null} {t('grille.panel.hoursAssigned', { hours, slots })}