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:
AdamThomas
2026-09-15 22:32:02 +02:00
co-authored by Claude Sonnet 5
parent 166e2c3366
commit 11bc8cbe36
8 changed files with 115 additions and 16 deletions
+26 -4
View File
@@ -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(),
),
};
})
+23
View File
@@ -6,6 +6,7 @@ import type {
PositionCategory,
ShiftRead,
Violation,
VolunteerRead,
} from '@/api/schema';
import { t } from '@/i18n';
@@ -112,6 +113,28 @@ export function windowsOverlap(a: TimeWindow, b: TimeWindow): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
/**
* 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é (le vœu reste à
* sens unique en base — voir `VolunteerCompanionWish` côté back-end — cette
* réciprocité n'est calculée qu'à l'usage, jamais enregistrée). Indexé par
* id de bénévole.
*/
export function buildEffectiveCompanionIds(
volunteers: Pick<VolunteerRead, 'id' | 'wished_companion_ids'>[],
): Map<string, Set<string>> {
const effective = new Map<string, Set<string>>();
for (const v of volunteers) {
effective.set(v.id, new Set(v.wished_companion_ids));
}
for (const v of volunteers) {
for (const companionId of v.wished_companion_ids) {
effective.get(companionId)?.add(v.id);
}
}
return effective;
}
const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
+15 -2
View File
@@ -24,6 +24,7 @@ import { Loading } from '@/components/state';
import {
artistPerformanceWindow,
availabilityCoverage,
buildEffectiveCompanionIds,
candidateChanceScore,
categoryLabel,
hoursByVolunteer,
@@ -126,6 +127,11 @@ export function AssignmentPanel({
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
const effectiveCompanionIds = useMemo(
() => buildEffectiveCompanionIds(volunteers.data ?? []),
[volunteers.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),
// compagnons souhaités déjà affectés à ce créneau.
@@ -140,14 +146,21 @@ export function AssignmentPanel({
const artist = artistsById.get(id);
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
});
const effective = effectiveCompanionIds.get(v.id);
let companionMatches = 0;
if (effective) {
for (const id of assignedIds) {
if (effective.has(id)) companionMatches += 1;
}
}
map.set(v.id, {
wishesPosition: v.wished_position_ids.includes(positionId),
conflictsWishedArtist,
companionMatches: v.wished_companion_ids.filter((id) => assignedIds.has(id)).length,
companionMatches,
});
}
return map;
}, [volunteers.data, artistsById, shift, positionId, assigned]);
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons