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:
AdamThomas
2026-09-15 21:12:30 +02:00
co-authored by Claude Sonnet 5
parent e405590bbc
commit 64e2f86647
6 changed files with 176 additions and 94 deletions
+14 -7
View File
@@ -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 - bonus si le poste fait partie des vœux du bénévole
(`VolunteerPositionWish`) ; (`VolunteerPositionWish`) ;
- malus proportionnel aux minutes hors des fenêtres de disponibilité - malus proportionnel aux minutes hors des fenêtres de disponibilité
déclarées (réutilise `validation.engine.uncovered_minutes`), au dépassement déclarées (réutilise `validation.engine.uncovered_minutes`), et si le
du cumul de 8 h ce jour-là, et si le repos avec une affectation déjà repos avec une affectation déjà décidée serait `< 8 h 00` ;
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 - 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 ;
- 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).
- Simplification assumée : cumul de 8 h et repos ne sont vérifiés que - **Pas de plafond de cumul journalier côté solveur** : contrairement à la
contre les affectations **déjà décidées**, pas entre deux suggestions du validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
même calcul — la validation finale à la confirmation reste seule juge de bénévole qui dépasserait 8 h sur la journée — seule la validation le
ce cas limite. 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` **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 (`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
+24 -47
View File
@@ -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). 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 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 `SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
8 h, repos, vœu de poste, vœu d'artiste, équité de charge) est un terme de chaînage sur le même poste, vœu de poste, vœu d'artiste, équité de charge) est
l'objectif, pas une contrainte — comme le moteur de validation, qui reste seul un terme de l'objectif, pas une contrainte — comme le moteur de validation,
juge au moment où la coordination confirme une affectation. 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 import datetime as dt
@@ -24,7 +26,7 @@ from app.solver.models import (
SolverVolunteer, SolverVolunteer,
) )
from app.validation.engine import uncovered_minutes 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 _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 _NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
_WISH_BONUS = 30 _WISH_BONUS = 30
_AVAIL_PENALTY_PER_HALF_HOUR = 5 _AVAIL_PENALTY_PER_HALF_HOUR = 5
_DAILY_OVERAGE_PENALTY_PER_MIN = 2
_REST_PENALTY = 60 _REST_PENALTY = 60
_FAIRNESS_PENALTY_PER_HOUR = 2 _FAIRNESS_PENALTY_PER_HOUR = 2
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir. # Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
_ARTIST_CONFLICT_PENALTY = 80 _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: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
return a.starts_at < b.ends_at and b.starts_at < a.ends_at 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: def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
candidate = shift.shift_info candidate = shift.shift_info
for other in volunteer.existing_shifts: for other in volunteer.existing_shifts:
@@ -93,16 +69,20 @@ def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bo
return False return False
def _score( def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
volunteer: SolverVolunteer, """Le bénévole a déjà (affectation décidée) le créneau précédent de ce
shift: SolverShift, même poste, le même jour."""
day_boundary: dt.time, if shift.predecessor_shift_id is None:
) -> int: 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. """Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
Purement additif (aucune interaction entre deux affectations proposées), 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 ce qui garde le modèle CP-SAT linéaire simple. Repos et chaînage ne sont
sont vérifiés que contre les affectations déjà décidées, pas entre deux 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) suggestions du même calcul : la validation finale (à la confirmation)
reste seule juge en cas de cas limite entre deux suggestions. reste seule juge en cas de cas limite entre deux suggestions.
""" """
@@ -111,11 +91,10 @@ def _score(
score += _WISH_BONUS score += _WISH_BONUS
missed = uncovered_minutes(shift.shift_info, volunteer.availability) missed = uncovered_minutes(shift.shift_info, volunteer.availability)
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR 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): if _has_insufficient_rest(volunteer, shift):
score -= _REST_PENALTY score -= _REST_PENALTY
if _chained_from_predecessor(volunteer, shift):
score -= _CHAIN_PENALTY
if any( if any(
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows _overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
): ):
@@ -131,8 +110,6 @@ def solve(
seed: int = 42, seed: int = 42,
) -> SolverSolution: ) -> SolverSolution:
"""Calcule les suggestions. Fonction pure : ne touche jamais la base.""" """Calcule les suggestions. Fonction pure : ne touche jamais la base."""
boundary = _day_boundary(data.day_parts)
model = cp_model.CpModel() model = cp_model.CpModel()
variables: dict[tuple[int, int], cp_model.IntVar] = {} variables: dict[tuple[int, int], cp_model.IntVar] = {}
scores: dict[tuple[int, int], int] = {} scores: dict[tuple[int, int], int] = {}
@@ -146,7 +123,7 @@ def solve(
continue continue
key = (vi, si) key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}") variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
scores[key] = _score(volunteer, shift, boundary) scores[key] = _score(volunteer, shift)
if not variables: if not variables:
return SolverSolution() return SolverSolution()
+19 -14
View File
@@ -10,7 +10,6 @@ from app.models import (
Artist, Artist,
Assignment, Assignment,
Availability, Availability,
DayPart,
Edition, Edition,
Shift, Shift,
Volunteer, Volunteer,
@@ -18,7 +17,7 @@ from app.models import (
VolunteerPositionWish, VolunteerPositionWish,
) )
from app.solver.models import SolverInput, SolverShift, SolverVolunteer 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: 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: 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))) shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
assignments = list( assignments = list(
session.exec(select(Assignment).where(Assignment.edition_id == edition.id)) session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
) )
shifts_by_id = {s.id: s for s in shifts} 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) assigned_count: dict = defaultdict(int)
shifts_by_volunteer: dict = defaultdict(list) shifts_by_volunteer: dict = defaultdict(list)
for a in assignments: for a in assignments:
@@ -62,6 +73,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
min_staff=s.min_staff, min_staff=s.min_staff,
ideal_staff=s.ideal_staff, ideal_staff=s.ideal_staff,
already_assigned=assigned_count.get(s.id, 0), already_assigned=assigned_count.get(s.id, 0),
predecessor_shift_id=predecessor_id.get(s.id),
) )
for s in shifts for s in shifts
if assigned_count.get(s.id, 0) < s.ideal_staff 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 for v in volunteers
) )
return SolverInput( return SolverInput(shifts=solver_shifts, volunteers=solver_volunteers)
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
),
)
+5 -3
View File
@@ -8,7 +8,7 @@ SQLModel), comme le moteur de validation dont il réutilise les briques
import datetime as dt import datetime as dt
from dataclasses import dataclass, field from dataclasses import dataclass, field
from app.validation.models import AvailabilityWindow, DayPartWindow, ShiftInfo from app.validation.models import AvailabilityWindow, ShiftInfo
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -22,6 +22,9 @@ class SolverShift:
min_staff: int min_staff: int
ideal_staff: int ideal_staff: int
already_assigned: 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 @property
def shift_info(self) -> ShiftInfo: def shift_info(self) -> ShiftInfo:
@@ -38,7 +41,7 @@ class SolverVolunteer:
# lui ferait manquer le passage. # lui ferait manquer le passage.
wished_artist_windows: tuple[ShiftInfo, ...] = () wished_artist_windows: tuple[ShiftInfo, ...] = ()
# Créneaux déjà occupés (affectations existantes, décisions figées) : # 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, ...] = () existing_shifts: tuple[ShiftInfo, ...] = ()
# Total déjà affecté sur l'édition (minutes), pour répartir la charge. # Total déjà affecté sur l'édition (minutes), pour répartir la charge.
existing_minutes: int = 0 existing_minutes: int = 0
@@ -48,7 +51,6 @@ class SolverVolunteer:
class SolverInput: class SolverInput:
shifts: tuple[SolverShift, ...] = field(default_factory=tuple) shifts: tuple[SolverShift, ...] = field(default_factory=tuple)
volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple) volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple)
day_parts: tuple[DayPartWindow, ...] = field(default_factory=tuple)
@dataclass(frozen=True) @dataclass(frozen=True)
+64 -3
View File
@@ -20,6 +20,7 @@ def _shift(
min_staff=1, min_staff=1,
ideal_staff=1, ideal_staff=1,
already_assigned=0, already_assigned=0,
predecessor_shift_id: str | None = None,
) -> SolverShift: ) -> SolverShift:
return SolverShift( return SolverShift(
id=sid, id=sid,
@@ -29,6 +30,7 @@ def _shift(
min_staff=min_staff, min_staff=min_staff,
ideal_staff=ideal_staff, ideal_staff=ideal_staff,
already_assigned=already_assigned, 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 assert len(picked_a) + len(picked_b) == 1
def test_avoids_volunteer_who_would_exceed_eight_hours_that_day(): def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
shift = _shift(start=_dt(10, 14), end=_dt(10, 18)) # 4 h # < 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 = _volunteer(
"tired", "tired",
availability=[_full_day(10)], 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)]) fresh = _volunteer("fresh", availability=[_full_day(10)])
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh))) solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
assert _by_shift(solution, "s1") == ("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(): 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) # 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 # que « fan » veut voir : le solveur préfère « indiff », qui n'a pas ce
+50 -20
View File
@@ -389,10 +389,12 @@ const SOLVER_URGENT_BASE = 1000;
const SOLVER_NORMAL_BASE = 20; const SOLVER_NORMAL_BASE = 20;
const SOLVER_WISH_BONUS = 30; const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5; const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
const SOLVER_REST_PENALTY = 60; const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80; const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2; 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 }; type TimeWindow = { starts_at: string; ends_at: string };
@@ -406,6 +408,34 @@ function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
.map((a) => shiftById(a.shift_id)); .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 /** 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). */ * chevauche l'une d'elles lui ferait manquer le passage). */
function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] { function wishedArtistWindows(volunteer: VolunteerRead): TimeWindow[] {
@@ -424,26 +454,19 @@ function solverScore(
assignedCount: number, assignedCount: number,
existing: ShiftRead[], existing: ShiftRead[],
wishedArtists: TimeWindow[], wishedArtists: TimeWindow[],
chainedFromPredecessor: boolean,
): 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;
if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) { if (wishedArtists.some((w) => timeRangesOverlap(shift, w))) {
score -= SOLVER_ARTIST_CONFLICT_PENALTY; score -= SOLVER_ARTIST_CONFLICT_PENALTY;
} }
if (chainedFromPredecessor) score -= SOLVER_CHAIN_PENALTY;
const windows = state.availability.get(volunteer.id) ?? []; const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at); const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR; 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) { for (const other of existing) {
if (overlaps(shift, other)) continue; if (overlaps(shift, other)) continue;
let gap: number | null = null; let gap: number | null = null;
@@ -482,6 +505,7 @@ function runSolver(): SolverRunResult {
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id)); existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v)); artistWindowsByVolunteer.set(v.id, wishedArtistWindows(v));
} }
const predecessorByShift = buildPredecessorMap();
// 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[]>();
@@ -503,16 +527,22 @@ function runSolver(): SolverRunResult {
]; ];
return !busy.some((other) => overlaps(shift, other)); return !busy.some((other) => overlaps(shift, other));
}) })
.map((v) => ({ .map((v) => {
volunteer: v, const existing = existingByVolunteer.get(v.id) ?? [];
score: solverScore( const predecessorId = predecessorByShift.get(shift.id);
v, const chained = predecessorId != null && existing.some((s) => s.id === predecessorId);
shift, return {
assignedCount.get(shift.id) ?? 0, volunteer: v,
existingByVolunteer.get(v.id) ?? [], score: solverScore(
artistWindowsByVolunteer.get(v.id) ?? [], v,
), shift,
})) assignedCount.get(shift.id) ?? 0,
existing,
artistWindowsByVolunteer.get(v.id) ?? [],
chained,
),
};
})
.filter((c) => c.score > 0) .filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, gap); .slice(0, gap);