- 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
89 lines
2.5 KiB
Python
89 lines
2.5 KiB
Python
"""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, ...] = ()
|