# Chantier — Endpoint apply XLSX → BDD pour les templates rapport

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

**Date** : 2026-05-07 (livraison initiale + itérations correctives session entière)
**Repo** : coolcare-app (port à prévoir sur missioflow-app à l'identique une fois validé en prod)
**Type** : Nouvelle feature admin (refonte de l'outil de review templates)
**Origine** : Suite logique du chantier review (admin/report_templates_review.php) — il fallait pouvoir réellement appliquer en BDD les sélections faites dans l'UI au lieu de juste exporter un JSON inutilisable.

## Contexte

L'outil [admin/report_templates_review.php](public/admin/report_templates_review.php) permettait depuis 2026-04-28 de comparer un XLSX cahier des charges (livré par Aurélien) contre la BDD `report_template_steps` et de cocher item par item ce que l'on voulait garder. Mais l'application des sélections n'existait pas (lecture seule, exportait juste un JSON).

Ce chantier livre l'endpoint complet d'application avec toutes les sécurités nécessaires pour qu'Aurélien puisse l'utiliser en autonomie sur la prod sans casser une intervention en cours, **plus** une vue de comparaison 3 colonnes post-apply pour la validation visuelle, **plus** un bouton apply massif pour traiter les ~20 templates en une passe.

## Décisions architecturales

### Cible exclusive

`report_template_*` uniquement (`report_template_steps`, `report_template_step_options`, `report_template_phases`, `report_templates`). **Ne touche jamais** `intervention_workflow_*`. Les workflows en cours gardent leur snapshot intact.

### Soft delete vs hard delete

**Soft delete** (`deleted_at DATETIME NULL`). Trois raisons :
1. `intervention_workflow_steps.template_step_id_source` pointe vers `report_template_steps.id`. Hard delete = FK orpheline sur les workflows historiques (PDF rétrospectifs cassés).
2. Si Aurélien livre v8 supprimant un step puis v9 le réintroduisant, on flippe `deleted_at = NULL` au lieu d'INSERT — préservation des ids et des liens FK.
3. Rollback manuel trivial (`UPDATE deleted_at = NULL`).

Convention : `WHERE deleted_at IS NULL` partout dans le code de lecture template.

### Granularité par template + Apply massif

1 appel API = 1 template. Le front itère sur les N templates (avec barre de progression). Permet de stopper proprement sur erreur, et d'appliquer un template en test sans risquer le tout-ou-rien.

**Bouton "Apply TOUS"** (ajout 2026-05-07 fin de session) : itère côté front sur tous les templates avec `db_template_id`, en séquentiel. Confirmation explicite (taper `APPLY ALL`), progression live ligne par ligne, récap final cumulé avec liste des échecs. Pas d'endpoint serveur dédié — chaque template reste sa propre transaction, le bouton orchestre côté JS.

### Garde-fous obligatoires (Aurélien-ready)

Tous activés en permanence, pas de mode "permissif" :
- **Pre-check workflows en cours** : refuse 409 si `intervention_workflows.status IN ('not_started','in_progress')` pour ce `template_id_source`. Renvoie la liste des workflows bloquants pour que l'admin puisse aller les terminer manuellement.
- **Pre-check cross-template parent_step_id** : `fk_steps_parent` n'a pas de garde-fou DB "même template_id". Si un step d'un autre template référence un step de celui qu'on modifie, refuse 409 (situation pathologique mais possible en théorie).
- **Lock migration_id** : refuse soft delete si le step lui-même OU **n'importe quel descendant récursif** porte un `migration_id` dans `rules_json`. Défense en profondeur (front lock visuel + service refus).
- **sha256 verification** : le XLSX uploadé à l'apply doit avoir le même sha256 que celui calculé au diff initial. Refuse 422 sinon (évite "j'ai uploadé v07 au diff puis v08 à l'apply par erreur").
- **dry_run par défaut** : `dry_run=1` si non spécifié explicitement. L'apply réel nécessite `dry_run=0` POSTé délibérément.
- **Radios front (XOR)** : impossible de cocher (1,1) ou (0,0). API refuse aussi ces cas en defense-in-depth.

### Cascade soft delete

Quand on soft delete un step (action `SOFT_DELETE_DB`, l'admin retire un step orphelin sans le remplacer) :
1. Recherche récursive de tous les descendants via `parent_step_id`.
2. Vérifie qu'aucun descendant ne porte `migration_id` (sinon refus avec id du protégé).
3. Soft delete du step + de tous ses enfants (depth-first) + de toutes leurs options (`report_template_step_options.deleted_at`).

Note : le `ON DELETE CASCADE` du DDL ne s'applique que sur hard delete. La cascade soft est implémentée manuellement dans le service.

### INSERT_REPLACE_DB : reparent au lieu de cascade (fix bug 2026-05-07)

**Bug** : quand l'admin coche XLSX sur un parent `common_divergent` (ex: `ETAT DE LA MACHINE A L'ARRIVEE` avec is_required différent), l'apply ancien faisait `softDeleteStepCascade(ancien parent)` qui tuait silencieusement TOUS les enfants actifs (DESCRIPTION/ACTIONS/PROBLEME RESOLU?), ignorant les sélections `keep_db` cochées par l'admin pour ces enfants. Résultat : 60+ enfants soft-deleted sur 18 templates malgré l'intention de les conserver.

**Fix** : pour `INSERT_REPLACE_DB`, ne plus cascader. Étapes :
1. `insertStepFromXlsx` insère le nouveau parent depuis XLSX → nouvel id
2. `reparentActiveChildren(oldParentId, newParentId)` re-rattache tous les enfants actifs vers le nouveau parent
3. `softDeleteStepWithoutCascade(oldParentId)` soft delete l'ancien parent **sans cascade** (les enfants ont déjà migré)

La cascade complète n'est utilisée que pour `SOFT_DELETE_DB` pur (admin retire un orphelin), pas pour `INSERT_REPLACE_DB`.

### Bump version_label

Format `<prefix>.YYYYMMDD`. Exemples :
- `v1` → `v1.20260507`
- `v1.20260101` → `v1.20260507` (suffixe horodaté remplacé)
- `v07.20260101.beta` → `v07.20260101.beta.20260507` (suffixe non-strict, on append)

### Audit log

Table dédiée `report_templates_apply_audit` avec :
- `apply_run_id` UUID v4 partagé par toutes les opérations d'un même appel (rollback unitaire possible)
- `old_state_json` / `new_state_json` (snapshot complet par opération)
- `dry_run` (les essais sont aussi loggés)
- `source_xlsx_filename` + `source_xlsx_sha256` (traçabilité du XLSX source)
- `verified_at` (rempli quand l'admin clique "tout est OK" après vérif visuelle)

### Snapshot pré-apply + Comparaison 3 colonnes (ajout 2026-05-07 fin de session)

Table dédiée `report_templates_backup` (1 ligne par `apply_run_id`) avec :
- `snapshot_json` : image complète pré-apply (`{phases:[], steps:[], options:[]}` actifs, indexes par id)
- `version_label_before`
- Lien 1-1 avec `report_templates_apply_audit` via `apply_run_id`

Le service `apply()` capture 2 snapshots autour de l'écriture :
1. `dbBefore` : juste avant `executeAllPairs` (insère dans `report_templates_backup` si `dry_run=0`)
2. `dbAfter` : juste après `commit` ou `rollback`

Puis `buildComparisonData()` combine `xlsx` (extrait du diff payload) + `dbBefore` + `dbAfter` en 3 listes plates et les renvoie dans la réponse JSON. Le front les affiche côte-à-côte dans la **modale étape 3/3** (CSS `.cmp-aligned-row`, 3 colonnes XLSX/Avant/Après avec badges OBLIGATOIRE, options, parent_item alignés).

Cette modale est la **seule garantie visuelle** que l'admin a après un apply réel — d'où l'importance de la rendre exhaustive.

### Workflow UI 3 étapes

1. **Preview** : appel `dry_run=1`, modale affiche les opérations + résumé
2. **Confirmation** : checkbox + retape le nom du sheet (friction volontaire)
3. **Apply réel** : `dry_run=0`, retour stats + **comparaison 3 colonnes** (XLSX / ancienne BDD / nouvelle BDD)

### Renumber sort_order canonique (fix bug 2026-05-07)

L'ancien `renumberSortOrdersForTemplate` triait par `(phase.sort_order, sort_order, id)`. Bug rencontré : si le XLSX numérote un step à `sort_order=44` qui collisionne avec un step BDD préservé (enfant migration_id ou autre), le tri par `id` plaçait le nouveau step XLSX entre le parent BDD (id<XLSX) et son enfant BDD (id<XLSX aussi), cassant la chaîne `parent_step_id → enfant` du moteur front (qui se base sur `lastParentKey` séquentiel, pas explicite).

**Fix** : `renumberSortOrdersFromOrderedIds()` reçoit le vecteur des step_ids actifs **dans l'ordre canonique du diff** (parcours phases-dans-l'ordre puis pairs-dans-l'ordre, accumulé pendant `executeAllPairs`). Renumérote 1..N selon cet ordre. Plus de collision possible.

### Pair_uid pour radios uniques (fix bug 2026-05-07)

Quand 2+ pairs ont le même `item` (cas légitime : 3 enfants `PHOTO DE LA PIECE AVEC REFERENCES` sous le même parent), l'ancien `pairKey = sheetName||xItem||dItem` collisionnait → toutes les radios partageaient le même `name=` → exclusivité de groupe → 1 seule sélection possible parmi les 3.

**Fix** : `buildDiffPayloadForTemplate()` injecte un `pair_uid` (`ph${phaseIdx}_p${pairIdx}`) sur chaque pair. `pairKey` (front) et `buildPairKey` (back) l'incluent dans la clé quand présent. Backward-compat : sans `pair_uid`, fallback sur l'ancien format.

### Comparaisons étendues dans classifyPair (fix bug 2026-05-07)

L'ancien `classifyPair` ne comparait que `input_kind` et `options`. Bug rencontré : XLSX OBLIGATOIRE + BDD facultatif (sans autre divergence) passait silencieusement en `common` → apply noop → `is_required` jamais propagé.

**Fix** : ajout de 3 comparaisons dans `diff_fields` :
- `is_required` (col I XLSX vs BDD)
- `action_label` (col B XLSX vs BDD)
- `parent_item` (rattachement parent)

### Décodeur XLSX étendu (fix bug 2026-05-07)

L'ancien décodeur lisait 8 colonnes (A-H). Aurélien a livré un format à 10 colonnes incluant **col I = OBLIGATOIRE (O/N)** et **col J = NOTES**. Le `is_required` n'était donc jamais lu côté XLSX → comparaison toujours faussée.

**Fix** : `processSheetRow` étendu pour lire A-J. `parseBooleanCell` (tolérant : oui/o/yes/y/true/1/vrai → 1) traite la col I. `assembleSheetStepRow` produit `is_required` et `note`.

### Self-heal curseur sur extensions skippables (fix bug 2026-05-07)

Bug rencontré : "Etape courante invalide dans le batch" en phase 3 quand le tech sélectionnait RIEN A SIGNALER sur les BLOCS. Cause : `current_step_key` BDD pointait sur un sub-enfant masqué côté front (DOM `.ext-hidden`) mais `is_visible=1` côté serveur (rules_json=NULL) → le batch save n'incluait pas ce step → 1er step ≠ current → fail.

**Fix** : `selfHealCurrentStep` étendu pour réutiliser `isExtensionSkippableForValidation()` (logique existante : skip si parent ≠ probleme/panne). Si current_step est skippable, on avance le curseur. `nextSaveableStep`/`firstSaveableStep`/`isStepSaveable` étendus en conséquence. Aucune nouvelle règle métier — juste cohérence curseur ↔ validation.

### Default db_only_orphan = XLSX (UX 2026-05-07)

Pour les pairs `db_only_orphan` (BDD seul, sans migration_id, pas dans XLSX), le default radio était "BDD = garder" (sécurité maximum). Retour utilisateur : ces orphelins sont en pratique des vestiges historiques à nettoyer, pas des ajouts terrain.

**Fix** : default = XLSX (= soft_delete) pour `db_only_orphan`. L'admin coche manuellement BDD pour conserver un orphelin légitime.

`STORAGE_KEY` bumpé en `cc_review_selections_v2` pour invalider les selections v1 stockées avec l'ancien default.

## Livrables

| Fichier | Rôle |
|---|---|
| [database/migration_report_templates_apply_audit.sql](coolcare-app/database/migration_report_templates_apply_audit.sql) | `deleted_at` sur steps + options, table `report_templates_apply_audit` |
| [database/migration_report_templates_backup.sql](coolcare-app/database/migration_report_templates_backup.sql) | Table `report_templates_backup` (snapshot pré-apply) |
| [src/exceptions/ReportTemplatesApplyException.php](coolcare-app/src/exceptions/ReportTemplatesApplyException.php) | Exception métier avec `error_code` structuré (8 codes) |
| [src/services/ReportTemplatesApplyService.php](coolcare-app/src/services/ReportTemplatesApplyService.php) | Service complet : `apply()` + pre-checks + cascade soft + bump version + audit + **snapshot + comparison + renumber canonique** |
| [src/services/InterventionWorkflowService.php](coolcare-app/src/services/InterventionWorkflowService.php) | `selfHealCurrentStep` + helpers étendus pour skip extensions skippables (fix curseur RIEN A SIGNALER) |
| [public/api/report_templates_apply.php](coolcare-app/public/api/report_templates_apply.php) | Endpoint POST multipart, mapping erreurs → HTTP, sha256 verif |
| [public/api/report_templates_import_functions.php](coolcare-app/public/api/report_templates_import_functions.php) | Décodeur XLSX 10 colonnes + `parseBooleanCell` + `pair_uid` injection + `classifyPair` étendu (is_required, action_label, parent_item) |
| [public/js/admin/report_templates_review.js](coolcare-app/public/js/admin/report_templates_review.js) | Refonte complète : radios + lock visuel + bouton apply per-template + modale 3 étapes + sha256 client-side + **modale 3 colonnes + bouton Apply TOUS + default XLSX** |
| [public/admin/report_templates_review.php](coolcare-app/public/admin/report_templates_review.php) | CSS pour radios/cadenas/modale/bouton apply + **CSS comparaison 3 colonnes + bouton Apply TOUS** |
| [tests/Unit/ReportTemplatesApplyServicePureTest.php](coolcare-app/tests/Unit/ReportTemplatesApplyServicePureTest.php) | 25 tests purs (matrice classifyOperation, version_label, migration_id detection, pairKey + pair_uid) |
| [tests/Unit/ReportTemplatesImportFunctionsTest.php](coolcare-app/tests/Unit/ReportTemplatesImportFunctionsTest.php) | Tests parseBooleanCell + col I/J |
| [tests/Integration/Cases/ReportTemplatesApplyIntegrationTest.php](coolcare-app/tests/Integration/Cases/ReportTemplatesApplyIntegrationTest.php) | 15 tests intégration : happy path + cascade soft + tous les cas adverses + is_required propagé |

## Mapping erreurs API

| `error_code` | HTTP | Sens |
|---|---|---|
| `TEMPLATE_NOT_FOUND` | 404 | id template inconnu |
| `WORKFLOWS_IN_PROGRESS` | 409 | workflows en cours sur ce template |
| `CROSS_TEMPLATE_PARENT` | 409 | parent_step_id cross-template détecté |
| `MIGRATION_ID_LOCKED` | 409 | step ou descendant protégé migration_id |
| `SHA256_MISMATCH` | 422 | re-upload XLSX différent du diff initial |
| `INVALID_SELECTION_PAIR` | 422 | (1,1) ou (0,0) reçu (radio bypass) |
| `PAYLOAD_INVALID` | 422 | champs manquants / JSON invalide |

## Test manuel à faire

1. **Happy path** : 1 template, 2-3 modifs simples (1 INSERT, 1 DELETE), apply → vérification visuelle via modale 3 colonnes
2. **Cas adverses** :
   - Apply avec workflow `not_started` → doit refuser 409 + liste
   - Apply avec dry_run=1 → vérifier en SELECT que rien n'est commit
   - Apply qui essaie de delete un step migration_id → 409
   - Re-upload XLSX différent → 422 sha256_mismatch
   - Cas (1,1) via curl bypass front → 422
3. **Apply massif** : bouton "Apply TOUS", confirmer avec `APPLY ALL`, vérifier que la progression scrolle ligne par ligne, vérifier le récap final

## Bugs PDF rendu (fix 2026-05-07 fin de session)

| Bug | Cause | Fix |
|---|---|---|
| Numéro intervention affiché 2× | Rendu en en-tête DETAILS + dans la liste des steps phase AVANT INTERVENTION | Ajout `'NUMERO INTERVENTION'` + `'NUMÉRO INTERVENTION'` dans `EXCLUDED_ITEMS` |
| `AVEC POSSIBILITE EXTENSION —` visible | Stocké en `action_label` post-apply, prepended au label par le template | Regex de purge dans `cleanLabel` (markers `AVEC POSSIBILITE EXTENSION` + `EXTENSION SI...`) |
| Titre fusionné `Liste test acidités OU analyses d'huile` | Titre fixe et trompeur | 2 sections séparées conditionnelles : "Liste des tests acides effectués" si test acide rempli, "Liste des prélèvements d'huile" si prélèvement rempli, rien sinon |
| ETAT MACHINE OFF/A L'ARRET ne montre pas DESCRIPTION/ACTIONS/PROBLEME RESOLU dans le PDF | `if (!$isProblem($parentStatus)) continue` ne matche que PROBLEME/PANNE/A REVOIR/IMPOSSIBLE/KO/NON CONFORME | Ajout `ws.rules_json` dans la SELECT + utilisation de `rules_json.visible_if_not_parent` (cohérent moteur serveur). Fallback `isProblem` si rules_json absent |

## Port missioflow

Une fois validé en prod coolcare, port à l'identique sur missioflow-app :
- Migrations SQL identiques (même schéma)
- Service + endpoint copiables tels quels
- Front review identique (même JS + CSS)
- Tests à rejouer

Aucune adaptation métier nécessaire — c'est un outil admin pur, pas de logique liée au tenant.

**Particularités missioflow** : pas d'enfants migration_id à protéger (l'historique prod-coolcare n'existe pas là-bas). Le retrait migration_id post-fix (steps `etat_machine_2026_04_24`) est inutile sur missioflow.

**Le markdown détaillé** [missioflow-app/docs/CHANTIER_REPORT_TEMPLATES_APPLY.md](missioflow-app/docs/CHANTIER_REPORT_TEMPLATES_APPLY.md) recense les **15 bugs débuggés** sur coolcare avec cause+fix, à lire avant de commencer le port pour ne pas re-tomber dans les mêmes pièges.
