Le vœu reste à sens unique en base (A choisit B sur sa fiche, B n'a rien à faire) mais si A souhaite B, B doit désormais profiter aussi de la présence de A — dans les deux sens : affecter A d'abord fait remonter B comme candidat, et affecter B d'abord fait remonter A, même si B n'a rien demandé. - Solveur (`loader.py`) : le vœu (A → B) alimente maintenant l'ensemble effectif des deux bénévoles (A et B), pas seulement celui du demandeur. Aucun changement dans `engine.py` : il ne fait que lire un ensemble déjà symétrisé. - Mock (`buildEffectiveCompanions`) et classement par défaut (`buildEffectiveCompanionIds` dans `lib/planning.ts`, utilisé par `AssignmentPanel.tsx`) : même symétrisation, pour rester cohérents avec le vrai solveur. - La fiche bénévole (vœux affichés/édités) n'est pas touchée : c'est le seul endroit qui reste à sens unique, par construction (la réciprocité n'est qu'une lecture, jamais une donnée enregistrée — sinon éditer sa propre liste risquerait d'effacer le vœu de quelqu'un d'autre). - Nouveau test d'intégration : le bénévole qui n'a rien demandé profite quand même de la présence de celui qui l'a choisi. - CLAUDE.md à jour. Vérifié : 193 tests backend pertinents (dont le nouveau), ruff, alembic check ; typecheck/lint/build/smoke frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
76 lines
2.8 KiB
Python
76 lines
2.8 KiB
Python
"""Types d'entrée/sortie du solveur — suggestions, pas d'affectation automatique.
|
|
|
|
Le solveur ne connaît que ces structures simples (jamais les modèles
|
|
SQLModel), comme le moteur de validation dont il réutilise les briques
|
|
(`app.validation.models`, `app.validation.engine.uncovered_minutes`).
|
|
"""
|
|
|
|
import datetime as dt
|
|
from dataclasses import dataclass, field
|
|
|
|
from app.validation.models import AvailabilityWindow, ShiftInfo
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SolverShift:
|
|
"""Un créneau à compléter (`assigned < ideal_staff`)."""
|
|
|
|
id: str
|
|
position_id: str
|
|
starts_at: dt.datetime
|
|
ends_at: dt.datetime
|
|
min_staff: int
|
|
ideal_staff: int
|
|
already_assigned: int
|
|
# Créneau(x) — n'importe quel poste — dont la fin coïncide avec le début
|
|
# de celui-ci, le même jour (vide s'il n'y en a pas) : sert au malus de
|
|
# chaînage (voir `engine._score`), pour limiter les enchaînements de
|
|
# shifts plutôt que cibler la rotation d'un poste en particulier.
|
|
predecessor_shift_ids: frozenset[str] = frozenset()
|
|
# Bénévoles déjà affectés à ce créneau (décisions déjà prises) : sert au
|
|
# bonus de compagnon de route (voir `engine._score`).
|
|
assigned_volunteer_ids: frozenset[str] = frozenset()
|
|
|
|
@property
|
|
def shift_info(self) -> ShiftInfo:
|
|
return ShiftInfo(id=self.id, starts_at=self.starts_at, ends_at=self.ends_at)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SolverVolunteer:
|
|
id: str
|
|
availability: tuple[AvailabilityWindow, ...] = ()
|
|
wished_position_ids: frozenset[str] = frozenset()
|
|
# Bénévoles (0 à N) avec qui il voudrait être affecté. Le vœu est saisi à
|
|
# sens unique (`VolunteerCompanionWish`) mais rendu réciproque ici par le
|
|
# loader : si A souhaite B, l'ensemble de B contient aussi A.
|
|
wished_companion_ids: frozenset[str] = frozenset()
|
|
# Passages des artistes que le bénévole veut voir (heure de passage),
|
|
# sous forme de fenêtres datées : un créneau qui chevauche l'une d'elles
|
|
# lui ferait manquer le passage.
|
|
wished_artist_windows: tuple[ShiftInfo, ...] = ()
|
|
# Créneaux déjà occupés (affectations existantes, décisions figées) :
|
|
# comptent pour chevauchement, repos, chaînage.
|
|
existing_shifts: tuple[ShiftInfo, ...] = ()
|
|
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
|
existing_minutes: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SolverInput:
|
|
shifts: tuple[SolverShift, ...] = field(default_factory=tuple)
|
|
volunteers: tuple[SolverVolunteer, ...] = field(default_factory=tuple)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ShiftSuggestion:
|
|
"""Bénévoles suggérés pour un créneau, meilleur en tête."""
|
|
|
|
shift_id: str
|
|
volunteer_ids: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SolverSolution:
|
|
suggestions: tuple[ShiftSuggestion, ...] = field(default_factory=tuple)
|