diff --git a/CLAUDE.md b/CLAUDE.md index 204bb0f..1941425 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,11 +85,13 @@ les tables sauf les tables d'association. voudrait tenir »), distinct de `Assignment` (décision) et de `Position. lead_account_id` (référent). - Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je - voudrais être affecté », 0 à N, à sens unique — A peut souhaiter B sans que - B ne le souhaite en retour). `CHECK (volunteer_id <> companion_id)` : un - bénévole ne peut pas se souhaiter lui-même. Le solveur (section 4) - favorise un candidat dont l'un des compagnons souhaités est déjà affecté - au créneau. + voudrais être affecté », 0 à N). **Saisi à sens unique** (A souhaite B sans + que B fasse le geste en retour) **mais traité comme réciproque par le + solveur** (section 4) : si A souhaite B, B profite aussi de la présence de + A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée + à la volée (le loader du solveur, pas la table elle-même, qui reste à sens + unique et éditable indépendamment par chacun). `CHECK (volunteer_id <> + companion_id)` : un bénévole ne peut pas se souhaiter lui-même. - **`Position`** : `name`, `location`, `category` (`PositionCategory`), `lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte). - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont @@ -291,9 +293,9 @@ affectation n'est jamais persistée par cet endpoint. - malus si le créneau chevauche le passage d'un artiste que le bénévole veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui ferait manquer le passage ; - - bonus par compagnon souhaité (`VolunteerCompanionWish`) déjà affecté - (affectation décidée) au créneau — plusieurs compagnons présents - cumulent le bonus ; + - bonus par compagnon souhaité (`VolunteerCompanionWish`, **réciproque** : + si A souhaite B, B en profite aussi) déjà affecté (affectation décidée) + au créneau — plusieurs compagnons présents cumulent le bonus ; - léger malus pour les bénévoles déjà bien chargés (répartition de la charge). - **Pas de plafond de cumul journalier côté solveur** : contrairement à la diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py index 8b7e1f4..c70c2ab 100644 --- a/backend/app/solver/engine.py +++ b/backend/app/solver/engine.py @@ -49,7 +49,8 @@ _ARTIST_CONFLICT_PENALTY = 80 # n'importe quel poste) : limite les enchaînements de shifts plutôt que de # faire tourner un poste en particulier. _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, +# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`). _COMPANION_BONUS_PER_MATCH = 25 diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py index 4066818..7503c1e 100644 --- a/backend/app/solver/loader.py +++ b/backend/app/solver/loader.py @@ -102,6 +102,9 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: ): wishes[w.volunteer_id].add(str(w.position_id)) + # Traité comme réciproque pour le solveur : si A souhaite B, B profite + # aussi de la présence de A, même s'il ne l'a pas demandé (voir CLAUDE.md + # section 4). companion_wishes: dict = defaultdict(set) for w in session.exec( select(VolunteerCompanionWish).join( @@ -109,6 +112,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput: ).where(Volunteer.edition_id == edition.id) ): companion_wishes[w.volunteer_id].add(str(w.companion_id)) + companion_wishes[w.companion_id].add(str(w.volunteer_id)) artists_by_id = { a.id: a diff --git a/backend/app/solver/models.py b/backend/app/solver/models.py index e6992bf..742dcfd 100644 --- a/backend/app/solver/models.py +++ b/backend/app/solver/models.py @@ -41,7 +41,9 @@ class SolverVolunteer: id: str availability: tuple[AvailabilityWindow, ...] = () wished_position_ids: frozenset[str] = frozenset() - # Bénévoles (0 à N) avec qui il voudrait être affecté — à sens unique. + # Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à + # sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le + # loader : si A souhaite B, l'ensemble de B contient aussi A. wished_companion_ids: frozenset[str] = frozenset() # Passages des artistes que le bénévole veut voir (heure de passage), # sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles diff --git a/backend/tests/test_api_solver.py b/backend/tests/test_api_solver.py index a030d45..27e645c 100644 --- a/backend/tests/test_api_solver.py +++ b/backend/tests/test_api_solver.py @@ -102,6 +102,38 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift( assert str(stranger.id) not in suggestion["volunteer_ids"] +def test_companion_bonus_applies_even_to_the_one_who_did_not_ask(client, edition, session): + # Le vœu de compagnon est à sens unique en base, mais traité comme + # réciproque par le solveur : « wisher » (déjà affecté) a souhaité être + # avec « wished », qui lui n'a rien demandé — il en profite quand même. + from app.models import Assignment + + position = make_position(session, edition) + shift = make_shift( + session, edition, position, + start=dt_at(10, 14), end=dt_at(10, 18), min_staff=1, ideal_staff=2, + ) + wisher = make_volunteer(session, edition, email="wisher@ex.fr") + session.add( + Assignment(edition_id=edition.id, volunteer_id=wisher.id, shift_id=shift.id) + ) + session.commit() + + wished = make_volunteer(session, edition, email="wished@ex.fr") + assert client.patch( + f"/editions/{edition.id}/volunteers/{wisher.id}", + json={"wished_companion_ids": [str(wished.id)]}, + ).status_code == 200 + stranger = make_volunteer(session, edition, email="stranger@ex.fr") + + resp = client.post(_url(edition, "/suggestions")) + suggestion = next( + s for s in resp.json()["suggestions"] if s["shift_id"] == str(shift.id) + ) + assert suggestion["volunteer_ids"] == [str(wished.id)] + assert str(stranger.id) not in suggestion["volunteer_ids"] + + def test_chain_penalty_applies_across_different_positions(client, edition, session): from app.models import Assignment, PositionCategory diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index eec8046..5286e79 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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 { ); } +/** 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> { + const effective = new Map>(); + 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, + effectiveCompanionIds: Set, ): 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(); @@ -571,6 +592,7 @@ function runSolver(): SolverRunResult { artistWindowsByVolunteer.get(v.id) ?? [], chained, alreadyAssignedIds, + effectiveCompanions.get(v.id) ?? new Set(), ), }; }) diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index c2d59d5..9f59c93 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -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[], +): Map> { + const effective = new Map>(); + 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; diff --git a/frontend/src/pages/grille/AssignmentPanel.tsx b/frontend/src/pages/grille/AssignmentPanel.tsx index 36518d7..37d11dc 100644 --- a/frontend/src/pages/grille/AssignmentPanel.tsx +++ b/frontend/src/pages/grille/AssignmentPanel.tsx @@ -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