From d722a81a874376899f49c34023c7b08d0b29827b Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Mon, 5 Oct 2026 21:29:58 +0200 Subject: [PATCH] =?UTF-8?q?Retrait=20des=20disponibilit=C3=A9s=20et=20des?= =?UTF-8?q?=20limites=20de=20temps=20dans=20la=20journ=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 89 +++---- backend/app/api/availability.py | 132 ---------- backend/app/api/schemas.py | 33 +-- backend/app/api/stats.py | 16 +- backend/app/i18n/locales/fr.json | 5 - backend/app/main.py | 2 - .../versions/0022_drop_availability.py | 55 +++++ backend/app/models/__init__.py | 2 - backend/app/models/domain.py | 40 +-- backend/app/seed.py | 166 +------------ backend/app/solver/engine.py | 54 +--- backend/app/solver/loader.py | 12 +- backend/app/solver/models.py | 7 +- backend/app/validation/__init__.py | 2 - backend/app/validation/engine.py | 109 +-------- backend/app/validation/loader.py | 24 -- backend/app/validation/models.py | 24 +- backend/tests/factories.py | 30 --- backend/tests/test_api_assignments.py | 44 ++-- backend/tests/test_api_availability.py | 114 --------- backend/tests/test_api_stats.py | 8 - backend/tests/test_api_volunteers.py | 12 +- backend/tests/test_models.py | 27 -- backend/tests/test_seed.py | 4 - backend/tests/test_solver_engine.py | 100 +++----- backend/tests/test_validation_engine.py | 230 ++---------------- backend/tests/test_validation_loader.py | 13 - frontend/src/api/hooks.ts | 33 --- frontend/src/api/http-client.ts | 11 - frontend/src/api/mock/dataset.ts | 39 +-- frontend/src/api/mock/mock-client.ts | 159 +----------- frontend/src/api/schema.ts | 35 +-- frontend/src/api/types.ts | 14 -- frontend/src/i18n/locales/fr.json | 35 +-- frontend/src/lib/planning.ts | 121 +-------- frontend/src/pages/StatsPage.tsx | 11 +- frontend/src/pages/VolunteerViewPage.tsx | 23 +- frontend/src/pages/config/ConfigRules.tsx | 13 +- .../src/pages/config/ConfigVolunteers.tsx | 153 +----------- frontend/src/pages/grille/AssignmentPanel.tsx | 57 +---- 40 files changed, 213 insertions(+), 1845 deletions(-) delete mode 100644 backend/app/api/availability.py create mode 100644 backend/app/migrations/versions/0022_drop_availability.py delete mode 100644 backend/tests/test_api_availability.py diff --git a/CLAUDE.md b/CLAUDE.md index b355cfe..5cda45d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/availability.py b/backend/app/api/availability.py deleted file mode 100644 index 9732da6..0000000 --- a/backend/app/api/availability.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Disponibilités déclarées par les bénévoles. - -Une disponibilité = « le , je peux de à ». -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, - ) - ) - ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index b626d57..757fe7a 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 56520fa..6c08274 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -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}), ) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 5f62918..3e1d180 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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.", diff --git a/backend/app/main.py b/backend/app/main.py index 414a40d..49b68f3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/migrations/versions/0022_drop_availability.py b/backend/app/migrations/versions/0022_drop_availability.py new file mode 100644 index 0000000..bcf8334 --- /dev/null +++ b/backend/app/migrations/versions/0022_drop_availability.py @@ -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 + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 08eb11c..b1919a0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 20ce39b..95ad544 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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__ = ( diff --git a/backend/app/seed.py b/backend/app/seed.py index 9a51c96..3fb921f 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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: diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py index c70c2ab..7cfd72d 100644 --- a/backend/app/solver/engine.py +++ b/backend/app/solver/engine.py @@ -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) diff --git a/backend/app/solver/loader.py b/backend/app/solver/loader.py index f19c720..bf441a8 100644 --- a/backend/app/solver/loader.py +++ b/backend/app/solver/loader.py @@ -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, ())), diff --git a/backend/app/solver/models.py b/backend/app/solver/models.py index 742dcfd..a06bff8 100644 --- a/backend/app/solver/models.py +++ b/backend/app/solver/models.py @@ -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 diff --git a/backend/app/validation/__init__.py b/backend/app/validation/__init__.py index d080113..1d7229a 100644 --- a/backend/app/validation/__init__.py +++ b/backend/app/validation/__init__.py @@ -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", diff --git a/backend/app/validation/engine.py b/backend/app/validation/engine.py index e8357f0..2956baf 100644 --- a/backend/app/validation/engine.py +++ b/backend/app/validation/engine.py @@ -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 diff --git a/backend/app/validation/loader.py b/backend/app/validation/loader.py index ea8ff19..cf07bc0 100644 --- a/backend/app/validation/loader.py +++ b/backend/app/validation/loader.py @@ -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 - ), ) diff --git a/backend/app/validation/models.py b/backend/app/validation/models.py index fd7ff0f..1193987 100644 --- a/backend/app/validation/models.py +++ b/backend/app/validation/models.py @@ -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 , je peux de à . » - - `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, ...] = () diff --git a/backend/tests/factories.py b/backend/tests/factories.py index 2c685d0..05ff20d 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -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) diff --git a/backend/tests/test_api_assignments.py b/backend/tests/test_api_assignments.py index 0a64380..10c520a 100644 --- a/backend/tests/test_api_assignments.py +++ b/backend/tests/test_api_assignments.py @@ -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): diff --git a/backend/tests/test_api_availability.py b/backend/tests/test_api_availability.py deleted file mode 100644 index 1bb81f5..0000000 --- a/backend/tests/test_api_availability.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_api_stats.py b/backend/tests/test_api_stats.py index 091ab1a..8d7d491 100644 --- a/backend/tests/test_api_stats.py +++ b/backend/tests/test_api_stats.py @@ -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 diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index ee57abb..244ce12 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -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 diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 4313982..0b53ec3 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -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). diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index b974f1b..fd6b435 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -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 diff --git a/backend/tests/test_solver_engine.py b/backend/tests/test_solver_engine.py index 1ef0dc0..12b41fb 100644 --- a/backend/tests/test_solver_engine.py +++ b/backend/tests/test_solver_engine.py @@ -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",) diff --git a/backend/tests/test_validation_engine.py b/backend/tests/test_validation_engine.py index afbea8d..201d3e3 100644 --- a/backend/tests/test_validation_engine.py +++ b/backend/tests/test_validation_engine.py @@ -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 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) ------------------------ diff --git a/backend/tests/test_validation_loader.py b/backend/tests/test_validation_loader.py index 171ea37..18f0665 100644 --- a/backend/tests/test_validation_loader.py +++ b/backend/tests/test_validation_loader.py @@ -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): diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 1a29de2..7bb692f 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index bd89b4e..8c92cce 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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(); diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index b85a645..b454c1a 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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; positions: PositionRead[]; shifts: ShiftRead[]; assignments: AssignmentRead[]; @@ -266,37 +264,10 @@ export function buildMockState(): MockState { ); const volunteers: VolunteerRead[] = []; - const availability = new Map(); - 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, diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 88d4729..e880089 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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( 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; assigned: number }; + type Row = { shifts: number; min: number; ideal: number; assigned: number }; const rows = new Map(); 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(); - 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(); 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(); - 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({ volunteer_id: volunteer.id, pending_token: null, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 9a411f3..31470b5 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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; }; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index af09b29..6cea4e6 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; remove(editionId: string, id: string): Promise; }; - availability: { - /** Fenêtres déclarées par un bénévole. */ - list(editionId: string, volunteerId: string): Promise; - /** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */ - replace( - editionId: string, - volunteerId: string, - body: AvailabilitySet, - ): Promise; - /** Toutes les fenêtres de l'édition (grille des besoins). */ - listEdition(editionId: string): Promise; - }; imports: { /** Phase 1 : lit le fichier, propose un mapping des colonnes. */ analyze(editionId: string, file: File): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index ce8bd97..39b71f8 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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 ✓", diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 0c38206..12ff80d 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -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>, -): 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(); - 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')}`; -} diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 7627108..e8589ab 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -57,8 +57,8 @@ export function StatsPage() { label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })} /> @@ -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], }))} /> @@ -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), ])} /> diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx index 4c19054..35449b8 100644 --- a/frontend/src/pages/VolunteerViewPage.tsx +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -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() { setFilter('all')}> {t('volunteers.filter.all')} - setFilter('overloaded')}> - {t('volunteers.filter.overloaded')} - setFilter('unassigned')}> {t('volunteers.filter.unassigned')} @@ -182,7 +177,6 @@ type Row = { volunteer: VolunteerRead; cellByKey: Map; total: number; - over: boolean; empty: boolean; }; @@ -201,11 +195,6 @@ function VolunteerRow({ {row.volunteer.first_name} {row.volunteer.last_name} - {row.over ? ( - - {t('volunteers.badge.overloaded')} - - ) : null} {row.empty ? ( {t('volunteers.badge.unassigned')} @@ -224,14 +213,8 @@ function VolunteerRow({ ); })} -
+
{row.total} - {row.over ? ▲ : null}
); diff --git a/frontend/src/pages/config/ConfigRules.tsx b/frontend/src/pages/config/ConfigRules.tsx index 3fe50c2..99bfdb4 100644 --- a/frontend/src/pages/config/ConfigRules.tsx +++ b/frontend/src/pages/config/ConfigRules.tsx @@ -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() {
- - -
+
{t('config.rules.rulesTitle')}
diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index fcaf5f4..f24de83 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -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() { - -
); } - -// --------------------------------------------------------------------------- // -// É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([]); - const [dirty, setDirty] = useState(false); - - const serverRows = useMemo( - () => - (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) => { - 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 ( -
- - {t('config.vol.avail.title')} - -
- {rows.map((row, i) => ( -
- - patch(i, { start: e.target.value })} - className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]" - /> - patch(i, { end: e.target.value })} - className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]" - /> - -
- ))} - {rows.length === 0 ? ( - {t('config.vol.avail.none')} - ) : null} -
- -
- - -
- {replace.isError ? : null} -
- ); -} diff --git a/frontend/src/pages/grille/AssignmentPanel.tsx b/frontend/src/pages/grille/AssignmentPanel.tsx index db6b81f..3f1299e 100644 --- a/frontend/src/pages/grille/AssignmentPanel.tsx +++ b/frontend/src/pages/grille/AssignmentPanel.tsx @@ -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(); - 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(); - 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(); @@ -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 = { - 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({
- - {t(COVERAGE_STYLE[coverage].key)} - {wishesPosition ? ( {t('grille.panel.wishesPosition')}