# Audit et chantiers moteur de planification CoolCare

> ## ✅ CHANTIER LIVRÉ EN PRODUCTION
> **Vérifié le 2026-05-29** — Ne plus relire pour valider la livraison.

**Statut :** plan validé, en attente de clarification Aurélien (cf question en suspens en bas) avant lancement
**Date plan :** 2026-05-02
**Source audit :** [src/services/MaintenanceOccurrencePlanner.php](../coolcare-app/src/services/MaintenanceOccurrencePlanner.php), [src/services/MaintenancePlanningService.php](../coolcare-app/src/services/MaintenancePlanningService.php), [src/controllers/MachineApiController.php](../coolcare-app/src/controllers/MachineApiController.php)
**À porter sur :** missioflow-app (multi-tenant)

## Contexte du modèle métier

Le moteur de planification doit générer **3 séries d'interventions indépendantes** sur les machines, à partir d'une ancre `date_premiere_intervention_annuelle` et d'un horizon `site.date_renouvellement_contrat` :

1. **Maintenance annuelle obligatoire** : 1 fois par an sur la date anniversaire de l'ancre. Systématique dès que le site est sous contrat et l'ancre saisie. Pas de cadence configurable. Aucune exemption possible (c'est dicté par le contrat).
2. **CERFA** : selon TeqCO2 + détecteur de fuite, fréquence calculée auto par le frontend. Peut être "Non requis (TeqCO2 < 5t)" auquel cas 0 occurrence générée.
3. **Maintenance supplémentaire** : optionnelle, déclenchée par la case `machine_sous_contrat` cochée + fréquence saisie (`frequence_controle_contrat`). Pertinente uniquement pour des fréquences inférieures à 12 mois (semestriel, trimestriel, etc.). Une fréquence de 12 mois cochée ici est un doublon avec la maintenance annuelle obligatoire et ne devrait pas arriver en pratique.

**État actuel du moteur (vérifié dans le code)** : seules les séries 2 et 3 sont implémentées. La série 1 manque, ce qui crée un trou métier sur **34 machines en BDD locale** (sites sous contrat avec ancre saisie, CERFA "Non requis" et maintenance supplémentaire décochée → 0 intervention générée alors qu'il devrait y en avoir 1 par an).

---

## Chantier 1 — Bug de validation formulaire machine + nettoyage BDD

### Problème

Quand la case `machine_sous_contrat` est décochée, le champ `frequence_controle_contrat` conserve sa valeur précédente en BDD au lieu d'être vidé. Plusieurs machines (DASSAULT SYSTEMES, LARIBOISIERE) ont des valeurs résiduelles "1 mois" alors que la case est décochée. Le moteur les ignore grâce à son garde-fou actuel, mais la donnée est sale et risque une réactivation accidentelle si la case est recochée.

Volumétrie BDD locale : **105 machines** avec donnée orpheline (95 × "1 mois" + 10 × "2 année(s)").

### Travail à faire

- **Frontend** : dans le formulaire d'édition machine, lier l'état du champ `frequence_controle_contrat` à la case `machine_sous_contrat`. Quand la case est décochée, vider visuellement le champ et le désactiver. Quand elle est cochée, le réactiver vide (sans valeur par défaut, l'admin doit choisir).
- **Backend** : dans le contrôleur API machine (création et mise à jour), forcer `frequence_controle_contrat = NULL` côté serveur dès que `machine_sous_contrat = 0`. Ne jamais faire confiance au frontend, le backend doit garantir l'invariant. *(Helper `MachineParamsBuilder::effectiveContractFreq` déjà implémenté en local, à valider et commit.)*
- **BDD** : migration SQL pour nettoyer les résidus existants. Pattern habituel du projet (backup mysqldump systématique, fichiers `.sql` + `.rollback.sql` séparés, assertion `SELECT COUNT` préalable). La migration met à NULL le champ `frequence_controle_contrat` sur toutes les machines où `machine_sous_contrat = 0` ET `frequence_controle_contrat IS NOT NULL`. *(Fichier `database/migration_machines_freq_contrat_orphelines.sql` déjà créé en local, manque le rollback et l'assertion.)*
- **Tests** : ajouter des tests PHPUnit qui vérifient l'invariant côté contrôleur, et un test Jest sur le comportement frontend.

---

## Chantier 2 — Élargissement du bandeau d'alerte des machines silencieuses

### Problème

Le bandeau d'alerte côté admin (cf `MachineApiController::handleSilentMachines`) pré-filtre en SQL pour ne remonter que les machines ayant au moins une source d'occurrences (CERFA actif ou maintenance supplémentaire cochée). Conséquence : les **34 machines** en cas "site contrat + ancre + CERFA non requis + maintenance supp décochée" sont **doublement invisibles** (pas de génération + pas d'alerte).

De plus, le wording actuel "Ancre passée + contrat court" suggère une erreur à corriger alors que c'est le plus souvent un état normal de fin de cycle contractuel qui appelle un rappel anticipé pour anticiper le renouvellement avec le client.

### Travail à faire

- **Élargir le SQL pré-filtre** pour inclure les machines silencieuses sans source de visite. Ajouter une raison `no_visit_source` : site sous contrat + `date_renouvellement_contrat` saisie + ancre saisie + CERFA non requis ou NULL + maintenance supplémentaire décochée.
- **Ajouter une raison `missing_contract_end_date`** : site avec `sous_contrat = 1` mais `date_renouvellement_contrat IS NULL`. Cas symétrique de l'incohérence existante.
- **Adapter le wording de la raison `past_anchor_short_horizon`** : le message doit refléter qu'il s'agit d'un rappel anticipé de fin de cycle, pas d'une erreur. Proposition :
  - Titre : "Cycle d'interventions terminé sous le contrat actuel. Pensez à anticiper le renouvellement avec le client"
  - Sous-titre : "Si l'ancre est incorrecte, vous pouvez la corriger"
- **Tests** : ajouter des cas de test qui couvrent les nouvelles raisons, vérifier les compteurs par catégorie en BDD locale.

---

## Chantier 3 — Ajout de la 3ème série « maintenance annuelle obligatoire » dans le moteur

### Travail à faire

- **Dans `MaintenanceOccurrencePlanner.php`** : ajouter une méthode `collectAnnualMaintenanceOccurrences` appelée depuis `collectAllOccurrences` aux côtés des deux méthodes existantes.
- **Conditions de déclenchement** : `sous_contrat_site = 1` (ou logique équivalente vérifiant que la machine est rattachée à un site sous contrat avec date de fin saisie) + `date_premiere_intervention_annuelle` non NULL. Aucune autre condition. Pas de garde-fou sur CERFA ni sur maintenance supp.
- **Cadence** : 12 mois en dur, depuis l'ancre, jusqu'à l'horizon `site.date_renouvellement_contrat`.
- **Libellé du type d'intervention** : distinguer ce nouveau type des deux existants. Proposition : `"maintenance_annuelle"` (à valider selon les conventions de nommage existantes du projet). Le regroupement par date via `groupOccurrencesByDate` doit fusionner correctement quand cette occurrence tombe le même jour qu'une CERFA ou une maintenance supp (ce qui sera le cas systématique sur la date anniversaire puisque toutes utilisent la même ancre).

### Migration des données existantes

Au déploiement, les 34 machines actuellement silencieuses vont voir leur premier `scheduleMachineById` générer les occurrences manquantes. **Avant de déployer, faire tourner une requête de simulation à blanc** pour estimer le volume d'interventions qui vont apparaître au planning et identifier d'éventuels doublons avec des interventions manuelles déjà saisies.

#### Requête de classification à exécuter et à analyser

```sql
SELECT
  m.id,
  m.nom_machine,
  s.nom_lieu,
  m.date_premiere_intervention_annuelle AS ancre,
  s.date_renouvellement_contrat AS fin_contrat,
  CASE
    WHEN DATE_ADD(m.date_premiere_intervention_annuelle,
                  INTERVAL (TIMESTAMPDIFF(YEAR, m.date_premiere_intervention_annuelle, CURDATE()) + 1) YEAR
         ) > s.date_renouvellement_contrat
    THEN 'horizon_court_pas_de_generation'
    ELSE 'cas_pur_generation_attendue'
  END AS classification,
  DATE_ADD(m.date_premiere_intervention_annuelle,
           INTERVAL (TIMESTAMPDIFF(YEAR, m.date_premiere_intervention_annuelle, CURDATE()) + 1) YEAR
  ) AS prochaine_occurrence_theorique
FROM machines m
JOIN sites_clients s ON m.site_id = s.id
WHERE m.sous_contrat_site = 1
  AND COALESCE(m.statut, 'actif') = 'actif'
  AND s.sous_contrat = 1
  AND s.date_renouvellement_contrat IS NOT NULL
  AND m.date_premiere_intervention_annuelle IS NOT NULL
  AND (m.frequence_controle_cerfa IS NULL OR m.frequence_controle_cerfa = '' OR m.frequence_controle_cerfa LIKE 'Non requis%')
  AND m.machine_sous_contrat = 0
ORDER BY classification, m.id;
```

Pour les machines en `cas_pur_generation_attendue`, requêter aussi les interventions existantes (terminée, planifiée, en_attente, reportée) sur ces machines pour détecter les éventuels doublons avec de la maintenance annuelle créée manuellement par le passé. Si des interventions manuelles existent à proximité de la date anniversaire (écart < 30 jours), proposer une stratégie de migration (annulation des manuelles avant replanif, ou décalage de la replanif à l'année suivante).

### Tests

- PHPUnit unitaires sur la nouvelle méthode (cas avec CERFA seul, maintenance supp seule, les deux, aucun des deux)
- Tests d'intégration sur le `scheduleMachineById` complet
- Vérification du comportement de `groupOccurrencesByDate` avec les 3 séries fusionnées le même jour

---

## Chantier 1bis (suivi) — Préservation des interventions en retard légitimes

### Problème (découvert via comparaison BDD prod ↔ local 2026-05-02)

Le commit chantier 1 initial faisait `DELETE WHERE statut NOT IN ('en_cours', 'terminee')` lors d'une désactivation, **sans filtre de date**. Conséquence : les interventions `planifiee` en retard qui tombaient dans la fenêtre contractuelle étaient supprimées, alors que ce sont des **visites contractuelles non honorées à régulariser avec le client** — leur perte est une perte de donnée métier.

Diff prod ↔ local après désactivation : 26 interventions en retard légitimes manquantes (DASSAULT, GARAGE MANNES, TOTAL BOUGIVAL, BICÊTRE).

### Décision métier (user 2026-05-02)

- Par défaut, préserver les interventions en retard dans la fenêtre [date_premiere_intervention_annuelle, site.date_renouvellement_contrat].
- Demander à l'admin via une modal s'il veut les garder ou les supprimer (3 choix : Garder / Supprimer / Annuler).

### Implémentation

- Backend `deletePendingFutureInterventionsForMachine($id, $keepLegitimateLate = true)` : SQL conditionnel selon le mode.
- Backend `loadContractualWindow($id)` + `countLegitimateLateInterventionsForMachine($id)`.
- Endpoint `GET ?action=check_deactivate&id=X` : pré-check pour piloter la modal frontend.
- `handleDeactivate` accepte `keep_late=1|0` dans le POST. Défaut `1`.
- `handleDelete` passe `$keepLate = $hasPast` (préserve si désactivation avec historique, supprime tout si suppression physique).
- Frontend `toggleMachineStatut` : pré-check + modal 3 choix si `late_count > 0`.
- Tests PHPUnit : 4 nouveaux tests sur les modes + 2 tests `check_deactivate`. Adaptation des tests `handleDelete` existants (1 SELECT en plus).

## Chantier 4 — Cycle de vie du contrat site (cascade replanif + expiration)

**Statut :** ✅ TERMINÉ le 2026-05-04 (commits 0526483, be53247, a8cf63b, a3e642a, bec0c75 — locaux non poussés en attente fin de journée). Tous tests verts (3722 unit), 0 régression.

**Historique :** débloqué le 2026-05-02 par la réponse Aurélien sur LARIBOISIERE (cas (a) erreur de saisie, garde-fou frontend+backend en place via le chantier `MACHINE_SOUS_CONTRAT_GUARD`). Périmètre clarifié et redécoupé le 2026-05-04, puis implémenté en 5 commits locaux successifs.

> **Implémentation effective :**
>
> **4.1 (commit 0526483)** — Mode `incremental` ajouté à `MaintenancePlanningService::scheduleMachineById`. Repository : `listAutoPlannedDatesInWindow` + `deleteAutoPlannedAfterDate`. SiteApiController.replanAllMachinesOfSite passe `mode=incremental` (préserve les ajustements manuels sur auto-planifiées existantes). Mode `reset` reste défaut pour les autres callers (rétrocompat). +6 tests.
>
> **4.2 (commit be53247)** — Helpers privés `detectDateChangeDirection` (7 directions : no_change, add_contract, remove_contract, set_date, extend, reduce_future, past) + `simulateGenerabilityForSite`. `performSiteUpdate` refuse 422 avec `requires_past_date_confirm` ou `requires_no_generation_confirm` selon le cas. `MaintenancePlanningService` accepte `override_horizon` pour les dry-runs. Endpoint GET `?action=preview_date_change&id=X&new_date=Y[&new_sous_contrat=Z]`. +10 tests.
>
> **4.3 (commit a8cf63b)** — `sites.js::handleFormSubmit` extrait son corps dans `submitSiteData(data, id)` qui intercepte les 422 et ouvre `CoolCareConfirm` variant `warning`. Boutons "Confirmer" (resoumet avec `confirm_past_date` / `confirm_no_generation`) / "Corriger" (focus champ date).
>
> **4.5 (commit a3e642a)** — Endpoint GET `?action=expired_contracts` (sites avec `date_renouvellement_contrat < today` + machines actives + nb interventions auto futures). Bandeau orange CSS dans `public/admin/sites.php`, JS `loadExpiredContracts` au pageload. Bouton "Voir machines à désactiver" par site (stub remplacé en 4.4). +2 tests.
>
> **4.4 (commit bec0c75)** — Endpoint GET `?action=expired_contract_machines&id=X` (machines actives + nb interventions futures décomposé auto/manuelle). Modal cascade dans `sites.js::renderCascadeModal` avec checkbox + boutons "Tout cocher" / "Tout décocher" / compteur dynamique / submit "Désactiver les machines cochées". Submit boucle `Promise.all` sur `/api/machines.php?id=X&action=deactivate&keep_late=1` (réutilise chantier 1bis). Toast récap + reload du bandeau. +2 tests.
>
> **Fix bonus 1 (commit e814205)** — Bug détecté pendant les tests du chantier 4 : la modal de confirmation suppression site affichait `0 machine(s) associée(s)` sur les sites volumineux (DASSAULT 56 machines en local) à cause d'un `Out of sort memory` MySQL silencieusement catché dans `Site::getSiteMachines` (`SELECT m.*` + `ORDER BY` qui dépasse `sort_buffer_size`). Fix : SELECT ciblé sur les colonnes utilisées par la modal. + correction de la cascade `Site::deleteSite($id, true)` qui ne supprimait pas les interventions auto-planifiées des machines supprimées (orphelines). Maintenant DELETE des interventions hors en_cours/terminee avant DELETE machines.
>
> **Fix bonus 2 (commit af617af)** — Suite logique du fix bonus 1 : les interventions terminées (préservées par la règle métier) restaient orphelines avec `machine_id` pointant vers une ligne supprimée → perte de traçabilité historique. Solution : option A snapshots (validée user 2026-05-04). Nouvelle migration `migration_interventions_snapshots.sql` ajoute 3 colonnes `site_nom_snapshot`, `site_adresse_snapshot`, `machine_nom_snapshot` + rend `site_id`/`machine_id` nullable. Cascade `Site::deleteSite` fait UPDATE snapshot avant DELETE. Lectures admin via `COALESCE(s.nom_lieu, i.site_nom_snapshot)` + flags `site_supprime`/`machine_supprimee` pour le frontend. À porter sur missioflow → markdown dédié `CHANTIER_INTERVENTIONS_SNAPSHOTS.md`.

**Fusion :** ce chantier absorbe l'ancien plan `CHANTIER_MACHINES_EXPIRATION_CONTRAT_SITE.md` (2026-05-01). Les deux concernent le même flux métier : "réagir à un changement d'état du contrat site". Les sous-tâches 4.4 et 4.5 ci-dessous reprennent ce qui était prévu côté expiration.

### Audit de l'existant (2026-05-04)

Avant de poser le périmètre, point sur ce qui est déjà en place :

| Fonctionnalité | Existant | Référence code |
|---|---|---|
| Rappel mail 60/30/15j avant fin de contrat | ✅ Fait | [`maintenance_cron.php::checkContractExpiryReminders`](../coolcare-app/public/api/maintenance_cron.php) lignes 190-260, envoi via `EmailSender`. Flags `rappel_*j_envoye` reset par `resetReminders()` quand la date change. |
| Pas de génération au-delà de la date de fin | ✅ Fait | Horizon = `site.date_renouvellement_contrat` dans [`MaintenancePlanningService::scheduleMachineById`](../coolcare-app/src/services/MaintenancePlanningService.php) ligne 90. Si la date est passée, aucune occurrence créée. |
| Replan auto déclenché au changement de date | 🟡 Brut | [`SiteApiController::performSiteUpdate`](../coolcare-app/src/controllers/SiteApiController.php) ligne 437 → `replanAllMachinesOfSite` qui appelle `scheduleMachineById` (mode DELETE auto-planifiées futures + REGEN). Pas de détection direction du changement. |
| Désactivation auto machines à expiration | ❌ Pas fait | — |
| Bandeau alerte sites avec contrat expiré | ❌ Pas fait | — |
| Modal de confirmation si réduction de date | ❌ Pas fait | — |

### Décisions métier (user 2026-05-04, affinées dans la même session)

1. **Allongement de la date** : générer les nouvelles occurrences manquantes **sans supprimer les existantes**. Le mode actuel DELETE auto + REGEN doit devenir incrémental.
2. **Réduction de la date mais nouvelle date toujours future** : silencieux. Cascade incrémentale qui purge les auto-planifiées au-delà de la nouvelle date et conserve celles dans la nouvelle fenêtre. Pas d'alerte tant qu'au moins 1 occurrence reste générable.
3. **Réduction extrême — aucune occurrence générable** : la nouvelle date est tellement proche de l'ancre que plus aucune intervention ne pourra être planifiée → **alerte modal** "Avec cette date, plus aucune intervention ne pourra être planifiée. Confirmer ou corriger ?".
4. **Date saisie dans le passé (typo type 2023 au lieu de 2026)** : **alerte d'incohérence** "Vous avez saisi une date antérieure à aujourd'hui. Est-ce une erreur ?" → bouton "Corriger" (revert) ou "Confirmer" (volontaire = mise hors contrat). Pas de désactivation cascade automatique depuis ce flux : c'est trop destructif pour partir d'une typo confirmée à la hâte.
5. **Date arrivée à terme naturellement** (date était déjà <= today à l'ouverture de la page sites, contrat expiré sans renouvellement) : détectée via le **bandeau page sites** (4.5) → bouton ouvre la **modal désactivation cascade** (4.4). Ce flux est séparé de l'UPDATE form.
6. **Rappel mail** : déjà fait, ne pas y toucher.

### Sous-tâches

#### 4.1 — Replan incrémental (allongement)

Refondre `MaintenancePlanningService::scheduleMachineById` ou ajouter un mode `incremental`. Comportement :

- Calculer les occurrences théoriques sur la fenêtre `[ancre, nouvel horizon]` (3 séries comme aujourd'hui)
- Charger les auto-planifiées existantes de la machine (`created_by IS NULL AND date_prevue >= NOW()`)
- Insérer **uniquement** les occurrences théoriques absentes des existantes (match par date + types)
- Conserver les existantes (préserver les éventuels ajustements manuels comme un décalage de date ou une note)

L'appel depuis `replanAllMachinesOfSite` passe le mode incrémental quand la date est allongée ou inchangée. Le mode DELETE+REGEN existant reste utilisable pour les contextes "hard reset" (création de site, remplacement d'ancre).

#### 4.2 — Détection direction du changement

Dans [`SiteApiController::performSiteUpdate`](../coolcare-app/src/controllers/SiteApiController.php), comparer `oldDateFin` et `newDateFin` après l'UPDATE et appeler un dry-run du moteur sur 1 machine témoin du site pour évaluer la générabilité (`occurrence_count` sur la nouvelle fenêtre) :

| Cas | Action |
|---|---|
| `null → date future` ou `date future → date plus longue` | Cascade incrémentale silencieuse (4.1) |
| `date → date plus courte mais future` ET `occurrence_count >= 1` | Cascade incrémentale silencieuse (4.1) — purge les auto-planifiées au-delà de la nouvelle date, conserve celles dans la nouvelle fenêtre |
| `date → date plus courte mais future` ET `occurrence_count == 0` | **Refuser** côté backend si `confirm_no_generation` absent. Retourner `requires_no_generation_confirm: true` |
| `date future → date <= today` (typo probable) | **Refuser** côté backend si `confirm_past_date` absent. Retourner `requires_past_date_confirm: true`. Si confirmé : purge auto-planifiées futures (la machine devient silencieuse), **pas** de désactivation cascade (l'admin la déclenche séparément via le bandeau 4.5 si vraiment expiration volontaire) |
| `sous_contrat 1→0` | Cascade purge auto-planifiées futures (silencieux) |
| `sous_contrat 0→1` avec date future saisie | Cascade incrémentale (4.1) |

L'idée : le backend ne fait jamais de purge destructive **inattendue** sans confirmation frontend. Mais une réduction de date qui ne supprime que des auto-planifiées dans la nouvelle fenêtre est silencieuse (pas une "perte" puisque le moteur peut tout regénérer).

#### 4.3 — Modal d'alerte saisie incohérente

Frontend dans `public/js/admin/sites.js`. Deux modals possibles selon la réponse backend :

**Cas A — date dans le passé (`requires_past_date_confirm`)**
- Composant : `CoolCareConfirm` variant `warning`
- Titre : "Date antérieure à aujourd'hui"
- Message : "Vous avez saisi une date de fin de contrat dans le passé. Est-ce volontaire ?\n\nSi c'est une erreur de saisie, cliquez sur Corriger pour modifier la date.\nSi vous voulez vraiment passer ce site hors contrat, cliquez sur Confirmer (les interventions auto-planifiées futures seront supprimées)."
- Boutons : "Corriger" (revert le champ, focus dessus) / "Confirmer" (re-submit avec `confirm_past_date: true`)

**Cas B — date trop courte pour générer (`requires_no_generation_confirm`)**
- Composant : `CoolCareConfirm` variant `warning`
- Titre : "Plus aucune intervention planifiable"
- Message : "Avec cette date, le moteur ne pourra plus planifier aucune intervention sur ce site (la fenêtre est trop courte par rapport aux ancres des machines).\n\nSi c'est volontaire (résiliation anticipée), cliquez sur Confirmer.\nSinon, corrigez la date."
- Boutons : "Corriger" / "Confirmer" (re-submit avec `confirm_no_generation: true`)

Pas de modal pour une simple réduction qui laisse au moins 1 occurrence : silencieux.

#### 4.4 — Modal désactivation cascade machines

Modal listant les machines actives du site avec :

- Checkbox + nom machine + nb interventions futures (auto + manuelles séparées si possible)
- Boutons : "Désactiver les machines cochées" / "Tout cocher" / "Annuler"
- Submit → boucle sur les machines cochées et appelle l'endpoint existant `?action=deactivate&id=X` (réutilise le chantier 1bis : modal "garder en retard légitimes" reste accessible par machine)
- Toast récap : "X machine(s) désactivée(s), Y intervention(s) supprimée(s)"

**Déclencheur** : uniquement le bandeau 4.5 (clic admin volontaire). **Pas** déclenché automatiquement depuis le UPDATE form, même si l'admin confirme une date dans le passé. Raison : confirmer une typo doit rester réversible facilement (juste re-saisir la bonne date), désactiver 30 machines en cascade ne l'est pas.

#### 4.5 — Bandeau page sites pour contrats expirés

- Nouveau endpoint GET `?action=expired_contracts` dans `SiteApiController` : retourne les sites avec `date_renouvellement_contrat < today` ET `sous_contrat = 1` ET au moins 1 machine active
- Dans [`public/admin/sites_clients.php`](../coolcare-app/public/admin/sites_clients.php) : bandeau d'alerte en haut de page, bouton "Voir machines à désactiver" → ouvre la modal 4.4 sur le site sélectionné
- C'est **le seul point d'entrée** vers la cascade de désactivation. Garantie : aucune désactivation cascade ne se déclenche sans clic admin explicite

### Points d'attention

- **Idempotence** : la cascade ne doit rien faire si la date n'a pas changé. Sauvegarde répétée du même site = no-op.
- **Concurrence** : 2 onglets admin ouverts → l'autre voit le bandeau jusqu'au prochain reload. Acceptable V1.
- **Pas de mass-action endpoint** : V1 = N appels HTTP successifs depuis la modal 4.4. Si > 50 machines/site, prévoir un batch endpoint plus tard.
- **Traçabilité** : `ActivityLogger::logSiteUpdated` existe déjà. Ajouter un log spécifique "contract_expired_processed" pour le flux 4.4.
- **Tests** : PHPUnit sur chaque cas de transition (allongement / réduction confirmée / réduction refusée / expiration / sous_contrat 1→0 / sous_contrat 0→1). Vérifier que le mode incrémental ne crée pas de doublon sur les dates déjà couvertes.

### Estimation

- 4.1 (replan incrémental) : 0.5j (refonte ciblée + tests)
- 4.2 (détection direction + endpoints preview/confirm) : 0.5j
- 4.3 (modal réduction) : 0.5j
- 4.4 (modal expiration) : 0.5j
- 4.5 (bandeau page sites) : 0.5j
- Tests + chasse régressions : 0.5j
- **Total coolcare : ~3 jours**
- Port missioflow : ~1j (multi-tenant à appliquer sur chaque endpoint + adaptation wording)

---

## Ordre d'exécution

Traiter dans l'ordre **1 → 2 → 3 → 4**.

- Chantier 1 nettoie la BDD avant que les suivants complexifient le moteur. ✅ FAIT
- Chantier 2 améliore la visibilité sans toucher au moteur. ✅ FAIT
- Chantier 3 ajoute la 3ème série maintenance annuelle. ✅ FAIT
- Chantier 1bis préserve les en retard légitimes. ✅ FAIT
- Chantier 4 cycle de vie contrat. **À faire**.

Pour chaque chantier : PR séparée, tests unitaires + intégration, couverture maintenue à 88 % backend minimum, SonarQube local avant push.

---

## Question résolue — LARIBOISIERE (2026-05-02)

Aurélien a tranché : **cas (a) erreur de saisie**. Règle métier confirmée : "machines hors contrat sur site sous contrat OK, l'inverse impossible". Garde-fou frontend + backend implémenté via le chantier `CHANTIER_MACHINE_SOUS_CONTRAT_GUARD.md`. Migration `migration_lariboisiere_correction_orphelines.sql` prête à appliquer en prod (decoche 17 machines + purge 106 fantômes).
