---
suivi: 1484
date: 2026-09-08
sujet: Diagnostic NV2 SAS RACINE / enseigne HECTARE (source enseigne vs circuit)
chantier: factures
type: diagnostic
statut: poussé
hash: cf5c9f45
fichiers:
  - docs/suivi/SUIVI_1484_diag_nv2_enseigne.md
---

# SUIVI #1484 — PHASE 1 DIAGNOSTIC — NV2 sur programme HECTARE (SAS RACINE)

## PROMPT ENVOYÉ

PHASE 1 — diagnostic seul. SAS RACINE (code HEC66-001, société RACINE,
enseigne HECTARE) envoie ses factures en NV2. Règle métier : NV2
uniquement pour les programmes enseigne ENVOL ; HECTARE et GEMME
passent de NV1 à « à comptabiliser ». Aucun code applicatif, aucune
migration, aucun chiffre inventé, aucun rattrapage des dépôts NV2.

## BASE GIT

```
git log --oneline -1 origin/main
851e054b chore(suivi): hash fiche #1483
```

MySQL agent : 127.0.0.1:3306 refused. Aucun chiffre de production.
Aucun fichier PHP / Vue / migration modifié. Cast `ocr_raw` non touché.

## FICHES LUES

- **#1385** (`SUIVI_1385_diag_nv2_enseigne_hectare.md`, LEÇON) — avant #1386,
  le saut NV2 n’existait que pour `programme_id === null` (frais généraux).
  L’enseigne était déjà en base mais ne décidait pas **si** NV2 existait,
  seulement **qui** validait. Impact : ne pas relire le code comme s’il
  n’y avait toujours aucun test d’enseigne.
- **#1386** (`SUIVI_1386_nv2_uniquement_envol.md`, LEÇON) — helper
  `FacturePasseParValidationNv2` ajouté. Décision Robin **alors** :
  source = `programme → entite → entites.enseigne` (**pas**
  `programmes.enseigne_id`). Cas prudent : entité absente ou
  `enseigne_id` NULL → **conserver NV2**. `entites.nom` peut diverger
  de l’enseigne (GEM-01 nommé « HECTARE »). Impact : le symptôme RACINE
  n’est pas « l’enseigne n’existe pas » ; c’est un **écart de source**
  entre l’UI (programme) et le circuit (entité), plus le défaut prudent
  NULL → NV2.

Consultation `docs/suivi` sur les chemins du lot (lecture seule) :
`FacturePasseParValidationNv2`, `FactureWorkflowApresNv1Service`,
`DepotFactureController`, `DepotFactureSaisieForm.vue` — confirmé par
#1385 / #1386. Pas d’autre fiche RACINE / HEC66-001.

## SYNTHÈSE

Clause d’arrêt A : **non déclenchée**. L’enseigne existe en schéma, sur
**deux** colonnes distinctes. Ne pas inventer de troisième.

---

### A. Où vit l’enseigne

#### A1. Colonne / relation sur `programmes`

Oui : `programmes.enseigne_id` (unsignedBigInteger, FK vers `enseignes.id`).

- Migration création (nullable, après `entite_id`) :
  `database/migrations/2026_06_05_110100_add_enseigne_id_to_programmes_table.php`
  L12–17 : `foreignId('enseigne_id')->nullable()->after('entite_id')->constrained('enseignes')->nullOnDelete()`.
- Passage NOT NULL + FK `restrictOnDelete` :
  `database/migrations/2026_06_05_110400_make_programmes_enseigne_id_not_null.php`
  L13–32 (`nullable(false)->change()` après backfill).
- Modèle : `Programme::enseigne()` BelongsTo, `app/Models/Programme.php` L108–111.
  `enseigne_id` est dans `$fillable` (L28).

Valeurs possibles : **pas un ENUM MySQL**. Table de référence `enseignes`.

- Création table : `database/migrations/2026_06_05_110000_create_enseignes_table.php`
  L15–28 : `code` string unique, `nom` string, `modules_actifs` JSON, `actif` bool.
- Codes seedés (chaînes, pas enum PHP métier) dans
  `app/Support/EnseignesSetup.php` L93–122 : `ENVOL`, `HECTARE`, `GEMME`.
- UI capture « enseigne HECTARE » : `ProgrammeDashboardPresenter::buildEntete`
  (`app/Support/ProgrammeDashboardPresenter.php` L53–55) lit
  `$programme->enseigne->nom` — donc **`programmes.enseigne_id`**, pas l’entité.

