# Migration de taxonomie #320 — mode d'emploi et preuve de vérification

Décision du 11/08 (arbitrage « Relation client » du 12/08) : **les catégories et
sous-catégories portent les THÉMATIQUES ; le type — cours, tuto, parcours —
appartient au contenu** (`courses.course_type_id`, ou l'entité `learning_paths`).

C'est la **seule migration de données client** du lot 4.6. Elle ne part donc pas
toute seule.

## Les trois pièces

| Pièce | Rôle |
|---|---|
| `database/data/taxonomie_320_mapping.php` | La DÉCISION : table de correspondance nominative, committée pour l'audit et le retour arrière. |
| `app/Console/Commands/Taxonomie320Recategorisation.php` | L'EXÉCUTION : `taxonomie:320`, simulation par défaut. |
| `tests/Feature/Back320TaxonomieTest.php` + `Back320InvariantTypeTest.php` | Les GARDES R5. |

**Pourquoi une commande et pas une migration Laravel** : le déploiement joue
`php artisan migrate --force` (procédure 12). Une migration partirait au prochain
déploiement, alors que l'exécution doit être une décision prise en regardant la
simulation sur la vraie base.

## Le mode d'emploi

```bash
# 1. Simulation — n'écrit RIEN (transaction annulée en fin de course).
#    Imprime la TABLE DE VALIDATION : une ligne par contenu, son rangement
#    d'aujourd'hui et sa cible. C'est ce qui se relit avant de décider.
php artisan taxonomie:320

# 1 bis. La même table en CSV, pour la relire et l'annoter hors terminal
php artisan taxonomie:320 --export=storage/app/private/taxonomie-320/validation.csv

# 2. Application, avec journal de retour arrière
php artisan taxonomie:320 --apply

# 3. Retour arrière, si besoin
php artisan taxonomie:320 --rollback=storage/app/private/taxonomie-320/journal-<horodatage>.json
```

Ce que la commande refuse de faire toute seule :

- **elle s'arrête si la base ne ressemble pas au relevé du 11/08** (comptes par
  ligne, total des 14 tutoriels, identifiant 13 de « Relation client ») —
  `--accepter-ecarts` pour passer outre, en connaissance de cause ;
- **elle ne range aucun contenu qu'une ligne du mapping ne couvre pas** : elle le
  liste et bloque l'application tant qu'il est actif ;
- **elle ne supprime rien** : les données de test sont désactivées
  (`courses.status` = `'0'`, `learning_paths.status` = `'draft'`) ;
- **elle ne dissout une catégorie que si elle est vide** de tout contenu actif, et
  dissoudre veut dire désactiver, jamais supprimer.

Elle est **idempotente** : un second passage n'écrit rien (vérifié, cf. plus bas).

## La table de validation — pourquoi elle existe

Le mapping se **décide** à la sous-catégorie ; il se **valide** au contenu.
« 8 cours » ne se valide pas : on ne fait que croire un comptage. Huit noms, si.

La simulation imprime donc une ligne par contenu — cours **et** parcours —
groupée par geste :

| Groupe | Ce qu'il contient |
|---|---|
| suit sa sous-catégorie | le gros du volume : le contenu ne bouge pas, c'est sa sous-catégorie qui change de parent |
| DÉPLACÉ nominativement | les exceptions du mapping (« Relation client ») |
| donnée de test → désactivé | ce qui sera désactivé, jamais supprimé |
| déjà à sa cible | l'état normal après une exécution — rejouabilité |
| NON COUVERT | sous une catégorie du relevé, mais aucune ligne ne le range |
| **HORS RELEVÉ** | vit sous une catégorie que le relevé du 11/08 ne connaît pas |

