Solveur : retire le plafond de cumul journalier, ajoute un malus de chaînage
- Retiré : le solveur ne pénalise plus un bénévole qui dépasserait 8 h sur la journée (`_daily_overage_minutes`/`_DAILY_OVERAGE_PENALTY_PER_MIN`, côté back et mock). Seule la validation le signale encore, à la confirmation (`DAILY_HOURS_EXCEEDED`, inchangée). `SolverInput.day_parts` et son chargement sont retirés au passage : ils ne servaient qu'à ça. - Ajouté : malus (-40) si le bénévole a déjà (affectation décidée) le créneau précédent de ce même poste, le même jour (`SolverShift.predecessor_shift_id`, calculé par le loader à partir de tous les créneaux du poste — pas seulement ceux à compléter). Favorise la rotation plutôt que d'enchaîner toujours la même personne sur un poste. Reste un malus, pas une exclusion : le seul candidat pour un créneau urgent est quand même proposé. - Miroir côté mock (`runSolver`) pour que la démonstration se comporte pareil. - CLAUDE.md à jour (§4). Tests : remplacé le test du plafond journalier par un test de non-régression explicite (le cumul n'est plus pénalisé) + un test isolant le repos (qui, lui, reste vérifié) ; ajouté 3 tests pour le chaînage (préfère un bénévole frais, reste proposé si c'est le seul candidat urgent, un créneau existant non-prédécesseur ne déclenche pas le malus). Vérifié : 183 tests backend, 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
e405590bbc
commit
64e2f86647
@@ -274,18 +274,25 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
- bonus si le poste fait partie des vœux du bénévole
|
||||
(`VolunteerPositionWish`) ;
|
||||
- malus proportionnel aux minutes hors des fenêtres de disponibilité
|
||||
déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement
|
||||
du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà
|
||||
décidée serait `< 8 h 00` ;
|
||||
déclarées (réutilise `validation.engine.uncovered_minutes`), et si le
|
||||
repos avec une affectation déjà décidée serait `< 8 h 00` ;
|
||||
- malus si le bénévole a déjà (affectation décidée) le créneau précédent de
|
||||
ce même poste, le même jour (`SolverShift.predecessor_shift_id`) —
|
||||
favorise la rotation plutôt que d'enchaîner toujours la même personne sur
|
||||
un poste ;
|
||||
- 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 ;
|
||||
- léger malus pour les bénévoles déjà bien chargés (répartition de la
|
||||
charge).
|
||||
- Simplification assumée : cumul de 8 h et repos ne sont vérifiés que
|
||||
contre les affectations **déjà décidées**, pas entre deux suggestions du
|
||||
même calcul — la validation finale à la confirmation reste seule juge de
|
||||
ce cas limite.
|
||||
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
|
||||
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
|
||||
bénévole qui dépasserait 8 h sur la journée — seule la validation le
|
||||
signale, à la confirmation.
|
||||
- Simplification assumée : repos et chaînage ne sont vérifiés que contre
|
||||
les affectations **déjà décidées**, pas entre deux suggestions du même
|
||||
calcul — la validation finale à la confirmation reste seule juge de ce
|
||||
cas limite.
|
||||
|
||||
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
|
||||
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
|
||||
|
||||
@@ -6,10 +6,12 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
|
||||
contente de faire remonter ces candidats en tête de liste).
|
||||
|
||||
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, cumul de
|
||||
8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de
|
||||
l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul
|
||||
juge au moment où la coordination confirme une affectation.
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
|
||||
chaînage sur le même poste, vœu de poste, vœu d'artiste, équité de charge) est
|
||||
un terme de l'objectif, pas une contrainte — comme le moteur de validation,
|
||||
qui reste seul juge au moment où la coordination confirme une affectation.
|
||||
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
|
||||
reste vérifiée par la validation, à la confirmation.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
@@ -24,7 +26,7 @@ from app.solver.models import (
|
||||
SolverVolunteer,
|
||||
)
|
||||
from app.validation.engine import uncovered_minutes
|
||||
from app.validation.models import DayPartWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
_EIGHT_HOURS_MIN = 8 * 60
|
||||
|
||||
@@ -38,45 +40,19 @@ _URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
||||
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
||||
_WISH_BONUS = 30
|
||||
_AVAIL_PENALTY_PER_HALF_HOUR = 5
|
||||
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
|
||||
_REST_PENALTY = 60
|
||||
_FAIRNESS_PENALTY_PER_HOUR = 2
|
||||
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
||||
_ARTIST_CONFLICT_PENALTY = 80
|
||||
# Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) :
|
||||
# favorise la rotation plutôt que d'enchaîner toujours la même personne.
|
||||
_CHAIN_PENALTY = 40
|
||||
|
||||
|
||||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||
|
||||
|
||||
def _day_boundary(day_parts: tuple[DayPartWindow, ...]) -> dt.time:
|
||||
if not day_parts:
|
||||
return dt.time(0, 0)
|
||||
return min(day_parts, key=lambda part: part.order).start_time
|
||||
|
||||
|
||||
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
if moment.time() >= boundary:
|
||||
return moment.date()
|
||||
return moment.date() - dt.timedelta(days=1)
|
||||
|
||||
|
||||
def _daily_overage_minutes(
|
||||
volunteer: SolverVolunteer, shift: SolverShift, boundary: dt.time
|
||||
) -> int:
|
||||
"""Minutes qui dépasseraient les 8 h de la journée du créneau, en ne
|
||||
comptant que les affectations déjà décidées (pas les autres suggestions
|
||||
du même calcul — ce cas limite reste sous la vigilance du moteur de
|
||||
validation au moment de la confirmation)."""
|
||||
key = _day_key(shift.starts_at, boundary)
|
||||
total = shift.ends_at - shift.starts_at
|
||||
for other in volunteer.existing_shifts:
|
||||
if _day_key(other.starts_at, boundary) == key:
|
||||
total += other.ends_at - other.starts_at
|
||||
minutes = int(total.total_seconds() // 60)
|
||||
return max(0, minutes - _EIGHT_HOURS_MIN)
|
||||
|
||||
|
||||
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
candidate = shift.shift_info
|
||||
for other in volunteer.existing_shifts:
|
||||
@@ -93,16 +69,20 @@ def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def _score(
|
||||
volunteer: SolverVolunteer,
|
||||
shift: SolverShift,
|
||||
day_boundary: dt.time,
|
||||
) -> int:
|
||||
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
"""Le bénévole a déjà (affectation décidée) le créneau précédent de ce
|
||||
même poste, le même jour."""
|
||||
if shift.predecessor_shift_id is None:
|
||||
return False
|
||||
return any(s.id == shift.predecessor_shift_id for s in volunteer.existing_shifts)
|
||||
|
||||
|
||||
def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||
|
||||
Purement additif (aucune interaction entre deux affectations proposées),
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Cumul de 8 h et repos ne
|
||||
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Repos et chaînage ne sont
|
||||
vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
suggestions du même calcul : la validation finale (à la confirmation)
|
||||
reste seule juge en cas de cas limite entre deux suggestions.
|
||||
"""
|
||||
@@ -111,11 +91,10 @@ def _score(
|
||||
score += _WISH_BONUS
|
||||
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
|
||||
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
|
||||
score -= _DAILY_OVERAGE_PENALTY_PER_MIN * _daily_overage_minutes(
|
||||
volunteer, shift, day_boundary
|
||||
)
|
||||
if _has_insufficient_rest(volunteer, shift):
|
||||
score -= _REST_PENALTY
|
||||
if _chained_from_predecessor(volunteer, shift):
|
||||
score -= _CHAIN_PENALTY
|
||||
if any(
|
||||
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
|
||||
):
|
||||
@@ -131,8 +110,6 @@ def solve(
|
||||
seed: int = 42,
|
||||
) -> SolverSolution:
|
||||
"""Calcule les suggestions. Fonction pure : ne touche jamais la base."""
|
||||
boundary = _day_boundary(data.day_parts)
|
||||
|
||||
model = cp_model.CpModel()
|
||||
variables: dict[tuple[int, int], cp_model.IntVar] = {}
|
||||
scores: dict[tuple[int, int], int] = {}
|
||||
@@ -146,7 +123,7 @@ def solve(
|
||||
continue
|
||||
key = (vi, si)
|
||||
variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
|
||||
scores[key] = _score(volunteer, shift, boundary)
|
||||
scores[key] = _score(volunteer, shift)
|
||||
|
||||
if not variables:
|
||||
return SolverSolution()
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
Volunteer,
|
||||
@@ -18,7 +17,7 @@ from app.models import (
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
|
||||
|
||||
def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
@@ -35,16 +34,28 @@ def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
|
||||
|
||||
def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
|
||||
assignments = list(
|
||||
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
|
||||
)
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
|
||||
# Prédécesseur (même poste, même jour, créneau juste avant) : sert au
|
||||
# malus de chaînage (voir `engine._score`). Calculé sur tous les créneaux
|
||||
# du poste, pas seulement ceux à compléter.
|
||||
predecessor_id: dict = {}
|
||||
by_position: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
by_position[s.position_id].append(s)
|
||||
for position_shifts in by_position.values():
|
||||
by_day: dict = defaultdict(list)
|
||||
for s in position_shifts:
|
||||
by_day[s.starts_at.date()].append(s)
|
||||
for day_shifts in by_day.values():
|
||||
day_shifts.sort(key=lambda s: s.starts_at)
|
||||
for previous, current in zip(day_shifts, day_shifts[1:], strict=False):
|
||||
predecessor_id[current.id] = str(previous.id)
|
||||
|
||||
assigned_count: dict = defaultdict(int)
|
||||
shifts_by_volunteer: dict = defaultdict(list)
|
||||
for a in assignments:
|
||||
@@ -62,6 +73,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
min_staff=s.min_staff,
|
||||
ideal_staff=s.ideal_staff,
|
||||
already_assigned=assigned_count.get(s.id, 0),
|
||||
predecessor_shift_id=predecessor_id.get(s.id),
|
||||
)
|
||||
for s in shifts
|
||||
if assigned_count.get(s.id, 0) < s.ideal_staff
|
||||
@@ -118,11 +130,4 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
for v in volunteers
|
||||
)
|
||||
|
||||
return SolverInput(
|
||||
shifts=solver_shifts,
|
||||
volunteers=solver_volunteers,
|
||||
day_parts=tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
|
||||
for dp in day_parts
|
||||
),
|
||||
)
|
||||
return SolverInput(shifts=solver_shifts, volunteers=solver_volunteers)
|
||||
|
||||
@@ -8,7 +8,7 @@ SQLModel), comme le moteur de validation dont il réutilise les briques
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,6 +22,9 @@ class SolverShift:
|
||||
min_staff: int
|
||||
ideal_staff: int
|
||||
already_assigned: int
|
||||
# Créneau du même poste, même jour, juste avant celui-ci (`None` s'il n'y
|
||||
# en a pas) — sert au malus de chaînage (voir `engine._score`).
|
||||
predecessor_shift_id: str | None = None
|
||||
|
||||
@property
|
||||
def shift_info(self) -> ShiftInfo:
|
||||
@@ -38,7 +41,7 @@ class SolverVolunteer:
|
||||
# lui ferait manquer le passage.
|
||||
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
||||
# Créneaux déjà occupés (affectations existantes, décisions figées) :
|
||||
# comptent pour chevauchement, cumul journalier, repos.
|
||||
# comptent pour chevauchement, repos, chaînage sur le même poste.
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
||||
existing_minutes: int = 0
|
||||
@@ -48,7 +51,6 @@ class SolverVolunteer:
|
||||
class SolverInput:
|
||||
shifts: tuple[SolverShift, ...] = field(default_factory=tuple)
|
||||
volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple)
|
||||
day_parts: tuple[DayPartWindow, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ def _shift(
|
||||
min_staff=1,
|
||||
ideal_staff=1,
|
||||
already_assigned=0,
|
||||
predecessor_shift_id: str | None = None,
|
||||
) -> SolverShift:
|
||||
return SolverShift(
|
||||
id=sid,
|
||||
@@ -29,6 +30,7 @@ def _shift(
|
||||
min_staff=min_staff,
|
||||
ideal_staff=ideal_staff,
|
||||
already_assigned=already_assigned,
|
||||
predecessor_shift_id=predecessor_shift_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -138,18 +140,77 @@ def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_who_would_exceed_eight_hours_that_day():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 12))], # 6 h déjà
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
|
||||
|
||||
|
||||
def test_prefers_volunteer_without_the_previous_shift_on_the_same_position():
|
||||
# Même poste, repos suffisant (8 h pile) entre les deux créneaux : seul
|
||||
# le chaînage doit différencier les deux bénévoles.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
|
||||
def test_still_suggests_the_only_candidate_despite_chaining():
|
||||
# Le chaînage reste un avertissement, pas un blocage : personne d'autre
|
||||
# pour ce créneau urgent, le solveur le propose quand même.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
assert _by_shift(solution, "s1") == ("chained",)
|
||||
|
||||
|
||||
def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
|
||||
# Le bénévole a bien un créneau existant, mais ce n'est pas le
|
||||
# prédécesseur désigné de ce créneau (ex. un autre poste) : pas de malus
|
||||
# de chaînage (le prédécesseur est identifié par id, pas par proximité).
|
||||
# Les deux candidats doivent donc être à égalité.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_id="prev")
|
||||
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
|
||||
almost_chained = _volunteer(
|
||||
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
|
||||
def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
# Le créneau (14 h - 18 h) chevauche le passage de l'artiste (16 h - 17 h)
|
||||
# que « fan » veut voir : le solveur préfère « indiff », qui n'a pas ce
|
||||
|
||||
@@ -389,10 +389,12 @@ const SOLVER_URGENT_BASE = 1000;
|
||||
const SOLVER_NORMAL_BASE = 20;
|
||||
const SOLVER_WISH_BONUS = 30;
|
||||
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
|
||||
const SOLVER_REST_PENALTY = 60;
|
||||
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
// Le bénévole a déjà tenu le créneau précédent de ce même poste (même jour) :
|
||||
// favorise la rotation plutôt que d'enchaîner toujours la même personne.
|
||||
const SOLVER_CHAIN_PENALTY = 40;
|
||||
|
||||
type TimeWindow = { starts_at: string; ends_at: string };
|
||||
|
||||
@@ -406,6 +408,34 @@ function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
|
||||
.map((a) => shiftById(a.shift_id));
|
||||
}
|
||||
|
||||
/** Créneau de même poste, même jour, juste avant chaque créneau (indexé par
|
||||
* id de créneau) — sert au malus de chaînage (voir `solverScore`). */
|
||||
function buildPredecessorMap(): Map<string, string> {
|
||||
const byPosition = new Map<string, ShiftRead[]>();
|
||||
for (const s of state.shifts) {
|
||||
const list = byPosition.get(s.position_id) ?? [];
|
||||
list.push(s);
|
||||
byPosition.set(s.position_id, list);
|
||||
}
|
||||
const predecessor = new Map<string, string>();
|
||||
for (const positionShifts of byPosition.values()) {
|
||||
const byDay = new Map<string, ShiftRead[]>();
|
||||
for (const s of positionShifts) {
|
||||
const day = s.starts_at.slice(0, 10);
|
||||
const list = byDay.get(day) ?? [];
|
||||
list.push(s);
|
||||
byDay.set(day, list);
|
||||
}
|
||||
for (const dayShifts of byDay.values()) {
|
||||
dayShifts.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
for (let i = 1; i < dayShifts.length; i += 1) {
|
||||
predecessor.set(dayShifts[i].id, dayShifts[i - 1].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return predecessor;
|
||||
}
|
||||
|
||||
/** Fenêtres datées des passages que le bénévole veut voir (un créneau qui
|
||||
* chevauche l'une d'elles lui ferait manquer le passage). */
|
||||
function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] {
|
||||
@@ -424,26 +454,19 @@ function solverScore(
|
||||
assignedCount: number,
|
||||
existing: ShiftRead[],
|
||||
wishedArtists: TimeWindow[],
|
||||
chainedFromPredecessor: boolean,
|
||||
): 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;
|
||||
if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) {
|
||||
score -= SOLVER_ARTIST_CONFLICT_PENALTY;
|
||||
}
|
||||
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
|
||||
|
||||
const windows = state.availability.get(volunteer.id) ?? [];
|
||||
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
||||
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
|
||||
const dayKey = shift.starts_at.slice(0, 10);
|
||||
let totalMin = naiveMinutes(shift.ends_at) - naiveMinutes(shift.starts_at);
|
||||
for (const other of existing) {
|
||||
if (other.starts_at.slice(0, 10) === dayKey) {
|
||||
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
|
||||
}
|
||||
}
|
||||
score -= Math.max(0, totalMin - EIGHT_HOURS_MIN) * SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN;
|
||||
|
||||
for (const other of existing) {
|
||||
if (overlaps(shift, other)) continue;
|
||||
let gap: number | null = null;
|
||||
@@ -482,6 +505,7 @@ function runSolver(): SolverRunResult {
|
||||
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
|
||||
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
|
||||
}
|
||||
const predecessorByShift = buildPredecessorMap();
|
||||
// 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[]>();
|
||||
@@ -503,16 +527,22 @@ function runSolver(): SolverRunResult {
|
||||
];
|
||||
return !busy.some((other) => overlaps(shift, other));
|
||||
})
|
||||
.map((v) => ({
|
||||
volunteer: v,
|
||||
score: solverScore(
|
||||
v,
|
||||
shift,
|
||||
assignedCount.get(shift.id) ?? 0,
|
||||
existingByVolunteer.get(v.id) ?? [],
|
||||
artistWindowsByVolunteer.get(v.id) ?? [],
|
||||
),
|
||||
}))
|
||||
.map((v) => {
|
||||
const existing = existingByVolunteer.get(v.id) ?? [];
|
||||
const predecessorId = predecessorByShift.get(shift.id);
|
||||
const chained = predecessorId != null && existing.some((s) => s.id === predecessorId);
|
||||
return {
|
||||
volunteer: v,
|
||||
score: solverScore(
|
||||
v,
|
||||
shift,
|
||||
assignedCount.get(shift.id) ?? 0,
|
||||
existing,
|
||||
artistWindowsByVolunteer.get(v.id) ?? [],
|
||||
chained,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((c) => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, gap);
|
||||
|
||||
Reference in New Issue
Block a user