---
suivi: 1519
date: 2026-09-11
sujet: Diagnostic — générateur code_envol vs soft-delete (HEC11-018)
chantier: programmes / code_envol
type: diagnostic
statut: poussé
hash: 9e426b32
fichiers:
  - docs/suivi/SUIVI_1519_diag_generateur_code_programme.md
---

# SUIVI #1519 — PHASE 1 diagnostic : générateur `code_envol`

## PROMPT ENVOYÉ

Diagnostic seul. Incident prod 11/09/2026 : création programme
(enseigne_id 2, entite_id 4, département 11, CASTELNAU D'AUDE,
LE PETIT DOMAINE) → 500 × 5, user 7, `Duplicate entry 'HEC11-018'`
sur `programmes.programmes_code_envol_unique`. Cause connue :
soft-delete du programme qui portait HEC11-018 ; le générateur
ne le voit plus et repropose le même code. Ne pas coder.
Règle métier PHASE 2 (consignée, non implémentée) : un code
brûlé reste brûlé ; premier code réellement libre ; message
clair en cas de collision, jamais une 500.

## BASE GIT

HEAD local = `origin/main` = `9db2dce1` (chore hash fiche #1517).
Aucun rebase. Aucun fichier applicatif modifié. `Programme.php`,
`ProgrammeController.php`, `Create.vue` lus, non écrits.
Aucun runtime PHP. Aucun MySQL (requêtes du point 6 à coller).

## FICHES LUES

- **#1391 LEÇON** — hors ERP : `code_envol` nullable, UNIQUE
  nullable, pas de génération obligatoire. Impact : le chemin
  `storeHorsErp` (L357-386) ne passe pas par le générateur ;
  hors cause de HEC11-018.
- **#1323 LEÇON** — MAX+1 sans verrou + UNIQUE = collision
  certaine. Les marchés ont basculé sur `marche_reference_sequences`
  + `withTrashed` au seed. Impact : le générateur programme est
  resté sur l’ancien motif MAX+1 **sans** `withTrashed`.
- **#1484 LEÇON / tableau code** — `code_envol` est un nom
  historique ; préfixe `HEC` = `EnseignesSetup::prefixePourCode('HECTARE')`,
  pas une preuve d’enseigne ENVOL. Impact : HEC11-018 = enseigne
  HECTARE + département 11, pas « enseigne ENVOL ».
- **#1516** — format de fiche diagnostic (SQL à coller, pas de
  code). Aucun impact métier.

