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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user