Files
benevoles/backend/app/solver/models.py
T
AdamThomasandClaude Sonnet 5 11bc8cbe36 Vœu de compagnon : traité comme réciproque par le solveur
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
2026-09-15 22:32:02 +02:00

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)