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
+5 -3
View File
@@ -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)