Deux postes en même temps possibles, malus dans le solveur
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 24s
ci-cd / deploy (push) Successful in 27s

Le chevauchement de créneaux (SHIFT_OVERLAP) n'est plus bloquant : il
remonte en avertissement et l'affectation est enregistrée. Le solveur ne
l'exclut plus mais applique un fort malus au bénévole déjà affecté
ailleurs au même moment (proposé seulement sous l'effectif minimal, faute
de mieux) ; il n'est jamais reproposé sur un créneau où il est déjà.
Même malus dans le classement par défaut du panneau et dans le mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-25 10:38:52 +01:00
co-authored by Claude Opus 5.5
parent d0fa1e109c
commit 0f904f5e3d
11 changed files with 114 additions and 37 deletions
+20 -7
View File
@@ -536,13 +536,19 @@ Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation
un cas passant + un cas déclenchant par règle. un cas passant + un cas déclenchant par règle.
Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle Retourne une liste de violations typées `BLOCKING` ou `WARNING` (aucune règle
`WARNING` aujourd'hui : le type reste prévu par le contrat de l'API). Chaque `BLOCKING` aujourd'hui depuis l'étape 44 : le type reste prévu par le contrat
de l'API). Chaque
violation est un **code** + des paramètres structurés, jamais une phrase (la violation est un **code** + des paramètres structurés, jamais une phrase (la
traduction se fait via `locales/fr.json`). traduction se fait via `locales/fr.json`).
| Code | Sévérité | Règle | | Code | Sévérité | Règle |
|---------------------------|------------|-------------------------------------------------------------| |---------------------------|------------|-------------------------------------------------------------|
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. | | `SHIFT_OVERLAP` | `WARNING` | Deux créneaux d'un même bénévole se chevauchent. |
**Deux postes en même temps sont permis** (étape 44) : le chevauchement ne
bloque plus l'affectation, il remonte en avertissement dans le panneau
d'affectation (« Déjà affecté sur … au même moment »). C'est le solveur qui
évite de le proposer (malus, § 4).
**Aucune limite de temps dans la journée** (étape 42) : plus de cumul **Aucune limite de temps dans la journée** (étape 42) : plus de cumul
journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal journalier maximal (`DAILY_HOURS_EXCEEDED`, ex-8 h), plus de repos minimal
@@ -597,10 +603,15 @@ affectation n'est jamais persistée par cet endpoint.
recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie recalcule à la demande (rien n'est mis en cache côté serveur) et renvoie
`{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered, `{ suggestions: [{ shift_id, volunteer_ids }], shifts_considered,
volunteers_considered }`, `volunteer_ids` du meilleur au moins bon. volunteers_considered }`, `volunteer_ids` du meilleur au moins bon.
- **Seule contrainte dure** : aucun chevauchement de créneaux pour un même - **Contraintes dures** : jamais deux créneaux qui se chevauchent suggérés au
bénévole (règle `SHIFT_OVERLAP`) — à la fois contre les affectations déjà même bénévole dans un même calcul ; jamais un bénévole suggéré sur un
décidées et entre deux créneaux nouvellement suggérés. L'effectif idéal est créneau où il est déjà affecté. L'effectif idéal est un plafond dur (le
un plafond dur (le solveur ne sur-staffe jamais). solveur ne sur-staffe jamais). Un bénévole **déjà affecté à un autre poste
au même moment** reste proposable (la validation ne fait qu'avertir, § 3)
mais avec un fort malus (`_ALREADY_BUSY_PENALTY`, 300 — plus que tous les
bonus cumulés et que la base d'un créneau au-dessus de son minimum) : en
pratique, il n'est proposé que pour un créneau sous son effectif minimal,
et seulement faute de mieux.
- Tout le reste est un terme de l'objectif (maximisation), pas une - Tout le reste est un terme de l'objectif (maximisation), pas une
contrainte — cohérent avec le moteur de validation, qui reste seul juge au contrainte — cohérent avec le moteur de validation, qui reste seul juge au
moment où la coordination confirme une affectation : moment où la coordination confirme une affectation :
@@ -632,7 +643,8 @@ affectation n'est jamais persistée par cet endpoint.
**Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts` **Classement par défaut, sans lancer le solveur** — `frontend/src/lib/planning.ts`
(`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de (`candidateChanceScore`) porte une version simplifiée du même scoring (vœu de
poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau, poste, conflit d'artiste, compagnons souhaités déjà présents sur le créneau,
charge déjà affectée ; pas de chaînage). C'est ce déjà affecté ailleurs au même moment, charge déjà affectée ; pas de
chaînage). C'est ce
qui classe la colonne des candidats dans le panneau d'affectation dès qui classe la colonne des candidats dans le panneau d'affectation dès
l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a l'ouverture du détail d'un poste, sans requête réseau. Si le solveur a
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
@@ -904,6 +916,7 @@ Aucune dépendance locale requise hors Docker.
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider | | 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, 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 | à valider | | 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, 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 | à valider |
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider | | 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+15 -5
View File
@@ -5,8 +5,10 @@ effectif idéal, une liste ordonnée de bénévoles candidats. C'est la
coordination qui affecte (dans l'app, via l'écran habituel — le solveur se coordination qui affecte (dans l'app, via l'écran habituel — le solveur se
contente de faire remonter ces candidats en tête de liste). 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 Contrainte dure : jamais deux suggestions qui se chevauchent pour un même
`SHIFT_OVERLAP`, cf. `app.validation`). Tout le reste (chaînage entre bénévole dans un même calcul. Un bénévole déjà affecté à un autre poste au
même moment reste proposable (la validation ne fait qu'avertir,
`SHIFT_OVERLAP`) mais avec un fort malus. Tout le reste (chaînage entre
créneaux consécutifs, vœu de poste, vœu de compagnon, vœu d'artiste, équité de 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 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 par journée ni de temps de repos : ni le solveur ni la validation n'en
@@ -42,6 +44,11 @@ _CHAIN_PENALTY = 40
# Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole, # Par compagnon souhaité déjà affecté au créneau (0 à N vœux par bénévole,
# rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`). # rendus réciproques par le loader — voir `SolverVolunteer.wished_companion_ids`).
_COMPANION_BONUS_PER_MATCH = 25 _COMPANION_BONUS_PER_MATCH = 25
# Le bénévole est déjà affecté (affectation décidée) à un créneau qui
# chevauche celui-ci. Supérieur à tous les bonus cumulables et à la base
# normale : il n'est proposé que pour un créneau sous son minimum, et
# seulement faute de mieux.
_ALREADY_BUSY_PENALTY = 300
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
@@ -78,6 +85,8 @@ def _score(volunteer: SolverVolunteer, shift: SolverShift) -> int:
_overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows _overlaps(shift.shift_info, passage) for passage in volunteer.wished_artist_windows
): ):
score -= _ARTIST_CONFLICT_PENALTY score -= _ARTIST_CONFLICT_PENALTY
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts):
score -= _ALREADY_BUSY_PENALTY
score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60 score -= (_FAIRNESS_PENALTY_PER_HOUR * volunteer.existing_minutes) // 60
return score return score
@@ -98,7 +107,8 @@ def solve(
if gap <= 0: if gap <= 0:
continue continue
for vi, volunteer in enumerate(data.volunteers): for vi, volunteer in enumerate(data.volunteers):
if any(_overlaps(shift.shift_info, other) for other in volunteer.existing_shifts): # Déjà affecté à ce créneau : rien à suggérer.
if any(other.id == shift.id for other in volunteer.existing_shifts):
continue continue
key = (vi, si) key = (vi, si)
variables[key] = model.NewBoolVar(f"x_{vi}_{si}") variables[key] = model.NewBoolVar(f"x_{vi}_{si}")
@@ -115,8 +125,8 @@ def solve(
if shift_vars and gap > 0: if shift_vars and gap > 0:
model.Add(sum(shift_vars) <= gap) model.Add(sum(shift_vars) <= gap)
# Aucun chevauchement entre deux créneaux nouvellement suggérés au même # Jamais deux créneaux qui se chevauchent suggérés au même bénévole dans
# bénévole (règle `SHIFT_OVERLAP`, bloquante). # un même calcul (contre les affectations décidées : malus, cf. `_score`).
for vi in range(len(data.volunteers)): for vi in range(len(data.volunteers)):
my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables] my_shifts = [si for si in range(len(data.shifts)) if (vi, si) in variables]
for a in range(len(my_shifts)): for a in range(len(my_shifts)):
+1 -1
View File
@@ -26,7 +26,7 @@ def has_blocking(violations: list[Violation]) -> bool:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# SHIFT_OVERLAP (bloquante) # # SHIFT_OVERLAP (avertissement) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool: def _overlaps(a: ShiftInfo, b: ShiftInfo) -> bool:
# Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent # Bornes strictes : deux créneaux jointifs (fin == début) ne se chevauchent
+3 -1
View File
@@ -21,7 +21,9 @@ class ViolationCode(StrEnum):
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = { SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING, # Avertissement seulement : un bénévole peut tenir deux postes à la fois
# (le solveur l'évite par un malus, cf. `app.solver.engine`).
ViolationCode.SHIFT_OVERLAP: Severity.WARNING,
} }
+10 -7
View File
@@ -51,7 +51,11 @@ def test_long_day_without_rest_raises_no_warning(client, edition, session, day_p
assert resp.json()["warnings"] == [] assert resp.json()["warnings"] == []
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts): def test_create_assignment_overlap_is_allowed_with_warning(
client, edition, session, day_parts
):
# Un bénévole peut tenir deux postes en même temps : l'affectation est
# enregistrée, le chevauchement remonte en avertissement.
volunteer, shift = _setup(session, edition) volunteer, shift = _setup(session, edition)
assert client.post( assert client.post(
_url(edition), _url(edition),
@@ -69,15 +73,14 @@ def test_create_assignment_blocked_by_overlap(client, edition, session, day_part
"shift_id": str(overlapping.id), "shift_id": str(overlapping.id),
}, },
) )
assert resp.status_code == 422 assert resp.status_code == 201
detail = resp.json()["detail"] warnings = resp.json()["warnings"]
assert detail["code"] == "error.validation_failed" assert [w["code"] for w in warnings] == ["SHIFT_OVERLAP"]
assert any(v["code"] == "SHIFT_OVERLAP" for v in detail["violations"]) assert warnings[0]["severity"] == "WARNING"
# Rien n'a été enregistré pour ce créneau.
listed = client.get( listed = client.get(
_url(edition), params={"shift_id": str(overlapping.id)} _url(edition), params={"shift_id": str(overlapping.id)}
).json() ).json()
assert listed == [] assert len(listed) == 1
def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts): def test_create_duplicate_assignment_conflicts(client, edition, session, day_parts):
+34
View File
@@ -74,6 +74,40 @@ def test_fully_staffed_shift_gets_no_suggestion():
assert solution.suggestions == () assert solution.suggestions == ()
def test_busy_volunteer_is_still_suggested_for_an_urgent_gap_as_last_resort():
# Déjà affecté ailleurs au même moment : malus, mais seul candidat pour un
# créneau sous son minimum ⇒ proposé quand même.
shift = _shift(min_staff=1, ideal_staff=1)
busy = _volunteer(
"busy",
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ("busy",)
def test_volunteer_already_on_the_shift_is_never_suggested_again():
shift = _shift(min_staff=2, ideal_staff=2, already_assigned=1)
on_it = _volunteer(
"on_it",
existing_shifts=[ShiftInfo(id="s1", starts_at=_dt(10, 14), ends_at=_dt(10, 18))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(on_it,)))
assert _by_shift(solution, "s1") == ()
def test_busy_volunteer_is_not_suggested_above_the_minimum():
# Minimum déjà atteint : le malus l'emporte, personne n'est proposé.
shift = _shift(min_staff=1, ideal_staff=2, already_assigned=1)
busy = _volunteer(
"busy",
wished_position_ids={"p1"},
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
)
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy,)))
assert _by_shift(solution, "s1") == ()
def test_picks_the_only_free_volunteer_over_a_busy_one(): def test_picks_the_only_free_volunteer_over_a_busy_one():
shift = _shift() shift = _shift()
busy = _volunteer( busy = _volunteer(
+4 -3
View File
@@ -43,16 +43,17 @@ def test_overlap_pass_when_shifts_are_adjacent():
assert validate(data) == [] assert validate(data) == []
def test_overlap_triggers_and_is_blocking(): def test_overlap_triggers_as_a_warning_only():
data = ValidationInput( data = ValidationInput(
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)), candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
) )
violations = validate(data) violations = validate(data)
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP] assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
assert violations[0].severity is Severity.BLOCKING # Deux postes en même temps restent possibles : avertissement seulement.
assert violations[0].severity is Severity.WARNING
assert violations[0].params["other_shift_id"] == "e" assert violations[0].params["other_shift_id"] == "e"
assert has_blocking(violations) is True assert has_blocking(violations) is False
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+7 -9
View File
@@ -446,12 +446,12 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
const violations: Violation[] = []; const violations: Violation[] = [];
// SHIFT_OVERLAP (bloquante) // SHIFT_OVERLAP (avertissement : deux postes à la fois restent possibles)
for (const other of existing) { for (const other of existing) {
if (overlaps(candidate, other)) { if (overlaps(candidate, other)) {
violations.push({ violations.push({
code: 'SHIFT_OVERLAP', code: 'SHIFT_OVERLAP',
severity: 'BLOCKING', severity: 'WARNING',
params: { params: {
other_shift_id: other.id, other_shift_id: other.id,
other_starts_at: other.starts_at, other_starts_at: other.starts_at,
@@ -643,6 +643,8 @@ const SOLVER_CHAIN_PENALTY = 40;
// traité comme réciproque : si A souhaite B, B en profite aussi (voir // traité comme réciproque : si A souhaite B, B en profite aussi (voir
// `buildEffectiveCompanions`). // `buildEffectiveCompanions`).
const SOLVER_COMPANION_BONUS_PER_MATCH = 25; const SOLVER_COMPANION_BONUS_PER_MATCH = 25;
// Déjà affecté à un autre poste au même moment (cf. `_ALREADY_BUSY_PENALTY`).
const SOLVER_ALREADY_BUSY_PENALTY = 300;
type TimeWindow = { starts_at: string; ends_at: string }; type TimeWindow = { starts_at: string; ends_at: string };
@@ -745,6 +747,7 @@ function solverScore(
if (effectiveCompanionIds.has(id)) companionsPresent += 1; if (effectiveCompanionIds.has(id)) companionsPresent += 1;
} }
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent; score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
if (existing.some((other) => overlaps(shift, other))) score -= SOLVER_ALREADY_BUSY_PENALTY;
const existingMinutes = existing.reduce( const existingMinutes = existing.reduce(
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)), (sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
@@ -787,13 +790,8 @@ function runSolver(): SolverRunResult {
if (gap <= 0) continue; if (gap <= 0) continue;
const alreadyAssignedIds = assignedVolunteerIds(shift.id); const alreadyAssignedIds = assignedVolunteerIds(shift.id);
const picked = state.volunteers const picked = state.volunteers
.filter((v) => { .filter((v) => !alreadyAssignedIds.has(v.id))
const busy = [ .filter((v) => !(takenByVolunteer.get(v.id) ?? []).some((other) => overlaps(shift, other)))
...(existingByVolunteer.get(v.id) ?? []),
...(takenByVolunteer.get(v.id) ?? []),
];
return !busy.some((other) => overlaps(shift, other));
})
.map((v) => { .map((v) => {
const existing = existingByVolunteer.get(v.id) ?? []; const existing = existingByVolunteer.get(v.id) ?? [];
const predecessorIds = predecessorByShift.get(shift.id); const predecessorIds = predecessorByShift.get(shift.id);
+2 -2
View File
@@ -71,7 +71,7 @@
"position.category.other": "Autre", "position.category.other": "Autre",
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau", "validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment", "validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.", "validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent (avertissement, non bloquant).",
"grille.filter.day": "JOUR", "grille.filter.day": "JOUR",
"grille.filter.position": "POSTE", "grille.filter.position": "POSTE",
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE", "grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
@@ -203,7 +203,7 @@
"config.rules.slotAdd": "AJOUTER", "config.rules.slotAdd": "AJOUTER",
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.", "config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION", "config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.thresholdsNote": "Seule règle appliquée : le double emploi au même moment est toujours bloquant. Aucune limite d'heures par journée ni de temps de repos.", "config.rules.thresholdsNote": "Seule règle : le double emploi au même moment donne un avertissement, sans bloquer (un bénévole peut tenir deux postes à la fois) ; le solveur évite de le proposer. Aucune limite d'heures par journée ni de temps de repos.",
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES", "config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
"config.rules.rolesTitle2": "RÔLES ET ACCÈS", "config.rules.rolesTitle2": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination", "config.rules.roleCoordinator": "Coordination",
+5 -1
View File
@@ -82,11 +82,13 @@ const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80; const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2; const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25; const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
// Déjà affecté à un autre poste au même moment : possible, mais en fin de liste.
const CHANCE_ALREADY_BUSY_PENALTY = 300;
/** /**
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec * Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau, * un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
* charge déjà assignée. Plus haut = * déjà affecté ailleurs au même moment, charge déjà assignée. Plus haut =
* meilleur candidat. Sert uniquement à trier la liste affichée ; la * meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge. * validation (blocage / avertissements) reste seule juge.
*/ */
@@ -94,9 +96,11 @@ export function candidateChanceScore(params: {
wishesPosition: boolean; wishesPosition: boolean;
conflictsWishedArtist: boolean; conflictsWishedArtist: boolean;
companionMatches: number; companionMatches: number;
busyElsewhere: boolean;
assignedHours: number; assignedHours: number;
}): number { }): number {
let score = 0; let score = 0;
if (params.busyElsewhere) score -= CHANCE_ALREADY_BUSY_PENALTY;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS; if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY; if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches; score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
+13 -1
View File
@@ -134,6 +134,17 @@ export function AssignmentPanel({
return map; return map;
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]); }, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
// Bénévoles déjà affectés à un autre créneau qui chevauche celui-ci.
const busyElsewhereIds = useMemo(() => {
const ids = new Set<string>();
for (const a of allAssignments.data ?? []) {
if (a.shift_id === shift.shift_id) continue;
const other = shiftsById.get(a.shift_id);
if (other && windowsOverlap(shift, other)) ids.add(a.volunteer_id);
}
return ids;
}, [allAssignments.data, shiftsById, shift]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste, // Score de chance de chaque candidat pour CE créneau : vœu de poste,
// conflit avec un artiste qu'il veut voir, compagnons souhaités déjà présents, charge déjà assignée — c'est ce qui classe la // conflit avec un artiste qu'il veut voir, compagnons souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// liste, pas juste « le moins chargé ». // liste, pas juste « le moins chargé ».
@@ -147,12 +158,13 @@ export function AssignmentPanel({
wishesPosition: details?.wishesPosition ?? false, wishesPosition: details?.wishesPosition ?? false,
conflictsWishedArtist: details?.conflictsWishedArtist ?? false, conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
companionMatches: details?.companionMatches ?? 0, companionMatches: details?.companionMatches ?? 0,
busyElsewhere: busyElsewhereIds.has(v.id),
assignedHours: hours.get(v.id) ?? 0, assignedHours: hours.get(v.id) ?? 0,
}), }),
); );
} }
return map; return map;
}, [volunteers.data, detailsByVolunteer, hours]); }, [volunteers.data, detailsByVolunteer, busyElsewhereIds, hours]);
const needle = foldAccents(search.trim()); const needle = foldAccents(search.trim());