Grille : les candidats sont classés par score de chance, pas par charge
Dans le panneau d'affectation, la liste des candidats d'un créneau n'était triée que par disponibilité puis par nombre d'heures déjà assignées — les vœux n'y jouaient aucun rôle tant qu'on ne cliquait pas sur « Suggestions (solveur) ». Nouveau `candidateChanceScore` (`lib/planning.ts`), portage local et synchrone du scoring du solveur : vœu de poste (bonus), disponibilité déclarée y compris conflit avec un artiste souhaité (malus), charge déjà assignée (malus). C'est ce score qui classe désormais la liste par défaut, dès l'ouverture du détail d'un poste — sans requête réseau ni clic sur le solveur. Si celui-ci a effectivement tourné, ses candidats retenus (vraie optimisation jointe) restent prioritaires en tête. Chaque candidat affiche maintenant, en plus de sa disponibilité, des badges « Vœu de poste » et « Manquerait un artiste souhaité » quand applicable — pour que le classement reste lisible. 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:
co-authored by
Claude Sonnet 5
parent
282be85498
commit
55f634e25b
@@ -279,6 +279,16 @@ affectation n'est jamais persistée par cet endpoint.
|
|||||||
même calcul — la validation finale à la confirmation reste seule juge de
|
même calcul — la validation finale à la confirmation reste seule juge de
|
||||||
ce cas limite.
|
ce cas limite.
|
||||||
|
|
||||||
|
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
|
||||||
|
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
|
||||||
|
poste, disponibilité y compris conflit d'artiste, charge déjà affectée ; pas
|
||||||
|
de cumul 8 h / repos, déjà couverts par les badges de validation). C'est ce
|
||||||
|
qui classe la colonne des candidats dans le panneau d'affectation dès
|
||||||
|
l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a
|
||||||
|
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
|
||||||
|
passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation
|
||||||
|
jointe, pas juste un score local).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Périmètre
|
## 5. Périmètre
|
||||||
|
|||||||
@@ -299,5 +299,7 @@
|
|||||||
"grille.solver.result": "{count} suggestion(s)",
|
"grille.solver.result": "{count} suggestion(s)",
|
||||||
"grille.panel.suggested": "Suggéré",
|
"grille.panel.suggested": "Suggéré",
|
||||||
"config.artists.startTime": "Heure de début du passage",
|
"config.artists.startTime": "Heure de début du passage",
|
||||||
"config.artists.endTime": "Heure de fin du passage"
|
"config.artists.endTime": "Heure de fin du passage",
|
||||||
|
"grille.panel.wishesPosition": "Vœu de poste",
|
||||||
|
"grille.panel.artistConflict": "Manquerait un artiste souhaité"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
@@ -79,6 +80,63 @@ export function availabilityCoverage(
|
|||||||
return gap >= total ? 'none' : 'partial';
|
return gap >= total ? 'none' : 'partial';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Score de chance d'un candidat pour un créneau — portage simplifié du //
|
||||||
|
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
|
||||||
|
// candidats dans le panneau d'affectation sans lancer de calcul CP-SAT. //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface TimeWindow {
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _addDaysIso = (dateIso: string, n: number): string => {
|
||||||
|
const [y, m, d] = dateIso.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fenêtre datée du passage d'un artiste (même convention que les créneaux :
|
||||||
|
* `end_time <= start_time` ⇒ le passage finit le lendemain). */
|
||||||
|
export function artistPerformanceWindow(artist: ArtistRead): TimeWindow {
|
||||||
|
const endDate =
|
||||||
|
artist.end_time > artist.start_time
|
||||||
|
? artist.performance_date
|
||||||
|
: _addDaysIso(artist.performance_date, 1);
|
||||||
|
return {
|
||||||
|
starts_at: `${artist.performance_date}T${artist.start_time}`,
|
||||||
|
ends_at: `${endDate}T${artist.end_time}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowsOverlap(a: TimeWindow, b: TimeWindow): boolean {
|
||||||
|
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANCE_WISH_POSITION_BONUS = 30;
|
||||||
|
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||||
|
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||||
|
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
|
||||||
|
* (y compris le conflit avec un artiste qu'il veut voir), charge déjà
|
||||||
|
* assignée. Plus haut = meilleur candidat. Sert uniquement à trier la liste
|
||||||
|
* affichée ; la validation (blocage / avertissements) reste seule juge.
|
||||||
|
*/
|
||||||
|
export function candidateChanceScore(params: {
|
||||||
|
wishesPosition: boolean;
|
||||||
|
uncoveredMinutes: number;
|
||||||
|
conflictsWishedArtist: boolean;
|
||||||
|
assignedHours: number;
|
||||||
|
}): number {
|
||||||
|
let score = 0;
|
||||||
|
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
|
||||||
|
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||||
|
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
|
||||||
|
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
/** Colonne de la grille : une (date, heure de début) distincte. */
|
/** Colonne de la grille : une (date, heure de début) distincte. */
|
||||||
export interface LatticeColumn {
|
export interface LatticeColumn {
|
||||||
key: string; // `YYYY-MM-DD|HH:MM`
|
key: string; // `YYYY-MM-DD|HH:MM`
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export function GrillePage() {
|
|||||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<AssignmentPanel
|
<AssignmentPanel
|
||||||
|
positionId={selected.position.id}
|
||||||
positionName={selected.position.name}
|
positionName={selected.position.name}
|
||||||
positionLocation={selected.position.location}
|
positionLocation={selected.position.location}
|
||||||
positionCategory={selected.position.category}
|
positionCategory={selected.position.category}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import {
|
import {
|
||||||
|
useArtists,
|
||||||
useAssignments,
|
useAssignments,
|
||||||
useCreateAssignment,
|
useCreateAssignment,
|
||||||
useDeleteAssignment,
|
useDeleteAssignment,
|
||||||
@@ -21,18 +22,21 @@ import type {
|
|||||||
import { Btn } from '@/components/ui';
|
import { Btn } from '@/components/ui';
|
||||||
import { Loading } from '@/components/state';
|
import { Loading } from '@/components/state';
|
||||||
import {
|
import {
|
||||||
|
artistPerformanceWindow,
|
||||||
availabilityCoverage,
|
availabilityCoverage,
|
||||||
|
candidateChanceScore,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
hoursByVolunteer,
|
hoursByVolunteer,
|
||||||
|
uncoveredMinutes,
|
||||||
violationText,
|
violationText,
|
||||||
|
windowsOverlap,
|
||||||
type Coverage,
|
type Coverage,
|
||||||
} from '@/lib/planning';
|
} from '@/lib/planning';
|
||||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
const COVERAGE_ORDER: Record<Coverage, number> = { full: 0, partial: 1, none: 2 };
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
positionId: string;
|
||||||
positionName: string;
|
positionName: string;
|
||||||
positionLocation: string | null;
|
positionLocation: string | null;
|
||||||
positionCategory: PositionCategory;
|
positionCategory: PositionCategory;
|
||||||
@@ -43,6 +47,7 @@ interface Props {
|
|||||||
const MAX_CANDIDATES = 10;
|
const MAX_CANDIDATES = 10;
|
||||||
|
|
||||||
export function AssignmentPanel({
|
export function AssignmentPanel({
|
||||||
|
positionId,
|
||||||
positionName,
|
positionName,
|
||||||
positionLocation,
|
positionLocation,
|
||||||
positionCategory,
|
positionCategory,
|
||||||
@@ -55,6 +60,7 @@ export function AssignmentPanel({
|
|||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const availability = useEditionAvailability();
|
const availability = useEditionAvailability();
|
||||||
|
const artists = useArtists();
|
||||||
const solverSuggestions = useSolverSuggestions();
|
const solverSuggestions = useSolverSuggestions();
|
||||||
const deleteAssignment = useDeleteAssignment();
|
const deleteAssignment = useDeleteAssignment();
|
||||||
|
|
||||||
@@ -65,22 +71,31 @@ export function AssignmentPanel({
|
|||||||
return entry?.volunteer_ids ?? [];
|
return entry?.volunteer_ids ?? [];
|
||||||
}, [solverSuggestions.data, shift.shift_id]);
|
}, [solverSuggestions.data, shift.shift_id]);
|
||||||
|
|
||||||
const coverageByVolunteer = useMemo(() => {
|
const windowsByVolunteer = useMemo(() => {
|
||||||
const byVol = new Map<string, AvailabilityRead[]>();
|
const byVol = new Map<string, AvailabilityRead[]>();
|
||||||
for (const w of availability.data ?? []) {
|
for (const w of availability.data ?? []) {
|
||||||
const list = byVol.get(w.volunteer_id) ?? [];
|
const list = byVol.get(w.volunteer_id) ?? [];
|
||||||
list.push(w);
|
list.push(w);
|
||||||
byVol.set(w.volunteer_id, list);
|
byVol.set(w.volunteer_id, list);
|
||||||
}
|
}
|
||||||
|
return byVol;
|
||||||
|
}, [availability.data]);
|
||||||
|
|
||||||
|
const coverageByVolunteer = useMemo(() => {
|
||||||
const map = new Map<string, Coverage>();
|
const map = new Map<string, Coverage>();
|
||||||
for (const v of volunteers.data ?? []) {
|
for (const v of volunteers.data ?? []) {
|
||||||
map.set(
|
map.set(
|
||||||
v.id,
|
v.id,
|
||||||
availabilityCoverage(byVol.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [availability.data, volunteers.data, shift.starts_at, shift.ends_at]);
|
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
|
||||||
|
|
||||||
|
const artistsById = useMemo(
|
||||||
|
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
|
||||||
|
[artists.data],
|
||||||
|
);
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||||
@@ -111,25 +126,61 @@ export function AssignmentPanel({
|
|||||||
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
|
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
|
||||||
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
|
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
|
||||||
|
|
||||||
|
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
|
||||||
|
// qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée).
|
||||||
|
const detailsByVolunteer = useMemo(() => {
|
||||||
|
const map = new Map<string, { wishesPosition: boolean; conflictsWishedArtist: boolean }>();
|
||||||
|
for (const v of volunteers.data ?? []) {
|
||||||
|
const conflictsWishedArtist = v.artist_ids.some((id) => {
|
||||||
|
const artist = artistsById.get(id);
|
||||||
|
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
|
||||||
|
});
|
||||||
|
map.set(v.id, {
|
||||||
|
wishesPosition: v.wished_position_ids.includes(positionId),
|
||||||
|
conflictsWishedArtist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [volunteers.data, artistsById, shift, positionId]);
|
||||||
|
|
||||||
|
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
|
||||||
|
// disponibilité (dont conflit avec un artiste qu'il veut voir), charge déjà
|
||||||
|
// assignée — c'est ce qui classe la liste, pas juste « le moins chargé ».
|
||||||
|
const scoreByVolunteer = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const v of volunteers.data ?? []) {
|
||||||
|
const details = detailsByVolunteer.get(v.id);
|
||||||
|
map.set(
|
||||||
|
v.id,
|
||||||
|
candidateChanceScore({
|
||||||
|
wishesPosition: details?.wishesPosition ?? false,
|
||||||
|
uncoveredMinutes: uncoveredMinutes(
|
||||||
|
windowsByVolunteer.get(v.id) ?? [],
|
||||||
|
shift.starts_at,
|
||||||
|
shift.ends_at,
|
||||||
|
),
|
||||||
|
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
|
||||||
|
assignedHours: hours.get(v.id) ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
|
||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||||
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
|
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
|
||||||
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
|
|
||||||
return (volunteers.data ?? [])
|
return (volunteers.data ?? [])
|
||||||
.filter((v) => !assignedIds.has(v.id))
|
.filter((v) => !assignedIds.has(v.id))
|
||||||
// Le solveur d'abord (dans son ordre), puis les disponibles, puis les
|
// Le solveur d'abord (dans son ordre, s'il a tourné), puis par score de
|
||||||
// moins chargés.
|
// chance décroissant (vœu de poste, disponibilité, charge).
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const ra = suggestedRank.get(a.id) ?? Infinity;
|
const ra = suggestedRank.get(a.id) ?? Infinity;
|
||||||
const rb = suggestedRank.get(b.id) ?? Infinity;
|
const rb = suggestedRank.get(b.id) ?? Infinity;
|
||||||
return (
|
return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0);
|
||||||
ra - rb ||
|
|
||||||
cov(a.id) - cov(b.id) ||
|
|
||||||
(hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0)
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.slice(0, MAX_CANDIDATES);
|
.slice(0, MAX_CANDIDATES);
|
||||||
}, [volunteers.data, assigned, hours, coverageByVolunteer, suggestedIds]);
|
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds]);
|
||||||
|
|
||||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
@@ -225,6 +276,10 @@ export function AssignmentPanel({
|
|||||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||||
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
||||||
|
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
|
||||||
|
conflictsWishedArtist={
|
||||||
|
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
|
||||||
|
}
|
||||||
suggested={suggestedIds.includes(volunteer.id)}
|
suggested={suggestedIds.includes(volunteer.id)}
|
||||||
resolvePosition={resolvePosition}
|
resolvePosition={resolvePosition}
|
||||||
/>
|
/>
|
||||||
@@ -246,6 +301,8 @@ function CandidateRow({
|
|||||||
hours,
|
hours,
|
||||||
slots,
|
slots,
|
||||||
coverage,
|
coverage,
|
||||||
|
wishesPosition,
|
||||||
|
conflictsWishedArtist,
|
||||||
suggested,
|
suggested,
|
||||||
resolvePosition,
|
resolvePosition,
|
||||||
}: {
|
}: {
|
||||||
@@ -254,6 +311,8 @@ function CandidateRow({
|
|||||||
hours: number;
|
hours: number;
|
||||||
slots: number;
|
slots: number;
|
||||||
coverage: Coverage;
|
coverage: Coverage;
|
||||||
|
wishesPosition: boolean;
|
||||||
|
conflictsWishedArtist: boolean;
|
||||||
suggested: boolean;
|
suggested: boolean;
|
||||||
resolvePosition: (shiftId: string) => string | undefined;
|
resolvePosition: (shiftId: string) => string | undefined;
|
||||||
}) {
|
}) {
|
||||||
@@ -304,12 +363,22 @@ function CandidateRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-[7px] pb-[7px]">
|
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||||
<span
|
<span
|
||||||
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
|
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
|
||||||
>
|
>
|
||||||
{t(COVERAGE_STYLE[coverage].key)}
|
{t(COVERAGE_STYLE[coverage].key)}
|
||||||
</span>
|
</span>
|
||||||
|
{wishesPosition ? (
|
||||||
|
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('grille.panel.wishesPosition')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{conflictsWishedArtist ? (
|
||||||
|
<span className="inline-block border-[1.5px] border-ink bg-orange px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('grille.panel.artistConflict')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{blocking ? (
|
{blocking ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user