Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022, API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) ainsi que les règles OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus de disponibilité et de repos du solveur et le filtre « Surchargés » de la vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f671bb85bf
commit
d722a81a87
@@ -6,17 +6,13 @@ coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
|
||||
contente de faire remonter ces candidats en tête de liste).
|
||||
|
||||
Contrainte dure : aucun chevauchement de créneaux pour un même bénévole (règle
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (disponibilité, repos,
|
||||
chaînage entre créneaux consécutifs, vœu de poste, vœu de compagnon, vœu
|
||||
d'artiste, équité de charge) est un terme de l'objectif, pas une contrainte —
|
||||
comme le moteur de validation, qui reste seul juge au moment où la
|
||||
coordination confirme une affectation.
|
||||
Pas de plafond de cumul journalier côté solveur : `DAILY_HOURS_EXCEEDED`
|
||||
reste vérifiée par la validation, à la confirmation.
|
||||
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (chaînage entre
|
||||
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de
|
||||
charge) est un terme de l'objectif, pas une contrainte. Aucune limite d'heures
|
||||
par journée ni de temps de repos : ni le solveur ni la validation n'en
|
||||
tiennent compte.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
from app.solver.models import (
|
||||
@@ -26,22 +22,16 @@ from app.solver.models import (
|
||||
SolverSolution,
|
||||
SolverVolunteer,
|
||||
)
|
||||
from app.validation.engine import uncovered_minutes
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
_EIGHT_HOURS_MIN = 8 * 60
|
||||
|
||||
# Poids de l'objectif (unités arbitraires, cf. docstring de `_score`).
|
||||
# L'écart à un effectif minimal non atteint prime sur tout le reste : le
|
||||
# solveur doit toujours proposer quelqu'un pour un créneau sous-staffé, quitte
|
||||
# à ce que la disponibilité soit imparfaite (avertissement, pas un blocage —
|
||||
# la validation reste seule juge à la confirmation). Au-delà du minimum, un
|
||||
# candidat mal assorti peut légitimement ne pas être suggéré du tout.
|
||||
# à ce que le candidat soit mal assorti. Au-delà du minimum, un candidat mal
|
||||
# assorti peut légitimement ne pas être suggéré du tout.
|
||||
_URGENT_BASE = 1000 # créneau encore sous son effectif minimal
|
||||
_NORMAL_BASE = 20 # créneau entre effectif minimal et idéal
|
||||
_WISH_BONUS = 30
|
||||
_AVAIL_PENALTY_PER_HALF_HOUR = 5
|
||||
_REST_PENALTY = 60
|
||||
_FAIRNESS_PENALTY_PER_HOUR = 2
|
||||
# Le créneau lui ferait manquer le passage d'un artiste qu'il veut voir.
|
||||
_ARTIST_CONFLICT_PENALTY = 80
|
||||
@@ -58,22 +48,6 @@ def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
|
||||
return a.starts_at < b.ends_at and b.starts_at < a.ends_at
|
||||
|
||||
|
||||
def _has_insufficient_rest(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
candidate = shift.shift_info
|
||||
for other in volunteer.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
else:
|
||||
continue
|
||||
if gap < dt.timedelta(minutes=_EIGHT_HOURS_MIN):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _chained_from_predecessor(volunteer: SolverVolunteer, shift: SolverShift) -> bool:
|
||||
"""Le bénévole a déjà (affectation décidée) l'un des créneaux qui
|
||||
précèdent immédiatement celui-ci, le même jour — n'importe quel poste :
|
||||
@@ -88,20 +62,14 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
|
||||
"""Score d'affinité (bénévole, créneau) : plus haut = meilleur candidat.
|
||||
|
||||
Purement additif (aucune interaction entre deux affectations proposées),
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Repos, chaînage et
|
||||
compagnons ne sont vérifiés que contre les affectations déjà décidées,
|
||||
pas entre deux suggestions du même calcul : la validation finale (à la
|
||||
confirmation) reste seule juge en cas de cas limite entre deux
|
||||
suggestions (et deux compagnons suggérés ensemble dans le même calcul ne
|
||||
se renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
ce qui garde le modèle CP-SAT linéaire simple. Chaînage et compagnons ne
|
||||
sont vérifiés que contre les affectations déjà décidées, pas entre deux
|
||||
suggestions du même calcul (deux compagnons suggérés ensemble ne se
|
||||
renforcent pas mutuellement, faute d'interaction entre variables).
|
||||
"""
|
||||
score = _URGENT_BASE if shift.already_assigned < shift.min_staff else _NORMAL_BASE
|
||||
if shift.position_id in volunteer.wished_position_ids:
|
||||
score += _WISH_BONUS
|
||||
missed = uncovered_minutes(shift.shift_info, volunteer.availability)
|
||||
score -= (missed // 30) * _AVAIL_PENALTY_PER_HALF_HOUR
|
||||
if _has_insufficient_rest(volunteer, shift):
|
||||
score -= _REST_PENALTY
|
||||
if _chained_from_predecessor(volunteer, shift):
|
||||
score -= _CHAIN_PENALTY
|
||||
companions_present = len(volunteer.wished_companion_ids & shift.assigned_volunteer_ids)
|
||||
|
||||
Reference in New Issue
Block a user