Étape 4 : moteur de validation des affectations

- Module pur app/validation/ : aucune dépendance à la base, entrée
  ValidationInput (dataclasses), sortie list[Violation] (code + params, pas
  de phrase)
- 5 règles conformes à CLAUDE.md § 3 :
  SHIFT_OVERLAP (bloquante), OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED,
  INSUFFICIENT_REST, MISSING_REQUIRED_SKILL (avertissements)
- Sémantique figée : bornes strictes (créneaux jointifs non chevauchants),
  frontière de journée au 1er DayPart, cumul > 8 h strict, repos < 8 h strict,
  statuts PROPOSED/CONFIRMED côté appelant
- has_blocking() pour l'appelant
- TDD : 23 tests, un cas passant + un cas déclenchant par règle + cas limites
  (minuit, frontière de journée, multi-créneaux, combinaisons) ; couverture 99 %
- Clés de traduction validation.<CODE> dans locales/fr.json

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-09 22:44:09 +02:00
co-authored by Claude Sonnet 5
parent bb919554ff
commit c96e85af37
6 changed files with 641 additions and 5 deletions
+7 -1
View File
@@ -25,5 +25,11 @@
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
"import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.",
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »)."
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({date}, {day_part_code}).",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise absente : {skill_code}."
}
+24 -2
View File
@@ -1,5 +1,27 @@
"""Moteur de validation des affectations — cœur du produit.
Implémenté à l'étape 4, en TDD : les tests précèdent le code, chaque règle est
couverte par un cas passant et un cas déclenchant. Voir CLAUDE.md § 3.
Voir CLAUDE.md § 3 pour les règles et la sémantique.
"""
from app.validation.engine import has_blocking, validate
from app.validation.models import (
AvailabilityDeclaration,
DayPartWindow,
Severity,
ShiftInfo,
ValidationInput,
Violation,
ViolationCode,
)
__all__ = [
"AvailabilityDeclaration",
"DayPartWindow",
"Severity",
"ShiftInfo",
"ValidationInput",
"Violation",
"ViolationCode",
"has_blocking",
"validate",
]
+192
View File
@@ -0,0 +1,192 @@
"""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.models import AvailabilityStatus
from app.validation.models import (
Severity,
ShiftInfo,
ValidationInput,
Violation,
ViolationCode,
)
_EIGHT_HOURS = dt.timedelta(hours=8)
def validate(data: ValidationInput) -> list[Violation]:
violations: list[Violation] = []
violations.extend(_check_overlap(data))
violations.extend(_check_outside_availability(data))
violations.extend(_check_daily_hours(data))
violations.extend(_check_insufficient_rest(data))
violations.extend(_check_missing_skills(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)
]
# --------------------------------------------------------------------------- #
# OUTSIDE_AVAILABILITY (avertissement) #
# --------------------------------------------------------------------------- #
def _covered_slots(
shift: ShiftInfo, day_parts: tuple
) -> list[tuple[dt.date, str]]:
"""Demi-journées (date, code) dont la fenêtre horaire recoupe le créneau."""
if not day_parts:
return []
slots: list[tuple[dt.date, str]] = []
day = shift.starts_at.date() - dt.timedelta(days=1)
last = shift.ends_at.date()
while day <= last:
for part in day_parts:
window_start = dt.datetime.combine(day, part.start_time)
if part.end_time > part.start_time:
window_end = dt.datetime.combine(day, part.end_time)
else:
window_end = dt.datetime.combine(
day + dt.timedelta(days=1), part.end_time
)
if shift.starts_at < window_end and window_start < shift.ends_at:
slots.append((day, part.code))
day += dt.timedelta(days=1)
return slots
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
slots = _covered_slots(data.candidate_shift, data.day_parts)
if not slots:
return []
declared = {
(a.date, a.day_part_code): a.status for a in data.availabilities
}
out: list[Violation] = []
for day, code in slots:
status = declared.get((day, code))
if status is None:
out.append(
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=day.isoformat(),
day_part_code=code,
status="UNDECLARED",
)
)
elif status is AvailabilityStatus.UNAVAILABLE:
out.append(
Violation.of(
ViolationCode.OUTSIDE_AVAILABILITY,
date=day.isoformat(),
day_part_code=code,
status="UNAVAILABLE",
)
)
return out
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- #
def _day_boundary(day_parts: tuple) -> dt.time:
if not day_parts:
return dt.time(0, 0)
return min(day_parts, key=lambda part: part.order).start_time
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = _day_boundary(data.day_parts)
candidate = data.candidate_shift
key = _day_key(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if _day_key(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS:
return [
Violation.of(
ViolationCode.DAILY_HOURS_EXCEEDED,
date=key.isoformat(),
total_minutes=int(total.total_seconds() // 60),
)
]
return []
# --------------------------------------------------------------------------- #
# INSUFFICIENT_REST (avertissement) #
# --------------------------------------------------------------------------- #
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
candidate = data.candidate_shift
out: list[Violation] = []
for other in data.existing_shifts:
if _overlaps(candidate, other):
continue
if other.ends_at <= candidate.starts_at:
gap = candidate.starts_at - other.ends_at
direction = "BEFORE"
elif candidate.ends_at <= other.starts_at:
gap = other.starts_at - candidate.ends_at
direction = "AFTER"
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
continue
if gap < _EIGHT_HOURS:
out.append(
Violation.of(
ViolationCode.INSUFFICIENT_REST,
other_shift_id=other.id,
direction=direction,
gap_minutes=int(gap.total_seconds() // 60),
)
)
return out
# --------------------------------------------------------------------------- #
# MISSING_REQUIRED_SKILL (avertissement) #
# --------------------------------------------------------------------------- #
def _check_missing_skills(data: ValidationInput) -> list[Violation]:
missing = sorted(
data.candidate_shift.required_skill_codes
- data.volunteer_skill_codes
)
return [
Violation.of(ViolationCode.MISSING_REQUIRED_SKILL, skill_code=code)
for code in missing
]
+88
View File
@@ -0,0 +1,88 @@
"""Types d'entrée et de sortie du moteur de validation.
Découplé de la base : le moteur ne connaît que ces structures simples, jamais
les modèles SQLModel. La couche appelante construit l'entrée depuis la base.
"""
import datetime as dt
from dataclasses import dataclass
from enum import StrEnum
from pydantic import BaseModel, Field
from app.models import AvailabilityStatus
class Severity(StrEnum):
BLOCKING = "BLOCKING"
WARNING = "WARNING"
class ViolationCode(StrEnum):
SHIFT_OVERLAP = "SHIFT_OVERLAP"
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
INSUFFICIENT_REST = "INSUFFICIENT_REST"
MISSING_REQUIRED_SKILL = "MISSING_REQUIRED_SKILL"
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
ViolationCode.MISSING_REQUIRED_SKILL: Severity.WARNING,
}
class Violation(BaseModel):
"""Une violation : un **code** + des paramètres structurés, jamais de phrase
(traduction via `locales/fr.json`, clés `validation.<CODE>`)."""
code: ViolationCode
severity: Severity
params: dict[str, str | int] = Field(default_factory=dict)
@classmethod
def of(cls, code: ViolationCode, **params: str | int) -> "Violation":
return cls(code=code, severity=SEVERITY_BY_CODE[code], params=params)
@dataclass(frozen=True)
class ShiftInfo:
id: str
starts_at: dt.datetime
ends_at: dt.datetime
required_skill_codes: frozenset[str] = frozenset()
@dataclass(frozen=True)
class DayPartWindow:
code: str
start_time: dt.time
# end_time <= start_time ⇒ la demi-journée passe minuit.
end_time: dt.time
order: int
@dataclass(frozen=True)
class AvailabilityDeclaration:
date: dt.date
day_part_code: str
status: AvailabilityStatus
@dataclass(frozen=True)
class ValidationInput:
"""Tout ce qu'il faut pour valider une affectation candidate.
`existing_shifts` = les créneaux des autres affectations **actives**
(`PROPOSED` / `CONFIRMED`) du même bénévole ; le filtrage par statut est à
la charge de l'appelant.
"""
candidate_shift: ShiftInfo
volunteer_skill_codes: frozenset[str] = frozenset()
existing_shifts: tuple[ShiftInfo, ...] = ()
availabilities: tuple[AvailabilityDeclaration, ...] = ()
day_parts: tuple[DayPartWindow, ...] = ()