---
suivi: 1597
date: 2026-09-17
sujet: Hotfix migration corbeille dossiers — unicité programme applicative
chantier: foncier / dossiers
type: hotfix
statut: poussé
hash: e51f22a0
fichiers:
  - database/migrations/2026_09_17_140000_add_soft_deletes_to_dossiers_fonciers_and_satellites.php
  - database/migrations/2026_09_17_170000_drop_programme_id_vivant_and_unique_dossiers_fonciers.php
  - app/Support/Foncier/DossierProgrammeLink.php
  - app/Models/DossierFoncier.php
  - app/Http/Controllers/Foncier/DossierFoncierController.php
  - app/Http/Controllers/ProgrammeController.php
  - tests/Unit/DossierFoncier1589CorbeilleSourceTest.php
  - tests/Unit/DossierFoncier1597UniciteProgrammeSourceTest.php
  - docs/suivi/SUIVI_1597_hotfix_unicite_programme_dossier.md
---

# SUIVI #1597 — PHASE 2 hotfix unicité programme (échec 1215)

## PROMPT ENVOYÉ

Correction urgente. Prod ramenée à e8c887a1 (#1588). Migration
#1589 échouée (MySQL 1215 colonne générée STORED AFTER une FK).
Retirer `programme_id_vivant`, drop UNIQUE `programme_id`, unicité
applicative ignorants la corbeille. Vérifier 1553 avant dropUnique.
Couvrir tous les chemins de rattachement.

## FICHES LUES

- **#1589 LEÇON** — unique programme via STORED vivant. Impact :
  cette voie est **abandonnée** (1215 en prod). SoftDeletes
  satellites, dont `entite_commentaires.deleted_at`, **conservés**.
- **#1253 LEÇON** — dropUnique 1553 si l’index unique est le support
  d’une FK. Impact : `df_programme_id_idx` **avant** dropUnique
  (voie a). Voie b (drop FK → drop unique → recréer FK) en repli
  si 1553 malgré l’index.
- **#1556 LEÇON** — UNIQUE `df_programme_uq` + FK `df_programme_fk`
  côté dossier. Impact : la FK **sortante** `programme_id →
  programmes.id` s’appuie sur un index de `programme_id`. Sans
  index de remplacement, dropUnique = 1553.
- **#1249 LEÇON** — STORED + UNIQUE pour unique + soft delete.
  Impact : ne plus réutiliser ce motif **après une colonne à FK**.

## CAUSE

`ALTER TABLE dossiers_fonciers ADD programme_id_vivant … STORED
AFTER programme_id` : MySQL 8 refuse (1215) parce que
`programme_id` porte `df_programme_fk`. La migration s’est arrêtée
après `deleted_at` / `deleted_by` / snapshot / index / dropUnique
(prod partielle possible) et **avant** `entite_commentaires.deleted_at`.
Code SoftDeletes + colonne absente → 500 sur les fiches dossier.

## FK ET INDEX (erreur 1553)

| Objet | Rôle |
|---|---|
| `df_programme_fk` | FK `dossiers_fonciers.programme_id` → `programmes.id` (`nullOnDelete`) |
| `df_programme_uq` | UNIQUE sur `programme_id` (#1556) — **peut** être l’index de support de la FK |
| `df_programme_id_idx` | index non unique créé **avant** `dropUnique` (#1253 voie a) |

Aucune FK **entrante** ne référence `dossiers_fonciers.programme_id`.
On ne drop **pas** la FK en nominal : MySQL bascule le support sur
`df_programme_id_idx`. Si 1553 quand même : drop `df_programme_fk`,
drop unique, recréer la FK (l’index non unique la porte).

En prod le `dropUnique` avait **réussi** ; l’échec était 1215 sur la
colonne générée, pas 1553.

## UNICITÉ APPLICATIVE

Un programme ne peut être rattaché qu’à **un dossier vivant**.
Un dossier en corbeille ne compte pas.

Chemins :

| Chemin | Contrôle |
|---|---|
| Associer un programme existant | `associerProgramme` → `assertProgrammeLibrePourDossierVivant` |
| Créer un programme depuis un dossier | `ProgrammeController::store` (même assert) ; `exists` dossier `whereNull(deleted_at)` |
| Création de dossier | `programme_id` retiré du payload (`validateDossierCreate`) |
| Tout `save()` vivant avec `programme_id` dirty | `DossierFoncier::saving` |
| Liste associable / hub / detach | `queryDossiersVivantsSansAcl` (`deleted_at IS NULL`, hors ACL) |

Pas de restauration (#1589) : pas de chemin restore.

## DÉPLOIEMENT-TEST

Prod est à `e8c887a1`, 0 migration en attente. `git pull` ramène
#1589–#1595 **et** ce hotfix. Migrer **tout de suite** après le pull
(SoftDeletes code vs colonnes).

```
git pull origin main
php artisan migrate --force
php artisan optimize:clear
php artisan view:clear
```

Pas de `journal:sync` (correctif technique, pas d’entrée journal).
Pas de `route:clear`. Pas de build Vite.

## LEÇON

Ne jamais ajouter une colonne générée STORED `AFTER` une colonne qui
porte une FK (MySQL 1215). Unique + soft delete : soit une colonne
générée **sans** `AFTER` FK, soit (retenu) drop UNIQUE + contrôle
applicatif `whereNull(deleted_at)`. Tester le DDL sur un MySQL avec
les FK réelles. Une migration idempotente `Schema::has*` permet de
rejouer un état partiel ; le code SoftDeletes ne doit pas précéder
la colonne en prod.
