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
@@ -85,11 +85,13 @@ les tables sauf les tables d'association.
|
|||||||
voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
|
voudrait tenir »), distinct de `Assignment` (décision) et de `Position.
|
||||||
lead_account_id` (référent).
|
lead_account_id` (référent).
|
||||||
- Vœux de compagnon : `volunteer_companion_wish` (« bénévoles avec qui je
|
- 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
|
voudrais être affecté », 0 à N). **Saisi à sens unique** (A souhaite B sans
|
||||||
B ne le souhaite en retour). `CHECK (volunteer_id <> companion_id)` : un
|
que B fasse le geste en retour) **mais traité comme réciproque par le
|
||||||
bénévole ne peut pas se souhaiter lui-même. Le solveur (section 4)
|
solveur** (section 4) : si A souhaite B, B profite aussi de la présence de
|
||||||
favorise un candidat dont l'un des compagnons souhaités est déjà affecté
|
A, même s'il ne l'a rien demandé — la réciprocité est artificielle, calculée
|
||||||
au créneau.
|
à 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`),
|
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
|
||||||
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
|
||||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
- **`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
|
- 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
|
veut voir (`VolunteerArtist` + `Artist.start_time`/`end_time`) — lui
|
||||||
ferait manquer le passage ;
|
ferait manquer le passage ;
|
||||||
- bonus par compagnon souhaité (`VolunteerCompanionWish`) déjà affecté
|
- bonus par compagnon souhaité (`VolunteerCompanionWish`, **réciproque** :
|
||||||
(affectation décidée) au créneau — plusieurs compagnons présents
|
si A souhaite B, B en profite aussi) déjà affecté (affectation décidée)
|
||||||
cumulent le bonus ;
|
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
|
- léger malus pour les bénévoles déjà bien chargés (répartition de la
|
||||||
charge).
|
charge).
|
||||||
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
|
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ _ARTIST_CONFLICT_PENALTY = 80
|
|||||||
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
# n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
||||||
# faire tourner un poste en particulier.
|
# faire tourner un poste en particulier.
|
||||||
_CHAIN_PENALTY = 40
|
_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
|
_COMPANION_BONUS_PER_MATCH = 25
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
|||||||
):
|
):
|
||||||
wishes[w.volunteer_id].add(str(w.position_id))
|
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)
|
companion_wishes: dict = defaultdict(set)
|
||||||
for w in session.exec(
|
for w in session.exec(
|
||||||
select(VolunteerCompanionWish).join(
|
select(VolunteerCompanionWish).join(
|
||||||
@@ -109,6 +112,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
|||||||
).where(Volunteer.edition_id == edition.id)
|
).where(Volunteer.edition_id == edition.id)
|
||||||
):
|
):
|
||||||
companion_wishes[w.volunteer_id].add(str(w.companion_id))
|
companion_wishes[w.volunteer_id].add(str(w.companion_id))
|
||||||
|
companion_wishes[w.companion_id].add(str(w.volunteer_id))
|
||||||
|
|
||||||
artists_by_id = {
|
artists_by_id = {
|
||||||
a.id: a
|
a.id: a
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ class SolverVolunteer:
|
|||||||
id: str
|
id: str
|
||||||
availability: tuple[AvailabilityWindow, ...] = ()
|
availability: tuple[AvailabilityWindow, ...] = ()
|
||||||
wished_position_ids: frozenset[str] = frozenset()
|
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()
|
wished_companion_ids: frozenset[str] = frozenset()
|
||||||
# Passages des artistes que le bénévole veut voir (heure de passage),
|
# 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
|
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
|
||||||
|
|||||||
@@ -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"]
|
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):
|
def test_chain_penalty_applies_across_different_positions(client, edition, session):
|
||||||
from app.models import Assignment, PositionCategory
|
from app.models import Assignment, PositionCategory
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// n'importe quel poste) : limite les enchaînements de shifts plutôt que de
|
||||||
// faire tourner un poste en particulier.
|
// faire tourner un poste en particulier.
|
||||||
const SOLVER_CHAIN_PENALTY = 40;
|
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;
|
const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
|
||||||
|
|
||||||
type TimeWindow = { starts_at: string; ends_at: string };
|
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
|
/** 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
|
* 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
|
* chaînage (voir `solverScore`), pour limiter les enchaînements de shifts
|
||||||
@@ -480,6 +498,7 @@ function solverScore(
|
|||||||
wishedArtists: TimeWindow[],
|
wishedArtists: TimeWindow[],
|
||||||
chainedFromPredecessor: boolean,
|
chainedFromPredecessor: boolean,
|
||||||
alreadyAssignedIds: Set<string>,
|
alreadyAssignedIds: Set<string>,
|
||||||
|
effectiveCompanionIds: Set<string>,
|
||||||
): number {
|
): number {
|
||||||
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
|
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;
|
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
|
||||||
@@ -487,9 +506,10 @@ function solverScore(
|
|||||||
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
|
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
|
||||||
}
|
}
|
||||||
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
|
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
|
||||||
const companionsPresent = volunteer.wished_companion_ids.filter((id) =>
|
let companionsPresent = 0;
|
||||||
alreadyAssignedIds.has(id),
|
for (const id of alreadyAssignedIds) {
|
||||||
).length;
|
if (effectiveCompanionIds.has(id)) companionsPresent += 1;
|
||||||
|
}
|
||||||
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
||||||
|
|
||||||
const windows = state.availability.get(volunteer.id) ?? [];
|
const windows = state.availability.get(volunteer.id) ?? [];
|
||||||
@@ -535,6 +555,7 @@ function runSolver(): SolverRunResult {
|
|||||||
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
|
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
|
||||||
}
|
}
|
||||||
const predecessorByShift = buildPredecessorMap();
|
const predecessorByShift = buildPredecessorMap();
|
||||||
|
const effectiveCompanions = buildEffectiveCompanions();
|
||||||
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
|
// 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.
|
// fois un même bénévole sur des créneaux qui se chevauchent.
|
||||||
const takenByVolunteer = new Map<string, ShiftRead[]>();
|
const takenByVolunteer = new Map<string, ShiftRead[]>();
|
||||||
@@ -571,6 +592,7 @@ function runSolver(): SolverRunResult {
|
|||||||
artistWindowsByVolunteer.get(v.id) ?? [],
|
artistWindowsByVolunteer.get(v.id) ?? [],
|
||||||
chained,
|
chained,
|
||||||
alreadyAssignedIds,
|
alreadyAssignedIds,
|
||||||
|
effectiveCompanions.get(v.id) ?? new Set(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
PositionCategory,
|
PositionCategory,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
Violation,
|
Violation,
|
||||||
|
VolunteerRead,
|
||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { t } from '@/i18n';
|
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;
|
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_WISH_POSITION_BONUS = 30;
|
||||||
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||||
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Loading } from '@/components/state';
|
|||||||
import {
|
import {
|
||||||
artistPerformanceWindow,
|
artistPerformanceWindow,
|
||||||
availabilityCoverage,
|
availabilityCoverage,
|
||||||
|
buildEffectiveCompanionIds,
|
||||||
candidateChanceScore,
|
candidateChanceScore,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
hoursByVolunteer,
|
hoursByVolunteer,
|
||||||
@@ -126,6 +127,11 @@ 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]);
|
||||||
|
|
||||||
|
const effectiveCompanionIds = useMemo(
|
||||||
|
() => buildEffectiveCompanionIds(volunteers.data ?? []),
|
||||||
|
[volunteers.data],
|
||||||
|
);
|
||||||
|
|
||||||
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
|
// 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),
|
// 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.
|
// compagnons souhaités déjà affectés à ce créneau.
|
||||||
@@ -140,14 +146,21 @@ export function AssignmentPanel({
|
|||||||
const artist = artistsById.get(id);
|
const artist = artistsById.get(id);
|
||||||
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
|
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, {
|
map.set(v.id, {
|
||||||
wishesPosition: v.wished_position_ids.includes(positionId),
|
wishesPosition: v.wished_position_ids.includes(positionId),
|
||||||
conflictsWishedArtist,
|
conflictsWishedArtist,
|
||||||
companionMatches: v.wished_companion_ids.filter((id) => assignedIds.has(id)).length,
|
companionMatches,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return map;
|
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,
|
// 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
|
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
|
||||||
|
|||||||
Reference in New Issue
Block a user