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
@@ -20,7 +20,6 @@ colonne, route et variable utilise la colonne « Code / base ».
|
||||
| Poste | `Position` |
|
||||
| Référent d'un poste | `Position.lead_account_id` → `Account` |
|
||||
| Créneau | `Shift` |
|
||||
| Disponibilité (fenêtre horaire libre) | `Availability` (`date`, `start_time`, `end_time`) |
|
||||
| Affectation | `Assignment` |
|
||||
| Artiste programmé | `Artist` (`name`, `performance_date`, `start_time`, `end_time`) |
|
||||
| Vœu « je veux voir cet artiste » | `VolunteerArtist` (N-N `Volunteer` ↔ `Artist`) |
|
||||
@@ -39,14 +38,14 @@ colonne, route et variable utilise la colonne « Code / base ».
|
||||
| Rôle | `COORDINATOR` / `LEAD` / `VOLUNTEER` |
|
||||
| Catégorie de poste | `BAR` / `TICKETING` / `WELCOME` / `PARKING` / `CLEANING` / `CATERING` / `SETUP` / `STAGE` / `OTHER` |
|
||||
|
||||
### Distinction critique (ne jamais confondre, ni en base ni dans l'UI)
|
||||
### Pas de disponibilités déclarées
|
||||
|
||||
- **`Availability`** = une *déclaration* du bénévole : « le samedi, je peux
|
||||
venir de 14 h à 23 h » — fenêtre horaire libre, plusieurs par jour.
|
||||
- **`Assignment`** = une *décision* de l'organisation : « tu tiens le bar
|
||||
samedi 18 h – 22 h ».
|
||||
|
||||
Deux tables distinctes, deux écrans distincts, deux vocabulaires distincts.
|
||||
Les fenêtres de disponibilité (`Availability`) ont été retirées à l'étape 42
|
||||
(table supprimée, migration `0022`) : l'appli ne connaît que les
|
||||
**`Assignment`**, *décisions* de l'organisation (« tu tiens le bar samedi
|
||||
18 h – 22 h »). Seule subsiste la case informative « je suis disponible » du
|
||||
formulaire public (`Volunteer.self_declared_available`), sans aucun effet sur
|
||||
les affectations.
|
||||
|
||||
---
|
||||
|
||||
@@ -63,7 +62,7 @@ les tables sauf les tables d'association.
|
||||
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
|
||||
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
|
||||
`code`) ; `code` figé après création. Sert à générer les créneaux 0/0 des
|
||||
postes (pas les disponibilités, qui sont des fenêtres libres).
|
||||
postes.
|
||||
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
|
||||
l'écriture).
|
||||
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
|
||||
@@ -81,7 +80,7 @@ les tables sauf les tables d'association.
|
||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||
`self_declared_available` (case à cocher **purement informative**, sans
|
||||
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
|
||||
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||||
paiement SumUp plus bas).
|
||||
@@ -131,14 +130,6 @@ les tables sauf les tables d'association.
|
||||
0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les
|
||||
créneaux », idempotent — n'écrase jamais un créneau déjà généré, même
|
||||
modifié depuis).
|
||||
- **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time`
|
||||
(`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre
|
||||
horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique
|
||||
(`volunteer_id`, `date`, `start_time`, `end_time`). Éditée en bloc :
|
||||
`GET`/`PUT /editions/{id}/volunteers/{vid}/availability` (lecture : COORD /
|
||||
LEAD ou le bénévole lui-même ; `PUT` : COORDINATION, remplace tout).
|
||||
`GET /editions/{id}/availability` liste celles de toute l'édition (COORD /
|
||||
LEAD) — pour la grille des besoins.
|
||||
- **`Assignment`** : `volunteer_id`, `shift_id`, `source` (`AssignmentSource`,
|
||||
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
|
||||
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création
|
||||
@@ -203,8 +194,7 @@ les tables sauf les tables d'association.
|
||||
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
|
||||
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
|
||||
bénévoles : par **journée de festival** (§ 3), heures × personnes
|
||||
demandées (min/idéal) vs heures de disponibilité déclarées vs heures
|
||||
affectées ; par poste, places min/idéales, places **pourvues plafonnées
|
||||
demandées (min/idéal) vs heures affectées ; par poste, places min/idéales, places **pourvues plafonnées
|
||||
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
|
||||
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
|
||||
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
|
||||
@@ -386,7 +376,7 @@ Conventions techniques :
|
||||
- Suppression en cascade au niveau base (`ON DELETE CASCADE`) sur tous les
|
||||
`edition_id` et sur les liens vers `volunteer`, `shift`, `position`,
|
||||
`artist`, `account`. La suppression complète d'un bénévole efface
|
||||
donc ses `Availability`, `Assignment`, vœux (artistes, postes, compagnons —
|
||||
donc ses `Assignment`, vœux (artistes, postes, compagnons —
|
||||
dans les deux sens : ceux qu'il a émis et ceux qui le visaient) et son
|
||||
`Account`.
|
||||
- Dates/heures **naïves**, heure locale Europe/Paris (`TZ` du conteneur +
|
||||
@@ -411,7 +401,6 @@ Matrice appliquée par les endpoints :
|
||||
| `assignments` écriture | ✔ | ses postes | — |
|
||||
| `assignments` lecture | ✔ | ✔ | les siennes |
|
||||
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
||||
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||
| `badges` (config + génération) | ✔ | — | — |
|
||||
| `position-sheets` (génération) | ✔ | — | — |
|
||||
@@ -536,26 +525,28 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
||||
Module isolé `backend/app/validation/`. **Tests écrits avant l'implémentation**,
|
||||
un cas passant + un cas déclenchant par règle.
|
||||
|
||||
Retourne une liste de violations typées `BLOCKING` ou `WARNING`. Chaque
|
||||
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
|
||||
violation est un **code** + des paramètres structurés, jamais une phrase (la
|
||||
traduction se fait via `locales/fr.json`).
|
||||
|
||||
| Code | Sévérité | Règle |
|
||||
|---------------------------|------------|-------------------------------------------------------------|
|
||||
| `SHIFT_OVERLAP` | `BLOCKING` | Deux créneaux d'un même bénévole se chevauchent. |
|
||||
| `OUTSIDE_AVAILABILITY` | `WARNING` | Le créneau n'est pas entièrement couvert par les fenêtres de disponibilité déclarées ce jour-là (params : `date`, `uncovered_minutes`). |
|
||||
| `DAILY_HOURS_EXCEEDED` | `WARNING` | > 8 h 00 d'affectations cumulées sur une même journée. |
|
||||
| `INSUFFICIENT_REST` | `WARNING` | < 8 h 00 entre la fin d'un créneau et le début du suivant. |
|
||||
|
||||
**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
|
||||
entre deux créneaux (`INSUFFICIENT_REST`, ex-8 h), plus de contrôle de
|
||||
disponibilité (`OUTSIDE_AVAILABILITY`). Ni la validation ni le solveur n'en
|
||||
tiennent compte.
|
||||
|
||||
Sémantique figée :
|
||||
|
||||
- Toutes les affectations du bénévole comptent pour chevauchement / cumul /
|
||||
repos (une affectation = une décision, toujours active).
|
||||
- `OUTSIDE_AVAILABILITY` : on prend l'**union** des fenêtres `Availability` du
|
||||
bénévole (converties en instants, minuit géré) ; les minutes du créneau hors
|
||||
de cette union déclenchent l'avertissement. Aucune fenêtre déclarée ⇒
|
||||
créneau entièrement à découvert.
|
||||
- « Journée » (cumul 8 h) : commence au **début du plus long creux** que
|
||||
- Toutes les affectations du bénévole comptent pour le chevauchement (une
|
||||
affectation = une décision, toujours active).
|
||||
- « Journée de festival » (plus utilisée par la validation elle-même, mais par
|
||||
les grilles, les feuilles de poste, les statistiques et le chaînage du
|
||||
solveur) : commence au **début du plus long creux** que
|
||||
laissent les `DayPart` sur le cadran de 24 h, c.-à-d. à l'heure où le
|
||||
dernier créneau type de la journée se termine (Matin 08–13, Après-midi
|
||||
13–19, Soirée 19–02 ⇒ journée de 02:00 à 02:00). **Indépendant de
|
||||
@@ -574,8 +565,6 @@ Sémantique figée :
|
||||
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
|
||||
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
|
||||
matin du même jour calendaire.
|
||||
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
|
||||
- Repos : `< 8 h 00` strict.
|
||||
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
||||
- Une violation `BLOCKING` empêche l'enregistrement, sans dérogation.
|
||||
- Une violation `WARNING` est retournée à l'appelant mais n'empêche rien
|
||||
@@ -607,13 +596,9 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
moment où la coordination confirme une affectation :
|
||||
- un créneau encore sous son effectif **minimal** prime largement sur tout
|
||||
le reste (le solveur doit presque toujours proposer quelqu'un pour un
|
||||
trou urgent, quitte à ce que ce soit un pis-aller signalé ensuite par un
|
||||
avertissement) ;
|
||||
trou urgent, quitte à ce que ce soit un pis-aller) ;
|
||||
- bonus si le poste fait partie des vœux du bénévole
|
||||
(`VolunteerPositionWish`) ;
|
||||
- malus proportionnel aux minutes hors des fenêtres de disponibilité
|
||||
déclarées (réutilise `validation.engine.uncovered_minutes`), et si le
|
||||
repos avec une affectation déjà décidée serait `< 8 h 00` ;
|
||||
- malus si le bénévole a déjà (affectation décidée) le créneau
|
||||
immédiatement précédent, le même jour — **n'importe quel poste**
|
||||
(`SolverShift.predecessor_shift_ids`) : limite les enchaînements de
|
||||
@@ -626,11 +611,9 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
au créneau — plusieurs compagnons présents cumulent le bonus ;
|
||||
- léger malus pour les bénévoles déjà bien chargés (répartition de la
|
||||
charge).
|
||||
- **Pas de plafond de cumul journalier côté solveur** : contrairement à la
|
||||
validation (`DAILY_HOURS_EXCEEDED`, § 3), le solveur ne pénalise pas un
|
||||
bénévole qui dépasserait 8 h sur la journée — seule la validation le
|
||||
signale, à la confirmation.
|
||||
- Simplification assumée : repos, chaînage et compagnons ne sont vérifiés
|
||||
- **Aucune limite de temps** : ni cumul journalier, ni repos minimal, ni
|
||||
disponibilité (§ 3).
|
||||
- Simplification assumée : 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 dans le même calcul ne
|
||||
se renforcent pas mutuellement) — la validation finale à la confirmation
|
||||
@@ -638,9 +621,8 @@ affectation n'est jamais persistée par cet endpoint.
|
||||
|
||||
**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
|
||||
poste, disponibilité y compris conflit d'artiste, compagnons souhaités déjà
|
||||
présents sur le créneau, charge déjà affectée ; pas de cumul 8 h / repos /
|
||||
chaînage, déjà couverts par les badges de validation). C'est ce
|
||||
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
|
||||
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
|
||||
effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus
|
||||
@@ -693,7 +675,7 @@ adapté à son tableau bénévole × créneau.
|
||||
|
||||
1. Modèle de données complet + migrations Alembic.
|
||||
2. Import CSV/Excel (identité des bénévoles : nom, email, téléphone,
|
||||
commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux
|
||||
commentaire), en deux
|
||||
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
|
||||
validé. Rapport ligne par ligne. Détection des doublons : upsert sur
|
||||
(`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
|
||||
@@ -706,7 +688,7 @@ adapté à son tableau bénévole × créneau.
|
||||
5. Endpoint matrice d'une édition :
|
||||
`[{ position, shifts: [{ shift, min_staff, ideal_staff, assigned }] }]`.
|
||||
6. Jeu de données de démonstration : 120 bénévoles, 15 postes, 4 jours,
|
||||
quelques trous d'effectif, 2–3 avertissements actifs, programmation
|
||||
quelques trous d'effectif, programmation
|
||||
(artistes les jours de festival) et vœux des bénévoles (postes, artistes)
|
||||
pour que le solveur ait matière à arbitrer.
|
||||
7. Solveur de suggestions OR-Tools CP-SAT (section 4) — en cours.
|
||||
@@ -746,7 +728,7 @@ l'étape 21.)*
|
||||
│ │ ├── api/ # routers + schemas Pydantic + deps
|
||||
│ │ │ # health, auth, accounts, editions,
|
||||
│ │ │ # day_parts, imports, artists, volunteers,
|
||||
│ │ │ # availability, positions, shifts,
|
||||
│ │ │ # positions, shifts,
|
||||
│ │ │ # assignments, solver, matrix, notifications,
|
||||
│ │ │ # badges, position_sheets, forms, stats
|
||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||
@@ -806,8 +788,8 @@ Aucune dépendance locale requise hors Docker.
|
||||
- `Edition.retention_days` : durée de rétention paramétrable.
|
||||
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
|
||||
- **Dates et heures :** stockées naïves, interprétées en heure locale
|
||||
Europe/Paris (heure murale). Les règles « 8 h », « journée », « demi-journée »
|
||||
sont toutes en heure locale.
|
||||
Europe/Paris (heure murale). La « journée de festival » est en heure
|
||||
locale.
|
||||
- **Validation TDD :** tests avant implémentation, un cas passant + un cas
|
||||
déclenchant par règle.
|
||||
- **Git :** un commit atomique par étape validée, message en français.
|
||||
@@ -892,6 +874,7 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
|
||||
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à 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 |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Disponibilités déclarées par les bénévoles.
|
||||
|
||||
Une disponibilité = « le <date>, je peux de <start_time> à <end_time> ».
|
||||
Fenêtre horaire libre (pas liée aux créneaux types) ; plusieurs par jour
|
||||
possibles. `end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
|
||||
Ces fenêtres sont prises en compte par le moteur de validation (règle
|
||||
`OUTSIDE_AVAILABILITY`) au moment d'affecter un bénévole à un créneau.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import owned_or_404
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import AvailabilityRead, AvailabilitySet
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
require_coordinator,
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Availability, Edition, Role, Volunteer
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}", tags=["availability"])
|
||||
|
||||
|
||||
def _ensure_can_read(account: Account, volunteer_id: uuid.UUID) -> None:
|
||||
if account.role in (Role.COORDINATOR, Role.LEAD):
|
||||
return
|
||||
if account.volunteer_id == volunteer_id:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail={"code": "error.forbidden"})
|
||||
|
||||
|
||||
def _windows(session: Session, volunteer_id: uuid.UUID) -> list[Availability]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.volunteer_id == volunteer_id)
|
||||
.order_by(Availability.date, Availability.start_time)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def list_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
_ensure_can_read(account, volunteer_id)
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/volunteers/{volunteer_id}/availability",
|
||||
response_model=list[AvailabilityRead],
|
||||
)
|
||||
def replace_availability(
|
||||
volunteer_id: uuid.UUID,
|
||||
payload: AvailabilitySet,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
|
||||
|
||||
for w in payload.windows:
|
||||
if not edition.start_date <= w.date <= edition.end_date:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "availability.date_outside_edition",
|
||||
"params": {"date": w.date.isoformat()},
|
||||
},
|
||||
)
|
||||
if w.start_time == w.end_time:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "availability.empty_window"},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
delete(Availability).where(Availability.volunteer_id == volunteer_id)
|
||||
)
|
||||
# Dé-doublonne (date, start, end) : la contrainte d'unicité l'imposerait.
|
||||
seen: set[tuple] = set()
|
||||
for w in payload.windows:
|
||||
key = (w.date, w.start_time, w.end_time)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer_id,
|
||||
date=w.date,
|
||||
start_time=w.start_time,
|
||||
end_time=w.end_time,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return _windows(session, volunteer_id)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=list[AvailabilityRead])
|
||||
def list_edition_availability(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_staff),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[Availability]:
|
||||
"""Toutes les disponibilités de l'édition (pour la grille des besoins)."""
|
||||
return list(
|
||||
session.exec(
|
||||
select(Availability)
|
||||
.where(Availability.edition_id == edition.id)
|
||||
.order_by(
|
||||
Availability.volunteer_id,
|
||||
Availability.date,
|
||||
Availability.start_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -254,33 +254,6 @@ class DayPartUpdate(ApiModel):
|
||||
order: int | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Availability (disponibilités déclarées par le bénévole) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AvailabilityWindowIn(ApiModel):
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
# end_time <= start_time ⇒ la fenêtre se termine le lendemain.
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
class AvailabilitySet(ApiModel):
|
||||
"""Remplace l'intégralité des disponibilités d'un bénévole."""
|
||||
|
||||
windows: list[AvailabilityWindowIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AvailabilityRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
volunteer_id: uuid.UUID
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assignment #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -714,15 +687,12 @@ class StatsArtistWish(ApiModel):
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures de disponibilité
|
||||
déclarées, heures déjà affectées."""
|
||||
× personnes demandées (effectif minimal / idéal), heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
available_hours: float
|
||||
available_volunteers: int
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
@@ -745,7 +715,6 @@ class StatsNeedPosition(ApiModel):
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_availability: int
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
@@ -126,9 +125,6 @@ def get_stats(
|
||||
assignments = list(
|
||||
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
|
||||
)
|
||||
availabilities = list(
|
||||
session.exec(select(Availability).where(Availability.edition_id == edition.id))
|
||||
)
|
||||
artists = list(
|
||||
session.exec(
|
||||
select(Artist)
|
||||
@@ -203,8 +199,7 @@ def get_stats(
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
day_rows: dict[dt.date, dict] = defaultdict(
|
||||
lambda: {
|
||||
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
|
||||
"available_volunteers": set(), "assigned_hours": 0.0,
|
||||
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "assigned_hours": 0.0,
|
||||
}
|
||||
)
|
||||
for s in shifts:
|
||||
@@ -219,12 +214,6 @@ def get_stats(
|
||||
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
|
||||
s.starts_at, s.ends_at
|
||||
)
|
||||
for w in availabilities:
|
||||
starts_at = dt.datetime.combine(w.date, w.start_time)
|
||||
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
row = day_rows[festival_day(starts_at, boundary)]
|
||||
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
|
||||
row["available_volunteers"].add(w.volunteer_id)
|
||||
|
||||
wishers = Counter(w.position_id for w in position_wishes)
|
||||
assigned_by_shift = Counter(a.shift_id for a in assignments)
|
||||
@@ -239,8 +228,6 @@ def get_stats(
|
||||
shifts=row["shifts"],
|
||||
min_hours=_round(row["min_hours"]),
|
||||
ideal_hours=_round(row["ideal_hours"]),
|
||||
available_hours=_round(row["available_hours"]),
|
||||
available_volunteers=len(row["available_volunteers"]),
|
||||
assigned_hours=_round(row["assigned_hours"]),
|
||||
)
|
||||
for day, row in sorted(day_rows.items())
|
||||
@@ -260,7 +247,6 @@ def get_stats(
|
||||
)
|
||||
for p in positions
|
||||
],
|
||||
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
|
||||
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
|
||||
"validation.SHIFT_OVERLAP": "Chevauchement avec un autre créneau du bénévole.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées le {date} ({uncovered_minutes} min non couvertes).",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectation cumulées le {date}.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux.",
|
||||
"notification.no_recipients": "Aucun bénévole ciblé.",
|
||||
"email.magic_link.subject": "Ton lien de connexion",
|
||||
"email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.",
|
||||
@@ -58,8 +55,6 @@
|
||||
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
|
||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.image_unreadable": "L'image est illisible ou corrompue.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.api.accounts import router as account_router
|
||||
from app.api.artists import router as artist_router
|
||||
from app.api.assignments import router as assignment_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.availability import router as availability_router
|
||||
from app.api.badges import router as badge_router
|
||||
from app.api.day_parts import router as day_part_router
|
||||
from app.api.editions import router as edition_router
|
||||
@@ -67,7 +66,6 @@ app.include_router(account_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(artist_router)
|
||||
app.include_router(volunteer_router)
|
||||
app.include_router(availability_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Suppression des disponibilités déclarées
|
||||
|
||||
Revision ID: 0022_drop_availability
|
||||
Revises: 0021_payment_option_free_amount
|
||||
Create Date: 2026-10-05
|
||||
|
||||
Les fenêtres de disponibilité (`availability`) disparaissent, avec la règle
|
||||
`OUTSIDE_AVAILABILITY` qui les exploitait. Les données existantes sont
|
||||
perdues : le `downgrade` recrée la table vide. La case « je suis
|
||||
disponible » du formulaire public (`volunteer.self_declared_available`)
|
||||
n'est pas concernée.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0022_drop_availability"
|
||||
down_revision: str | None = "0021_payment_option_free_amount"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(op.f("ix_availability_volunteer_id"), table_name="availability")
|
||||
op.drop_index(op.f("ix_availability_edition_id"), table_name="availability")
|
||||
op.drop_table("availability")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"availability",
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("volunteer_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("end_time", sa.Time(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["volunteer_id"], ["volunteer.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_edition_id"), "availability", ["edition_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_availability_volunteer_id"), "availability", ["volunteer_id"], unique=False
|
||||
)
|
||||
@@ -9,7 +9,6 @@ from app.models.domain import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
BadgeField,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
@@ -41,7 +40,6 @@ __all__ = [
|
||||
"Artist",
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"Availability",
|
||||
"BadgeAlign",
|
||||
"BadgeField",
|
||||
"BadgeFieldType",
|
||||
|
||||
@@ -184,8 +184,7 @@ class Volunteer(TimestampMixin, table=True):
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
companion_wish_note: str | None = None
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de lien avec `Availability`, pas de
|
||||
# workflow de paiement).
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
@@ -237,9 +236,6 @@ class Volunteer(TimestampMixin, table=True):
|
||||
link_model=VolunteerPositionWish,
|
||||
sa_relationship_kwargs=_PASSIVE,
|
||||
)
|
||||
availabilities: list["Availability"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
assignments: list["Assignment"] = Relationship(
|
||||
back_populates="volunteer", sa_relationship_kwargs=_PASSIVE
|
||||
)
|
||||
@@ -352,43 +348,11 @@ class Shift(TimestampMixin, table=True):
|
||||
)
|
||||
|
||||
|
||||
class Availability(TimestampMixin, table=True):
|
||||
"""Déclaration du bénévole : « tel jour, je peux venir de telle à telle
|
||||
heure ». Fenêtre horaire libre (pas liée aux créneaux types). Plusieurs
|
||||
fenêtres par jour possibles.
|
||||
|
||||
Heures naïves, locales Europe/Paris. `end_time <= start_time` ⇒ la fenêtre
|
||||
passe minuit (se termine le lendemain). À ne pas confondre avec Assignment
|
||||
(décision de l'organisation).
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"volunteer_id", "date", "start_time", "end_time",
|
||||
name="uq_availability_window",
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
volunteer_id: uuid.UUID = Field(
|
||||
foreign_key="volunteer.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="availabilities")
|
||||
|
||||
|
||||
class Assignment(TimestampMixin, table=True):
|
||||
"""Décision de l'organisation : « tu tiens tel créneau ».
|
||||
|
||||
Existe ⇒ le bénévole est affecté (pas d'étape de confirmation). Retirer une
|
||||
affectation, c'est supprimer la ligne. À ne pas confondre avec Availability
|
||||
(déclaration du bénévole).
|
||||
affectation, c'est supprimer la ligne.
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
+3
-163
@@ -9,9 +9,8 @@ Déterministe (graine fixe). Aucune donnée nominative réelle.
|
||||
import datetime as dt
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
@@ -22,7 +21,6 @@ from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
@@ -35,14 +33,7 @@ from app.models import (
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
validate,
|
||||
)
|
||||
from app.validation import Severity, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
SEED = 20260709
|
||||
@@ -227,7 +218,6 @@ class Summary:
|
||||
companion_wishes: int = 0
|
||||
fee_paid_classique: int = 0
|
||||
fee_paid_mecenat: int = 0
|
||||
warnings: Counter = field(default_factory=Counter)
|
||||
blocking: int = 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -245,10 +235,7 @@ class Summary:
|
||||
f" Vœux de compagnon déclarés : {self.companion_wishes}",
|
||||
f" Cotisation payée (classique) : {self.fee_paid_classique}",
|
||||
f" Cotisation payée (mécénat) : {self.fee_paid_mecenat}",
|
||||
f" Avertissements actifs : {sum(self.warnings.values())}",
|
||||
]
|
||||
for code, count in sorted(self.warnings.items()):
|
||||
lines.append(f" {code} : {count}")
|
||||
lines.append(f" Violations bloquantes : {self.blocking}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
@@ -309,9 +296,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
]
|
||||
session.add_all(day_parts)
|
||||
session.flush()
|
||||
dp_windows = tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order) for dp in day_parts
|
||||
)
|
||||
|
||||
coordination = Account(
|
||||
edition_id=edition.id,
|
||||
@@ -428,27 +412,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
)
|
||||
)
|
||||
n_companion_wishes += 1
|
||||
# Disponibilités : chaque bénévole déclare, pour ~80 % des jours, une
|
||||
# large fenêtre couvrant la journée et la nuit (06:00 → 04:00 le lendemain).
|
||||
_FULL_DAY = (dt.time(6, 0), dt.time(4, 0))
|
||||
availabilities: dict[object, list[AvailabilityWindow]] = {}
|
||||
for volunteer in volunteers:
|
||||
windows: list[AvailabilityWindow] = []
|
||||
for day in dates:
|
||||
if rng.random() < 0.8:
|
||||
start, end = _FULL_DAY
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
)
|
||||
windows.append(AvailabilityWindow(day, start, end))
|
||||
availabilities[volunteer.id] = windows
|
||||
|
||||
shifts: list[Shift] = []
|
||||
for position, (_n, category, _l, _r) in zip(positions, POSITIONS, strict=True):
|
||||
for day in dates:
|
||||
@@ -470,28 +433,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
|
||||
assigned: dict[object, list[Shift]] = {v.id: [] for v in volunteers}
|
||||
|
||||
# Trois bénévoles réservés aux scénarios d'avertissement : exclus de
|
||||
# l'affectation générale pour rester libres.
|
||||
reserved = volunteers[:3]
|
||||
reserved_ids = {v.id for v in reserved}
|
||||
assignable = [v for v in volunteers if v.id not in reserved_ids]
|
||||
|
||||
def _clean(volunteer: Volunteer, shift: Shift) -> bool:
|
||||
data = ValidationInput(
|
||||
candidate_shift=ShiftInfo(
|
||||
id=str(shift.id),
|
||||
starts_at=shift.starts_at,
|
||||
ends_at=shift.ends_at,
|
||||
),
|
||||
existing_shifts=tuple(
|
||||
ShiftInfo(id=str(s.id), starts_at=s.starts_at, ends_at=s.ends_at)
|
||||
for s in assigned[volunteer.id]
|
||||
),
|
||||
availabilities=tuple(availabilities[volunteer.id]),
|
||||
day_parts=dp_windows,
|
||||
)
|
||||
return not validate(data)
|
||||
|
||||
n_assignments = 0
|
||||
order = shifts[:]
|
||||
rng.shuffle(order)
|
||||
@@ -500,7 +441,7 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
target = max(0, shift.min_staff - 1)
|
||||
else:
|
||||
target = rng.randint(shift.min_staff, shift.ideal_staff)
|
||||
pool = assignable[:]
|
||||
pool = volunteers[:]
|
||||
rng.shuffle(pool)
|
||||
placed = 0
|
||||
for volunteer in pool:
|
||||
@@ -508,8 +449,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
break
|
||||
if any(_overlap(shift, s) for s in assigned[volunteer.id]):
|
||||
continue
|
||||
if not _clean(volunteer, shift):
|
||||
continue
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -522,9 +461,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
placed += 1
|
||||
n_assignments += 1
|
||||
|
||||
n_assignments += _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
)
|
||||
n_fee_classique, n_fee_mecenat = _add_demo_payments(session, volunteers, rng)
|
||||
session.flush()
|
||||
|
||||
@@ -558,74 +494,6 @@ def build(session: Session, *, rng: random.Random | None = None) -> Summary:
|
||||
return summary
|
||||
|
||||
|
||||
def _clear_day(session, volunteer, day, availabilities) -> None:
|
||||
session.execute(
|
||||
delete(Availability)
|
||||
.where(Availability.volunteer_id == volunteer.id)
|
||||
.where(Availability.date == day)
|
||||
)
|
||||
availabilities[volunteer.id] = [
|
||||
w for w in availabilities[volunteer.id] if w.date != day
|
||||
]
|
||||
|
||||
|
||||
def _declare_window(
|
||||
session, edition, volunteer, day, start, end, availabilities
|
||||
) -> None:
|
||||
session.add(
|
||||
Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=day,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
)
|
||||
)
|
||||
availabilities[volunteer.id].append(AvailabilityWindow(day, start, end))
|
||||
|
||||
|
||||
def _add_demo_warnings(
|
||||
session, edition, shifts, reserved, assigned, availabilities
|
||||
) -> int:
|
||||
"""Quelques affectations qui déclenchent des avertissements (et rien
|
||||
d'autre) : hors disponibilité, repos insuffisant. `reserved` = trois
|
||||
bénévoles tenus à l'écart de l'affectation générale.
|
||||
"""
|
||||
a, b, tired = reserved
|
||||
added = 0
|
||||
|
||||
# OUTSIDE_AVAILABILITY : deux bénévoles affectés un jour où ils n'ont
|
||||
# déclaré aucune disponibilité.
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for volunteer, shift in ((a, plain[3]), (b, plain[7])):
|
||||
_clear_day(session, volunteer, shift.starts_at.date(), availabilities)
|
||||
_add_active(session, edition, volunteer, shift, assigned)
|
||||
added += 1
|
||||
|
||||
# INSUFFICIENT_REST : deux créneaux espacés de moins de 8 h, sur deux
|
||||
# journées (pour ne pas déclencher le cumul), tous deux couverts par une
|
||||
# disponibilité.
|
||||
pair = _find_rest_pair(shifts)
|
||||
if pair is not None:
|
||||
first, second = pair
|
||||
for shift in (first, second):
|
||||
_clear_day(session, tired, shift.starts_at.date(), availabilities)
|
||||
_declare_window(
|
||||
session,
|
||||
edition,
|
||||
tired,
|
||||
shift.starts_at.date(),
|
||||
dt.time(6, 0),
|
||||
dt.time(4, 0),
|
||||
availabilities,
|
||||
)
|
||||
_add_active(session, edition, tired, first, assigned)
|
||||
_add_active(session, edition, tired, second, assigned)
|
||||
added += 2
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def _add_demo_payments(
|
||||
session: Session, volunteers: list[Volunteer], rng: random.Random
|
||||
) -> tuple[int, int]:
|
||||
@@ -664,32 +532,6 @@ def _add_demo_payments(
|
||||
return len(classique), len(mecenat)
|
||||
|
||||
|
||||
def _find_rest_pair(shifts: list[Shift]) -> tuple[Shift, Shift] | None:
|
||||
plain = sorted(shifts, key=lambda s: s.starts_at)
|
||||
for i, first in enumerate(plain):
|
||||
for second in plain[i + 1 :]:
|
||||
gap = second.starts_at - first.ends_at
|
||||
if (
|
||||
first.ends_at <= second.starts_at
|
||||
and dt.timedelta(0) < gap < dt.timedelta(hours=8)
|
||||
and first.starts_at.date() != second.starts_at.date()
|
||||
):
|
||||
return first, second
|
||||
return None
|
||||
|
||||
|
||||
def _add_active(session, edition, volunteer, shift, assigned) -> None:
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
shift_id=shift.id,
|
||||
source=AssignmentSource.MANUAL,
|
||||
)
|
||||
)
|
||||
assigned[volunteer.id].append(shift)
|
||||
|
||||
|
||||
def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
session.flush()
|
||||
assignments = session.exec(
|
||||
@@ -709,8 +551,6 @@ def _verify(session: Session, edition: Edition, summary: Summary) -> None:
|
||||
):
|
||||
if violation.severity is Severity.BLOCKING:
|
||||
summary.blocking += 1
|
||||
else:
|
||||
summary.warnings[violation.code] += 1
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,6 @@ from sqlmodel import Session, select
|
||||
from app.models import (
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
@@ -20,7 +19,7 @@ from app.models import (
|
||||
)
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _artist_window(artist: Artist) -> ShiftInfo:
|
||||
@@ -93,14 +92,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
if assigned_count.get(s.id, 0) < s.ideal_staff
|
||||
)
|
||||
|
||||
availabilities: dict = defaultdict(list)
|
||||
for a in session.exec(
|
||||
select(Availability).where(Availability.edition_id == edition.id)
|
||||
):
|
||||
availabilities[a.volunteer_id].append(
|
||||
AvailabilityWindow(date=a.date, start_time=a.start_time, end_time=a.end_time)
|
||||
)
|
||||
|
||||
wishes: dict = defaultdict(set)
|
||||
for w in session.exec(
|
||||
select(VolunteerPositionWish).join(
|
||||
@@ -141,7 +132,6 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
|
||||
solver_volunteers = tuple(
|
||||
SolverVolunteer(
|
||||
id=str(v.id),
|
||||
availability=tuple(availabilities.get(v.id, ())),
|
||||
wished_position_ids=frozenset(wishes.get(v.id, ())),
|
||||
wished_companion_ids=frozenset(companion_wishes.get(v.id, ())),
|
||||
wished_artist_windows=tuple(artist_wishes.get(v.id, ())),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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`).
|
||||
(`app.validation.models`).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -39,7 +39,6 @@ class SolverShift:
|
||||
@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
|
||||
@@ -50,7 +49,7 @@ class SolverVolunteer:
|
||||
# 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.
|
||||
# comptent pour chevauchement et chaînage.
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
# Total déjà affecté sur l'édition (minutes), pour répartir la charge.
|
||||
existing_minutes: int = 0
|
||||
|
||||
@@ -5,7 +5,6 @@ Voir CLAUDE.md § 3 pour les règles et la sémantique.
|
||||
|
||||
from app.validation.engine import has_blocking, validate
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -15,7 +14,6 @@ from app.validation.models import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AvailabilityWindow",
|
||||
"DayPartWindow",
|
||||
"Severity",
|
||||
"ShiftInfo",
|
||||
|
||||
@@ -7,7 +7,6 @@ sortie. Règles et sémantique figées : voir CLAUDE.md § 3.
|
||||
import datetime as dt
|
||||
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
@@ -15,15 +14,10 @@ from app.validation.models import (
|
||||
ViolationCode,
|
||||
)
|
||||
|
||||
_EIGHT_HOURS = dt.timedelta(hours=8)
|
||||
|
||||
|
||||
def validate(data: ValidationInput) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
violations.extend(_check_overlap(data))
|
||||
violations.extend(_check_outside_availability(data))
|
||||
violations.extend(_check_daily_hours(data))
|
||||
violations.extend(_check_insufficient_rest(data))
|
||||
return violations
|
||||
|
||||
|
||||
@@ -55,57 +49,8 @@ def _check_overlap(data: ValidationInput) -> list[Violation]:
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _window_interval(w: AvailabilityWindow) -> tuple[dt.datetime, dt.datetime]:
|
||||
start = dt.datetime.combine(w.date, w.start_time)
|
||||
end_day = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
return start, dt.datetime.combine(end_day, w.end_time)
|
||||
|
||||
|
||||
def uncovered_minutes(
|
||||
shift: ShiftInfo, windows: tuple[AvailabilityWindow, ...]
|
||||
) -> int:
|
||||
"""Minutes du créneau qui ne tombent dans aucune fenêtre de disponibilité.
|
||||
|
||||
Le bénévole peut déclarer plusieurs fenêtres ; on regarde leur union.
|
||||
"""
|
||||
cursor = shift.starts_at
|
||||
intervals = sorted(
|
||||
(iv for iv in map(_window_interval, windows) if iv[1] > iv[0]),
|
||||
key=lambda iv: iv[0],
|
||||
)
|
||||
gap = dt.timedelta()
|
||||
for start, end in intervals:
|
||||
if end <= cursor:
|
||||
continue
|
||||
if start > shift.ends_at:
|
||||
break
|
||||
if start > cursor:
|
||||
gap += min(start, shift.ends_at) - cursor
|
||||
cursor = max(cursor, end)
|
||||
if cursor >= shift.ends_at:
|
||||
return int(gap.total_seconds() // 60)
|
||||
if cursor < shift.ends_at:
|
||||
gap += shift.ends_at - cursor
|
||||
return int(gap.total_seconds() // 60)
|
||||
|
||||
|
||||
def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
||||
missing = uncovered_minutes(data.candidate_shift, data.availabilities)
|
||||
if missing == 0:
|
||||
return []
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
date=data.candidate_shift.starts_at.date().isoformat(),
|
||||
uncovered_minutes=missing,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED (avertissement) #
|
||||
# « Journée de festival » — plus utilisée par la validation elle-même, mais #
|
||||
# partagée par le solveur, les feuilles de poste et les statistiques. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
_MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
@@ -161,53 +106,3 @@ def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
|
||||
if moment.time() >= boundary:
|
||||
return moment.date()
|
||||
return moment.date() - dt.timedelta(days=1)
|
||||
|
||||
|
||||
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
||||
boundary = day_boundary(data.day_parts)
|
||||
candidate = data.candidate_shift
|
||||
key = festival_day(candidate.starts_at, boundary)
|
||||
|
||||
total = candidate.ends_at - candidate.starts_at
|
||||
for other in data.existing_shifts:
|
||||
if festival_day(other.starts_at, boundary) == key:
|
||||
total += other.ends_at - other.starts_at
|
||||
|
||||
if total > _EIGHT_HOURS:
|
||||
return [
|
||||
Violation.of(
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED,
|
||||
date=key.isoformat(),
|
||||
total_minutes=int(total.total_seconds() // 60),
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST (avertissement) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _check_insufficient_rest(data: ValidationInput) -> list[Violation]:
|
||||
candidate = data.candidate_shift
|
||||
out: list[Violation] = []
|
||||
for other in data.existing_shifts:
|
||||
if _overlaps(candidate, other):
|
||||
continue
|
||||
if other.ends_at <= candidate.starts_at:
|
||||
gap = candidate.starts_at - other.ends_at
|
||||
direction = "BEFORE"
|
||||
elif candidate.ends_at <= other.starts_at:
|
||||
gap = other.starts_at - candidate.ends_at
|
||||
direction = "AFTER"
|
||||
else: # pragma: no cover - inatteignable : non-chevauchement ⇒ avant ou après
|
||||
continue
|
||||
if gap < _EIGHT_HOURS:
|
||||
out.append(
|
||||
Violation.of(
|
||||
ViolationCode.INSUFFICIENT_REST,
|
||||
other_shift_id=other.id,
|
||||
direction=direction,
|
||||
gap_minutes=int(gap.total_seconds() // 60),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -8,15 +8,11 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.models import (
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
from app.validation.models import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
ShiftInfo,
|
||||
ValidationInput,
|
||||
)
|
||||
@@ -30,10 +26,6 @@ def build_validation_input(
|
||||
shift: Shift,
|
||||
exclude_assignment_id: uuid.UUID | None = None,
|
||||
) -> ValidationInput:
|
||||
day_parts = list(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
# Toute affectation existante du bénévole compte (une affectation = une
|
||||
# décision, toujours active).
|
||||
stmt = (
|
||||
@@ -49,17 +41,6 @@ def build_validation_input(
|
||||
for s in session.exec(stmt)
|
||||
)
|
||||
|
||||
declarations = tuple(
|
||||
AvailabilityWindow(
|
||||
date=a.date, start_time=a.start_time, end_time=a.end_time
|
||||
)
|
||||
for a in session.exec(
|
||||
select(Availability).where(
|
||||
Availability.volunteer_id == volunteer.id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return ValidationInput(
|
||||
candidate_shift=ShiftInfo(
|
||||
id=str(shift.id),
|
||||
@@ -67,9 +48,4 @@ def build_validation_input(
|
||||
ends_at=shift.ends_at,
|
||||
),
|
||||
existing_shifts=existing_shifts,
|
||||
availabilities=declarations,
|
||||
day_parts=tuple(
|
||||
DayPartWindow(dp.code, dp.start_time, dp.end_time, dp.order)
|
||||
for dp in day_parts
|
||||
),
|
||||
)
|
||||
|
||||
@@ -18,16 +18,10 @@ class Severity(StrEnum):
|
||||
|
||||
class ViolationCode(StrEnum):
|
||||
SHIFT_OVERLAP = "SHIFT_OVERLAP"
|
||||
OUTSIDE_AVAILABILITY = "OUTSIDE_AVAILABILITY"
|
||||
DAILY_HOURS_EXCEEDED = "DAILY_HOURS_EXCEEDED"
|
||||
INSUFFICIENT_REST = "INSUFFICIENT_REST"
|
||||
|
||||
|
||||
SEVERITY_BY_CODE: dict[ViolationCode, Severity] = {
|
||||
ViolationCode.SHIFT_OVERLAP: Severity.BLOCKING,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY: Severity.WARNING,
|
||||
ViolationCode.DAILY_HOURS_EXCEEDED: Severity.WARNING,
|
||||
ViolationCode.INSUFFICIENT_REST: Severity.WARNING,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +47,8 @@ class ShiftInfo:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayPartWindow:
|
||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée » pour le
|
||||
cumul des 8 h (début du premier créneau type)."""
|
||||
"""Créneau type. Ne sert plus qu'à fixer la borne de « journée de
|
||||
festival » (`engine.day_boundary`)."""
|
||||
|
||||
code: str
|
||||
start_time: dt.time
|
||||
@@ -63,18 +57,6 @@ class DayPartWindow:
|
||||
order: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailabilityWindow:
|
||||
"""« Le <date>, je peux de <start_time> à <end_time>. »
|
||||
|
||||
`end_time <= start_time` ⇒ la fenêtre se termine le lendemain.
|
||||
"""
|
||||
|
||||
date: dt.date
|
||||
start_time: dt.time
|
||||
end_time: dt.time
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationInput:
|
||||
"""Tout ce qu'il faut pour valider une affectation candidate.
|
||||
@@ -85,5 +67,3 @@ class ValidationInput:
|
||||
|
||||
candidate_shift: ShiftInfo
|
||||
existing_shifts: tuple[ShiftInfo, ...] = ()
|
||||
availabilities: tuple[AvailabilityWindow, ...] = ()
|
||||
day_parts: tuple[DayPartWindow, ...] = ()
|
||||
|
||||
@@ -4,7 +4,6 @@ import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Artist,
|
||||
Availability,
|
||||
Position,
|
||||
PositionCategory,
|
||||
Shift,
|
||||
@@ -74,34 +73,5 @@ def make_volunteer(
|
||||
return volunteer
|
||||
|
||||
|
||||
def make_availability(
|
||||
session, edition, volunteer, *, date, start_time, end_time
|
||||
):
|
||||
"""Fenêtre de disponibilité libre. `end_time <= start_time` ⇒ passe minuit."""
|
||||
row = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=date,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def declare_available_all_day(session, edition, volunteer, date):
|
||||
"""Disponible de 00:00 à 23:59 le jour donné (couvre tout un créneau
|
||||
diurne ; pour la nuit, déclarer une fenêtre dédiée)."""
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=date,
|
||||
start_time=dt.time(0, 0),
|
||||
end_time=dt.time(23, 59),
|
||||
)
|
||||
|
||||
|
||||
def dt_at(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
return dt.datetime(2026, 7, day, hour, minute)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import datetime as dt
|
||||
|
||||
from tests.factories import (
|
||||
declare_available_all_day,
|
||||
dt_at,
|
||||
make_position,
|
||||
make_shift,
|
||||
@@ -13,16 +12,12 @@ def _url(edition, suffix=""):
|
||||
return f"/editions/{edition.id}/assignments{suffix}"
|
||||
|
||||
|
||||
def _setup(session, edition, *, available=True):
|
||||
def _setup(session, edition):
|
||||
position = make_position(session, edition)
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 14), end=dt_at(10, 18)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
if available:
|
||||
declare_available_all_day(
|
||||
session, edition, volunteer, dt.date(2026, 7, 10)
|
||||
)
|
||||
return volunteer, shift
|
||||
|
||||
|
||||
@@ -39,16 +34,21 @@ def test_create_assignment_clean(client, edition, session, day_parts):
|
||||
assert body["assignment"]["locked"] is False
|
||||
|
||||
|
||||
def test_create_assignment_returns_warnings(client, edition, session, day_parts):
|
||||
# Bénévole non déclaré disponible sur le créneau.
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
def test_long_day_without_rest_raises_no_warning(client, edition, session, day_parts):
|
||||
# Plus de limite d'heures par jour ni de repos minimal : 14h–18h puis
|
||||
# 18h–04h (14 h d'affilée) passe sans avertissement.
|
||||
volunteer, shift = _setup(session, edition)
|
||||
position = make_position(session, edition, name="Bar")
|
||||
following = make_shift(
|
||||
session, edition, position, start=dt_at(10, 18), end=dt_at(11, 4)
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
codes = {w["code"] for w in resp.json()["warnings"]}
|
||||
assert "OUTSIDE_AVAILABILITY" in codes
|
||||
for s in (shift, following):
|
||||
resp = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(s.id)},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_create_assignment_blocked_by_overlap(client, edition, session, day_parts):
|
||||
@@ -110,7 +110,7 @@ def test_assignment_wrong_edition_is_422(client, edition, session, day_parts):
|
||||
|
||||
|
||||
def test_validate_endpoint_does_not_persist(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
volunteer, shift = _setup(session, edition)
|
||||
resp = client.post(
|
||||
_url(edition, "/validate"),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -118,14 +118,12 @@ def test_validate_endpoint_does_not_persist(client, edition, session, day_parts)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["blocking"] is False
|
||||
assert any(v["code"] == "OUTSIDE_AVAILABILITY" for v in body["violations"])
|
||||
assert body["violations"] == []
|
||||
assert client.get(_url(edition)).json() == []
|
||||
|
||||
|
||||
def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
client, edition, session, day_parts
|
||||
):
|
||||
volunteer, shift = _setup(session, edition, available=False)
|
||||
def test_patch_locked_keeps_assignment(client, edition, session, day_parts):
|
||||
volunteer, shift = _setup(session, edition)
|
||||
aid = client.post(
|
||||
_url(edition),
|
||||
json={"volunteer_id": str(volunteer.id), "shift_id": str(shift.id)},
|
||||
@@ -134,9 +132,7 @@ def test_patch_locked_keeps_assignment_and_returns_warnings(
|
||||
resp = client.patch(_url(edition, f"/{aid}"), json={"locked": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["assignment"]["locked"] is True
|
||||
assert any(
|
||||
w["code"] == "OUTSIDE_AVAILABILITY" for w in resp.json()["warnings"]
|
||||
)
|
||||
assert resp.json()["warnings"] == []
|
||||
|
||||
|
||||
def test_delete_assignment(client, edition, session, day_parts):
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
from app.models import Role
|
||||
from tests.factories import make_volunteer
|
||||
|
||||
|
||||
def _url(edition, volunteer, suffix=""):
|
||||
return f"/editions/{edition.id}/volunteers/{volunteer.id}/availability{suffix}"
|
||||
|
||||
|
||||
def test_put_then_get_returns_windows_sorted(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "14:00", "end_time": "19:00"},
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"},
|
||||
{"date": "2026-07-10", "start_time": "19:00", "end_time": "02:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
got = [(w["date"], w["start_time"], w["end_time"]) for w in resp.json()]
|
||||
assert got == [
|
||||
("2026-07-10", "08:00:00", "13:00:00"),
|
||||
("2026-07-10", "19:00:00", "02:00:00"),
|
||||
("2026-07-11", "14:00:00", "19:00:00"),
|
||||
]
|
||||
assert client.get(_url(edition, v)).json() == resp.json()
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_set_and_dedupes(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
{"date": "2026-07-11", "start_time": "09:00", "end_time": "17:00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert len(body) == 1
|
||||
assert body[0]["date"] == "2026-07-11"
|
||||
|
||||
|
||||
def test_put_rejects_date_outside_edition(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-08-01", "start_time": "08:00", "end_time": "12:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.date_outside_edition"
|
||||
|
||||
|
||||
def test_put_rejects_empty_window(client, edition, session):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
resp = client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "09:00", "end_time": "09:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "availability.empty_window"
|
||||
|
||||
|
||||
def test_edition_wide_listing(client, edition, session):
|
||||
v1 = make_volunteer(session, edition, first_name="A", email="a@e.fr")
|
||||
v2 = make_volunteer(session, edition, first_name="B", email="b@e.fr")
|
||||
for v in (v1, v2):
|
||||
client.put(
|
||||
_url(edition, v),
|
||||
json={
|
||||
"windows": [
|
||||
{"date": "2026-07-10", "start_time": "08:00", "end_time": "13:00"}
|
||||
]
|
||||
},
|
||||
)
|
||||
body = client.get(f"/editions/{edition.id}/availability").json()
|
||||
assert {w["volunteer_id"] for w in body} == {str(v1.id), str(v2.id)}
|
||||
|
||||
|
||||
def test_lead_can_read_but_not_write(edition, session, make_account, as_account):
|
||||
v = make_volunteer(session, edition, email="v@e.fr")
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
api = as_account(lead)
|
||||
assert api.get(_url(edition, v)).status_code == 200
|
||||
resp = api.put(_url(edition, v), json={"windows": []})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_volunteer_reads_only_their_own(edition, session, make_account, as_account):
|
||||
me = make_volunteer(session, edition, email="me@e.fr")
|
||||
other = make_volunteer(session, edition, email="other@e.fr")
|
||||
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="acc@e.fr")
|
||||
api = as_account(account)
|
||||
assert api.get(_url(edition, me)).status_code == 200
|
||||
assert api.get(_url(edition, other)).status_code == 403
|
||||
@@ -14,7 +14,6 @@ from app.models import (
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -141,10 +140,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
)
|
||||
alex = make_volunteer(session, edition, first_name="Alex")
|
||||
sam = make_volunteer(session, edition, first_name="Sam")
|
||||
make_availability(
|
||||
session, edition, alex,
|
||||
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
|
||||
)
|
||||
session.add_all([
|
||||
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
||||
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
||||
@@ -159,8 +154,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"shifts": 2,
|
||||
"min_hours": 3.0,
|
||||
"ideal_hours": 5.0,
|
||||
"available_hours": 6.0,
|
||||
"available_volunteers": 1,
|
||||
"assigned_hours": 2.0,
|
||||
}
|
||||
]
|
||||
@@ -176,7 +169,6 @@ def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
"wishes": 2,
|
||||
}
|
||||
]
|
||||
assert needs["volunteers_with_availability"] == 1
|
||||
assert needs["volunteers_with_assignment"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ from openpyxl import load_workbook
|
||||
from pypdf import PdfReader
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Assignment, Availability, FeePaymentStatus, Role, Volunteer
|
||||
from app.models import Assignment, FeePaymentStatus, Role, Volunteer
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -208,14 +207,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
shift = make_shift(
|
||||
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
|
||||
)
|
||||
make_availability(
|
||||
session,
|
||||
edition,
|
||||
volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(8, 0),
|
||||
end_time=dt.time(13, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id,
|
||||
@@ -230,7 +221,6 @@ def test_delete_is_a_full_cascade(client, edition, session):
|
||||
assert client.get(_url(edition, f"/{volunteer.id}")).status_code == 404
|
||||
assert session.exec(select(Volunteer)).all() == []
|
||||
assert session.exec(select(Assignment)).all() == []
|
||||
assert session.exec(select(Availability)).all() == []
|
||||
# Le créneau subsiste.
|
||||
from app.models import Shift
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy import text
|
||||
from app.models import (
|
||||
Assignment,
|
||||
AssignmentSource,
|
||||
Availability,
|
||||
Edition,
|
||||
Position,
|
||||
PositionCategory,
|
||||
@@ -25,7 +24,6 @@ EXPECTED_TABLES = {
|
||||
"magic_link_token",
|
||||
"position",
|
||||
"shift",
|
||||
"availability",
|
||||
"assignment",
|
||||
"badge_template",
|
||||
"badge_field",
|
||||
@@ -101,31 +99,6 @@ def test_create_core_graph(session):
|
||||
assert volunteer.assignments[0].id == assignment.id
|
||||
|
||||
|
||||
def test_availability_window_round_trip(session):
|
||||
edition = _edition(session)
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id, first_name="Sam", last_name="Nuit"
|
||||
)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
|
||||
avail = Availability(
|
||||
edition_id=edition.id,
|
||||
volunteer_id=volunteer.id,
|
||||
date=dt.date(2026, 7, 9),
|
||||
start_time=dt.time(19, 0),
|
||||
end_time=dt.time(2, 0), # passe minuit
|
||||
)
|
||||
session.add(avail)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
reloaded = session.get(Availability, avail.id)
|
||||
assert reloaded.start_time == dt.time(19, 0)
|
||||
assert reloaded.end_time == dt.time(2, 0)
|
||||
|
||||
|
||||
def test_volunteer_email_can_be_shared(session):
|
||||
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
|
||||
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
|
||||
|
||||
@@ -13,9 +13,6 @@ def test_build_creates_a_realistic_dataset(session):
|
||||
assert summary.assignments > 100
|
||||
assert summary.understaffed_shifts >= 3 # « quelques trous d'effectif »
|
||||
assert summary.blocking == 0 # aucune violation bloquante
|
||||
# Au moins deux règles d'avertissement représentées, quelques alertes.
|
||||
assert len(summary.warnings) >= 2
|
||||
assert sum(summary.warnings.values()) >= 3
|
||||
# Programmation et vœux des bénévoles : le mode démo doit les illustrer.
|
||||
assert summary.artists > 0
|
||||
assert summary.position_wishes > 0
|
||||
@@ -58,7 +55,6 @@ def test_build_is_deterministic_and_idempotent(session):
|
||||
)
|
||||
assert second.assignments == first.assignments
|
||||
assert second.understaffed_shifts == first.understaffed_shifts
|
||||
assert dict(second.warnings) == dict(first.warnings)
|
||||
assert second.artists == first.artists
|
||||
assert second.position_wishes == first.position_wishes
|
||||
assert second.artist_wishes == first.artist_wishes
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime as dt
|
||||
|
||||
from app.solver.engine import solve
|
||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
||||
from app.validation.models import ShiftInfo
|
||||
|
||||
|
||||
def _dt(day: int, hour: int, minute: int = 0) -> dt.datetime:
|
||||
@@ -39,7 +39,6 @@ def _shift(
|
||||
def _volunteer(
|
||||
vid: str,
|
||||
*,
|
||||
availability=(),
|
||||
wished_position_ids=frozenset(),
|
||||
wished_companion_ids=frozenset(),
|
||||
wished_artist_windows=(),
|
||||
@@ -48,7 +47,6 @@ def _volunteer(
|
||||
) -> SolverVolunteer:
|
||||
return SolverVolunteer(
|
||||
id=vid,
|
||||
availability=tuple(availability),
|
||||
wished_position_ids=frozenset(wished_position_ids),
|
||||
wished_companion_ids=frozenset(wished_companion_ids),
|
||||
wished_artist_windows=tuple(wished_artist_windows),
|
||||
@@ -57,10 +55,6 @@ def _volunteer(
|
||||
)
|
||||
|
||||
|
||||
def _full_day(day: int) -> AvailabilityWindow:
|
||||
return AvailabilityWindow(dt.date(2026, 7, day), dt.time(6, 0), dt.time(4, 0))
|
||||
|
||||
|
||||
def _by_shift(solution, shift_id: str) -> tuple[str, ...]:
|
||||
for s in solution.suggestions:
|
||||
if s.shift_id == shift_id:
|
||||
@@ -75,7 +69,7 @@ def test_no_variables_returns_empty_solution():
|
||||
|
||||
def test_fully_staffed_shift_gets_no_suggestion():
|
||||
shift = _shift(ideal_staff=1, already_assigned=1)
|
||||
volunteer = _volunteer("v1", availability=[_full_day(10)])
|
||||
volunteer = _volunteer("v1")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert solution.suggestions == ()
|
||||
|
||||
@@ -84,105 +78,77 @@ def test_picks_the_only_free_volunteer_over_a_busy_one():
|
||||
shift = _shift()
|
||||
busy = _volunteer(
|
||||
"busy",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 15), ends_at=_dt(10, 19))],
|
||||
)
|
||||
free = _volunteer("free", availability=[_full_day(10)])
|
||||
free = _volunteer("free")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(busy, free)))
|
||||
assert _by_shift(solution, "s1") == ("free",)
|
||||
|
||||
|
||||
def test_does_not_exceed_ideal_staff_cap():
|
||||
shift = _shift(ideal_staff=2, already_assigned=1) # il ne manque qu'une place
|
||||
a = _volunteer("a", availability=[_full_day(10)])
|
||||
b = _volunteer("b", availability=[_full_day(10)])
|
||||
a = _volunteer("a")
|
||||
b = _volunteer("b")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(a, b)))
|
||||
assert len(_by_shift(solution, "s1")) == 1
|
||||
|
||||
|
||||
def test_prefers_volunteer_with_matching_position_wish():
|
||||
shift = _shift(position_id="regie")
|
||||
plain = _volunteer("plain", availability=[_full_day(10)])
|
||||
plain = _volunteer("plain")
|
||||
wisher = _volunteer(
|
||||
"wisher", availability=[_full_day(10)], wished_position_ids={"regie"}
|
||||
"wisher", wished_position_ids={"regie"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(plain, wisher)))
|
||||
assert _by_shift(solution, "s1") == ("wisher",)
|
||||
|
||||
|
||||
def test_prefers_fully_available_volunteer_over_partially_available():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full",)
|
||||
|
||||
|
||||
def test_ranks_both_candidates_when_two_slots_are_open():
|
||||
shift = _shift(ideal_staff=2)
|
||||
full = _volunteer("full", availability=[_full_day(10)])
|
||||
partial = _volunteer(
|
||||
"partial",
|
||||
availability=[AvailabilityWindow(dt.date(2026, 7, 10), dt.time(14, 0), dt.time(16, 0))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(full, partial)))
|
||||
assert _by_shift(solution, "s1") == ("full", "partial")
|
||||
|
||||
|
||||
def test_never_double_books_a_volunteer_across_two_new_suggestions():
|
||||
# Deux créneaux qui se chevauchent, un seul bénévole disponible pour les
|
||||
# deux : le solveur ne le propose qu'une fois.
|
||||
shift_a = _shift("a", start=_dt(10, 14), end=_dt(10, 18))
|
||||
shift_b = _shift("b", start=_dt(10, 16), end=_dt(10, 20))
|
||||
only = _volunteer("only", availability=[_full_day(10)])
|
||||
only = _volunteer("only")
|
||||
solution = solve(SolverInput(shifts=(shift_a, shift_b), volunteers=(only,)))
|
||||
picked_a = _by_shift(solution, "a")
|
||||
picked_b = _by_shift(solution, "b")
|
||||
assert len(picked_a) + len(picked_b) == 1
|
||||
|
||||
|
||||
def test_avoids_volunteer_with_insufficient_rest_since_last_shift():
|
||||
# < 8 h entre la fin d'un créneau déjà décidé et le début de celui-ci.
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
def test_short_rest_is_not_penalized():
|
||||
# Plus de repos minimal (8 h) : 4 h après un créneau déjà décidé, le
|
||||
# bénévole reste à égalité avec un autre.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 14), end=_dt(10, 18))
|
||||
tired = _volunteer(
|
||||
"tired",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))], # repos 4 h
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 8), ends_at=_dt(10, 10))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(tired, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
assert set(_by_shift(solution, "s1")) == {"tired", "fresh"}
|
||||
|
||||
|
||||
def test_daily_cumulative_hours_no_longer_penalized():
|
||||
# Le solveur ne plafonne plus le cumul de 8 h/jour (retiré) : seule la
|
||||
# validation, à la confirmation, le signale désormais
|
||||
# (`DAILY_HOURS_EXCEEDED`). Repos respecté (8 h pile) ; cumul du jour
|
||||
# (7 h + 2 h = 9 h) aurait auparavant été pénalisé.
|
||||
# Aucun plafond de cumul journalier : 7 h + 2 h = 9 h sur la journée ne
|
||||
# pénalise pas le bénévole.
|
||||
shift = _shift(ideal_staff=2, start=_dt(10, 21), end=_dt(10, 23))
|
||||
long_day = _volunteer(
|
||||
"long_day",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="e", starts_at=_dt(10, 6), ends_at=_dt(10, 13))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(long_day, fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"long_day", "fresh"}
|
||||
|
||||
|
||||
def test_prefers_volunteer_without_the_immediately_preceding_shift():
|
||||
# Repos suffisant (8 h pile) entre les deux créneaux : seul le chaînage
|
||||
# doit différencier les deux bénévoles.
|
||||
# Seul le chaînage doit différencier les deux bénévoles.
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -196,10 +162,9 @@ def test_chain_penalty_applies_across_different_positions():
|
||||
)
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
fresh = _volunteer("fresh", availability=[_full_day(10)])
|
||||
fresh = _volunteer("fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained, fresh)))
|
||||
assert _by_shift(solution, "s1") == ("fresh",)
|
||||
|
||||
@@ -210,7 +175,6 @@ def test_still_suggests_the_only_candidate_despite_chaining():
|
||||
shift = _shift(start=_dt(10, 20), end=_dt(10, 23), predecessor_shift_ids={"prev"})
|
||||
chained = _volunteer(
|
||||
"chained",
|
||||
availability=[_full_day(10)],
|
||||
existing_shifts=[ShiftInfo(id="prev", starts_at=_dt(10, 8), ends_at=_dt(10, 12))],
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(chained,)))
|
||||
@@ -227,9 +191,9 @@ def test_an_unrelated_existing_shift_does_not_trigger_the_chain_penalty():
|
||||
)
|
||||
unrelated = ShiftInfo(id="not-the-predecessor", starts_at=_dt(10, 8), ends_at=_dt(10, 12))
|
||||
almost_chained = _volunteer(
|
||||
"almost_chained", availability=[_full_day(10)], existing_shifts=[unrelated]
|
||||
"almost_chained", existing_shifts=[unrelated]
|
||||
)
|
||||
truly_fresh = _volunteer("truly_fresh", availability=[_full_day(10)])
|
||||
truly_fresh = _volunteer("truly_fresh")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(almost_chained, truly_fresh)))
|
||||
assert set(_by_shift(solution, "s1")) == {"almost_chained", "truly_fresh"}
|
||||
|
||||
@@ -241,9 +205,9 @@ def test_avoids_volunteer_whose_wished_artist_plays_during_the_shift():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
indiff = _volunteer("indiff", availability=[_full_day(10)])
|
||||
indiff = _volunteer("indiff")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan, indiff)))
|
||||
assert _by_shift(solution, "s1") == ("indiff",)
|
||||
|
||||
@@ -254,7 +218,7 @@ def test_still_suggests_the_only_candidate_despite_an_artist_conflict():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 16), ends_at=_dt(10, 17))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -266,7 +230,7 @@ def test_artist_conflict_outside_shift_hours_has_no_effect():
|
||||
shift = _shift(start=_dt(10, 14), end=_dt(10, 18))
|
||||
passage = ShiftInfo(id="art1", starts_at=_dt(10, 20), ends_at=_dt(10, 21))
|
||||
fan = _volunteer(
|
||||
"fan", availability=[_full_day(10)], wished_artist_windows=[passage]
|
||||
"fan", wished_artist_windows=[passage]
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(fan,)))
|
||||
assert _by_shift(solution, "s1") == ("fan",)
|
||||
@@ -277,9 +241,9 @@ def test_prefers_volunteer_whose_wished_companion_is_already_on_the_shift():
|
||||
# préféré à « stranger », qui n'a pas ce lien.
|
||||
shift = _shift(ideal_staff=2, already_assigned=1, assigned_volunteer_ids={"copain"})
|
||||
buddy = _volunteer(
|
||||
"buddy", availability=[_full_day(10)], wished_companion_ids={"copain"}
|
||||
"buddy", wished_companion_ids={"copain"}
|
||||
)
|
||||
stranger = _volunteer("stranger", availability=[_full_day(10)])
|
||||
stranger = _volunteer("stranger")
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(buddy, stranger)))
|
||||
assert _by_shift(solution, "s1") == ("buddy",)
|
||||
|
||||
@@ -290,10 +254,10 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
ideal_staff=4, already_assigned=2, assigned_volunteer_ids={"a", "b"}
|
||||
)
|
||||
two_friends = _volunteer(
|
||||
"two_friends", availability=[_full_day(10)], wished_companion_ids={"a", "b"}
|
||||
"two_friends", wished_companion_ids={"a", "b"}
|
||||
)
|
||||
one_friend = _volunteer(
|
||||
"one_friend", availability=[_full_day(10)], wished_companion_ids={"a"}
|
||||
"one_friend", wished_companion_ids={"a"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(two_friends, one_friend)))
|
||||
assert _by_shift(solution, "s1") == ("two_friends", "one_friend")
|
||||
@@ -302,7 +266,7 @@ def test_companion_bonus_scales_with_number_of_matches():
|
||||
def test_wished_companion_absent_from_the_shift_has_no_effect():
|
||||
shift = _shift()
|
||||
volunteer = _volunteer(
|
||||
"v", availability=[_full_day(10)], wished_companion_ids={"not-here"}
|
||||
"v", wished_companion_ids={"not-here"}
|
||||
)
|
||||
solution = solve(SolverInput(shifts=(shift,), volunteers=(volunteer,)))
|
||||
assert _by_shift(solution, "s1") == ("v",)
|
||||
|
||||
@@ -4,7 +4,6 @@ règle, plus les cas limites de la sémantique figée (CLAUDE.md § 3)."""
|
||||
import datetime as dt
|
||||
|
||||
from app.validation import (
|
||||
AvailabilityWindow,
|
||||
DayPartWindow,
|
||||
Severity,
|
||||
ShiftInfo,
|
||||
@@ -33,14 +32,6 @@ def _codes(violations) -> list[ViolationCode]:
|
||||
return [v.code for v in violations]
|
||||
|
||||
|
||||
def _win(day: int, start_h: int, end_h: int) -> AvailabilityWindow:
|
||||
"""Fenêtre de dispo le <day> juillet, de start_h:00 à end_h:00.
|
||||
`end_h <= start_h` ⇒ se termine le lendemain."""
|
||||
return AvailabilityWindow(
|
||||
dt.date(2026, 7, day), dt.time(start_h % 24, 0), dt.time(end_h % 24, 0)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SHIFT_OVERLAP #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -48,233 +39,42 @@ def test_overlap_pass_when_shifts_are_adjacent():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
assert ViolationCode.SHIFT_OVERLAP not in _codes(validate(data))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_overlap_triggers_and_is_blocking():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
violations = validate(data)
|
||||
overlap = [v for v in violations if v.code is ViolationCode.SHIFT_OVERLAP]
|
||||
assert len(overlap) == 1
|
||||
assert overlap[0].severity is Severity.BLOCKING
|
||||
assert overlap[0].params["other_shift_id"] == "e"
|
||||
assert _codes(violations) == [ViolationCode.SHIFT_OVERLAP]
|
||||
assert violations[0].severity is Severity.BLOCKING
|
||||
assert violations[0].params["other_shift_id"] == "e"
|
||||
assert has_blocking(violations) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# OUTSIDE_AVAILABILITY #
|
||||
# Plus de limite de temps dans la journée #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_availability_pass_when_shift_within_declared_window():
|
||||
def test_long_day_and_short_rest_raise_nothing():
|
||||
"""Ni cumul journalier (> 8 h) ni repos minimal (< 8 h) : seuls les
|
||||
chevauchements sont signalés."""
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 22)),
|
||||
existing_shifts=(
|
||||
_shift("matin", _dt(10, 6), _dt(10, 14)),
|
||||
_shift("nuit", _dt(10, 23), _dt(11, 3)),
|
||||
),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_pass_when_window_exactly_matches_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
availabilities=(_win(10, 14, 18),),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_triggers_when_nothing_declared():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].severity is Severity.WARNING
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
assert violations[0].params["uncovered_minutes"] == 240
|
||||
|
||||
|
||||
def test_availability_triggers_on_partial_coverage_and_counts_the_gap():
|
||||
# Créneau 16:00 -> 20:00 ; dispo seulement 13:00 -> 19:00 ⇒ 60 min à découvert.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 16), _dt(10, 20)),
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 60
|
||||
|
||||
|
||||
def test_availability_union_of_several_windows_covers_the_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 12), _dt(10, 20)),
|
||||
availabilities=(_win(10, 12, 16), _win(10, 15, 20)),
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_midnight_crossing_window_covers_night_shift():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 22), _dt(11, 2)),
|
||||
availabilities=(_win(10, 19, 4),), # 19:00 -> 04:00 le lendemain
|
||||
)
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_availability_gap_between_two_windows_is_flagged():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 18)),
|
||||
availabilities=(_win(10, 10, 12), _win(10, 14, 18)), # trou 12:00-14:00
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert violations[0].params["uncovered_minutes"] == 120
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DAILY_HOURS_EXCEEDED #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_daily_hours_pass_at_exactly_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h pile
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_win(10, 8, 18),),
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_daily_hours_triggers_above_eight():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 20, 30)),), # +30 min
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["total_minutes"] == 510
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_day_starts_at_first_day_part_boundary():
|
||||
# Frontière de journée = 06:00 (début de MORNING). Un créneau de nuit qui
|
||||
# commence à 03:00 le 11 appartient à la journée du 10.
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 15)), # 7 h, journée 10
|
||||
existing_shifts=(_shift("e", _dt(11, 3), _dt(11, 5)),), # 2 h, journée 10
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.DAILY_HOURS_EXCEEDED
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["date"] == "2026-07-10"
|
||||
|
||||
|
||||
def test_daily_hours_ignores_other_days():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 9), _dt(10, 17)), # 8 h journée 10
|
||||
existing_shifts=(_shift("e", _dt(9, 9), _dt(9, 17)),), # 8 h journée 9
|
||||
day_parts=DAY_PARTS,
|
||||
)
|
||||
assert ViolationCode.DAILY_HOURS_EXCEEDED not in _codes(validate(data))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# INSUFFICIENT_REST #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_rest_pass_with_more_than_eight_hours_gap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 8), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(9, 18), _dt(9, 23)),), # écart 9 h
|
||||
availabilities=(_win(10, 8, 12),),
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_pass_at_exactly_eight_hours():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 4), _dt(10, 12)),
|
||||
existing_shifts=(_shift("e", _dt(10, 20), _dt(10, 22)),), # écart 8 h pile
|
||||
)
|
||||
assert ViolationCode.INSUFFICIENT_REST not in _codes(validate(data))
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_before():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 2), _dt(10, 6)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "BEFORE"
|
||||
assert violations[0].params["gap_minutes"] == 240
|
||||
|
||||
|
||||
def test_rest_triggers_for_shift_after():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 10), _dt(10, 14)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 20)),), # écart 4 h
|
||||
)
|
||||
violations = [
|
||||
v for v in validate(data) if v.code is ViolationCode.INSUFFICIENT_REST
|
||||
]
|
||||
assert len(violations) == 1
|
||||
assert violations[0].params["direction"] == "AFTER"
|
||||
|
||||
|
||||
def test_back_to_back_shifts_are_rest_violation_not_overlap():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 18), _dt(10, 22)),),
|
||||
availabilities=(_win(10, 8, 23),),
|
||||
)
|
||||
codes = _codes(validate(data))
|
||||
assert ViolationCode.SHIFT_OVERLAP not in codes
|
||||
assert ViolationCode.INSUFFICIENT_REST in codes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Combinaisons #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_clean_assignment_has_no_violation():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(9, 8), _dt(9, 12)),),
|
||||
day_parts=DAY_PARTS,
|
||||
availabilities=(_win(10, 13, 19),),
|
||||
)
|
||||
data = ValidationInput(candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)))
|
||||
assert validate(data) == []
|
||||
|
||||
|
||||
def test_multiple_rules_fire_together():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
existing_shifts=(_shift("e", _dt(10, 16), _dt(10, 20)),), # chevauchement
|
||||
# aucune dispo déclarée ⇒ OUTSIDE_AVAILABILITY
|
||||
)
|
||||
codes = set(_codes(validate(data)))
|
||||
assert {
|
||||
ViolationCode.SHIFT_OVERLAP,
|
||||
ViolationCode.OUTSIDE_AVAILABILITY,
|
||||
} <= codes
|
||||
assert has_blocking(validate(data)) is True
|
||||
|
||||
|
||||
def test_only_warnings_is_not_blocking():
|
||||
data = ValidationInput(
|
||||
candidate_shift=_shift("c", _dt(10, 14), _dt(10, 18)),
|
||||
# pas de dispo ⇒ un seul avertissement OUTSIDE_AVAILABILITY
|
||||
)
|
||||
violations = validate(data)
|
||||
assert _codes(violations) == [ViolationCode.OUTSIDE_AVAILABILITY]
|
||||
assert has_blocking(violations) is False
|
||||
assert has_blocking([]) is False
|
||||
|
||||
|
||||
# --- Début de « journée de festival » (day_boundary) ------------------------
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import datetime as dt
|
||||
|
||||
from app.models import Assignment
|
||||
from app.validation import AvailabilityWindow
|
||||
from app.validation.loader import build_validation_input
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
@@ -21,11 +17,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
session, edition, position, start=dt_at(11, 9), end=dt_at(11, 12)
|
||||
)
|
||||
volunteer = make_volunteer(session, edition, email="v@ex.fr")
|
||||
make_availability(
|
||||
session, edition, volunteer,
|
||||
date=dt.date(2026, 7, 10),
|
||||
start_time=dt.time(13, 0), end_time=dt.time(19, 0),
|
||||
)
|
||||
session.add(
|
||||
Assignment(
|
||||
edition_id=edition.id, volunteer_id=volunteer.id,
|
||||
@@ -41,10 +32,6 @@ def test_build_input_gathers_everything_from_db(client, edition, session, day_pa
|
||||
assert data.candidate_shift.id == str(candidate.id)
|
||||
# Toute affectation existante compte, sauf le créneau candidat lui-même.
|
||||
assert [s.id for s in data.existing_shifts] == [str(other_shift.id)]
|
||||
assert len(data.day_parts) == 3
|
||||
assert data.availabilities == (
|
||||
AvailabilityWindow(dt.date(2026, 7, 10), dt.time(13, 0), dt.time(19, 0)),
|
||||
)
|
||||
|
||||
|
||||
def test_exclude_assignment_id_is_honoured(client, edition, session, day_parts):
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateResult,
|
||||
AvailabilitySet,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeSizeUpdate,
|
||||
@@ -135,23 +134,6 @@ export function useShifts(positionId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fenêtres de disponibilité d'un bénévole. */
|
||||
export function useVolunteerAvailability(volunteerId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['availability', edition, volunteerId ?? null],
|
||||
queryFn: () => api.availability.list(edition, volunteerId as string),
|
||||
enabled: Boolean(volunteerId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Toutes les disponibilités de l'édition (grille des besoins). */
|
||||
export function useEditionAvailability() {
|
||||
return useQuery({
|
||||
queryKey: ['availability', edition, 'all'],
|
||||
queryFn: () => api.availability.listEdition(edition),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignments(
|
||||
params?: { volunteerId?: string; shiftId?: string },
|
||||
options?: { enabled?: boolean },
|
||||
@@ -247,7 +229,6 @@ function useInvalidate() {
|
||||
const client = useQueryClient();
|
||||
return {
|
||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
|
||||
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
|
||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||
@@ -408,20 +389,6 @@ export function useExportMecenatReceipts() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
|
||||
export function useReplaceAvailability() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) =>
|
||||
api.availability.replace(edition, volunteerId, body),
|
||||
onSuccess: () => {
|
||||
invalidate.availability();
|
||||
// Les avertissements « hors disponibilité » de la grille en dépendent.
|
||||
invalidate.planning();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Import de bénévoles — phase 1 : analyse du fichier. */
|
||||
export function useAnalyzeImport() {
|
||||
return useMutation({
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateRequest,
|
||||
AvailabilitySet,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeSizeUpdate,
|
||||
@@ -172,16 +171,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
availability: {
|
||||
list: (editionId, volunteerId) =>
|
||||
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
|
||||
replace: (editionId, volunteerId, body: AvailabilitySet) =>
|
||||
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listEdition: (editionId) => request(`${base(editionId)}/availability`),
|
||||
},
|
||||
imports: {
|
||||
analyze: (editionId, file: File) => {
|
||||
const form = new FormData();
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
AccountRead,
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
BadgeFieldRead,
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
@@ -67,7 +66,6 @@ export interface MockState {
|
||||
artists: ArtistRead[];
|
||||
volunteers: VolunteerRead[];
|
||||
/** volunteerId -> fenêtres de disponibilité déclarées. */
|
||||
availability: Map<string, AvailabilityRead[]>;
|
||||
positions: PositionRead[];
|
||||
shifts: ShiftRead[];
|
||||
assignments: AssignmentRead[];
|
||||
@@ -266,37 +264,10 @@ export function buildMockState(): MockState {
|
||||
);
|
||||
|
||||
const volunteers: VolunteerRead[] = [];
|
||||
const availability = new Map<string, AvailabilityRead[]>();
|
||||
let availSeq = 0;
|
||||
const window = (
|
||||
volunteerId: string,
|
||||
date: string,
|
||||
start_time: string,
|
||||
end_time: string,
|
||||
): AvailabilityRead => ({
|
||||
id: `av${availSeq++}`,
|
||||
edition_id,
|
||||
volunteer_id: volunteerId,
|
||||
date,
|
||||
start_time,
|
||||
end_time,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
|
||||
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
|
||||
const id = `v${i}`;
|
||||
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
|
||||
const windows: AvailabilityRead[] = [];
|
||||
for (const dateStr of DAY_DATES) {
|
||||
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
|
||||
if (rnd() < (edge ? 0.55 : 0.8)) {
|
||||
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
|
||||
}
|
||||
}
|
||||
availability.set(id, windows);
|
||||
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
|
||||
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
|
||||
const wished_position_ids =
|
||||
@@ -354,10 +325,6 @@ export function buildMockState(): MockState {
|
||||
|
||||
const me = volunteers[0];
|
||||
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
|
||||
availability.set(
|
||||
me.id,
|
||||
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
|
||||
);
|
||||
NEVER_ASSIGNED.forEach((i) => {
|
||||
volunteers[i].comment = 'Inscrit, pas encore affecté';
|
||||
});
|
||||
@@ -390,10 +357,7 @@ export function buildMockState(): MockState {
|
||||
.sort((a, b) => score(b) - score(a));
|
||||
|
||||
function score(v: VolunteerRead): number {
|
||||
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
|
||||
? 100
|
||||
: 0;
|
||||
return available - (totalHours.get(v.id) ?? 0) * 3;
|
||||
return -(totalHours.get(v.id) ?? 0) * 3;
|
||||
}
|
||||
|
||||
for (const v of pool.slice(0, target)) {
|
||||
@@ -510,7 +474,6 @@ export function buildMockState(): MockState {
|
||||
assignments,
|
||||
accounts,
|
||||
passwords,
|
||||
availability,
|
||||
badgeTemplate: {
|
||||
frontImage: null,
|
||||
frontImageType: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { dayBoundaryFromDayParts, festivalDay, uncoveredMinutes } from '@/lib/planning';
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
ArtistUpdate,
|
||||
AssignmentRead,
|
||||
AssignmentValidateResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
@@ -54,7 +52,6 @@ import { buildMockState, type MockPendingSubmission, type MockState } from './da
|
||||
// comportement (chargements, invalidations) proche du back-end réel.
|
||||
|
||||
const LATENCY_MS = 120;
|
||||
const EIGHT_HOURS_MIN = 8 * 60;
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
let state: MockState = buildMockState();
|
||||
@@ -199,14 +196,6 @@ const slugCode = (label: string): string =>
|
||||
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
|
||||
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
|
||||
|
||||
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
|
||||
[...ws].sort(
|
||||
(a, b) =>
|
||||
a.volunteer_id.localeCompare(b.volunteer_id) ||
|
||||
a.date.localeCompare(b.date) ||
|
||||
a.start_time.localeCompare(b.start_time),
|
||||
);
|
||||
|
||||
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
|
||||
function resolveWishes<T extends { id: string }>(
|
||||
pool: T[],
|
||||
@@ -260,7 +249,6 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
};
|
||||
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
|
||||
pending.promotedVolunteerId = volunteer.id;
|
||||
pending.status = 'PAID';
|
||||
@@ -471,63 +459,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
|
||||
}
|
||||
}
|
||||
|
||||
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
|
||||
// fenêtres de disponibilité déclarées.
|
||||
const gapMin = uncoveredMinutes(
|
||||
state.availability.get(volunteerId) ?? [],
|
||||
candidate.starts_at,
|
||||
candidate.ends_at,
|
||||
);
|
||||
if (gapMin > 0) {
|
||||
violations.push({
|
||||
code: 'OUTSIDE_AVAILABILITY',
|
||||
severity: 'WARNING',
|
||||
params: {
|
||||
date: candidate.starts_at.slice(0, 10),
|
||||
uncovered_minutes: gapMin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
|
||||
const dayKey = candidate.starts_at.slice(0, 10);
|
||||
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
|
||||
for (const other of existing) {
|
||||
if (other.starts_at.slice(0, 10) === dayKey) {
|
||||
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
|
||||
}
|
||||
}
|
||||
if (totalMin > EIGHT_HOURS_MIN) {
|
||||
violations.push({
|
||||
code: 'DAILY_HOURS_EXCEEDED',
|
||||
severity: 'WARNING',
|
||||
params: { date: dayKey, total_minutes: totalMin },
|
||||
});
|
||||
}
|
||||
|
||||
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
|
||||
for (const other of existing) {
|
||||
if (overlaps(candidate, other)) continue;
|
||||
let gap: number;
|
||||
let direction: 'BEFORE' | 'AFTER';
|
||||
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
|
||||
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
|
||||
direction = 'BEFORE';
|
||||
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
|
||||
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
|
||||
direction = 'AFTER';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (gap < EIGHT_HOURS_MIN) {
|
||||
violations.push({
|
||||
code: 'INSUFFICIENT_REST',
|
||||
severity: 'WARNING',
|
||||
params: { other_shift_id: other.id, direction, gap_minutes: gap },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -567,10 +498,10 @@ function buildStats(): EditionStats {
|
||||
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
|
||||
|
||||
const boundary = dayBoundaryFromDayParts(state.dayParts);
|
||||
type Row = { shifts: number; min: number; ideal: number; avail: number; who: Set<string>; assigned: number };
|
||||
type Row = { shifts: number; min: number; ideal: number; assigned: number };
|
||||
const rows = new Map<string, Row>();
|
||||
const row = (d: string) => {
|
||||
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, avail: 0, who: new Set(), assigned: 0 });
|
||||
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 });
|
||||
return rows.get(d) as Row;
|
||||
};
|
||||
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
|
||||
@@ -585,17 +516,6 @@ function buildStats(): EditionStats {
|
||||
const s = shiftsById.get(a.shift_id);
|
||||
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
|
||||
}
|
||||
const withAvailability = new Set<string>();
|
||||
for (const [vid, windows] of state.availability) {
|
||||
for (const w of windows) {
|
||||
const start = `${w.date}T${w.start_time.slice(0, 5)}:00`;
|
||||
const endDate = w.end_time > w.start_time ? w.date : addDay(w.date);
|
||||
const r = row(festivalDay(start, boundary));
|
||||
r.avail += hoursBetween(start, `${endDate}T${w.end_time.slice(0, 5)}:00`);
|
||||
r.who.add(vid);
|
||||
withAvailability.add(vid);
|
||||
}
|
||||
}
|
||||
const assignedByShift = new Map<string, number>();
|
||||
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
|
||||
|
||||
@@ -650,8 +570,6 @@ function buildStats(): EditionStats {
|
||||
shifts: r.shifts,
|
||||
min_hours: round(r.min),
|
||||
ideal_hours: round(r.ideal),
|
||||
available_hours: round(r.avail),
|
||||
available_volunteers: r.who.size,
|
||||
assigned_hours: round(r.assigned),
|
||||
})),
|
||||
by_position: [...state.positions]
|
||||
@@ -669,7 +587,6 @@ function buildStats(): EditionStats {
|
||||
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
|
||||
};
|
||||
}),
|
||||
volunteers_with_availability: withAvailability.size,
|
||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
||||
},
|
||||
};
|
||||
@@ -714,8 +631,6 @@ function buildMatrix(): MatrixResponse {
|
||||
const SOLVER_URGENT_BASE = 1000;
|
||||
const SOLVER_NORMAL_BASE = 20;
|
||||
const SOLVER_WISH_BONUS = 30;
|
||||
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const SOLVER_REST_PENALTY = 60;
|
||||
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
|
||||
@@ -829,24 +744,6 @@ function solverScore(
|
||||
}
|
||||
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
|
||||
|
||||
const windows = state.availability.get(volunteer.id) ?? [];
|
||||
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
||||
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
|
||||
for (const other of existing) {
|
||||
if (overlaps(shift, other)) continue;
|
||||
let gap: number | null = null;
|
||||
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
|
||||
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
|
||||
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
|
||||
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
|
||||
}
|
||||
if (gap !== null && gap < EIGHT_HOURS_MIN) {
|
||||
score -= SOLVER_REST_PENALTY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const existingMinutes = existing.reduce(
|
||||
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
|
||||
0,
|
||||
@@ -1112,7 +1009,6 @@ export function createMockApi(): PlanningApi {
|
||||
updated_at: now,
|
||||
};
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay(volunteer);
|
||||
},
|
||||
update: (_edition, id, body) => {
|
||||
@@ -1154,7 +1050,6 @@ export function createMockApi(): PlanningApi {
|
||||
remove: (_edition, id) => {
|
||||
state.volunteers = state.volunteers.filter((x) => x.id !== id);
|
||||
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
|
||||
state.availability.delete(id);
|
||||
state.photos.delete(id);
|
||||
// RGPD : effacement complet, y compris le compte de connexion éventuel
|
||||
// et les vœux de compagnon dont il était la cible.
|
||||
@@ -1256,50 +1151,6 @@ export function createMockApi(): PlanningApi {
|
||||
},
|
||||
},
|
||||
|
||||
availability: {
|
||||
list: (_edition, volunteerId) => {
|
||||
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
||||
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
|
||||
},
|
||||
listEdition: () =>
|
||||
delay(
|
||||
sortWindows(
|
||||
[...state.availability.values()].flat().filter((w) =>
|
||||
state.volunteers.some((v) => v.id === w.volunteer_id),
|
||||
),
|
||||
),
|
||||
),
|
||||
replace: (_edition, volunteerId, body: AvailabilitySet) => {
|
||||
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
|
||||
const seen = new Set<string>();
|
||||
const windows: AvailabilityRead[] = [];
|
||||
for (const w of body.windows) {
|
||||
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
|
||||
throw new ApiError(422, 'availability.date_outside_edition');
|
||||
}
|
||||
const start = hms(w.start_time);
|
||||
const end = hms(w.end_time);
|
||||
if (start === end) throw new ApiError(422, 'availability.empty_window');
|
||||
const key = `${w.date}|${start}|${end}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const now = touch();
|
||||
windows.push({
|
||||
id: `av${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
volunteer_id: volunteerId,
|
||||
date: w.date,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
state.availability.set(volunteerId, windows);
|
||||
return delay(sortWindows(windows));
|
||||
},
|
||||
},
|
||||
|
||||
imports: {
|
||||
analyze: async (_edition, file: File) => {
|
||||
const { headers, rows } = await readImportTable(file);
|
||||
@@ -1480,9 +1331,6 @@ export function createMockApi(): PlanningApi {
|
||||
|
||||
if (!mapping.dry_run) {
|
||||
state.volunteers = working;
|
||||
for (const v of working) {
|
||||
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
|
||||
}
|
||||
}
|
||||
return delay(report);
|
||||
},
|
||||
@@ -1935,7 +1783,6 @@ export function createMockApi(): PlanningApi {
|
||||
};
|
||||
if (photo) state.photos.set(volunteer.id, photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
pending_token: null,
|
||||
|
||||
@@ -18,11 +18,7 @@ export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||
export type AuthMethod = 'password' | 'magic_link';
|
||||
|
||||
export type ViolationCode =
|
||||
| 'SHIFT_OVERLAP'
|
||||
| 'OUTSIDE_AVAILABILITY'
|
||||
| 'DAILY_HOURS_EXCEEDED'
|
||||
| 'INSUFFICIENT_REST';
|
||||
export type ViolationCode = 'SHIFT_OVERLAP';
|
||||
|
||||
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
|
||||
|
||||
@@ -177,32 +173,6 @@ export interface VolunteerPlanningEntry {
|
||||
referent_phone: string | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Availability (disponibilités déclarées par le bénévole) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface AvailabilityWindowInput {
|
||||
/** Date ISO (YYYY-MM-DD). */
|
||||
date: string;
|
||||
/** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
export interface AvailabilitySet {
|
||||
windows: AvailabilityWindowInput[];
|
||||
}
|
||||
|
||||
export interface AvailabilityRead {
|
||||
id: string;
|
||||
edition_id: string;
|
||||
volunteer_id: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Artist (programmation) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -766,8 +736,6 @@ export interface StatsNeedDay {
|
||||
shifts: number;
|
||||
min_hours: number;
|
||||
ideal_hours: number;
|
||||
available_hours: number;
|
||||
available_volunteers: number;
|
||||
assigned_hours: number;
|
||||
}
|
||||
|
||||
@@ -801,7 +769,6 @@ export interface EditionStats {
|
||||
needs: {
|
||||
by_day: StatsNeedDay[];
|
||||
by_position: StatsNeedPosition[];
|
||||
volunteers_with_availability: number;
|
||||
volunteers_with_assignment: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import type {
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
@@ -106,18 +104,6 @@ export interface PlanningApi {
|
||||
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
};
|
||||
availability: {
|
||||
/** Fenêtres déclarées par un bénévole. */
|
||||
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
|
||||
/** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */
|
||||
replace(
|
||||
editionId: string,
|
||||
volunteerId: string,
|
||||
body: AvailabilitySet,
|
||||
): Promise<AvailabilityRead[]>;
|
||||
/** Toutes les fenêtres de l'édition (grille des besoins). */
|
||||
listEdition(editionId: string): Promise<AvailabilityRead[]>;
|
||||
};
|
||||
imports: {
|
||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
|
||||
|
||||
@@ -70,12 +70,6 @@
|
||||
"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.rule": "Deux créneaux d'un même bénévole se chevauchent.",
|
||||
"validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({gap} non couvertes).",
|
||||
"validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.",
|
||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
|
||||
"validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.",
|
||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})",
|
||||
"validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.",
|
||||
"grille.filter.day": "JOUR",
|
||||
"grille.filter.position": "POSTE",
|
||||
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
||||
@@ -107,18 +101,16 @@
|
||||
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
|
||||
"volunteers.filter.show": "AFFICHER",
|
||||
"volunteers.filter.all": "TOUS",
|
||||
"volunteers.filter.overloaded": "SURCHARGÉS",
|
||||
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
||||
"volunteers.legend.assigned": "AFFECTATION",
|
||||
"volunteers.count": "{count} BÉNÉVOLES",
|
||||
"volunteers.totalHours": "TOTAL H",
|
||||
"volunteers.name": "NOM",
|
||||
"volunteers.cumulative": "CUMUL",
|
||||
"volunteers.badge.overloaded": "▲ SURCHARGE",
|
||||
"volunteers.badge.unassigned": "○ OUBLIÉ",
|
||||
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
|
||||
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
|
||||
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
|
||||
"volunteers.footnote": "Oublié = aucune affectation.",
|
||||
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
|
||||
"mobileVol.greeting": "SALUT {name}",
|
||||
"mobileVol.slots": "{count} CRÉNEAUX",
|
||||
@@ -174,7 +166,7 @@
|
||||
"config.vol.phone": "TÉLÉPHONE",
|
||||
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
||||
"config.vol.delete": "SUPPRIMER",
|
||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (affectations, vœux) ?",
|
||||
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
|
||||
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
|
||||
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
|
||||
@@ -209,9 +201,7 @@
|
||||
"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.thresholdsTitle": "MOTEUR DE VALIDATION",
|
||||
"config.rules.maxDaily": "Maximum d'heures par journée",
|
||||
"config.rules.minRest": "Repos minimum entre deux créneaux",
|
||||
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
|
||||
"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.rulesTitle": "RÈGLES APPLIQUÉES",
|
||||
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
|
||||
"config.rules.roleCoordinator": "Coordination",
|
||||
@@ -325,17 +315,6 @@
|
||||
"config.vol.fee.short.failed": "échouée",
|
||||
"config.vol.fee.short.expired": "expirée",
|
||||
"config.vol.wishesCount": "{count} vœu(x)",
|
||||
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
||||
"config.vol.avail.title": "Disponibilités",
|
||||
"config.vol.avail.hint": "Plages horaires où le bénévole peut venir. Une heure de fin ≤ début signifie que la plage se termine le lendemain.",
|
||||
"config.vol.avail.none": "Aucune disponibilité déclarée",
|
||||
"config.vol.avail.add": "Ajouter une plage",
|
||||
"config.vol.avail.save": "Enregistrer les disponibilités",
|
||||
"grille.panel.coverage.full": "Disponible",
|
||||
"grille.panel.coverage.partial": "Partiellement dispo",
|
||||
"grille.panel.coverage.none": "Dispo non déclarée",
|
||||
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
|
||||
"availability.empty_window": "Une plage doit avoir un début et une fin différents.",
|
||||
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
||||
"grille.solver.running": "CALCUL EN COURS…",
|
||||
"grille.solver.result": "{count} suggestion(s)",
|
||||
@@ -392,7 +371,7 @@
|
||||
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
|
||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||
"stats.kpi.withAvailability": "Ont déclaré des disponibilités · sur {total}",
|
||||
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
|
||||
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||
"stats.showTable": "Voir le tableau",
|
||||
"stats.unit.hours": "h",
|
||||
@@ -424,13 +403,12 @@
|
||||
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
|
||||
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
|
||||
"stats.needs.title": "Besoins et bénévoles",
|
||||
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures de disponibilité déclarées et aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
|
||||
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
|
||||
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
|
||||
"stats.needs.byDay": "Par jour (heures × personnes)",
|
||||
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
|
||||
"stats.needs.min": "Besoin minimal",
|
||||
"stats.needs.ideal": "Besoin idéal",
|
||||
"stats.needs.available": "Disponibilités déclarées",
|
||||
"stats.needs.assigned": "Déjà affecté",
|
||||
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
|
||||
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
|
||||
@@ -443,7 +421,6 @@
|
||||
"stats.col.paymentType": "Type de paiement",
|
||||
"stats.col.amount": "Montant",
|
||||
"stats.col.shifts": "Créneaux",
|
||||
"stats.col.availableVolunteers": "Bénévoles dispo.",
|
||||
"stats.col.position": "Poste",
|
||||
"stats.col.minSlots": "Places min.",
|
||||
"stats.col.idealSlots": "Places idéales",
|
||||
@@ -459,7 +436,7 @@
|
||||
"form.field.companionWish": "Collègue apprécié",
|
||||
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
|
||||
"form.field.available": "Case « je suis disponible »",
|
||||
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
|
||||
"form.field.available.help": "Purement informatif — simple case à cocher, sans effet sur les affectations.",
|
||||
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
|
||||
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
|
||||
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type {
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
DayPartRead,
|
||||
MatrixResponse,
|
||||
PositionCategory,
|
||||
@@ -25,71 +24,6 @@ export function foldAccents(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
|
||||
// (portage de `backend/app/validation/engine.py`). //
|
||||
// --------------------------------------------------------------------------- //
|
||||
const _dayNum = (iso: string): number => {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000);
|
||||
};
|
||||
|
||||
/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */
|
||||
function absMinutes(iso: string): number {
|
||||
const [date, time] = iso.split('T');
|
||||
const [h, min] = time.split(':').map(Number);
|
||||
return _dayNum(date) * 1440 + h * 60 + min;
|
||||
}
|
||||
|
||||
function windowInterval(w: AvailabilityRead): [number, number] {
|
||||
const [sh, sm] = w.start_time.split(':').map(Number);
|
||||
const [eh, em] = w.end_time.split(':').map(Number);
|
||||
const start = _dayNum(w.date) * 1440 + sh * 60 + sm;
|
||||
let end = _dayNum(w.date) * 1440 + eh * 60 + em;
|
||||
if (end <= start) end += 1440; // fenêtre à cheval sur minuit
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */
|
||||
export function uncoveredMinutes(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): number {
|
||||
const shiftStart = absMinutes(startIso);
|
||||
const shiftEnd = absMinutes(endIso);
|
||||
const intervals = windows
|
||||
.map(windowInterval)
|
||||
.filter(([a, b]) => b > a)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
let cursor = shiftStart;
|
||||
let gap = 0;
|
||||
for (const [s, e] of intervals) {
|
||||
if (e <= cursor) continue;
|
||||
if (s > shiftEnd) break;
|
||||
if (s > cursor) gap += Math.min(s, shiftEnd) - cursor;
|
||||
cursor = Math.max(cursor, e);
|
||||
if (cursor >= shiftEnd) return gap;
|
||||
}
|
||||
if (cursor < shiftEnd) gap += shiftEnd - cursor;
|
||||
return gap;
|
||||
}
|
||||
|
||||
export type Coverage = 'full' | 'partial' | 'none';
|
||||
|
||||
/** État de couverture d'un créneau par les fenêtres d'un bénévole. */
|
||||
export function availabilityCoverage(
|
||||
windows: AvailabilityRead[],
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): Coverage {
|
||||
if (windows.length === 0) return 'none';
|
||||
const gap = uncoveredMinutes(windows, startIso, endIso);
|
||||
if (gap === 0) return 'full';
|
||||
const total = absMinutes(endIso) - absMinutes(startIso);
|
||||
return gap >= total ? 'none' : 'partial';
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Score de chance d'un candidat pour un créneau — portage simplifié du //
|
||||
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
|
||||
@@ -145,28 +79,25 @@ export function buildEffectiveCompanionIds(
|
||||
}
|
||||
|
||||
const CHANCE_WISH_POSITION_BONUS = 30;
|
||||
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
|
||||
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
|
||||
|
||||
/**
|
||||
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
|
||||
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
|
||||
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
|
||||
* 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,
|
||||
* charge déjà assignée. Plus haut =
|
||||
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
|
||||
* validation (blocage / avertissements) reste seule juge.
|
||||
*/
|
||||
export function candidateChanceScore(params: {
|
||||
wishesPosition: boolean;
|
||||
uncoveredMinutes: number;
|
||||
conflictsWishedArtist: boolean;
|
||||
companionMatches: number;
|
||||
assignedHours: number;
|
||||
}): number {
|
||||
let score = 0;
|
||||
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
|
||||
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
|
||||
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
|
||||
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
|
||||
@@ -239,7 +170,7 @@ export function dayBoundaryFromDayParts(
|
||||
* « journée de festival » plutôt que par date calendaire brute : un créneau
|
||||
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
|
||||
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
|
||||
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */
|
||||
* 19:00 — voir CLAUDE.md § 3. */
|
||||
export function buildLattice(
|
||||
starts: Array<{ starts_at: string; ends_at: string }>,
|
||||
dayBoundary = '00:00',
|
||||
@@ -460,32 +391,6 @@ export function hoursByVolunteer(
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
|
||||
export function isOverloaded(
|
||||
volunteerId: string,
|
||||
assignments: AssignmentRead[],
|
||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||
): boolean {
|
||||
const shifts = assignments
|
||||
.filter((a) => a.volunteer_id === volunteerId)
|
||||
.map((a) => shiftsById.get(a.shift_id))
|
||||
.filter((s): s is ShiftRead => Boolean(s))
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const s of shifts) {
|
||||
const day = datePart(s.starts_at);
|
||||
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
|
||||
}
|
||||
for (const hours of perDay.values()) if (hours > 8) return true;
|
||||
|
||||
for (let i = 1; i < shifts.length; i += 1) {
|
||||
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
|
||||
if (gap > 0 && gap < 8 * 60) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend une violation en texte lisible. Les `params` suivent exactement le
|
||||
* contrat du moteur back-end (`backend/app/validation/engine.py`).
|
||||
@@ -502,25 +407,7 @@ export function violationText(
|
||||
? t('validation.SHIFT_OVERLAP.at', { position: where })
|
||||
: t('validation.SHIFT_OVERLAP');
|
||||
}
|
||||
case 'OUTSIDE_AVAILABILITY':
|
||||
return t('validation.OUTSIDE_AVAILABILITY', {
|
||||
gap: formatMinutes(Number(p.uncovered_minutes)),
|
||||
});
|
||||
case 'DAILY_HOURS_EXCEEDED':
|
||||
return t('validation.DAILY_HOURS_EXCEEDED', {
|
||||
hours: formatMinutes(Number(p.total_minutes)),
|
||||
});
|
||||
case 'INSUFFICIENT_REST':
|
||||
return t('validation.INSUFFICIENT_REST', {
|
||||
rest: formatMinutes(Number(p.gap_minutes)),
|
||||
});
|
||||
default:
|
||||
return violation.code;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMinutes(total: number): string {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ export function StatsPage() {
|
||||
label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })}
|
||||
/>
|
||||
<StatCard
|
||||
value={fr(s.needs.volunteers_with_availability)}
|
||||
label={t('stats.kpi.withAvailability', { total: fr(s.registrations.total) })}
|
||||
value={fr(s.needs.volunteers_with_assignment)}
|
||||
label={t('stats.kpi.withAssignment', { total: fr(s.registrations.total) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -230,7 +230,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
}
|
||||
const series = [
|
||||
{ label: t('stats.needs.ideal'), color: SERIES.blue },
|
||||
{ label: t('stats.needs.available'), color: SERIES.orange },
|
||||
{ label: t('stats.needs.assigned'), color: SERIES.aqua },
|
||||
];
|
||||
return (
|
||||
@@ -243,7 +242,7 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
groups={n.by_day.map((d) => ({
|
||||
key: d.date,
|
||||
label: shortDayLabel(d.date),
|
||||
values: [d.ideal_hours, d.available_hours, d.assigned_hours],
|
||||
values: [d.ideal_hours, d.assigned_hours],
|
||||
}))}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
@@ -253,8 +252,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
t('stats.col.shifts'),
|
||||
t('stats.needs.min'),
|
||||
t('stats.needs.ideal'),
|
||||
t('stats.needs.available'),
|
||||
t('stats.col.availableVolunteers'),
|
||||
t('stats.needs.assigned'),
|
||||
]}
|
||||
rows={n.by_day.map((d) => [
|
||||
@@ -262,8 +259,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
fr(d.shifts),
|
||||
fr(d.min_hours, 1),
|
||||
fr(d.ideal_hours, 1),
|
||||
fr(d.available_hours, 1),
|
||||
fr(d.available_volunteers),
|
||||
fr(d.assigned_hours, 1),
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } f
|
||||
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||
import { Chip, HelpTip } from '@/components/ui';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer, isOverloaded } from '@/lib/planning';
|
||||
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
type Filter = 'all' | 'overloaded' | 'unassigned';
|
||||
type Filter = 'all' | 'unassigned';
|
||||
|
||||
export function VolunteerViewPage() {
|
||||
const volunteers = useVolunteers();
|
||||
@@ -48,7 +48,6 @@ export function VolunteerViewPage() {
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => {
|
||||
const own = byVolunteer.get(v.id) ?? [];
|
||||
if (filter === 'overloaded') return isOverloaded(v.id, all, shiftsById);
|
||||
if (filter === 'unassigned') return own.length === 0;
|
||||
return true;
|
||||
})
|
||||
@@ -67,7 +66,6 @@ export function VolunteerViewPage() {
|
||||
volunteer: v,
|
||||
cellByKey,
|
||||
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
|
||||
over: isOverloaded(v.id, all, shiftsById),
|
||||
empty: own.length === 0,
|
||||
};
|
||||
});
|
||||
@@ -102,9 +100,6 @@ export function VolunteerViewPage() {
|
||||
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
{t('volunteers.filter.all')}
|
||||
</Chip>
|
||||
<Chip active={filter === 'overloaded'} onClick={() => setFilter('overloaded')}>
|
||||
{t('volunteers.filter.overloaded')}
|
||||
</Chip>
|
||||
<Chip active={filter === 'unassigned'} onClick={() => setFilter('unassigned')}>
|
||||
{t('volunteers.filter.unassigned')}
|
||||
</Chip>
|
||||
@@ -182,7 +177,6 @@ type Row = {
|
||||
volunteer: VolunteerRead;
|
||||
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
|
||||
total: number;
|
||||
over: boolean;
|
||||
empty: boolean;
|
||||
};
|
||||
|
||||
@@ -201,11 +195,6 @@ function VolunteerRow({
|
||||
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
|
||||
{row.volunteer.first_name} {row.volunteer.last_name}
|
||||
</span>
|
||||
{row.over ? (
|
||||
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||
{t('volunteers.badge.overloaded')}
|
||||
</span>
|
||||
) : null}
|
||||
{row.empty ? (
|
||||
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||
{t('volunteers.badge.unassigned')}
|
||||
@@ -224,14 +213,8 @@ function VolunteerRow({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={cx(
|
||||
'sticky right-0 z-[2] flex items-center justify-center gap-[3px] border-b border-l-[3px] border-ink font-mono text-[13px] font-bold',
|
||||
row.over ? 'bg-orange' : 'bg-paper',
|
||||
)}
|
||||
>
|
||||
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
|
||||
{row.total}
|
||||
{row.over ? <span className="font-display text-[9px] font-bold">▲</span> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,14 +18,9 @@ import { t } from '@/i18n';
|
||||
|
||||
const hm = (time: string) => time.slice(0, 5); // "HH:MM:SS" -> "HH:MM"
|
||||
|
||||
const RULE_CODES = [
|
||||
'SHIFT_OVERLAP',
|
||||
'OUTSIDE_AVAILABILITY',
|
||||
'DAILY_HOURS_EXCEEDED',
|
||||
'INSUFFICIENT_REST',
|
||||
] as const;
|
||||
const RULE_CODES = ['SHIFT_OVERLAP'] as const;
|
||||
|
||||
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
|
||||
// Le moteur de validation n'a qu'une règle, figée (CLAUDE.md §3). L'édition (nom, dates) est
|
||||
// éditable — c'est là qu'on ajoute ou retire des jours.
|
||||
export function ConfigRules() {
|
||||
const volunteers = useVolunteers();
|
||||
@@ -62,9 +57,7 @@ export function ConfigRules() {
|
||||
<HelpTip text={t('config.rules.thresholdsNote')} side="bottom" />
|
||||
</PanelHeader>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
|
||||
<InfoRow label={t('config.rules.minRest')} value="8 h" />
|
||||
<div className="mt-2 border-t-2 border-ink pt-2">
|
||||
<div>
|
||||
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
|
||||
{t('config.rules.rulesTitle')}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import { roleLabelKey } from '@/auth/roles';
|
||||
@@ -10,16 +10,13 @@ import {
|
||||
useCreateVolunteer,
|
||||
useDeleteAccount,
|
||||
useDeleteVolunteer,
|
||||
useEdition,
|
||||
useExecuteImport,
|
||||
useExportMecenatReceipts,
|
||||
useExportVolunteers,
|
||||
usePositions,
|
||||
useReplaceAvailability,
|
||||
useUpdateAccount,
|
||||
useUpdatePosition,
|
||||
useUpdateVolunteer,
|
||||
useVolunteerAvailability,
|
||||
useVolunteerPhotoUrl,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
@@ -870,8 +867,6 @@ export function ConfigVolunteers() {
|
||||
|
||||
<PublicFormAnswers volunteer={selected} patch={patch} />
|
||||
|
||||
<AvailabilityEditor volunteerId={selected.id} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -941,7 +936,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
||||
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
|
||||
<span className="flex items-center gap-[7px] font-display text-[14px] font-black">
|
||||
{t('config.vol.addTitle')}
|
||||
<HelpTip text={t('config.vol.addAvailabilityHint')} side="bottom" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1361,148 +1355,3 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Éditeur de disponibilités (fiche bénévole) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
type WindowRow = { date: string; start: string; end: string };
|
||||
|
||||
function editionDays(start: string, end: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (
|
||||
let d = Date.parse(`${start}T00:00:00Z`);
|
||||
d <= Date.parse(`${end}T00:00:00Z`);
|
||||
d += 86_400_000
|
||||
) {
|
||||
out.push(new Date(d).toISOString().slice(0, 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function AvailabilityEditor({ volunteerId }: { volunteerId: string }) {
|
||||
const edition = useEdition();
|
||||
const windows = useVolunteerAvailability(volunteerId);
|
||||
const replace = useReplaceAvailability();
|
||||
|
||||
const [rows, setRows] = useState<WindowRow[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const serverRows = useMemo<WindowRow[]>(
|
||||
() =>
|
||||
(windows.data ?? []).map((w) => ({
|
||||
date: w.date,
|
||||
start: w.start_time.slice(0, 5),
|
||||
end: w.end_time.slice(0, 5),
|
||||
})),
|
||||
[windows.data],
|
||||
);
|
||||
|
||||
// Recharge depuis le serveur tant que l'utilisateur n'a rien modifié.
|
||||
useEffect(() => {
|
||||
if (!dirty) setRows(serverRows);
|
||||
}, [serverRows, dirty]);
|
||||
|
||||
// Repart de zéro quand on change de bénévole.
|
||||
useEffect(() => {
|
||||
setDirty(false);
|
||||
}, [volunteerId]);
|
||||
|
||||
const days = edition.data
|
||||
? editionDays(edition.data.start_date, edition.data.end_date)
|
||||
: [];
|
||||
|
||||
const patch = (i: number, next: Partial<WindowRow>) => {
|
||||
setDirty(true);
|
||||
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...next } : r)));
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
replace.mutate(
|
||||
{
|
||||
volunteerId,
|
||||
body: {
|
||||
windows: rows.map((r) => ({
|
||||
date: r.date,
|
||||
start_time: `${r.start}:00`,
|
||||
end_time: `${r.end}:00`,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => setDirty(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>
|
||||
{t('config.vol.avail.title')} <HelpTip text={t('config.vol.avail.hint')} />
|
||||
</FieldLabel>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-1">
|
||||
<select
|
||||
value={row.date}
|
||||
onChange={(e) => patch(i, { date: e.target.value })}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[6px] font-mono text-[11px]"
|
||||
>
|
||||
{days.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{shortDayLabel(`${d}T12:00`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="time"
|
||||
value={row.start}
|
||||
onChange={(e) => patch(i, { start: e.target.value })}
|
||||
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={row.end}
|
||||
onChange={(e) => patch(i, { end: e.target.value })}
|
||||
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDirty(true);
|
||||
setRows((rs) => rs.filter((_, j) => j !== i));
|
||||
}}
|
||||
aria-label={t('action.remove')}
|
||||
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<span className="font-mono text-[10px] opacity-55">{t('config.vol.avail.none')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={days.length === 0}
|
||||
onClick={() => {
|
||||
setDirty(true);
|
||||
setRows((rs) => [...rs, { date: days[0], start: '08:00', end: '18:00' }]);
|
||||
}}
|
||||
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
||||
>
|
||||
+ {t('config.vol.avail.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || replace.isPending}
|
||||
onClick={save}
|
||||
className="cursor-pointer border-2 border-ink bg-yellow px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-green hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{t('config.vol.avail.save')}
|
||||
</button>
|
||||
</div>
|
||||
{replace.isError ? <ErrorNote error={replace.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,33 +6,24 @@ import {
|
||||
useAssignments,
|
||||
useCreateAssignment,
|
||||
useDeleteAssignment,
|
||||
useEditionAvailability,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useSolverSuggestions,
|
||||
useValidateAssignment,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type {
|
||||
AvailabilityRead,
|
||||
MatrixShift,
|
||||
PositionCategory,
|
||||
VolunteerRead,
|
||||
} from '@/api/schema';
|
||||
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
|
||||
import { Btn } from '@/components/ui';
|
||||
import { Loading } from '@/components/state';
|
||||
import {
|
||||
artistPerformanceWindow,
|
||||
availabilityCoverage,
|
||||
buildEffectiveCompanionIds,
|
||||
candidateChanceScore,
|
||||
categoryLabel,
|
||||
foldAccents,
|
||||
hoursByVolunteer,
|
||||
uncoveredMinutes,
|
||||
violationText,
|
||||
windowsOverlap,
|
||||
type Coverage,
|
||||
} from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
@@ -63,7 +54,6 @@ export function AssignmentPanel({
|
||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const availability = useEditionAvailability();
|
||||
const artists = useArtists();
|
||||
const solverSuggestions = useSolverSuggestions();
|
||||
const deleteAssignment = useDeleteAssignment();
|
||||
@@ -75,27 +65,6 @@ export function AssignmentPanel({
|
||||
return entry?.volunteer_ids ?? [];
|
||||
}, [solverSuggestions.data, shift.shift_id]);
|
||||
|
||||
const windowsByVolunteer = useMemo(() => {
|
||||
const byVol = new Map<string, AvailabilityRead[]>();
|
||||
for (const w of availability.data ?? []) {
|
||||
const list = byVol.get(w.volunteer_id) ?? [];
|
||||
list.push(w);
|
||||
byVol.set(w.volunteer_id, list);
|
||||
}
|
||||
return byVol;
|
||||
}, [availability.data]);
|
||||
|
||||
const coverageByVolunteer = useMemo(() => {
|
||||
const map = new Map<string, Coverage>();
|
||||
for (const v of volunteers.data ?? []) {
|
||||
map.set(
|
||||
v.id,
|
||||
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
|
||||
|
||||
const artistsById = useMemo(
|
||||
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
|
||||
[artists.data],
|
||||
@@ -166,8 +135,7 @@ export function AssignmentPanel({
|
||||
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
|
||||
|
||||
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
|
||||
// disponibilité (dont 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é ».
|
||||
const scoreByVolunteer = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
@@ -177,11 +145,6 @@ export function AssignmentPanel({
|
||||
v.id,
|
||||
candidateChanceScore({
|
||||
wishesPosition: details?.wishesPosition ?? false,
|
||||
uncoveredMinutes: uncoveredMinutes(
|
||||
windowsByVolunteer.get(v.id) ?? [],
|
||||
shift.starts_at,
|
||||
shift.ends_at,
|
||||
),
|
||||
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
|
||||
companionMatches: details?.companionMatches ?? 0,
|
||||
assignedHours: hours.get(v.id) ?? 0,
|
||||
@@ -189,7 +152,7 @@ export function AssignmentPanel({
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
|
||||
}, [volunteers.data, detailsByVolunteer, hours]);
|
||||
|
||||
const needle = foldAccents(search.trim());
|
||||
|
||||
@@ -319,7 +282,6 @@ export function AssignmentPanel({
|
||||
shiftId={shift.shift_id}
|
||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
||||
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
|
||||
conflictsWishedArtist={
|
||||
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
|
||||
@@ -334,18 +296,11 @@ export function AssignmentPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const COVERAGE_STYLE: Record<Coverage, { cls: string; key: string }> = {
|
||||
full: { cls: 'bg-green text-white', key: 'grille.panel.coverage.full' },
|
||||
partial: { cls: 'bg-yellow-soft', key: 'grille.panel.coverage.partial' },
|
||||
none: { cls: 'bg-sand-dark', key: 'grille.panel.coverage.none' },
|
||||
};
|
||||
|
||||
function CandidateRow({
|
||||
volunteer,
|
||||
shiftId,
|
||||
hours,
|
||||
slots,
|
||||
coverage,
|
||||
wishesPosition,
|
||||
conflictsWishedArtist,
|
||||
companionMatches,
|
||||
@@ -356,7 +311,6 @@ function CandidateRow({
|
||||
shiftId: string;
|
||||
hours: number;
|
||||
slots: number;
|
||||
coverage: Coverage;
|
||||
wishesPosition: boolean;
|
||||
conflictsWishedArtist: boolean;
|
||||
companionMatches: number;
|
||||
@@ -411,11 +365,6 @@ function CandidateRow({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||
<span
|
||||
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
|
||||
>
|
||||
{t(COVERAGE_STYLE[coverage].key)}
|
||||
</span>
|
||||
{wishesPosition ? (
|
||||
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{t('grille.panel.wishesPosition')}
|
||||
|
||||
Reference in New Issue
Block a user