Règle projet : ne pas tester `=== 'HECTARE'` pour un module ; ici le
circuit NV2 teste déjà `=== 'ENVOL'` en dur (`CODE_ENSEIGNE_NV2`), ce
n’est pas `Enseigne::aModule()`.

#### A2. Chemin si l’enseigne n’était pas sur `programmes`

L’enseigne **est** sur `programmes`. En parallèle, **deuxième** FK :

- `entites.enseigne_id` nullable, FK `entites_enseigne_id_fk` → `enseignes`,
  `nullOnDelete`.
  Migration : `database/migrations/2026_07_07_110000_add_enseigne_id_to_entites_table.php`
  L16–20.
- Modèle : `Entite::enseigne()` BelongsTo, `app/Models/Entite.php` L28–31.

Chemin complet programme → société (entité) → enseigne entité :

`programmes.entite_id` → `entites.id` → `entites.enseigne_id` → `enseignes.id`.

C’est **ce** chemin que le circuit NV2 utilise (#1386), pas `programmes.enseigne_id`.

#### A3. Société ≠ enseigne — CONFIRMÉ par le schéma

Ce sont deux notions distinctes.

| Notion UI (capture) | Colonne / relation réelle |
|---|---|
| Programme SAS RACINE | `programmes.libelle` (jamais `.nom`) |
| Code HEC66-001 | `programmes.code_envol` (nullable unique, migration `2026_04_23_100000_add_fields_to_programmes_table.php` L14). Le nom de colonne est historique ; ce n’est **pas** une preuve d’enseigne ENVOL. Préfixe `HEC` = convention `EnseignesSetup::prefixePourCode('HECTARE')`. |
| Enseigne HECTARE | `programmes.enseigne_id` → `enseignes.code` / `enseignes.nom` |
| Société RACINE | `programmes.entite_id` → `entites.nom`. L’écran édition programme libelle le champ « Entité (société) » (`Programmes/Edit.vue`). |

Colonnes « société bancaire » **sur le programme**, distinctes encore :
`programmes.code_societe`, `raison_sociale`, etc.
(`2026_05_23_100000_add_societe_bancaire_to_programmes_table.php` L12–13).
Ce n’est ni l’enseigne ni `entites.nom`.

`Programme::societe()` existe (`app/Models/Programme.php` L92–95) mais
**aucune** colonne `programmes.societe_id` dans les migrations ni le
`$fillable`. Relation morte / vestige. La « société » métier affichée
est l’**entité**.

Garde-fou création / MAJ programme : si `entites.enseigne_id` est
renseigné, il **doit** égaler `programmes.enseigne_id`
(`ProgrammeController` storeHorsErp L365–372, updateHorsErp L931–938).
Si `entites.enseigne_id` est **NULL**, le garde-fou **ne s’applique pas** :
un programme HECTARE peut être rattaché à une entité sans enseigne.

#### A4. « Aucune colonne d’enseigne » — FAUX

Deux colonnes existent. La clause d’arrêt ne s’applique pas.

---

### B. Où est décidé le passage en NV2

#### B5. Tous les points qui écrivent ou décident `en_validation_nv2`

**Helper canonique (si / non)** — `app/Support/FacturePasseParValidationNv2.php`

Commentaire L10–11 : NV2 uniquement si l’enseigne de **l’entité** du
programme est ENVOL. Source : `programme → entite → entites.enseigne`
(**pas** `programmes.enseigne_id`, pas préfixe code).

Constante L15 : `CODE_ENSEIGNE_NV2 = 'ENVOL'` (chaîne en dur).

`check()` L44–61 : FG → false ; `programme_id` null → false ;
programme introuvable → **true (garder NV2)** ; sinon
`checkEntiteEnseigne($programme->entite)`.

`checkEntiteEnseigne()` L76–91 :

- pas d’entité **ou** `entite.enseigne_id === null` → **true (NV2)** ;
- `enseignes.code` vide → **true (NV2)** ;
- sinon `strtoupper(code) === 'ENVOL'` ;
- L88 : commentaire hook seuil HT, **non activé**.

**Écriture canonique du statut** — `app/Services/FactureWorkflowApresNv1Service.php`

- `apresNv1Valide()` L21–37 : si helper dit skip → `ouvrirMisePaiementEtAComptabiliser` ;
  sinon crée WV `niveau = nv2` + `Facture::STATUT_EN_VALIDATION_NV2` + sync dépôt.
- `creerWorkflowsApresDepotAutoNv1()` L61–79 : même bifurcation (WV nv2 vs mise_paiement).
- `statutsApresDepotAutoNv1()` L85–97 : retourne
  `en_validation_nv2` ou `a_comptabiliser`.

**Appels du service (ne dupliquent pas la règle, l’appliquent)**

- `FactureValidationService` L124–127 : NV1 validée → `apresNv1Valide()`.
  Sync dépôt L308–311 : même helper → `a_comptabiliser` ou `en_validation_nv2`.
- `DepotFactureValiderDejaValideService` L101, L305 (historique
  `envoye_nv2` vs `envoye_comptabilisation`), et L400 → `apresNv1Valide()`.
- `DepotFactureValiderEtComptabiliserService` L151–152 : NV1 pending →
  `apresNv1Valide()`.

**Envoi dépôt / auto-NV1** — `DepotFactureController::valider()`
(fichier sensible, ne pas modifier sans test manuel).

- L2104–2107 : statut initial facture `a_comptabiliser` vs
  `en_validation_nv2` via `passeDirectementAComptabiliserApresNv1PourProgrammeId`.
- L2143–2147 : statut dépôt via `statutsApresDepotAutoNv1`.
- L2289–2346 : messages, `logHistorique('envoye_nv2')`, notification NV2
  si `FacturePasseParValidationNv2::check($facture)`.

**Frise (affichage, pas d’écriture statut)** —
`FactureFriseEtapePresenter` L96 : saute l’étape NV2 si helper false
**et** aucun WV nv2 déjà présent.

**Réalignement** — `FactureWorkflowRealignementService::factureAvecNv2()`
L390–398 : helper **OU** un WV `niveau = nv2` existe déjà. Les 13
dépôts déjà en NV2 **restent** en circuit NV2 même si le helper dirait
skip. Cohérent avec « Robin les valide à la main ; pas de reprise ».

**Front** — pas de bouton métier « Envoyer en NV2 » sur le flux normal
(NV2 = conséquence de NV1). Libellé trompeur uniquement sur le flux
« déjà validée » : `DepotFactureSaisieForm.vue` L3640–3645 : si
destination ≠ `FRAIS_GENERAUX` → « Valider N1 et envoyer en NV2 ».
Le **back** de ce bouton passe quand même par le helper
(`DepotFactureValiderDejaValideService`). Le libellé ignore
HECTARE/GEMME.

**Qui valide (pas si)** — `ValidateurNv2Resolver::isEnseigneEnvol()`
L11–17 : lit **`programme.enseigne`** (`programmes.enseigne_id`).
Test source #1386 : volontairement **non** aligné sur l’entité.
Ne décide pas la création de l’étape NV2.

**Contournements / non-writers**

- Super-admin : `FactureSuperAdminCorrectionService` L31 autorise le
  statut cible `en_validation_nv2` (back-office).
- Aucun Observer trouvé qui pose ce statut.
- Aucune commande artisan trouvée qui pose ce statut.
- Aucun Job trouvé qui pose ce statut.
- `FacturePaiementStatutSyncService` / `MarcheController` : listes de
  statuts, pas de décision d’entrée NV2.
- Import / saisie historique : `apresNv2Valide()` L109–113 court-circuite
  **après** NV2 vers `payee` si `saisie_historique` ; l’entrée en NV2
  suit quand même le helper à la NV1.

#### B6. Condition actuelle — pourquoi RACINE n’est pas filtrée

Il **existe déjà** un test d’enseigne, mais sur **`entites.enseigne`**,
pas sur `programmes.enseigne_id` (celui de la capture HECTARE).

Aucun test de société (`entites.nom` / RACINE). Aucun test de préfixe
`HEC`. Aucun seuil HT actif.

Le helper **ne filtre pas RACINE** dans l’un de ces cas (à confirmer
par SQL, non mesuré ici) :

1. `entites.enseigne_id` NULL (défaut prudent #1386 → NV2) alors que
   `programmes.enseigne_id` = HECTARE — plausible : le garde-fou
   ProgrammeController ne s’applique que si l’entité a déjà une enseigne.
2. `entites.enseigne.code` = `ENVOL` alors que le programme est HECTARE
   (divergence autorisée tant que l’entité n’avait pas d’enseigne, ou
   données antérieures au garde-fou).
3. `programmes.entite_id` NULL / programme soft-deleted non chargé →
   `check()` retourne true.
4. Dépôts **antérieurs à #1386** (1er sept. 2026) encore en NV2 :
   le réalignement les **conserve** (`factureAvecNv2` OR WV existant).

Les tests unitaires #1386 (`FactureWorkflowApresNv1ServiceTest` L27–43)
posent l’enseigne sur **l’entité**, pas sur `programmes.enseigne_id`.
Ils ne détectent pas un programme HECTARE accroché à une entité ENVOL
ou sans enseigne.

#### B7. Point canonique vs contournements

**Point canonique à modifier (phase 2, après arbitrage Robin)** :
`FacturePasseParValidationNv2::checkEntiteEnseigne()` / `check()`.
Tous les writers applicatifs passent par ce helper, **sauf** le
super-admin (statut forcé) et le réalignement qui **conserve** un WV nv2
déjà créé.

Ne pas retoucher `DepotFactureController` au-delà de l’appel au helper
(zone #1189 / envoi).

#### B8. Circuit paramétrable ?

**Non.** Pas de table `circuit_validation`, pas de colonne
`skip_validation_nv2` sur `enseignes`, pas de config
`config/factures_workflow.php`. Circuit = helper + FG
(`FactureEstFraisGeneraux` = `programme_id === null`). Option B/C du
#1385 non retenues au #1386 (option A, condition en dur).

---

### C. Étape suivante après NV1 pour un non-ENVOL

#### C9. Statut cible si NV2 court-circuitée

Exactement le même chemin que **après une NV2 validée** (hors saisie
historique) :

`FactureWorkflowApresNv1Service::ouvrirMisePaiementEtAComptabiliser()`
L129–140, identique à `apresNv2Valide()` L116–124 :

- WV `niveau = mise_paiement`, `statut = en_attente` ;
- `factures.statut` = `a_comptabiliser` (`Facture::STATUT_A_COMPTABILISER`) ;
- `depot_factures.statut` = `a_comptabiliser` (`DepotFacture::STATUT_A_COMPTABILISER`) ;
- notification comptable, pas NV2.

Saisie historique après NV2 : `payee` / dépôt `paye` (L109–113) — hors
périmètre du skip enseigne.

#### C10. Frais généraux (sans programme)

Aujourd’hui : `FactureEstFraisGeneraux::check` = `programme_id === null`
→ le helper **refuse NV2** (`check()` L46–47 ;
`passeDirectementAComptabiliserApresNv1()` L22–24).
Même cible `a_comptabiliser` + WV mise_paiement.

Une facture sans programme n’a pas d’enseigne programme. Le code ne
consulte pas `factures.entite_id` pour inventer une enseigne FG.

**À trancher par Robin** : conserver ce skip FG (comportement actuel
#112 / #1386) ou autre circuit. Le diagnostic n’impose rien.

---

### D. Périmètre données — requêtes pour Robin (aucun chiffre ici)

Les 13 dépôts actuellement en NV2 : **ne pas les modifier**. Robin les
valide manuellement. Pas de script de reprise dans ce lot ni le suivant
sauf décision contraire.

Colonnes : `programmes.libelle`, `programmes.code_envol`,
`depot_factures.facture_id`, `fournisseurs.nom`. Jointure dernier dépôt
par `MAX(depot_factures.id)` GROUP BY `facture_id` (éviter
`Facture::depotFacture()` latestOfMany en SQL non qualifié — erreur 1052).

#### D11a. Programme RACINE (id à établir)

    SELECT
      p.id AS programme_id,
      p.libelle,
      p.code_envol,
      p.enseigne_id AS programme_enseigne_id,
      ep.code AS enseigne_programme_code,
      ep.nom AS enseigne_programme_nom,
      p.entite_id,
      e.nom AS societe_entite_nom,
      e.enseigne_id AS entite_enseigne_id,
      ee.code AS enseigne_entite_code,
      ee.nom AS enseigne_entite_nom
    FROM programmes p
    INNER JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE p.deleted_at IS NULL
      AND (
        p.code_envol = 'HEC66-001'
        OR p.libelle LIKE '%RACINE%'
      );

#### D11b. Tous les programmes : enseigne programme vs enseigne entité

    SELECT
      p.id AS programme_id,
      p.libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      e.nom AS societe_entite_nom,
      ee.code AS enseigne_entite_code
    FROM programmes p
    INNER JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE p.deleted_at IS NULL
    ORDER BY ep.code, p.libelle, p.id;

Divergence utile (hypothèse RACINE) :

    SELECT
      p.id AS programme_id,
      p.libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      e.nom AS societe_entite_nom,
      ee.code AS enseigne_entite_code
    FROM programmes p
    INNER JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE p.deleted_at IS NULL
      AND (
        e.id IS NULL
        OR e.enseigne_id IS NULL
        OR UPPER(IFNULL(ee.code, '')) <> UPPER(ep.code)
      )
    ORDER BY p.id;

#### D11c. Dépôts actuellement `en_validation_nv2` dont l’enseigne **programme** n’est pas ENVOL

Règle #1484 (UI / `programmes.enseigne_id`). Ne pas modifier les lignes.

    SELECT
      df.id AS depot_id,
      df.facture_id,
      f.reference AS facture_reference,
      df.numero_facture AS depot_numero_facture,
      p.id AS programme_id,
      p.libelle AS programme_libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      ee.code AS enseigne_entite_code,
      COALESCE(f.montant_ht, df.montant_ht) AS montant_ht,
      COALESCE(f.montant_ttc, df.montant_ttc) AS montant_ttc,
      four.nom AS fournisseur_nom
    FROM depot_factures df
    INNER JOIN (
      SELECT
        depot_factures.facture_id AS facture_id,
        MAX(depot_factures.id) AS depot_id
      FROM depot_factures
      WHERE depot_factures.deleted_at IS NULL
        AND depot_factures.facture_id IS NOT NULL
      GROUP BY depot_factures.facture_id
    ) latest ON latest.depot_id = df.id
    INNER JOIN factures f
      ON f.id = df.facture_id
      AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = f.programme_id
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    LEFT JOIN fournisseurs four
      ON four.id = COALESCE(f.fournisseur_id, df.fournisseur_id)
    WHERE df.deleted_at IS NULL
      AND df.statut = 'en_validation_nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL')
    ORDER BY df.id;

Complément : tous les dépôts NV2 (y compris sans facture), enseigne
programme via `COALESCE(f.programme_id, df.programme_id)` :

    SELECT
      df.id AS depot_id,
      df.facture_id,
      df.numero_facture AS depot_numero_facture,
      p.id AS programme_id,
      p.libelle AS programme_libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      ee.code AS enseigne_entite_code,
      df.montant_ht,
      df.montant_ttc
    FROM depot_factures df
    LEFT JOIN factures f
      ON f.id = df.facture_id
      AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = COALESCE(f.programme_id, df.programme_id)
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE df.deleted_at IS NULL
      AND df.statut = 'en_validation_nv2'
    ORDER BY df.id;

#### D11d. Historique : déjà passées par NV2 à tort (enseigne programme ≠ ENVOL)

Actions dépôt `envoye_nv2` :

    SELECT
      fh.id AS historique_id,
      fh.created_at,
      fh.depot_facture_id,
      fh.facture_id,
      fh.action,
      fh.commentaire,
      f.reference AS facture_reference,
      p.id AS programme_id,
      p.libelle AS programme_libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      ee.code AS enseigne_entite_code,
      COALESCE(f.montant_ht, df.montant_ht) AS montant_ht,
      COALESCE(f.montant_ttc, df.montant_ttc) AS montant_ttc
    FROM facture_historique fh
    INNER JOIN depot_factures df
      ON df.id = fh.depot_facture_id
      AND df.deleted_at IS NULL
    LEFT JOIN factures f
      ON f.id = COALESCE(fh.facture_id, df.facture_id)
      AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = COALESCE(f.programme_id, df.programme_id)
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE fh.action = 'envoye_nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL')
    ORDER BY fh.created_at, fh.id;

Workflows NV2 déjà créés (y compris validés) sur programme non ENVOL :

    SELECT
      wv.id AS workflow_id,
      wv.facture_id,
      wv.niveau,
      wv.statut AS workflow_statut,
      wv.date_assignation,
      wv.date_validation,
      f.reference AS facture_reference,
      f.statut AS facture_statut,
      p.id AS programme_id,
      p.libelle AS programme_libelle,
      p.code_envol,
      ep.code AS enseigne_programme_code,
      ee.code AS enseigne_entite_code
    FROM workflow_validations wv
    INNER JOIN factures f
      ON f.id = wv.facture_id
      AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = f.programme_id
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    LEFT JOIN entites e ON e.id = p.entite_id
    LEFT JOIN enseignes ee ON ee.id = e.enseigne_id
    WHERE wv.niveau = 'nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL')
    ORDER BY wv.id;

Comptages (Robin exécute ; ne pas remplir ici) :

    SELECT COUNT(*) AS nb
    FROM depot_factures df
    LEFT JOIN factures f
      ON f.id = df.facture_id AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = COALESCE(f.programme_id, df.programme_id)
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    WHERE df.deleted_at IS NULL
      AND df.statut = 'en_validation_nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL');

    SELECT COUNT(*) AS nb
    FROM facture_historique fh
    INNER JOIN depot_factures df
      ON df.id = fh.depot_facture_id AND df.deleted_at IS NULL
    LEFT JOIN factures f
      ON f.id = COALESCE(fh.facture_id, df.facture_id)
      AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = COALESCE(f.programme_id, df.programme_id)
      AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    WHERE fh.action = 'envoye_nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL');

    SELECT COUNT(*) AS nb
    FROM workflow_validations wv
    INNER JOIN factures f
      ON f.id = wv.facture_id AND f.deleted_at IS NULL
    LEFT JOIN programmes p
      ON p.id = f.programme_id AND p.deleted_at IS NULL
    LEFT JOIN enseignes ep ON ep.id = p.enseigne_id
    WHERE wv.niveau = 'nv2'
      AND (ep.code IS NULL OR UPPER(ep.code) <> 'ENVOL');

