---
suivi: 1523
date: 2026-09-11
sujet: Diagnostic — factures PROGRAMMES orphelines de poste / enveloppe budgétaire
chantier: budgets-programme / depot-factures
type: diagnostic
statut: poussé
hash: afc68773
fichiers:
  - docs/suivi/SUIVI_1523_diag_factures_orphelines_budget.md
---

# SUIVI #1523 — PHASE 1 diagnostic : factures orphelines de poste budgétaire

## PROMPT ENVOYÉ

PHASE 1 diagnostic. Constat prod : facture destination PROGRAMMES, option
« Pas de marché, rattacher à un poste budgétaire » choisie alors qu’aucun
poste / budget n’existait sur le programme. Enregistrement, envoi, NV1
acceptés. Invisible page budget et bilan. Créer le poste « hydraulique »
après coup ne rattache pas la facture. Décisions : ne pas bloquer l’envoi ;
rendre les orphelines visibles (budget + bilan) ; rattacher depuis cette
liste sans repasser par la fiche. Huit questions (imputation, asserts,
critère orpheline, requêtes budget/bilan, rattachement après coup, droits,
volume SQL, risques). Aucune reprise auto. Aucun blocage d’envoi. Aucune
modification des factures déjà imputées. Aucune migration, aucun code
applicatif. Fiche seule.

## BASE GIT

HEAD = `origin/main` = `1b781f05` (chore hash fiche #1522).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1492 LEÇON** — `depot_factures.poste_budgetaire_id` retirée (ancrage
  PROJET, jamais le chemin PROGRAMMES). Impact : ne pas la ressusciter ;
  l’ancrage PROGRAMMES hors marché est `poste_budgetaire_type_id` +
  `programme_budget_id`.
- **#1513 LEÇON** — `DepotFactureProjetAncrage` n’écrit le type que si
  destination = PROJET, pour ne pas écraser un poste PROGRAMMES. Impact :
  ce helper n’est **pas** le service de rattachement enveloppe ; le
  réutiliser pour un orphelin PROGRAMMES viderait le dossier et
  n’écrirait pas l’enveloppe.
- **#1520 LEÇON** — `assertRattachementProgrammeBudgetValide` L5131-5136
  traite `rattachement_poste_direct` (JSON seul) comme hors marché.
  Impact : le même assert est le filet poste ; le trou n’est pas les
  retenues.
- **#1031 LEÇON** — exigence de poste retirée pour `sans_marche` ;
  **conservée** pour `rattachement_poste_direct` (422 si type NULL, hors
  brouillon). Impact : le 422 existe encore, mais l’envoi #1450 compte
  le **drapeau** comme un poste.
- **#1029 / #1035 LEÇON** — double ancrage `programme_budget_id` dépôt +
  facture ; bilan hors marché = JOIN enveloppe via id effectif. Impact :
  sans cet id, la facture n’entre pas dans le bilan, même si le type est
  renseigné.
- **#1033 LEÇON** — backfill historique écrit `programme_budget_id` à
  partir du type. Créer une enveloppe **ne** rejoue **pas** ce backfill.
  Impact : « hydraulique créé après coup » ne récupère personne.
- **#1450 LEÇON** — enregistrement permissif (`brouillonPermissif`) ;
  envoi = `assertRattachementBudgetCompletPourEnvoi` où
  `rattachement_poste_direct` suffit. Impact : cause directe de l’envoi
  sans enveloppe.
- **#1025 LEÇON** — page Budget = CRUD enveloppes, pas de liste de
  factures. Impact : invisible n’est pas un filtre, c’est une absence
  d’écran.
- **#1392** — hors ERP : coerce `sans_marche`, interdit poste / enveloppe.
  À exclure du critère orpheline.

Aucune fiche #1518/#1519/#1521/#1522 n’a changé l’approche imputation.

---

## 1. COMMENT UNE FACTURE EST IMPUTÉE À UN POSTE

### 1.1 `poste_budgetaire_id` — morte

Colonne `depot_factures.poste_budgetaire_id` créée #1490, **drop** #1492
(`database/migrations/2026_09_09_164200_correct_depot_factures_projet_ancrage.php`).
Elle pointait une ligne `postes_budgetaires` pour la destination PROJET.
Elle n’a **jamais** été le chemin PROGRAMMES. Le fillable dépôt ne la
contient plus (`app/Models/DepotFacture.php` L151-160 : `poste_budgetaire_type_id`,
`dossier_foncier_id`, `programme_budget_id`).

Ce qui la remplace **aujourd’hui**, selon la destination :

| Destination | Colonne dépôt | Colonne facture | JSON `ocr_raw.saisie` |
|-------------|----------------|-----------------|------------------------|
| PROGRAMMES + marché | `marche_id` | `factures.marche_id` (prioritaire) | `marche_id` |
| PROGRAMMES hors marché (charge directe) | `poste_budgetaire_type_id` **et** `programme_budget_id` | `factures.programme_budget_id` (prioritaire) | `poste_budgetaire_type_id`, `programme_budget_id`, `rattachement_poste_direct` |
| PROJET | `dossier_foncier_id` + `poste_budgetaire_type_id` | aucune | les deux ids |
| FRAIS_GENERAUX | `categorie_id` (JSON) | — | `destination`, `categorie_id` |

