Nouveau vœu sur la fiche du bénévole — « je voudrais être affecté avec tel(s) bénévole(s) » — pris en compte à la fois pour le rendre visible et par le solveur. Backend : - `VolunteerCompanionWish` (`volunteer_id`, `companion_id`, à sens unique, `CHECK (volunteer_id <> companion_id)`) — migration 0010. - API : `wished_companion_ids` sur `VolunteerCreate`/`Update`/`Read`, mêmes règles que les autres vœux (ids inconnus → 422 `volunteer.unknown` ; se souhaiter soi-même → 422 `volunteer.cannot_wish_self`). - Solveur (`engine.py`) : bonus (+25 par correspondance) si un des compagnons souhaités du candidat est déjà affecté (décision déjà prise) au créneau — cumulable si plusieurs compagnons sont présents. Comme les autres termes de l'objectif, un bonus/malus, jamais une contrainte. - Seed de démo : ~20 % des bénévoles ont un vœu de compagnon (couple, amis). Frontend : - Fiche bénévole (`ConfigVolunteers.tsx`) : sélecteur de compagnons (ajout et édition), avec une bulle d'aide expliquant le sens unique et l'effet sur le solveur — c'est ce qui « fait apparaître » le lien entre bénévoles. - Panneau d'affectation : badge « avec N compagnon(s) souhaité(s) » sur un candidat, et bonus correspondant dans le classement par défaut (`candidateChanceScore`). - Mock (`runSolver`) : même bonus, pour que la démonstration se comporte pareil que le vrai solveur. CLAUDE.md à jour (modèle de domaine, section 4, journal). Vérifié : 194 tests backend (dont 13 nouveaux), ruff, alembic check ; typecheck/lint/build/smoke frontend ; solveur testé en direct sur le jeu de démo reseedé (0,25 s, 47 suggestions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
74 lines
2.7 KiB
Python
74 lines
2.7 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é — à sens unique.
|
|
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)
|