Vœu de compagnon : traité comme réciproque par le solveur
Le vœu reste à sens unique en base (A choisit B sur sa fiche, B n'a rien à faire) mais si A souhaite B, B doit désormais profiter aussi de la présence de A — dans les deux sens : affecter A d'abord fait remonter B comme candidat, et affecter B d'abord fait remonter A, même si B n'a rien demandé. - Solveur (`loader.py`) : le vœu (A → B) alimente maintenant l'ensemble effectif des deux bénévoles (A et B), pas seulement celui du demandeur. Aucun changement dans `engine.py` : il ne fait que lire un ensemble déjà symétrisé. - Mock (`buildEffectiveCompanions`) et classement par défaut (`buildEffectiveCompanionIds` dans `lib/planning.ts`, utilisé par `AssignmentPanel.tsx`) : même symétrisation, pour rester cohérents avec le vrai solveur. - La fiche bénévole (vœux affichés/édités) n'est pas touchée : c'est le seul endroit qui reste à sens unique, par construction (la réciprocité n'est qu'une lecture, jamais une donnée enregistrée — sinon éditer sa propre liste risquerait d'effacer le vœu de quelqu'un d'autre). - Nouveau test d'intégration : le bénévole qui n'a rien demandé profite quand même de la présence de celui qui l'a choisi. - CLAUDE.md à jour. Vérifié : 193 tests backend pertinents (dont le nouveau), 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
166e2c3366
commit
11bc8cbe36
@@ -405,7 +405,9 @@ const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
// n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
||||
// faire tourner un poste en particulier.
|
||||
const SOLVER_CHAIN_PENALTY = 40;
|
||||
// Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole).
|
||||
// Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole),
|
||||
// traité comme réciproque : si A souhaite B, B en profite aussi (voir
|
||||
// `buildEffectiveCompanions`).
|
||||
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
|
||||
|
||||
type TimeWindow = { starts_at: string; ends_at: string };
|
||||
@@ -428,6 +430,22 @@ function assignedVolunteerIds(shiftId: string): Set<string> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Vœu de compagnon traité comme réciproque : si A souhaite B, B profite
|
||||
* aussi de la présence de A (même s'il ne l'a pas demandé). Indexé par id de
|
||||
* bénévole — sert au bonus de compagnon de route (voir `solverScore`). */
|
||||
function buildEffectiveCompanions(): Map<string, Set<string>> {
|
||||
const effective = new Map<string, Set<string>>();
|
||||
for (const v of state.volunteers) {
|
||||
effective.set(v.id, new Set(v.wished_companion_ids));
|
||||
}
|
||||
for (const v of state.volunteers) {
|
||||
for (const companionId of v.wished_companion_ids) {
|
||||
effective.get(companionId)?.add(v.id);
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début de
|
||||
* chaque créneau, le même jour (indexé par id de créneau) — sert au malus de
|
||||
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
|
||||
@@ -480,6 +498,7 @@ function solverScore(
|
||||
wishedArtists: TimeWindow[],
|
||||
chainedFromPredecessor: boolean,
|
||||
alreadyAssignedIds: Set<string>,
|
||||
effectiveCompanionIds: Set<string>,
|
||||
): 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;
|
||||
@@ -487,9 +506,10 @@ function solverScore(
|
||||
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
|
||||
}
|
||||
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
|
||||
const companionsPresent = volunteer.wished_companion_ids.filter((id) =>
|
||||
alreadyAssignedIds.has(id),
|
||||
).length;
|
||||
let companionsPresent = 0;
|
||||
for (const id of alreadyAssignedIds) {
|
||||
if (effectiveCompanionIds.has(id)) companionsPresent += 1;
|
||||
}
|
||||
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
||||
|
||||
const windows = state.availability.get(volunteer.id) ?? [];
|
||||
@@ -535,6 +555,7 @@ function runSolver(): SolverRunResult {
|
||||
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
|
||||
}
|
||||
const predecessorByShift = buildPredecessorMap();
|
||||
const effectiveCompanions = buildEffectiveCompanions();
|
||||
// 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[]>();
|
||||
@@ -571,6 +592,7 @@ function runSolver(): SolverRunResult {
|
||||
artistWindowsByVolunteer.get(v.id) ?? [],
|
||||
chained,
|
||||
alreadyAssignedIds,
|
||||
effectiveCompanions.get(v.id) ?? new Set(),
|
||||
),
|
||||
};
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user