É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
+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, ...] = ()