"""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: 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, ...] = ()