Files
AdamThomasandClaude Opus 5.5 0f904f5e3d
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 27s
Deux postes en même temps possibles, malus dans le solveur
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>
2026-10-25 10:38:52 +01:00

109 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)