Deux postes en même temps possibles, malus dans le solveur
Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il remonte en avertissement et l'affectation est enregistrée. Le solveur ne l'exclut plus mais applique un fort malus au bénévole déjà affecté ailleurs au même moment (proposé seulement sous l'effectif minimal, faute de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà. Même malus dans le classement par défaut du panneau et dans le mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d0fa1e109c
commit
0f904f5e3d
@@ -446,12 +446,12 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
// SHIFT_OVERLAP (bloquante)
|
||||
// SHIFT_OVERLAP (avertissement : deux postes à la fois restent possibles)
|
||||
for (const other of existing) {
|
||||
if (overlaps(candidate, other)) {
|
||||
violations.push({
|
||||
code: 'SHIFT_OVERLAP',
|
||||
severity: 'BLOCKING',
|
||||
severity: 'WARNING',
|
||||
params: {
|
||||
other_shift_id: other.id,
|
||||
other_starts_at: other.starts_at,
|
||||
@@ -643,6 +643,8 @@ const SOLVER_CHAIN_PENALTY = 40;
|
||||
// traité comme réciproque : si A souhaite B, B en profite aussi (voir
|
||||
// `buildEffectiveCompanions`).
|
||||
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
|
||||
// Déjà affecté à un autre poste au même moment (cf. `_ALREADY_BUSY_PENALTY`).
|
||||
const SOLVER_ALREADY_BUSY_PENALTY = 300;
|
||||
|
||||
type TimeWindow = { starts_at: string; ends_at: string };
|
||||
|
||||
@@ -745,6 +747,7 @@ function solverScore(
|
||||
if (effectiveCompanionIds.has(id)) companionsPresent += 1;
|
||||
}
|
||||
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
||||
if (existing.some((other) => overlaps(shift, other))) score -= SOLVER_ALREADY_BUSY_PENALTY;
|
||||
|
||||
const existingMinutes = existing.reduce(
|
||||
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
|
||||
@@ -787,13 +790,8 @@ function runSolver(): SolverRunResult {
|
||||
if (gap <= 0) continue;
|
||||
const alreadyAssignedIds = assignedVolunteerIds(shift.id);
|
||||
const picked = state.volunteers
|
||||
.filter((v) => {
|
||||
const busy = [
|
||||
...(existingByVolunteer.get(v.id) ?? []),
|
||||
...(takenByVolunteer.get(v.id) ?? []),
|
||||
];
|
||||
return !busy.some((other) => overlaps(shift, other));
|
||||
})
|
||||
.filter((v) => !alreadyAssignedIds.has(v.id))
|
||||
.filter((v) => !(takenByVolunteer.get(v.id) ?? []).some((other) => overlaps(shift, other)))
|
||||
.map((v) => {
|
||||
const existing = existingByVolunteer.get(v.id) ?? [];
|
||||
const predecessorIds = predecessorByShift.get(shift.id);
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"position.category.other": "Autre",
|
||||
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
|
||||
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
|
||||
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.",
|
||||
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent (avertissement, non bloquant).",
|
||||
"grille.filter.day": "JOUR",
|
||||
"grille.filter.position": "POSTE",
|
||||
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
||||
@@ -203,7 +203,7 @@
|
||||
"config.rules.slotAdd": "AJOUTER",
|
||||
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
|
||||
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
|
||||
"config.rules.thresholdsNote": "Seule règle appliquée : le double emploi au même moment est toujours bloquant. Aucune limite d'heures par journée ni de temps de repos.",
|
||||
"config.rules.thresholdsNote": "Seule règle : le double emploi au même moment donne un avertissement, sans bloquer (un bénévole peut tenir deux postes à la fois) ; le solveur évite de le proposer. Aucune limite d'heures par journée ni de temps de repos.",
|
||||
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
|
||||
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
|
||||
"config.rules.roleCoordinator": "Coordination",
|
||||
|
||||
@@ -82,11 +82,13 @@ const CHANCE_WISH_POSITION_BONUS = 30;
|
||||
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
|
||||
// Déjà affecté à un autre poste au même moment : possible, mais en fin de liste.
|
||||
const CHANCE_ALREADY_BUSY_PENALTY = 300;
|
||||
|
||||
/**
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
|
||||
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
|
||||
* charge déjà assignée. Plus haut =
|
||||
* déjà affecté ailleurs au même moment, charge déjà assignée. Plus haut =
|
||||
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
|
||||
* validation (blocage / avertissements) reste seule juge.
|
||||
*/
|
||||
@@ -94,9 +96,11 @@ export function candidateChanceScore(params: {
|
||||
wishesPosition: boolean;
|
||||
conflictsWishedArtist: boolean;
|
||||
companionMatches: number;
|
||||
busyElsewhere: boolean;
|
||||
assignedHours: number;
|
||||
}): number {
|
||||
let score = 0;
|
||||
if (params.busyElsewhere) score -= CHANCE_ALREADY_BUSY_PENALTY;
|
||||
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
|
||||
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
|
||||
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
|
||||
|
||||
@@ -134,6 +134,17 @@ export function AssignmentPanel({
|
||||
return map;
|
||||
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
|
||||
|
||||
// Bénévoles déjà affectés à un autre créneau qui chevauche celui-ci.
|
||||
const busyElsewhereIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const a of allAssignments.data ?? []) {
|
||||
if (a.shift_id === shift.shift_id) continue;
|
||||
const other = shiftsById.get(a.shift_id);
|
||||
if (other && windowsOverlap(shift, other)) ids.add(a.volunteer_id);
|
||||
}
|
||||
return ids;
|
||||
}, [allAssignments.data, shiftsById, shift]);
|
||||
|
||||
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
|
||||
// conflit avec un artiste qu'il veut voir, compagnons souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
|
||||
// liste, pas juste « le moins chargé ».
|
||||
@@ -147,12 +158,13 @@ export function AssignmentPanel({
|
||||
wishesPosition: details?.wishesPosition ?? false,
|
||||
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
|
||||
companionMatches: details?.companionMatches ?? 0,
|
||||
busyElsewhere: busyElsewhereIds.has(v.id),
|
||||
assignedHours: hours.get(v.id) ?? 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [volunteers.data, detailsByVolunteer, hours]);
|
||||
}, [volunteers.data, detailsByVolunteer, busyElsewhereIds, hours]);
|
||||
|
||||
const needle = foldAccents(search.trim());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user