#### D12. Noms canoniques — respectés

`programmes.libelle`, `depot_factures.facture_id`, `fournisseurs.nom`.
Pas de `programmes.nom`. `marches` non joint ici.

---

## Conclusions pour la phase 2 (pas d’implémentation ici)

**Point de décision canonique** : `app/Support/FacturePasseParValidationNv2.php`
(`check` / `checkEntiteEnseigne`). Écriture statut :
`FactureWorkflowApresNv1Service`.

**Hypothèse RACINE (non mesurée)** : l’UI montre `programmes.enseigne` =
HECTARE ; le circuit lit `entites.enseigne` ; NULL ou ENVOL côté entité
→ NV2. Confirmer par D11a.

**À trancher par Robin**

1. Source enseigne NV2 : `programmes.enseigne_id` (libellé #1484 / UI)
   **ou** conserver `entites.enseigne_id` (#1386). Les deux colonnes
   existent ; elles peuvent diverger.
2. Entité absente / `enseigne_id` NULL : garder NV2 (prudent #1386) ou
   skip comme HECTARE.
3. Frais généraux : conserver skip NV2 actuel, ou autre règle.
4. Les 13 dépôts NV2 actuels : validation manuelle confirmée ; pas de
   script de reprise.

**Non établi (pas MySQL)** : id programme SAS RACINE ; enseigne réelle
de l’entité RACINE ; nombre de dépôts NV2 hors ENVOL ; part « leftover
#1386 » vs « helper encore vrai ».

**Non modifié** : aucun PHP, aucun Vue, aucune migration, pas de
`ocr_raw`, pas de journal utilisateur (diagnostic invisible).

## DÉPLOIEMENT-TEST

Phase 1 : lire la fiche. Exécuter les SQL D11. Aucun `journal:sync`.
Aucun `npm run build`. Sur OVH : rien au-delà du `git pull` si la fiche
doit être sur le serveur (doc uniquement).

## LEÇON

Deux enseignes coexistent : `programmes.enseigne_id` (écran, filtres,
`ValidateurNv2Resolver` = **qui**) et `entites.enseigne_id` (helper NV2
= **si**). #1386 a volontairement choisi l’entité et le défaut NULL →
NV2. Un programme HECTARE dont l’entité n’a pas d’enseigne (ou une
enseigne ENVOL) continue d’envoyer en NV2. Ne pas « ajouter un test
d’enseigne » en phase 2 sans arbitrer **laquelle**. Ne pas reprendre
les 13 dossiers NV2 ouverts.