Aucun PHP applicatif modifié. Aucune migration. Aucun journal
(#412 : diagnostic invisible tant que PHASE 2 n’est pas poussée).

---

## 1. LOCALISER LE GÉNÉRATEUR

**Fichier :** `app/Models/Programme.php`
**Méthode :** `Programme::prochainCode(string $departement, string $prefixe): string`
**Lignes :** 488-503.

Code exact (lu, non modifié) :

    public static function prochainCode(string $departement, string $prefixe): string
    {
        $prefix = $prefixe.$departement.'-';
        $max = 0;
        foreach (static::query()->where('code_envol', 'like', $prefix.'%')->pluck('code_envol') as $code) {
            if (! is_string($code) || ! str_starts_with($code, $prefix)) {
                continue;
            }
            $suffix = substr($code, strlen($prefix));
            if ($suffix !== '' && ctype_digit($suffix)) {
                $max = max($max, (int) $suffix);
            }
        }

        return $prefix.str_pad((string) ($max + 1), 3, '0', STR_PAD_LEFT);
    }

Alias historique `prochainCodeEnvol` L505-508 : force le préfixe
`'ENV'`. **Aucun appelant** dans le dépôt (grep) — mort.

**Appelants qui calculent le code à la création :**

| Appelant | Fichier | Lignes |
|---|---|---|
| Création UI | `ProgrammeController::store` | 446-447, insert 455-466 |
| Reprise SIWI | `SiwiRepriseActionService::creerProgramme` | 109-120, insert 123-130 |

**Aperçu UI (même algorithme, pas le même code) :**
`ProgrammeController::prochainsCodesParDepartement` L1094-1114,
exposé à `Create.vue` via `create()` L421 + `prochains_codes`.

Docblock orphelin L460-462 (« Prochain code ENV… ») est collé
sur `dureeProgrammeEnMois()`, pas sur `prochainCode`. Bruit de
lecture, pas une deuxième implémentation.

---

## 2. COMMENT IL CALCULE

### Périmètre

Préfixe **enseigne** + **département**, toutes entités confondues.

1. `$prefixe` = `EnseignesSetup::prefixePourCode($enseigne->code)`
   (`app/Support/EnseignesSetup.php` L138-146) :
   HECTARE → `HEC`, ENVOL → `ENV`, GEMME → `GEM`, défaut → `ENV`.
2. `$prefix = $prefixe.$departement.'-'` (Programme.php L490).
3. Filtre `code_envol LIKE 'HEC11-%'` — **pas** de `where enseigne_id`,
   **pas** de `where entite_id`.

Conséquence incident : enseigne_id 2 / entite_id 4 n’entrent pas
dans le calcul. `store` L457 pose `entite_id` = `enseigne->entite_par_defaut_id`
après coup. HEC11-018 = HECTARE + département `11`.

Deux programmes HECTARE du 11 partagent la séquence, même s’ils
sont sur des entités différentes. ENVOL 11 = `ENV11-xxx` (séquence
séparée).

### MAX + 1, pas COUNT + 1

Boucle PHP : plus grand suffixe numérique parmi les codes du
préfixe, puis `max + 1` paddé sur 3 chiffres (L491-502).
Pas de `COUNT`. Pas de table de séquence. Pas de `lockForUpdate`.
Les suffixes non numériques sont ignorés.

### SoftDeletes — confirmé sur le modèle, pas supposé

`app/Models/Programme.php` L11 : `use Illuminate\Database\Eloquent\SoftDeletes;`
L15 : `use SoftDeletes;`

Colonne : migration `database/migrations/2026_04_24_100000_add_soft_deletes_to_programmes_table.php`
L12 `$table->softDeletes();`

`static::query()` (L492) **et** `Programme::query()` du preview
(L1096) passent par Eloquent → scope global SoftDeletes →
`WHERE deleted_at IS NULL`. Les lignes soft-deleted sont
**invisibles** au générateur et au preview.

UNIQUE MySQL : migration
`database/migrations/2026_04_23_100000_add_fields_to_programmes_table.php`
L14 `$table->string('code_envol', 15)->nullable()->unique();`
Nom d’index Laravel = `programmes_code_envol_unique` (identique
à l’exception prod). Index **sur `code_envol` seul**, pas de
composante `deleted_at`. Une ligne supprimée continue d’occuper
la valeur.

Suppression : `ProgrammeController::destroy` L969 `$programme->delete();`
(soft). Réservé GS_DIRECTION_GENERALE (L956-957). Pas de
`forceDelete`.

Mécanisme de l’incident, certain d’après le source :

1. Un programme HEC11-018 est soft-deleted (`deleted_at` non NULL,
   `code_envol` inchangé).
2. `prochainCode('11', 'HEC')` lit les actifs : max suffixe = 17
   (si 018 était le plus haut actif).
3. Propose `HEC11-018`.
4. INSERT → 1062. Chaque retry repropose 018.

Si 018 est supprimé **et** 019 existe encore en actif, max actif
= 19 → propose 020 : pas de 500, trou 018. La 500 n’apparaît que
quand le code proposé (max actif + 1) est encore porté par une
ligne, typiquement une suppression du **plus haut** code, ou une
série de suppressions au sommet.

### Préfixe HEC11

Oui : enseigne + département, pas l’entité.

    HEC  +  11  +  -  +  018  =  HEC11-018

---

## 3. LE CHAMP EST-IL SAISISSABLE

**Non.** Toujours calculé serveur. Jamais posté.

`resources/js/Pages/Programmes/Create.vue` :

- `useForm` L37-50 : pas de `code_envol`.
- Aperçu lecture seule L63-71 (`codeEnvolPreview`) et L283-286
  (« Code programme : » + `font-mono`). Source = prop
  `prochains_codes[enseigne_id][departement]` calculée au GET
  `create` (Controller L421, L1078-1114).
- POST L155-159 : `form.post('/programmes')` sans ce champ.

`store` L427-439 : règles de validation **sans** `code_envol`.
L447 calcule, L461 injecte.

Édition : `update` L780-804 n’accepte pas `code_envol` (liste
blanche). L806-809 lit le code existant pour SharePoint.
`Edit.vue` L294 / L397 : affichage / chemin SharePoint, pas
d’input.

Hors ERP (`CreateHorsErp.vue` + `storeHorsErp` L357-386) : pas
de `code_envol` du tout (NULL, UNIQUE nullable). Hors incident.

Le preview a **la même faille** que l’insert : L1096
`Programme::query()->whereNotNull('code_envol')->pluck('code_envol')`
sans `withTrashed`. L’écran affichait probablement HEC11-018
comme « prochain code ».

---

## 4. GESTION DE L’ERREUR

### Insert

`ProgrammeController::store` L441-483 : `DB::transaction` →
`Programme::query()->create([...])` L455-466. **Aucun**
`try/catch` autour de l’insert. Le `try/catch` du contrôleur
est ailleurs (`destroy` SharePoint L960, `sharepointSync` L993)
et ne concerne pas le 1062.

La transaction rollback bien (l’insert n’est pas commité) mais
l’exception remonte. Laravel 11 n’a pas de handler local
(`app/Exceptions/` vide au grep QueryException /
UniqueConstraintViolation). → HTTP 500, stack
`Illuminate/Database/Connection.php:857`, conforme au constat.

Même absence de catch dans `SiwiRepriseActionService::creerProgramme`
L112-136. Les `catch (UniqueConstraintViolationException)` du
même fichier (L296, L543) concernent la reprise **marché /
référence SIWI**, pas le code programme.

### Pas de FormRequest

Pas de `StoreProgrammeRequest`. Validation inline `$request->validate`
L427-439. Champs : `enseigne_id`, `nom`, `departement`, `commune`,
`code_analytique_compta`, `banque`, `logo`, `responsables.*`,
`responsable_suivi_commercialisation_id`.

**Aucun `unique:` sur `code_envol`.** Le validateur ne voit pas
le champ (non posté, calculé après `validate()`). Il ne peut
pas intercepter le doublon.

Nuance : `unique:programmes,code_envol` (Query Builder, **sans**
SoftDeletes Eloquent) **aurait** vu la ligne supprimée et
renvoyé 422. L’absence de règle n’est pas un effet SoftDeletes
sur le validateur : c’est que la règle n’existe pas, parce que
le champ n’est pas saisissable.

Course concurrente (deux créations HEC11 simultanées) : même
MAX+1, même 500. Hors incident du 11/09 (retries du même user
sur un code déjà brûlé). LEÇON #1323 applicable en PHASE 2
comme filet, pas comme cause mesurée ici.

---

## 5. PÉRIMÈTRE DU PROBLÈME

Même motif « séquence + UNIQUE » ailleurs. Verdict = faille
**identique** (Eloquent sans `withTrashed` / sans table de
séquence) ou **protégé**.

### `dossiers_fonciers.reference`

`DossierFoncierController::generateReference` L1342-1356 :
préfixe `DF-{année}-`, `orderByDesc('reference')`, MAX+1 paddé
4. Unique : migration `2026_07_30_150200_create_dossiers_fonciers_table.php`
L37.

`DossierFoncier` (`app/Models/DossierFoncier.php` L17-22) :
**pas** de `SoftDeletes`. Pas la faille. Un hard-delete
libérerait la référence (réutilisation possible) — autre
règle métier, pas cet incident.

`withoutGlobalScope(RecordHabilitationScope::class)` L1346 :
voit tous les dossiers, pas seulement ceux du user. Correct
pour une séquence globale.

### `marches.reference` + `marche_reference_sequences`

`Marche::nextReference` L205-261. SoftDeletes **oui** (Marche.php
L8-12). Unique `marches.reference` (migration create L17).

**Protégé** contre la réutilisation d’un marché soft-deleted :

- table `marche_reference_sequences` (`annee` PK, `dernier`),
  `lockForUpdate`, incrément monotone (#1323) ;
- seed initial `withTrashed()` L226-229 si la ligne d’année
  n’existe pas encore.

Un marché supprimé ne recule pas `dernier`. Code brûlé.

### `appels_de_fonds_sequences` / `NumeroAppelFondGenerator`

`app/Services/NumeroAppelFondGenerator.php` L10-38 : table
`appels_de_fonds_sequences` (`code_societe`+`annee` UNIQUE,
`dernier_seq`), `lockForUpdate`, +1. Appelé par
`GenerationAppelFondService` L131.

`AppelFonds` a SoftDeletes (L8-12) ; `numero_interne` UNIQUE
(migration `2026_05_23_120100` L17). La séquence **ne
décrémente pas** à la suppression. **Protégé** (numéro brûlé).

### `Facture::prochainReference`

`app/Models/Facture.php` L255-278. SoftDeletes oui (L10-14).
UNIQUE `factures.reference` (create L17).

**Protégé** : `static::withTrashed()` L263-265 avant le MAX+1.
Un FAC supprimé reste dans le max. Pas de table de séquence →
course concurrente encore possible (LEÇON #1323), pas la
faille soft-delete.

### `AppelOffre::nextReference`

`app/Models/AppelOffre.php` L100-117. SoftDeletes oui (L10-15).
UNIQUE `appels_offres.reference` (create L14).

**Protégé** : `withTrashed()` L106-107, puis MAX+1. Même
réserve concurrence que les factures.

### Autres MAX+1 (hors « code métier partable Sage »)

| Objet | Lieu | SoftDeletes | Verdict |
|---|---|---|---|
| `ScenarioFaisabilite::prochainVersion` | modèle L112-119 | non | Pas la faille ; version interne dossier |
| `OffreFonciereController::prochainRang` | L1618-1625 | non | Rang interne dossier |
| `AideSlugGenerator` | slugs d’aide | n/a | Hors sujet |
| Fixtures faisabilité `*Reference` | tests | n/a | Hors sujet |

**Seul `Programme::prochainCode` (et son preview
`prochainsCodesParDepartement`) combine SoftDeletes + MAX+1
sans `withTrashed` + UNIQUE sur la valeur générée.** C’est
la faille de l’incident. Le chemin SIWI
`SiwiRepriseActionService::creerProgramme` L120 la partage.

---

## 6. ÉTAT DES DONNÉES — requêtes SQL (à coller en prod)

Cursor n’a pas MySQL. Aucun chiffre ci-dessous n’est une
mesure. Coller les sorties avant PHASE 2.

Objectif : (a) liste HEC11 + `deleted_at` ; (b) trous / codes
brûlés déjà créés par des suppressions ; (c) volume
soft-deleted ; (d) préfixes où le générateur **actuel**
re-proposerait un code encore occupé (même 500 qu’aujourd’hui).

Les requêtes sont dans la SYNTHÈSE ci-dessous, prêtes à
exécuter.

---

## CLAUSE D’ARRÊT / PHASE 2 (ne pas coder maintenant)

**Mécanisme : certain** d’après le source. La 500 1062 sur
HEC11-018 après soft-delete est exactement ce que
`prochainCode` + UNIQUE produisent.

**Mesure HEC11 : à coller.** Confirmer que HEC11-018 a un
`deleted_at` non NULL et qu’aucun actif ne porte 018.

Règle métier retenue (prompt) :

- sauter tout code occupé, **y compris** `deleted_at IS NOT NULL` ;
- premier code réellement libre ;
- un code brûlé reste brûlé (Sage / courrier) ;
- trous assumés ;
- collision résiduelle → message clair + prochain libre,
  jamais 500.

Traduction source (pour le lot suivant, un seul) :

1. `Programme::prochainCode` : considérer **toutes** les
   lignes (`withTrashed()` ou `DB::table('programmes')`).
   MINIMAL suffisant pour l’incident : MAX toutes lignes + 1
   (= ne jamais réutiliser un suffixe déjà vu, y compris
   supprimé). « Premier libre » = plus petit N dont
   `{préfixe}{N}` n’existe sur aucune ligne, y compris
   supprimée — ne comble un trou que s’il n’a **jamais** été
   attribué.
2. Aligner `prochainsCodesParDepartement` (preview Create.vue)
   sur le même ensemble, sinon l’UI ment encore.
3. Filet : `catch (UniqueConstraintViolationException)` autour
   de l’insert → `ValidationException` avec le prochain code
   libre recalculé. Couvre course + oubli `withTrashed`.
4. Même générateur pour `SiwiRepriseActionService::creerProgramme`.
5. Test source : occupant soft-deleted → code proposé ≠ code
   brûlé ; insert ne lève pas 1062.
6. Ne pas toucher marchés / AF / factures / AO (déjà protégés).
7. Journal utilisateur : uniquement si le message d’erreur
   devient visible (PHASE 2), pas ce diagnostic.

Hors périmètre PHASE 2 sauf demande : table de séquence type
#1323 ; saisie manuelle de `code_envol` ; `forceDelete`.

## JOURNAL

Aucune entrée (diagnostic, pas de changement utilisateur).

## DÉPLOIEMENT-TEST

    git pull origin main

Pas de migrate, route:clear, journal:sync, npm, optimize.

Robin : coller SQL 1–4. Création HEC11 **uniquement après**
PHASE 2.

## LEÇON

`SoftDeletes` filtre Eloquent, pas l’index UNIQUE MySQL.
MAX+1 sur `Model::query()` repropose le plus haut code
supprimé. `unique:` Laravel n’a pas d’occasion de jouer si
la valeur n’est pas dans la requête. Un aperçu GET calculé
sans `withTrashed` affiche le code qui va 500 au POST.
Marchés / AF l’avaient déjà appris (#1323, table de
séquence) ; `code_envol` non.

---

```markdown
## SUIVI #1519 — SYNTHÈSE diagnostic générateur code_envol

### Verdict
La 500 Duplicate entry HEC11-018 est produite par Programme::prochainCode
(app/Models/Programme.php L488-503) : MAX+1 sur Eloquent (SoftDeletes
L11+L15) donc sans les lignes deleted_at, puis INSERT UNIQUE
programmes_code_envol_unique (migration 2026_04_23 L14). destroy() fait
un soft-delete (ProgrammeController L969). Pas de FormRequest, pas de
unique: sur code_envol (champ jamais posté, Create.vue L37-50). Aucun
try/catch autour de l’insert (store L441-466) → 500 brute. Le preview
Create.vue a la même faille (Controller L1096).

Préfixe = EnseignesSetup::prefixePourCode (HECTARE→HEC) + département
(11) → HEC11. Périmètre : enseigne+département, pas l’entité.

Seul ce générateur (UI + SiwiRepriseActionService L120) a la faille
soft-delete+UNIQUE. Marchés (séquence + withTrashed), appels de fonds
(séquence), factures (withTrashed), appels d’offres (withTrashed) sont
protégés. dossiers_fonciers.reference : pas de SoftDeletes.

PHASE 2 (un lot, pas maintenant) : withTrashed / table brute + premier
N absent de toutes les lignes ; aligner le preview ; catch 1062 →
message + prochain libre. Code brûlé = brûlé. Trous assumés.

### Fichiers lus (non modifiés)
- app/Models/Programme.php L11, L15, L488-508
- app/Http/Controllers/ProgrammeController.php L425-493, L953-974, L1078-1114
- resources/js/Pages/Programmes/Create.vue L37-50, L63-71, L283-286
- app/Support/EnseignesSetup.php L138-146
- app/Services/SiwiRepriseActionService.php L107-136
- app/Models/Marche.php L205-261 ; Facture.php L255-278 ; AppelOffre.php L100-117
- app/Services/NumeroAppelFondGenerator.php L10-38
- app/Http/Controllers/Foncier/DossierFoncierController.php L1342-1356

### SQL 1 — liste HEC11 avec deleted_at
SELECT id, libelle, commune, departement, enseigne_id, entite_id,
       code_envol, deleted_at, created_at, updated_at
FROM programmes
WHERE code_envol LIKE 'HEC11-%'
ORDER BY CAST(SUBSTRING_INDEX(code_envol, '-', -1) AS UNSIGNED), id;

### SQL 2 — programmes soft-deleted (volume + codes occupés)
SELECT COUNT(*) AS nb_programmes_soft_deleted
FROM programmes
WHERE deleted_at IS NOT NULL;

SELECT id, libelle, enseigne_id, departement, commune, code_envol, deleted_at
FROM programmes
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC, id;

### SQL 3 — trous / codes brûlés (tous préfixes XXX99-NNN)
SELECT
  SUBSTRING_INDEX(p.code_envol, '-', 1) AS prefixe,
  CAST(SUBSTRING_INDEX(p.code_envol, '-', -1) AS UNSIGNED) AS n,
  p.id,
  p.libelle,
  p.enseigne_id,
  p.departement,
  p.deleted_at
FROM programmes p
WHERE p.code_envol IS NOT NULL
  AND p.code_envol LIKE '%-%'
  AND p.deleted_at IS NOT NULL
ORDER BY prefixe, n;

### SQL 4 — préfixes où le générateur ACTUEL recollerait un 1062
(max des ACTIFS + 1 encore occupé, y compris par un soft-delete)

SELECT
  a.prefixe,
  a.max_actif,
  CONCAT(a.prefixe, '-', LPAD(a.max_actif + 1, 3, '0')) AS code_propose_actuel,
  p.id AS occupant_id,
  p.libelle AS occupant_libelle,
  p.deleted_at AS occupant_deleted_at
FROM (
  SELECT
    SUBSTRING_INDEX(code_envol, '-', 1) AS prefixe,
    MAX(CAST(SUBSTRING_INDEX(code_envol, '-', -1) AS UNSIGNED)) AS max_actif
  FROM programmes
  WHERE code_envol IS NOT NULL
    AND code_envol LIKE '%-%'
    AND deleted_at IS NULL
    AND SUBSTRING_INDEX(code_envol, '-', -1) REGEXP '^[0-9]+$'
  GROUP BY SUBSTRING_INDEX(code_envol, '-', 1)
) a
INNER JOIN programmes p
  ON p.code_envol = CONCAT(a.prefixe, '-', LPAD(a.max_actif + 1, 3, '0'))
ORDER BY a.prefixe;

Attendu pour l’incident : une ligne prefixe=HEC11, code_propose_actuel=HEC11-018,
occupant_deleted_at NON NULL.

### Git
9e426b32 docs(suivi): diagnostic générateur code_envol vs soft-delete (#1519)
HEAD au diagnostic (avant ce lot) : 9db2dce1 chore(suivi): hash fiche #1517
Pas de code applicatif. Pas de journal. Pas de migrate.
```
