Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il remonte en avertissement et l'affectation est enregistrée. Le solveur ne l'exclut plus mais applique un fort malus au bénévole déjà affecté ailleurs au même moment (proposé seulement sous l'effectif minimal, faute de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà. Même malus dans le classement par défaut du panneau et dans le mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
4.4 KiB
Python
109 lines
4.4 KiB
Python
"""Moteur de validation des affectations — cœur du produit.
|
||
|
||
Fonction pure : une `ValidationInput` en entrée, une liste de `Violation` en
|
||
sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
||
"""
|
||
|
||
import datetime as dt
|
||
|
||
from app.validation.models import (
|
||
Severity,
|
||
ShiftInfo,
|
||
ValidationInput,
|
||
Violation,
|
||
ViolationCode,
|
||
)
|
||
|
||
|
||
def validate(data: ValidationInput) -> list[Violation]:
|
||
violations: list[Violation] = []
|
||
violations.extend(_check_overlap(data))
|
||
return violations
|
||
|
||
|
||
def has_blocking(violations: list[Violation]) -> bool:
|
||
return any(v.severity is Severity.BLOCKING for v in violations)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# SHIFT_OVERLAP (avertissement) #
|
||
# --------------------------------------------------------------------------- #
|
||
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
|
||
# pas.
|
||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||
|
||
|
||
def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||
candidate = data.candidate_shift
|
||
return [
|
||
Violation.of(
|
||
ViolationCode.SHIFT_OVERLAP,
|
||
other_shift_id=other.id,
|
||
other_starts_at=other.starts_at.isoformat(),
|
||
other_ends_at=other.ends_at.isoformat(),
|
||
)
|
||
for other in data.existing_shifts
|
||
if _overlaps(candidate, other)
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
|
||
# partagée par le solveur, les feuilles de poste et les statistiques. #
|
||
# --------------------------------------------------------------------------- #
|
||
_MINUTES_PER_DAY = 24 * 60
|
||
|
||
|
||
def day_boundary(day_parts) -> dt.time:
|
||
"""Début d'une « journée de festival » : début du plus long creux que
|
||
laissent les créneaux types (`DayPart`) sur le cadran de 24 h — l'heure à
|
||
laquelle le dernier de la journée se termine —, minuit à défaut — voir
|
||
CLAUDE.md § 3. Avec Matin 08 h–13 h, Après-midi 13 h–19 h, Soirée
|
||
19 h–02 h, le creux 02 h–08 h est le plus long : la journée
|
||
commence à 02 h, la soirée qui passe minuit reste sur le jour où elle a
|
||
commencé. Indépendant de `order`, qui n'est qu'un ordre d'affichage et
|
||
ne suit pas les heures quand on les modifie (un créneau type d'ordre 0
|
||
déplacé à 21 h faisait commencer chaque journée à 21 h). Sans creux (les
|
||
créneaux types couvrent 24 h), début du premier par `order`. Accepte
|
||
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`.
|
||
Miroir front : `lib/planning.ts:dayBoundaryFromDayParts`."""
|
||
day_parts = list(day_parts)
|
||
if not day_parts:
|
||
return dt.time(0, 0)
|
||
covered = [False] * _MINUTES_PER_DAY
|
||
for part in day_parts:
|
||
start = part.start_time.hour * 60 + part.start_time.minute
|
||
end = part.end_time.hour * 60 + part.end_time.minute
|
||
length = (end - start) % _MINUTES_PER_DAY or _MINUTES_PER_DAY
|
||
for offset in range(length):
|
||
covered[(start + offset) % _MINUTES_PER_DAY] = True
|
||
if all(covered):
|
||
return min(day_parts, key=lambda part: part.order).start_time
|
||
# Parcours circulaire à partir d'une minute couverte : chaque creux est
|
||
# vu d'un seul tenant, même s'il enjambe minuit.
|
||
origin = covered.index(True)
|
||
best_start, best_length = 0, 0
|
||
run_start, run_length = 0, 0
|
||
for offset in range(1, _MINUTES_PER_DAY + 1):
|
||
minute = (origin + offset) % _MINUTES_PER_DAY
|
||
if covered[minute]:
|
||
run_length = 0
|
||
continue
|
||
if run_length == 0:
|
||
run_start = minute
|
||
run_length += 1
|
||
if run_length > best_length:
|
||
best_start, best_length = run_start, run_length
|
||
return dt.time(best_start // 60, best_start % 60)
|
||
|
||
|
||
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
|
||
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
|
||
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
|
||
reste d'un seul tenant partout."""
|
||
if moment.time() >= boundary:
|
||
return moment.date()
|
||
return moment.date() - dt.timedelta(days=1)
|