Disponibilités déclarées supprimées (table `availability`, migration 0022, API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) ainsi que les règles OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus de disponibilité et de repos du solveur et le filtre « Surchargés » de la vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée. 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 (bloquante) #
|
||
# --------------------------------------------------------------------------- #
|
||
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)
|