`rattachement_poste_direct` n’a **pas** de colonne (`FactureMarcheDoubleAncrageService.php`
L115-117 ; #1029 L58-59). Drapeau JSON uniquement.

### 1.2 Deux ids distincts — ne pas les confondre

- `poste_budgetaire_type_id` = type de **nomenclature** enseigne
  (`postes_budgetaires_types`). C’est ce que le Select de saisie poste
  (`DepotFactureSaisieForm.vue` L8715-8725, options L256-262).
- `programme_budget_id` = **enveloppe** allouée
  (`programme_budgets`, N par programme × type, #1025). C’est ce que le
  bilan hors marché joint (`BilanFinancierService.php` L1289-1297).

Id effectif enveloppe, même motif que le marché :

`DepotFacture::sqlProgrammeBudgetEffectifId` L429-431 =
`COALESCE(factures.programme_budget_id, depot_factures.programme_budget_id)`.

PHP : `FactureMarcheDoubleAncrageService::resolveProgrammeBudgetIdEffectif`
L36-40.

### 1.3 Où c’est écrit, par quels appelants

**Saisie / PATCH brouillon et PATCH compta** —
`DepotFactureController::applyDepotSaisieFromValidated` L1560+ :

1. coerce `FactureMarcheDoubleAncrageService::coerceProgrammesMarcheEtPosteDansValidated`
   L1576 / L109-169 ;
2. overlay JSON L1697-1748 : `poste_budgetaire_type_id`, `programme_budget_id`,
   `rattachement_poste_direct` ;
3. colonnes via `appendDirectFkColumnsToUpdates` L1781-1787, avec
   `$sansMarche || $rattachementPoste` passé comme 3ᵉ argument.

Branche `elseif ($rattachementPoste)` du coerce L151-157 :

- `marche_id` = null, `sans_marche` = false ;
- **`programme_budget_id` = null** (forcé) ;
- le type n’est pas vidé.

`appendDirectFkColumnsToUpdates` L5944-5965 : si ce 3ᵉ argument est vrai,
`marche_id` null ; si `programme_budget_id` est dans `$validated`,
il est aussi mis à null quand `$sansMarche` (ici vrai à cause du
`|| $rattachementPoste`).

**Le front n’envoie jamais `programme_budget_id`.** Grep
`resources/js` : 0 occurrence. `buildDraftPayload` L6624-6637 envoie
`poste_budgetaire_type_id` + `rattachement_poste_direct`.
`buildValiderPayload` L7200-7242 spread le form (drapeau JSON) +
`poste_budgetaire_type_id` ; toujours pas d’enveloppe.

Conséquence : le chemin UI « Pas de marché, rattacher à un poste »
écrit **uniquement** le type (+ drapeau JSON). L’enveloppe reste NULL
même si une enveloppe du même type existe déjà sur le programme.

**Valider** — `Facture::query()->create` L2234-2257 : `marche_id` copié,
**pas** `programme_budget_id` (fillable facture L54 existe, non alimenté
à la création). Après engagement, un PATCH
`syncFactureLieeDepuisDepot` L1848-1853 recopie l’enveloppe dépôt →
facture si le dépôt en a une. Au premier envoi, les deux sont NULL.

**Engager déjà réglée** — `DepotFactureEngagerWorkflowService` L93-116 :
même trou, create facture sans enveloppe.

`DepotFactureProjetAncrage` : PROJET seulement (`depuisValidated` L20-24
retourne type null si ≠ PROJET). Hors sujet PROGRAMMES.

### 1.4 Selecteur saisie ≠ enveloppe budget

`buildPostesChargeDirecteParProgrammePayload` L5890-5926 : lignes
`postes_budgetaires` dont le type a `applicable_budget = 1`. **Pas**
de jointure `programme_budgets`.

Page Budget `ProgrammeBudgetService::creer` L38-48 : INSERT
`programme_budgets` uniquement. **Ne crée pas** de ligne
`postes_budgetaires`.

Donc : on peut choisir un type « hydraulique » dans la saisie **sans**
enveloppe ; créer l’enveloppe ensuite **n’écrit rien** sur la facture
(pas de hook, pas de replay #1033).

---

## 2. POURQUOI RIEN N’A BLOQUÉ

Trois couches, trois règles différentes.

### 2.1 Enregistrement (PATCH brouillon)

`applyDepotSaisieFromValidated` L1574 :
`assertRattachementProgrammeBudgetValide(..., brouillonPermissif: true)`.

`update` dépôt L1902 : idem.

L5119-5122 : le 422 « Choisissez un poste budgétaire pour rattacher
cette facture hors marché. » est **sauté** si `$brouillonPermissif`.
Posé volontairement #1450 (fiche L68). Un Enregistrer avec sentinelle
poste et Select vide est **accepté**.

Règles draft L945 : `poste_budgetaire_type_id` nullable (sauf PROJET
`required_if`). Pas d’exigence enveloppe.

Front : le Select a un `*` visuel (L8711-8712) mais **aucun** toast
d’enregistrement n’exige la valeur. PrimeVue `show-clear` L8724.

### 2.2 Envoi en validation (POST valider)

Deux asserts, dans cet ordre (L2054 puis L2062) :

**A. `assertRattachementProgrammeBudgetValide` sans permissif**

Vérifie exactement :

| Contrôle | Lignes | Si vrai |
|----------|--------|---------|
| `sans_marche` XOR `rattachement_poste_direct` | L5052-5056 | 422 incompatibles |
| marché XOR enveloppe | L5069-5073 | 422 |
| hors ERP | L5086 → `ProgrammeHorsErpGuard` L55-104 | 422 marché / poste / enveloppe |
| enveloppe existe, active, du programme, type `applicable_budget` | L5088-5114 | 422 |
| retenues interdites si enveloppe | L5116 | 422 |
| **`rattachement_poste_direct` et type NULL, hors brouillon** | **L5119-5122** | **422 type** |
| retenues hors marché PROGRAMMES | L5131-5136 | 422 (#1520) |
| si type renseigné : `applicable_budget` + ligne `postes_budgetaires` du programme | L5158-5176 | 422 |

Ce que cet assert **ne** vérifie **pas** :

- qu’une **enveloppe** `programme_budgets` existe pour ce type ;
- que `programme_budget_id` soit non null (au contraire, le coerce
  l’annule L155) ;
- le Select non vide **si** le type est déjà en base / JSON.

Si l’utilisateur a pu choisir un type dans `postes_charge_directe`
(ligne `postes_budgetaires` déjà là, enveloppe pas encore créée),
L5119 **passe**. C’est le scénario le plus cohérent avec « hydraulique
apparaît ensuite dans le bilan » : le bilan part de `postes_budgetaires`
LEFT JOIN enveloppes (`buildBudgetsAllouesByPoste` L1328-1334).

Si le Select était **vide** (aucune ligne `postes_budgetaires`
`applicable_budget`), L5119 devrait 422 à l’envoi. À confirmer en prod
par le SQL §7 (colonne type NULL vs non NULL). Le métier PHASE 2
**interdit** d’ajouter ce blocage.

**B. `assertRattachementBudgetCompletPourEnvoi` L5186-5241**

FRAIS_GENERAUX / PROJET : return L5194-5196.

PROGRAMMES L5226-5231 :

```
$hasPoste = $posteTypeId !== null || $programmeBudgetId !== null || $rattachementPoste;
if ($sansMarche || $hasMarche || $hasPoste) return;
```

Le **drapeau JSON** compte comme « a un poste ». Même trou front
L7638-7642 : `hasPoste = type renseigné || rattachement_poste_direct`.

Donc l’envoi #1450 est **satisfait par la sentinelle seule**, sans type
et sans enveloppe. Le seul filet restant est L5119 (type obligatoire
hors brouillon). Il ne sauve pas le cas « type choisi, enveloppe
absente » — et c’est précisément ce cas qui rend la facture invisible
au bilan.

Appels de l’assert envoi : valider L2062, déjà validé L2606, valider et
comptabiliser L2650, historique L2737. Pas de 3ᵉ copie.

### 2.3 Comptabilisation

`ComptabilisationController::assertProgrammeChargeAccountConfiguredPourComptabilisation`
L1095-1147 :

- `sans_marche` : return, 471000 réservé (L1101-1103) ;
- `rattachementPoste || programmeBudgetId > 0` : si drapeau **et** type
  ≤ 0 **et** enveloppe ≤ 0 → InvalidArgumentException L1117-1120
  « Choisissez un poste… avant de comptabiliser » ;
- sinon exige un `PosteBudgetaireType` + `accountno` via
  `ProgrammeChargeAccountResolver` L19-41 (enveloppe → marché → type
  explicite).

Une facture NV1 avec drapeau + type + enveloppe NULL **est
comptabilisable** dès que le type a un compte GL. CLASSID APBILL =
classe du **programme**, pas du poste (`classIdForBill` L1478-1500).

Le trou n’est donc **pas** la comptabilisation : c’est l’absence
d’enveloppe dans les états programme, dès l’envoi / NV1.

### 2.4 Pourquoi créer « hydraulique » ne récupère pas

Aucun observer / hook sur `ProgrammeBudget::create`. Le backfill #1033
(`ProgrammeBudgetAncrageHistoriqueBackfillService::selectEligibleDepots`
L155-164) est une commande one-shot : type NOT NULL, marché effectif
NULL, enveloppe effective NULL. Il n’est pas déclenché par le CRUD
Budget. Décision PHASE 2 : **pas** de reprise automatique — conforme.

---

## 3. COMMENT IDENTIFIER UNE FACTURE ORPHELINE

### 3.1 Critère métier retenu

Facture **à imputer** = destination PROGRAMMES, option « rattacher à un
poste », **sans marché**, **sans enveloppe** (invisible bilan), hors
frais généraux / projet / hors ERP / « marché non encore créé ».

Le bilan joint l’enveloppe, pas le type. Une facture avec type
« hydraulique » et `programme_budget_id` NULL est **orpheline d’état**
autant qu’une facture au Select vide. Les deux doivent apparaître dans
la liste « à imputer ». Ne pas exiger `poste_budgetaire_type_id IS NULL`
sinon on manque le cas prod.

`sans_marche = 1` (« Marché non encore créé ») est **un autre objet** :
attente d’un **marché**, pas d’une enveloppe. L’inclure = faux positif
(rapprochement marché, 471000). Hors liste.

### 3.2 Condition SQL (dépôts non supprimés)

```
destination JSON = PROGRAMMES
AND rattachement_poste_direct JSON vrai
AND COALESCE(factures.marche_id, depot_factures.marche_id) IS NULL
AND COALESCE(factures.programme_budget_id, depot_factures.programme_budget_id) IS NULL
AND COALESCE(depot.sans_marche, JSON saisie.sans_marche) = 0
AND programmes.statut <> 'hors_erp'
AND depot.deleted_at IS NULL
AND depot.statut NOT IN ('rejete', 'archive')
```

`destination` n’a **pas** de colonne (`ocr_raw.saisie.destination` seulement,
écrit L1698).

### 3.3 Condition Eloquent correspondante

```
DepotFacture::query()
    ->whereNull('deleted_at')
    ->whereNotIn('statut', [DepotFacture::STATUT_REJETE, DepotFacture::STATUT_ARCHIVE])
    ->where('sans_marche', false)
    ->whereNotNull('programme_id')
    ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'")
    ->where(function ($q) {
        $q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true','1')")
          ->orWhereRaw("JSON_EXTRACT(ocr_raw, '$.saisie.rattachement_poste_direct') = true");
    })
    ->whereRaw('('.DepotFacture::sqlMarcheEffectifId('depot_factures').') IS NULL')
    ->whereRaw('('.DepotFacture::sqlProgrammeBudgetEffectifId('depot_factures').') IS NULL')
    ->whereHas('programme', fn ($p) => $p->where('statut', '<>', Programme::STATUT_HORS_ERP));
```

Faux positifs évités :

| Population | Pourquoi exclue |
|------------|-----------------|
| `marche_id` effectif non null | marché : déjà dans `buildMarchesKpiProgramme` |
| FRAIS_GENERAUX / PROJET | destination JSON ≠ PROGRAMMES |
| hors ERP | `Programme::STATUT_HORS_ERP` L21 ; garde L39-47 force `sans_marche`, interdit le drapeau |
| `sans_marche` | sentinelle marché à créer, pas poste |
| rejet / archive | déjà hors bilan L1299 |

---

## 4. REQUÊTES PAGE BUDGET ET BILAN

### 4.1 Page budget du programme

`ProgrammeBudgetController::index` L24-40 →
`ProgrammeBudgetService::pourProgramme` L165-229.

Lit **uniquement** `programme_budgets` du programme (L167-174).
Sérialise enveloppes groupées par rubrique / type. **Aucune** requête
`depot_factures` / `factures`. Commentaire `estConsomme` L145-155 :
toujours `false` (lot 2 jamais branché).

`resources/js/Pages/Budget/Index.vue` : CRUD enveloppes. Zéro liste
de factures.

L’orpheline n’est pas « filtrée » : **l’écran n’a jamais montré de
dépense**. PHASE 2 = **ajouter** un bloc, pas desserrer un WHERE.

### 4.2 Bilan

Chaîne : `BilanFinancierService` (rubriques L792-817) :

1. `buildMarchesKpiProgramme` L1112 — INNER JOIN `postes_budgetaires`
   sur `marches.poste_budgetaire_type_id` L1117-1121. Hors marché
   **exclu** (pas de `marche_id`).
2. `buildDepotsHorsMarcheKpiParPoste` L1278-1317 — **INNER JOIN**
   `programme_budgets` sur id effectif L1290 + INNER JOIN
   `postes_budgetaires` L1291-1294 + `WHERE budget effectif IS NOT NULL`
   L1297 + marché effectif IS NULL L1296.
3. `buildBudgetsAllouesByPoste` L1326 — FROM `postes_budgetaires`
   LEFT JOIN enveloppes actives. C’est ici qu’« hydraulique » apparaît
   après création d’enveloppe (sous-ligne `__is_budget`,
   `Bilan/Index.vue` L155 / L1051). Sans facture.

Exclusion orpheline = **jointure interne + filtre id enveloppe**, pas
un oubli de ligne. Une facture type-only n’a pas de clé de join.

Totaux poste L975-983 : `facture_valide` / `facture_en_attente` =
marchés **+** hors-marché **déjà ancrés enveloppe**. Ces totaux
remontent dans `totaux_charges` L896-904 (et marges L907).

### 4.3 Où intervenir sans fausser les totaux

**Ne pas** élargir `buildDepotsHorsMarcheKpiParPoste` (INNER → LEFT ou
repli sur `poste_budgetaire_type_id`) : toute orpheline entrerait dans
`facture_valide` / `en_attente` **du poste** (L975-983) puis dans les
totaux rubrique / programme / marges. Si le type est NULL, pas de
`poste_budgetaire_id` : ligne orpheline dans un bucket inventé, ou
perte. Si le type est connu, gonflement silencieux du poste
« hydraulique » **avant** rattachement — contraire au point critique.

**Faire** : requête **séparée** (même critère §3), payload dédié
(`factures_a_imputer`: liste + `total_ht`), rendu :

- page Budget : bloc au-dessus ou sous les enveloppes ;
- Bilan : bandeau / section hors tableau des postes, montant total
  affiché **à part**, **non** additionné à `totaux_charges.facture_valide`
  ni aux colonnes poste.

Après rattachement PHASE 2 (écriture `programme_budget_id`), la facture
**sort** de ce bloc et **entre** dans `buildDepotsHorsMarcheKpiParPoste`
sur le bon poste — c’est le seul moment où elle doit bouger les totaux.

Pas d’export CSV/Excel dédié dans `BilanFinancierController`. Risque
export = uniquement si un lot ultérieur réutilise le payload bilan
sans ignorer la clé séparée.

---

## 5. LE RATTACHEMENT APRÈS COUP

### 5.1 Quoi écrire (plusieurs endroits, pas une colonne)

Pour qu’elle **apparaisse au bilan**, l’enveloppe est obligatoire :

| Cible | Champ | Valeur |
|-------|--------|--------|
| `depot_factures` | `programme_budget_id` | id enveloppe choisie |
| `factures` | `programme_budget_id` | **le même** (id effectif = COALESCE facture, dépôt) |
| `ocr_raw.saisie` | `programme_budget_id` | le même (sinon prochaine saisie / coerce relit un null) |
| `depot_factures` | `poste_budgetaire_type_id` | type de l’enveloppe (`programme_budgets.poste_budgetaire_type_id`) |
| `ocr_raw.saisie` | `poste_budgetaire_type_id` | le même |
| `ocr_raw.saisie` | `rattachement_poste_direct` | true |
| colonnes + JSON | `marche_id` | null |
| `depot_factures.sans_marche` + JSON | `sans_marche` | false |

Écrire seulement le type (comme la saisie actuelle) **ne suffit pas** :
le bilan continue d’exiger l’enveloppe. N enveloppes possibles par
type (`programme_budgets`) : le rattachement PHASE 2 doit viser une
**enveloppe** (`programme_budget_id`), le type se dérive.

Liste blanche serveur des champs (règle mises à jour partielles) :
`programme_budget_id` uniquement, appartenant au `programme_id` du
dépôt, actif, non soft-deleted, type `applicable_budget`. Dériver le
reste. Interdire si marché effectif non null.

### 5.2 Services existants — réutiliser / ne pas réutiliser

**Réutiliser** `FactureMarcheDoubleAncrageService::appliquer` L187-269 :
sait écrire dépôt + facture + JSON (`syncOcrRawSaisieMarche` L492-528)
pour `programme_budget_id` / `marche_id` / `sans_marche` / type si
vide. Déclenche le recalcul bilan. Appelé aujourd’hui au PATCH après
engagement L1848 et au déplacement marché.

Point d’attention : `appliquer` L207-209, si `marcheId` non null
l’enveloppe est annulée (marché gagne). Pour un orphelin, passer
`marcheId: null`, `sansMarche: false`, `programmeBudgetId: $enveloppe`.
Le drapeau `rattachement_poste_direct` n’est **pas** posé par
`syncOcrRawSaisieMarche` — à ajouter dans le wrapper PHASE 2 (JSON
`true`) sinon un prochain coerce L158-162 peut le remettre à false.

**Ne pas** réutiliser `DepotFactureProjetAncrage` : destination PROJET,
`depuisValidated` L20-24 met le type à null hors PROJET.

**Ne pas** réutiliser `MarcheDeplacerFactureService` : `assertDeplacable`
L141-144 exige un marché source. Table `facture_reaffectations`
(`FactureController` L450-457) = historique **marché → marché** seulement
(`ancien_marche_id` / `nouveau_marche_id`). Pas d’historique enveloppe.
PHASE 2 : `depot->logHistorique` suffit ; ne pas élargir cette table.

`ProgrammeBudgetAncrageHistoriqueBackfillService` : commande #1033,
hors périmètre (reprise auto interdite).

### 5.3 Sage — CLASSID vs compte de charge

CLASSID APBILL = classe **programme** (`ComptabilisationController`
L1478-1500). Changer l’enveloppe / le type **ne change pas** CLASSID.

Compte de charge = `ProgrammeChargeAccountResolver` (type de
l’enveloppe ou type explicite) → `accountno` GL. Les lignes
`lignes_comptables` / APBILL portent ce numéro. Avertissement déjà
existant si le compte saisi ≠ poste
(`avertissementCompteChargeDifferentDuPoste` L2562-2571 ; le
`sans_marche` short-circuite, pas le rattachement poste).

Si `intacct_bill_id` (dépôt ou facture) est non vide : l’écriture Sage
est **déjà partie**. Modifier le poste en ERP **sans** recréer l’APBILL
= désynchronisation compte de charge ERP/Sage. Même famille que
`MarcheDeplacerFactureService::construireAvertissements` L154-159
(déplacement marché : « ne modifie pas l’écriture Sage »).

**Verdict** : rattachement après comptabilisation n’est pas
« impossible » techniquement (pas de CLASSID poste), mais **interdit
en PHASE 2 hors override Admin ERP**, avec le même avertissement que
#658 (`FactureAdminErpEditOverride` : ERP only, pas de sync Sage).

### 5.4 Statuts — possible / interdit

| Statut dépôt | Rattachement orpheline |
|--------------|------------------------|
| `en_cours_saisie` / avant envoi | possible, mais l’écran dédié vise surtout le post-envoi ; la fiche saisie reste utilisable |
| `en_validation_nv1`, `en_validation_nv2`, `a_comptabiliser` | **autoriser** (décision : correction après coup, envoi non bloqué) |
| `comptabilisee`, `valide`, `paye` + `intacct_bill_id` non vide | **interdire** sauf GS_ADMIN_ERP (override, bandeau Sage) |
| `rejete`, `archive` | interdire |

`applyDepotSaisieFromValidated` L1563-1566 refuse déjà toute
modification post-compta hors Admin ERP. Un endpoint dédié doit
reproduire ce cran, pas le contourner.

`canEditDepot` L4605-4649 : GS_COMPTABILITE (tableau `groupes_m365`
**brut**) peut modifier jusqu’avant comptabilisation ; DG jusqu’à NV2 ;
déposant NV1 selon #1276. Le bouton « rattacher » de la liste budget /
bilan ne doit **pas** exiger d’ouvrir la fiche, mais les **mêmes**
groupes (voir §6).

---

## 6. DROITS

Lecture **brute** `User::m365GroupNames()` L119-121 =
`$this->m365_groups` tableau. `hasM365Group` / `hasAnyM365Group`
L134-152 : si `isAdminERP()` (L103-113 = `GS_ADMIN_ERP` **dans**
`m365_groups`) → **true pour n’importe quel groupe**.
`Gate::before` `AppServiceProvider` L69-74 : idem, tout Gate true pour
cet utilisateur.

Piège déjà en place : `ProgrammeBudgetController::userCanWrite` L104-107
utilise `hasAnyM365Group(['GS_DIRECTION_GENERALE', 'GS_ADMIN_ERP'])` —
inutilisable pour un test de droit PHASE 2.

Référence saine : `canEditDepot` L4630-4644 intersecte
`$user->groupes_m365` (alias brut L129-131) avec
`GS_COMPTABILITE` / `GS_ADMIN_ERP`, **sans** Gate.

Proposition PHASE 2 (rattacher depuis budget / bilan), `in_array` sur
`m365GroupNames()` :

- `GS_COMPTABILITE` — saisit et corrige les factures ;
- `GS_DIRECTION_GENERALE` — écrit déjà les enveloppes (intention UI
  Budget `Index.vue` L31-33, à recabler en brut) ;
- `GS_ADMIN_ERP` — plus override post-Sage.

Pas GS_DSI par défaut (aligné `canEditDepot` post-compta L4618).
Pas de Gate. Page Budget : conserver `programmeAccessService` L95-96
(accès programme) **en plus** du groupe.

---

## 7. VOLUME — SQL à coller en prod

Pas d’accès MySQL ici. `rattachement_poste_direct` n’est pas une
colonne.

Les requêtes sont dans la SYNTHÈSE copiable en fin de fiche.

---

## 8. RISQUES

### 8.1 Afficher sans précaution dans le bilan

| Risque | Gravité | Pourquoi |
|--------|---------|----------|
| Double comptage / totaux poste gonflés | **Haute** si on LEFT JOIN ou si on additionne le type dans `buildDepotsHorsMarcheKpiParPoste` | L975-983 additionne hors-marché **dans** le poste |
| Marges / `totaux_charges` faux | **Haute** même cause, L896-907 | |
| Ligne orpheline dans un poste inventé | Moyenne | type NULL → pas de `postes_budgetaires.id` |
| Factures marché / FG / hors ERP dans le bloc | Moyenne | critère §3 trop large |
| `sans_marche` dans « à imputer » | Moyenne | fausse piste, ces factures attendent un marché |
| Export | Faible aujourd’hui | pas d’export bilan ; danger si un consommateur itère toutes les clés du payload |

Les factures **déjà** ancrées enveloppe : ne pas les toucher (garde-fou).
Le nouveau SELECT est disjoint (`programme_budget_id` effectif IS NULL).

### 8.2 Rattacher après comptabilisation

Désync ERP/Sage sur **ACCOUNTNO** de charge, pas CLASSID. L’APBILL
n’est pas mis à jour (`FactureAdminErpEditOverride` L12-13). Interdire
hors Admin ERP.

Changer d’enveloppe après rattachement « propre » (déjà dans les
totaux poste) ferait **basculer** les KPI d’un poste à l’autre. Hors
périmètre PHASE 2 (on ne propose que orpheline → enveloppe, une fois).

### 8.3 Ne pas modifier le comportement des factures imputées

Imputée = `programme_budget_id` effectif non null **ou** `marche_id`
effectif non null. Hors du SELECT orphelines. Ne pas changer
`buildDepotsHorsMarcheKpiParPoste` ni `buildMarchesKpiProgramme`.

---

## PÉRIMÈTRE PHASE 2 (proposition, pas un fix)

Un lot, pas de migration, pas de backfill, pas de 422 nouveau à l’envoi.

1. Liste « Factures à imputer » (critère §3) sur **Budget/Index** et
   **Bilan**, total HT séparé, **non** versé dans les totaux par poste.
2. Action rattacher : choix d’une **enveloppe** du programme (pas
   seulement un type). Écriture via `FactureMarcheDoubleAncrageService::appliquer`
   + drapeau JSON `rattachement_poste_direct=true` + type dérivé.
   Liste blanche `programme_budget_id`. `logHistorique`.
3. Droits : `m365GroupNames()` brut, groupes §6. Interdit si marché
   effectif, hors ERP, rejet/archive, ou Sage (`intacct_bill_id`) hors
   Admin ERP.
4. Ne pas bloquer l’envoi. Ne pas élargir L5119. Ne pas rejouer #1033.
5. Ne pas modifier `buildDepotsHorsMarcheKpiParPoste` / totaux poste.
6. Journal utilisateur (impact visible). Rebuild Vite (Budget + Bilan
   = pages distinctes ; `AppShell` non touché si le menu inchangé).
7. Tests source : assert SQL orpheline n’inclut pas FG / marché /
   hors ERP / `sans_marche` ; assert totaux bilan inchangés si le bloc
   est non vide.

---

## DÉPLOIEMENT-TEST

```text
git pull origin main
```

Pas de migrate, journal:sync, npm, optimize. Coller les SQL du §7.

## LEÇON

Le bilan hors marché s’ancre sur `programme_budget_id` (#1035) ; la
saisie « rattacher à un poste » n’écrit que `poste_budgetaire_type_id`
et **force l’enveloppe à null** (coerce L155). Créer l’enveloppe plus
tard ne rejoue pas le backfill #1033. L’envoi #1450 compte le drapeau
JSON comme un poste : L5119 ne rattrape que le Select vraiment vide,
pas l’absence d’enveloppe. Afficher les orphelines = payload **à
côté** des totaux poste, jamais un LEFT JOIN dans
`buildDepotsHorsMarcheKpiParPoste`.

---

```markdown
## SUIVI #1523 — SYNTHÈSE diagnostic factures orphelines budget

### Verdict
Invisible parce que le bilan hors marché INNER JOIN programme_budgets
via programme_budget_id effectif (BilanFinancierService L1289-1297)
et que la page Budget ne liste aucune facture (pourProgramme L165-229).
La saisie « Pas de marché, rattacher à un poste » n’envoie jamais
programme_budget_id (grep Vue = 0) et le coerce L151-157 le force à
null. poste_budgetaire_id (#1490) a été droppée #1492 : remplacée par
poste_budgetaire_type_id (type) + programme_budget_id (enveloppe).
Créer l’enveloppe « hydraulique » n’écrit rien sur la facture.

### 1. Imputation
Colonne morte : depot_factures.poste_budgetaire_id (drop #1492).
PROGRAMMES hors marché : type sur le dépôt + JSON ; enveloppe sur
dépôt ET facture + JSON ; drapeau rattachement_poste_direct JSON seul.
Écriture : applyDepotSaisieFromValidated (JSON L1701-1705, colonnes
appendDirectFkColumnsToUpdates L1781-1787). Valider create facture
L2234 sans programme_budget_id. Helper Projet (#1513) hors sujet.

### 2. Pourquoi rien n’a bloqué
Enregistrement : brouillonPermissif saute L5119 (type obligatoire).
Envoi : assertRattachementBudgetCompletPourEnvoi L5227 compte le
drapeau comme hasPoste ; même trou Vue L7640-7641. L5119 hors
brouillon 422 seulement si type NULL — un type choisi dans
postes_budgetaires (sans enveloppe) passe. Comptabilisation : filet
compte GL L1117, CLASSID = programme L1478-1500, pas l’enveloppe.
assertRattachementProgrammeBudgetValide ne vérifie PAS l’existence
d’une enveloppe.

### 3. Orpheline (pas de faux positifs)
PROGRAMMES JSON + rattachement_poste_direct JSON + marché effectif
NULL + enveloppe effective NULL + sans_marche = 0 + programme
statut <> hors_erp + pas rejet/archive. Ne pas filtrer sur type NULL
(cas prod type renseigné, enveloppe absente). Exclure sans_marche
(attente marché), FG, PROJET, marchés, hors ERP.

### 4. Requêtes / totaux
Budget : aucune requête facture — ajouter un bloc.
Bilan : ne PAS toucher buildDepotsHorsMarcheKpiParPoste (sinon totaux
poste L975-983 et totaux_charges L896-904 faux). Payload séparé,
montant affiché à part.

### 5. Rattacher après coup
Écrire enveloppe (dépôt + facture + JSON) + type dérivé + drapeau
JSON. Service : FactureMarcheDoubleAncrageService::appliquer (pas
DepotFactureProjetAncrage, pas MarcheDeplacerFactureService /
facture_reaffectations). Sage : CLASSID inchangé ; ACCOUNTNO de
charge = type. Après intacct_bill_id : interdit hors Admin ERP
(désync GL, pas CLASSID). Autoriser nv1/nv2/a_comptabiliser.

### 6. Droits
m365GroupNames() brut. GS_COMPTABILITE + GS_DIRECTION_GENERALE +
GS_ADMIN_ERP. Jamais hasAnyM365Group / Gate (Gate::before L69-74).
userCanWrite Budget L104-107 est déjà le piège.

### 8. Risques
LEFT JOIN hors-marché → totaux/marges faux. Reprise auto #1033
interdite. Post-Sage → ERP/Sage compte de charge. Factures déjà
ancrées enveloppe ou marché : hors SELECT, intactes.

### PHASE 2
Bloc « à imputer » Budget + Bilan (total séparé). Rattacher vers
une enveloppe, liste blanche, logHistorique. Pas de 422 envoi, pas
de migration, pas de backfill. Journal + rebuild Vite Budget/Bilan.

### SQL 1 — volume orphelines par statut
SELECT d.statut, COUNT(*) AS nb,
       ROUND(SUM(COALESCE(d.montant_ht, 0)), 2) AS montant_ht
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id
LEFT JOIN programmes p ON p.id = d.programme_id
WHERE d.deleted_at IS NULL
  AND d.statut NOT IN ('rejete', 'archive')
  AND COALESCE(d.sans_marche, 0) = 0
  AND JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'
  AND (
    JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
  )
  AND COALESCE(f.marche_id, d.marche_id) IS NULL
  AND COALESCE(f.programme_budget_id, d.programme_budget_id) IS NULL
  AND (p.id IS NULL OR p.statut <> 'hors_erp')
GROUP BY d.statut
ORDER BY nb DESC;

### SQL 2 — montant par programme
SELECT d.programme_id, p.libelle AS programme_libelle, p.statut AS programme_statut,
       COUNT(*) AS nb,
       ROUND(SUM(COALESCE(d.montant_ht, 0)), 2) AS montant_ht,
       SUM(d.poste_budgetaire_type_id IS NULL) AS nb_sans_type,
       SUM(d.poste_budgetaire_type_id IS NOT NULL) AS nb_avec_type
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id
LEFT JOIN programmes p ON p.id = d.programme_id
WHERE d.deleted_at IS NULL
  AND d.statut NOT IN ('rejete', 'archive')
  AND COALESCE(d.sans_marche, 0) = 0
  AND JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'
  AND (
    JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
  )
  AND COALESCE(f.marche_id, d.marche_id) IS NULL
  AND COALESCE(f.programme_budget_id, d.programme_budget_id) IS NULL
  AND (p.id IS NULL OR p.statut <> 'hors_erp')
GROUP BY d.programme_id, p.libelle, p.statut
ORDER BY montant_ht DESC;

### SQL 3 — déjà dans Sage (potentiellement non corrigeables)
SELECT d.id, d.uuid, d.statut, d.programme_id, p.libelle,
       d.montant_ht, d.poste_budgetaire_type_id, pbt.libelle AS type_libelle,
       d.intacct_bill_id AS depot_bill, f.intacct_bill_id AS facture_bill,
       f.intacct_record_no
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id
LEFT JOIN programmes p ON p.id = d.programme_id
LEFT JOIN postes_budgetaires_types pbt ON pbt.id = d.poste_budgetaire_type_id
WHERE d.deleted_at IS NULL
  AND d.statut NOT IN ('rejete', 'archive')
  AND COALESCE(d.sans_marche, 0) = 0
  AND JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'
  AND (
    JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
  )
  AND COALESCE(f.marche_id, d.marche_id) IS NULL
  AND COALESCE(f.programme_budget_id, d.programme_budget_id) IS NULL
  AND (p.id IS NULL OR p.statut <> 'hors_erp')
  AND (
    (d.intacct_bill_id IS NOT NULL AND TRIM(d.intacct_bill_id) <> '')
    OR (f.intacct_bill_id IS NOT NULL AND TRIM(f.intacct_bill_id) <> '')
    OR (f.intacct_record_no IS NOT NULL AND TRIM(f.intacct_record_no) <> '')
  )
ORDER BY d.id DESC;

### SQL 4 — détail orphelines (contrôle type vide vs type sans enveloppe)
SELECT d.id, d.uuid, d.statut, d.programme_id, p.libelle,
       d.sans_marche, d.marche_id, d.poste_budgetaire_type_id, d.programme_budget_id,
       f.marche_id AS facture_marche_id, f.programme_budget_id AS facture_budget_id,
       d.montant_ht, d.intacct_bill_id,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) AS drapeau,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.poste_budgetaire_type_id')) AS json_type,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.programme_budget_id')) AS json_enveloppe
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id
LEFT JOIN programmes p ON p.id = d.programme_id
WHERE d.deleted_at IS NULL
  AND d.statut NOT IN ('rejete', 'archive')
  AND COALESCE(d.sans_marche, 0) = 0
  AND JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'
  AND (
    JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
  )
  AND COALESCE(f.marche_id, d.marche_id) IS NULL
  AND COALESCE(f.programme_budget_id, d.programme_budget_id) IS NULL
  AND (p.id IS NULL OR p.statut <> 'hors_erp')
ORDER BY d.id DESC;
```