`--export=<chemin.csv>` écrit la même chose en CSV (séparateur `;`, BOM UTF-8 :
Excel français l'ouvre sans manipulation).

### L'angle mort que ça corrige

Jusqu'ici, l'audit des contenus non couverts ne regardait que les **quatre
catégories du relevé** (`categories_heritees`). Un contenu rangé ailleurs — une
thématique arrivée depuis le 11/08, « Management » par exemple — n'apparaissait
**nulle part** : ni déplacé, ni signalé. Il traversait la migration en silence,
et le récapitulatif affichait « contenus non couverts : aucun ».

La table balaie désormais la base **entière**. Un contenu actif hors relevé
compte comme un **écart bloquant** : il ne dit pas que la migration est
dangereuse — il n'y touche pas — il dit que **la cible taxonomique est
incomplète**, ce qui est une décision à rendre avant d'exécuter.

Démonstration sur la base de développement (jeu de démonstration, sans rapport
avec le relevé) : 7 contenus actifs sous cinq catégories inconnues du mapping.
L'ancienne version imprimait « aucun ».

## Vérification sur MySQL — ce qui a été fait, et ce qui ne l'a pas été

> **Aucun dump de la base client n'est disponible sur la machine de dev.** La
> vérification a donc porté sur une **reconstitution** de l'inventaire du relevé
> (issue #320), chargée sur le **vrai schéma MySQL** (`mysqldump --no-data` de
> `efektiv_local`), dans une base jetable `efektiv_taxo320` détruite ensuite.
> Ce n'est PAS un clone de la base réelle : la structure et les comptes viennent
> du CA, pas de la production. **La simulation reste à rejouer sur un clone de la
> vraie base avant toute exécution** — c'est précisément à ça qu'elle sert.

État de départ (43 cours, 7 parcours, 4 catégories, 10 sous-catégories) :

| Catégorie › sous-catégorie | Type | n |
|---|---|---|
| Formation Construction › Assurance construction | Course | 9 (dont « Relation client », id 13) |
| Formations Groupe › Conformité | Course | 5 |
| Formations Groupe › Sécurité | Course | 3 |
| Formations Groupe › Grands risques | Course | 2 *(test)* |
| Tutoriels › Gsicass / Archie / RIO / GED / Outils informatiques | Tutorial | 14 |
| Tutoriels › Outils informatiques | Course | 3 *(test)* |
| Parcours › Testing | Course | 7 *(test)* |
| *(learning_paths)* | — | 7 *(test)* |

Après `--apply` (37 écritures) :

| Catégorie › sous-catégorie | Type | statut | n |
|---|---|---|---|
| Assurance construction › Fondamentaux | Course | actif | 8 |
| Compétences transverses › Relation client | Course | actif | 1 |
| Conformité › Conformité | Course | actif | 5 |
| Sécurité › Sécurité | Course | actif | 3 |
| Outils métiers › Gsicass / Archie / RIO / GED / Outils informatiques | Tutorial | actif | 14 |
| Outils métiers › Outils informatiques | Course | **inactif** | 3 |
| Formations Groupe › Grands risques | Course | **inactif** | 2 |
| Parcours › Testing | Course | **inactif** | 7 |
| *(learning_paths)* | — | **draft** | 7 |

Totaux : **43 cours avant, 43 après** (31 actifs, 12 désactivés) ; **7 parcours
avant, 7 après** (0 publié). **Zéro suppression.** `Tutoriels` et `Parcours`
désactivées, jamais supprimées. Les cinq catégories thématiques créées.

Cycle vérifié, dans cet ordre :

1. simulation → base **inchangée** (comparaison ligne à ligne identique) ;
2. `--apply` → tableau ci-dessus ;
3. `--apply` **une seconde fois** → « écritures : 0 », état strictement
   identique (diff vide) ;
4. `--rollback` → **37 opérations défaites, 0 ignorée**, état **strictement
   identique à l'initial** (diff vide, slugs et parents restaurés, catégories
   créées supprimées) ;
5. `--apply` de nouveau → même résultat qu'en 2 (aux identifiants
   auto-incrémentés près, forcément renumérotés après suppression).

## Ce qui reste à trancher avant d'exécuter en production

> Cette liste est celle des écarts **connus au 13/08, sur une reconstitution**.
> Elle n'est pas exhaustive et ne peut pas l'être : c'est la simulation sur la
> vraie base qui donnera la liste complète, contenu par contenu. Le point 8
> ci-dessous en est l'illustration — il n'a été vu qu'en balayant la base
> entière.

1. **Le libellé de la sous-catégorie cible d'Assurance construction**
   (« Fondamentaux » ?) — le CA le marque lui-même « à confirmer ».
2. **Le compte de la ligne Assurance construction** : le CA écrit « 8 cours
   (« Relation client » en sort) » sans dire si les 8 incluent « Relation
   client ». Le contrôle est donc **non armé** sur cette ligne ; la simulation
   imprime le compte réel, à valider.
3. **Le total annoncé ne recolle pas au détail** : l'en-tête du CA parle de
   « 46 contenus », la somme des lignes en fait **50** (8 + 1 + 5 + 3 + 14 + 19),
   ou 49 si les 8 incluent « Relation client ». À reprendre au moment de la
   simulation sur la vraie base — c'est elle qui donnera le compte vrai.
4. **La répartition des 14 tutoriels** entre les cinq outils n'est pas au relevé :
   seul le TOTAL est contrôlé (et il l'est).
5. **Le sort de « Formation Construction » et « Formations Groupe »**, qui se
   retrouvent vides de contenu actif sans être déclarées à dissoudre. La commande
   les signale et les laisse en place.
6. **Les 3 cours de test rangés sous « Tutoriels › Outils informatiques »**
   suivent leur sous-catégorie et atterrissent, désactivés, dans
   « Outils métiers ». Signalé à chaque passage ; la purge ultérieure les
   trouvera là.
7. **Les thématiques absentes de la cible.** Le mapping ne connaît que ce que
   le relevé contenait : assurance construction, conformité, sécurité, outils
   métiers, plus la catégorie transverse créée pour « Relation client ».
   **Rien n'est prévu pour le management**, ni pour les thématiques que le
   studio microlearning produira. Ce n'est pas un bug de la commande — elle
   les signalera et n'y touchera pas — c'est une **cible taxonomique
   incomplète**, à compléter avec Enguerran au vu de la table de validation.
8. **La suppression effective des 19 lignes de test** : décision humaine
   distincte, à prendre après avoir vu la liste imprimée par la simulation sur la
   vraie base.

## L'invariant de code

Audit du dépôt au 13/08 : **aucun code ne déduit le type d'un contenu depuis un
nom de catégorie.** Toutes les surfaces lisent `course_type_id` / `course_types.name`,
ou l'entité `learning_paths` pour un parcours ; les noms de catégorie ne servent
qu'à afficher, exporter, rechercher et filtrer par identifiant.

L'invariant est donc **préventif**, et son test est un garde-fou, pas un
correctif. Il est écrit de façon **structurelle** (règle R6.1) : on ne teste pas
une liste de noms interdits, on teste que **le type servi est invariant par
renommage de la catégorie** — le nom piégeux n'est qu'une illustration.

- Liste unifiée de l'écran Cours : déjà couverte par `Ea058ListeUnifieeTest`.
- Catalogue apprenant, vitrine publique, carte de parcours et filtre par type :
  `Back320InvariantTypeTest`.
