---
suivi: 1611
date: 2026-09-18
sujet: Diagnostic — factures à imputer (orphelines d’enveloppe budgétaire)
chantier: budgets-programme / depot-factures
type: diagnostic
statut: poussé
hash: 6796f1ca
fichiers:
  - docs/suivi/SUIVI_1611_diagnostic_factures_a_imputer.md
---

# SUIVI #1611 — diagnostic factures « À imputer » (aucun code applicatif)

## PROMPT ENVOYÉ

Le #1523 avait diagnostiqué des factures orphelines du budget (12
factures, 42 800 € HT, 8 programmes actifs) — jamais corrigé, chiffre
non réactualisé. Une orpheline porte `factures.programme_id` mais pas
`factures.programme_budget_id` : comptée sur le programme, invisible
du budget et du bilan. Objectif métier visé (PAS à coder ici) : zone
« À imputer » dans l’onglet Budget, imputation MANUELLE. Aucun
rattrapage auto, aucune déduction marché, aucun blocage à la source.
Libellé : « À imputer » (pas « non attribué »). Diagnostic lecture
seule. Mesure EN PRODUCTION. Fiche seule.

## BASE GIT

HEAD = `origin/main` = `ffc5210c` (chore hash fiche #1610).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1523 LEÇON** — le bilan hors marché INNER JOIN `programme_budgets`
  via id effectif ; la saisie « rattacher à un poste » n’écrit que le
  type et **force l’enveloppe à null** (coerce). Créer l’enveloppe ne
  rejoue pas le backfill #1033. L’envoi #1450 compte le drapeau JSON
  comme un poste. Impact : le trou est **toujours ouvert** ; ce lot
  actualise la mesure (année = critère décisif) et l’emplacement UI,
  sans changer le diagnostic de cause.
- **#1490 / #1492 LEÇON** — un `Schema::hasColumn` autour d’une colonne
  jamais migrée (puis droppée) = garde-fou mort. `poste_budgetaire_id`
  dépôt créée #1490, **drop** #1492. Impact : ne pas la ressusciter ;
  l’ancrage PROGRAMMES hors marché reste `poste_budgetaire_type_id`
  (type) + `programme_budget_id` (enveloppe).
- **#1448 LEÇON** — postes dossier sur la même table `postes_budgetaires`
  (XOR programme/dossier). Garde-fou écrit pour des colonnes *futures*.
  Impact : ce garde-fou est le filet **PROJET** (suppression de poste
  dossier), pas l’imputation PROGRAMMES. Vérifié ci-dessous, pas
  supposé.
- **#1035 / #1029 LEÇON** — id effectif enveloppe =
  `COALESCE(factures.programme_budget_id, depot_factures.programme_budget_id)`.
  Impact : le critère « colonne facture NULL » du prompt **n’est pas**
  le critère d’invisibilité bilan. Les deux mesures sont fournies.
- **#1033 LEÇON** — backfill one-shot écrit l’enveloppe depuis le type.
  Interdit ici (rattrapage auto exclu par Robin).
- **#1450 LEÇON** — enregistrement permissif ; envoi satisfait par le
  drapeau `rattachement_poste_direct`.
- **#1025 LEÇON** — page Budget = CRUD enveloppes, zéro liste de
  factures. `estConsomme` toujours `false`.
- **#1604 LEÇON** — `saisie_historique` ≠ type de pièce ; `Gate::before`
  / compte Robin ne prouve aucun 403. Impact : l’imputation post-Sage
  et les droits doivent passer par `m365GroupNames()` brut ;
  `SaisieHistoriqueEdition` est le modèle de référence.
- **#1610** — MySQL local `127.0.0.1:3306` refusé. Même constat ici.

Aucune fiche #1605/#1606/#1607/#1609 n’a changé l’ancrage enveloppe
ni la page Budget.

---

## ACCÈS DONNÉES — PROD INACCESSIBLE DEPUIS CURSOR

`Schema::getColumnListing()` tenté le 2026-09-18 via bootstrap Laravel :

```
DB=127.0.0.1:3306/erp_immo
FAIL: SQLSTATE[HY000] [2002] Aucune connexion n’a pu être établie car
l’ordinateur cible l’a expressément refusée
```

La base locale Cursor n’a **pas** les données de production. **Aucun
chiffre de ce lot n’est une mesure prod.** Les 12 factures / 42 800 € HT
/ 8 programmes du prompt sont la mesure Robin du #1523 (SQL collé dans
la fiche #1523, résultats non versionnés ici, date non figée). Ils ne
sont **pas** réactualisés.

Colonnes ci-dessous = dump phpMyAdmin prod `hectareenvol.sql` généré
le **2026-09-11** (schéma seul, zéro INSERT `factures`) + fillable
modèles + migrations postérieures au dump. Pas d’invention.

### Colonnes constatées (pas inventées)

**`factures`** (dump 11 sept. + fillable #1604) : `id`, `programme_id`
nullable, `entite_id`, `marche_id` nullable, `programme_budget_id`
nullable, `fournisseur_id`, `type_facture_id`, `reference`,
`numero_facture_fournisseur`, `montant_ht`, `montant_ttc`,
`date_facture` NOT NULL, `statut` ENUM
(`recue`,`en_validation_nv1`,`en_validation_nv2`,`a_comptabiliser`,
`comptabilisee`,`en_paiement`,`payee`,`rejetee`), `source` ENUM
(`erp_upload`,`intacct_import`,`facturx_intacct`), `intacct_bill_id`,
`intacct_record_no`, `deleted_at`, `paiement_manuel_at` (#1604, après
le dump). **Absentes** : `poste_budgetaire_id`, `dossier_foncier_id`,
`validateur_id`.

**`depot_factures`** : `programme_id`, `marche_id`,
`poste_budgetaire_type_id`, `dossier_foncier_id`, `programme_budget_id`
nullable, `facture_id`, `sans_marche`, `saisie_historique`,
`type_source`, `ocr_raw`, `intacct_bill_id`, `deleted_at`, `statut`.
**Absentes** : `poste_budgetaire_id` (drop #1492), `validateur_id`.

**`programmes`** : **pas de colonne `code`**. Identifiants métiers
disponibles : `code_analytique`, `code_envol`, `code_societe`,
`libelle` (jamais `nom`). `statut` : `actif` / `inactif` / `hors_erp`
(`Programme::STATUT_*`).

**`programme_budgets`** : `id`, `programme_id`, `poste_budgetaire_type_id`,
`libelle`, `montant_ht`, `actif`, `deleted_at` (SoftDeletes).

### SoftDeletes vs Query Builder

`Facture` et `DepotFacture` utilisent `SoftDeletes`. Toute requête
`DB::table('factures')` **compte les supprimées** sauf `deleted_at IS
NULL` explicite. Les SQL collés plus bas filtrent `deleted_at IS NULL`.

Asymétrie déjà en production dans le bilan : `buildDepotsHorsMarcheKpiParPoste`
(`BilanFinancierService.php` L1291-1301) = `DB::table('depot_factures as d')`
avec `d.deleted_at IS NULL` explicite. En revanche
`DepotFacture::sqlProgrammeBudgetEffectifId` L433 =

`COALESCE((SELECT f.programme_budget_id FROM factures f WHERE f.id = {$depotTable}.facture_id LIMIT 1), …)`

Cette sous-requête **n’a pas** `f.deleted_at IS NULL`. Si une facture
est soft-deleted et qu’un dépôt vivant pointe encore dessus, l’id
enveloppe de la facture morte gagne sur celui du dépôt. Cas limite,
à ne pas élargir.

`ProgrammeBudgetService::pourProgramme` L167 = Eloquent
`ProgrammeBudget::query()` → SoftDeletes **appliqué** (enveloppes
supprimées exclues). Cohérent.

---

## A. LA MESURE — REQUÊTES À EXÉCUTER EN PROD

Trois populations distinctes. Ne pas les fusionner.

| # | Nom | Critère | Sert à |
|---|-----|---------|--------|
| A0 | Colonne facture NULL (prompt) | `factures.programme_budget_id IS NULL` + vivant | Répondre littéralement à A |
| A1 | Invisibles bilan | id **effectif** enveloppe NULL (`COALESCE` facture, dépôt) + marché effectif NULL + PROGRAMMES + drapeau poste + pas `sans_marche` + pas hors ERP | Zone « À imputer » |
| A2 | 2026 seul | A1 ∩ `YEAR(date_facture) = 2026` | Continuité du flux |

**A0 est trop large** : toutes les factures **marché** ont
`programme_budget_id` NULL par conception (exclusivité #1029 : le
marché gagne, l’enveloppe est forcée à null). Les compter comme
« à imputer » noierait la zone. D’où A1 = critère métier retenu
(#1523 §3), toujours valide.

Robin pense que `programme_id` NULL n’existe pas sur une facture
vivante hors frais généraux. **À vérifier** (SQL A0b), ne pas
supposer.

### SQL A0 — volume brut colonne facture (prompt)

```sql
SELECT
  COUNT(*) AS nb_vivantes_sans_enveloppe,
  SUM(f.programme_id IS NOT NULL) AS nb_avec_programme_id,
  SUM(f.programme_id IS NULL) AS nb_sans_programme_id,
  SUM(f.marche_id IS NOT NULL) AS nb_avec_marche_id,
  SUM(f.marche_id IS NULL) AS nb_sans_marche_id,
  ROUND(SUM(COALESCE(f.montant_ht, 0)), 2) AS ht,
  ROUND(SUM(COALESCE(f.montant_ttc, 0)), 2) AS ttc
FROM factures f
WHERE f.deleted_at IS NULL
  AND f.programme_budget_id IS NULL;
```

### SQL A0b — sans programme_id (hypothèse Robin)

```sql
SELECT f.id, f.reference, f.statut, f.source, f.date_facture,
       f.montant_ht, f.montant_ttc, f.marche_id, f.entite_id
FROM factures f
WHERE f.deleted_at IS NULL
  AND f.programme_budget_id IS NULL
  AND f.programme_id IS NULL
ORDER BY f.date_facture DESC;
```

Attendu probable : frais généraux (destination JSON dépôt = FRAIS_GENERAUX)
et éventuellement le chemin `FactureController::store` si un type sans
marché obligatoire a été créé sans programme… mais `store` force
`programme_id => $programme->id` (L299). Donc `programme_id` NULL =
création dépôt destination ≠ PROGRAMMES (`valider` L2369 :
`$programmeIdCreate` depuis le validated, null si FG).

### SQL A0c — ventilation A0 par statut / année / programme

```sql
-- par statut
SELECT f.statut, COUNT(*) AS nb,
       SUM(f.programme_id IS NOT NULL) AS nb_avec_prog,
       SUM(f.marche_id IS NOT NULL) AS nb_avec_marche,
       ROUND(SUM(COALESCE(f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(f.montant_ttc, 0)), 2) AS ttc
FROM factures f
WHERE f.deleted_at IS NULL AND f.programme_budget_id IS NULL
GROUP BY f.statut ORDER BY nb DESC;

-- par année de date_facture
SELECT YEAR(f.date_facture) AS annee, COUNT(*) AS nb,
       SUM(f.programme_id IS NOT NULL AND f.marche_id IS NULL) AS nb_prog_sans_marche,
       ROUND(SUM(COALESCE(f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(f.montant_ttc, 0)), 2) AS ttc
FROM factures f
WHERE f.deleted_at IS NULL AND f.programme_budget_id IS NULL
GROUP BY YEAR(f.date_facture) ORDER BY annee;

-- par programme (code_analytique : PAS de colonne programmes.code)
SELECT p.id, p.code_analytique, p.code_envol, p.libelle, p.statut AS programme_statut,
       COUNT(*) AS nb,
       SUM(f.marche_id IS NOT NULL) AS nb_avec_marche,
       ROUND(SUM(COALESCE(f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(f.montant_ttc, 0)), 2) AS ttc
FROM factures f
LEFT JOIN programmes p ON p.id = f.programme_id
WHERE f.deleted_at IS NULL AND f.programme_budget_id IS NULL
GROUP BY p.id, p.code_analytique, p.code_envol, p.libelle, p.statut
ORDER BY ht DESC;
```

### SQL A1 — « À imputer » (critère métier, à coller en prod)

Même filtre que #1523 §3, plus année et TTC. Jointure dépôt : une
facture PROGRAMMES hors marché n’est invisible du bilan que si
**l’id effectif** enveloppe est NULL.

```sql
SELECT
  COUNT(*) AS nb,
  ROUND(SUM(COALESCE(d.montant_ht, f.montant_ht, 0)), 2) AS ht,
  ROUND(SUM(COALESCE(d.montant_ttc, f.montant_ttc, 0)), 2) AS ttc,
  SUM(COALESCE(f.marche_id, d.marche_id) IS NOT NULL) AS nb_avec_marche_id,
  SUM(d.saisie_historique = 1) AS nb_historique,
  SUM(
    (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) <> '')
  ) AS nb_deja_sage
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
LEFT JOIN programmes p ON p.id = COALESCE(d.programme_id, f.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');
```

### SQL A1 année (décisif)

```sql
SELECT YEAR(COALESCE(f.date_facture, d.date_facture)) AS annee,
       COUNT(*) AS nb,
       ROUND(SUM(COALESCE(d.montant_ht, f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(d.montant_ttc, f.montant_ttc, 0)), 2) AS ttc,
       SUM(d.saisie_historique = 1) AS nb_historique,
       SUM(
         (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) <> '')
       ) AS nb_deja_sage
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
LEFT JOIN programmes p ON p.id = COALESCE(d.programme_id, f.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 YEAR(COALESCE(f.date_facture, d.date_facture))
ORDER BY annee;
```

Lecture : **si 2026 > 0, le flux est toujours ouvert.** Le coût du
blocage différé = somme HT des années encore ouvertes, croissant tant
que la saisie « rattacher à un poste » n’écrit pas l’enveloppe.

### SQL A1 statut / programme (actifs)

```sql
-- par statut dépôt
SELECT d.statut, COUNT(*) AS nb,
       ROUND(SUM(COALESCE(d.montant_ht, f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(d.montant_ttc, f.montant_ttc, 0)), 2) AS ttc
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
LEFT JOIN programmes p ON p.id = COALESCE(d.programme_id, f.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;

-- par programme
SELECT p.id, p.code_analytique, p.code_envol, p.libelle, p.statut AS programme_statut,
       COUNT(*) AS nb,
       ROUND(SUM(COALESCE(d.montant_ht, f.montant_ht, 0)), 2) AS ht,
       ROUND(SUM(COALESCE(d.montant_ttc, f.montant_ttc, 0)), 2) AS ttc,
       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 AND f.deleted_at IS NULL
LEFT JOIN programmes p ON p.id = COALESCE(d.programme_id, f.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 p.id, p.code_analytique, p.code_envol, p.libelle, p.statut
ORDER BY ht DESC;
```

### SQL A1 impact totaux (inclusion ou non)

Même WHERE que A1, ventilé par le statut qui alimente aujourd’hui
`facture_valide` vs `facture_en_attente` du bilan
(`statutsDepotPayesMarcheKpi` vs `sqlConditionCompteDansFactureApresNv1Valide`).
À coller tel quel : c’est le **delta euro** si on incluait les
« À imputer » dans les totaux existants.

```sql
SELECT
  ROUND(SUM(COALESCE(d.montant_ht, 0)), 2) AS ht_total_a_imputer,
  ROUND(SUM(CASE WHEN d.statut IN ('comptabilisee', 'valide', 'paye')
                 THEN COALESCE(d.montant_ht, 0) ELSE 0 END), 2) AS ht_qui_irait_dans_facture_valide,
  ROUND(SUM(CASE WHEN d.statut NOT IN ('comptabilisee', 'valide', 'paye')
                 THEN COALESCE(d.montant_ht, 0) ELSE 0 END), 2) AS ht_qui_irait_dans_en_attente
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
LEFT JOIN programmes p ON p.id = COALESCE(d.programme_id, f.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');
```

---

## B. LA CAUSE

### B.1 Tous les `Facture::create` applicatifs

Aucun `Facture::create` dans `app/Console`. SIWI ne crée **pas** de
ligne `factures` (grep `Facture::` dans `app/**/Siwi*.php` = 0) : la
reprise écrit `marches` + `marche_reprises` (`facture_ht` agrégé,
`SiwiRepriseActionService` L377-391).

| # | Fichier | Ligne | Chemin | Copie `programme_budget_id` ? |
|---|---------|-------|--------|-------------------------------|
| 1 | `DepotFactureController::valider` | 2368-2391 | Envoi NV1 / déjà réglée | **NON** (marche_id copié, enveloppe absente du tableau) |
| 2 | `DepotFactureController::validerHistorique` | 2927-2950 | Saisie historique #1527/#1604 | **NON** — statut `payee` d’emblée |
| 3 | `DepotFactureEngagerWorkflowService` | 93-116 | Déjà réglée (engager) | **NON** |
| 4 | `DepotFactureValiderDejaValideService` | 193-216 | Déjà validée | **NON** |
| 5 | `FactureController::store` | 298-319 | Reversement CP depuis `Marches/Show.vue` L2908 ; aussi route `programmes.factures.store` | **NON** — `programme_id` forcé, `marche_id` du marché. Pas une orpheline budget (a un marché). |

Après create (1)(3)(4), le `depot->update` recopié ne porte **pas**
`programme_budget_id` non plus (`valider` L2422-2438). L’enveloppe
dépôt, si elle existait, n’est pas recopiée à la naissance de la
facture. Le bilan s’en sort via COALESCE (dépôt) ; la colonne facture
reste NULL jusqu’au prochain PATCH
`syncFactureLieeDepuisDepot` (L1981, seulement si `facture_id` déjà
posé — donc **après** engagement, pas à la création).

`source` : tous ces chemins écrivent `'erp_upload'`. Les valeurs ENUM
`intacct_import` / `facturx_intacct` n’ont **plus d’écrivain** applicatif
(seulement la migration `2026_04_22_100100`). Mesure A0 par `source`
utile pour dater d’éventuelles reliques.

### B.2 Le champ est-il seulement optionnel, ou est-il effacé après coup ?

Les deux.

**Optionnel à la naissance** : fillable présent (`Facture.php` L54),
nullable en schéma, jamais requis. Le front
`resources/js` : **0 occurrence** de `programme_budget_id` (toujours
vrai au 18 sept. 2026). `buildDraftPayload` n’envoie que
`poste_budgetaire_type_id` + `rattachement_poste_direct`.

**Effacé après coup** — chemins qui **écrivent null** :

1. `FactureMarcheDoubleAncrageService::coerceProgrammesMarcheEtPosteDansValidated`
   L129 (marché XOR enveloppe, marché gagne), L136 (`sans_marche`),
   L141 (marché explicite), **L155 (`rattachement_poste_direct` →
   `programme_budget_id = null` forcé)**, L161 (défaut si clé absente).
2. `DepotFactureController::appendDirectFkColumnsToUpdates` L6289-6300 :
   si `$sansMarche` (le 3e argument vaut aussi `$rattachementPoste`,
   L1917) → enveloppe null ; si marché effectif → enveloppe null.
3. `applyDepotSaisieFromValidated` destination ≠ PROGRAMMES L1908-1911 :
   `programme_budget_id` dépôt = null.
4. `syncFactureLieeDepuisDepot` destination ≠ PROGRAMMES L287-298 :
   `appliquer(..., programmeBudgetId: null)` **efface** l’enveloppe
   **facture**.
5. `ProgrammeHorsErpGuard` L42 : enveloppe null.
6. `FactureMarcheDoubleAncrageService::appliquer` L207-209 : si un
   `marcheId` est passé, l’enveloppe cible est null (marché gagne).

Le cas métier « Pas de marché, rattacher à un poste » **passe par
L155** : même si une enveloppe du même type existe déjà, elle est
vidée. Ce n’est pas un oubli de saisie : c’est un **coerce actif**.

### B.3 Saisie historique (#1604) et reprises SIWI

**Historique** : `validerHistorique` L2927 crée la facture sans
enveloppe, dépôt `type_source=historique`, `saisie_historique=true`,
statut dépôt `paye`. Même coerce L155 si l’utilisateur a coché
« rattacher à un poste ». **Oui, ce chemin produit des orphelines.**
Pas d’Intacct (`ComptabilisationController` refuse l’historique) :
imputer plus tard **ne désynchronise pas Sage**.

**SIWI** : pas de `Facture::create`. Les montants repris vivent sur
`marche_reprises` et alimentent le bilan **marché** (#1344), pas
l’enveloppe. **SIWI ne produit pas d’orphelines `factures`.**

### B.4 Garde-fou écrit mais inactif

Trois couches, une seule analogie #1448 encore vraie.

**1. Garde-fou #1448 (dossiers fonciers) — RÉACTIVÉ pour PROJET, hors sujet PROGRAMMES.**

`DossierFoncierPosteBudgetaireService::posteUtiliseParFacture` L254-265
lit aujourd’hui `depot_factures.dossier_foncier_id` +
`poste_budgetaire_type_id` (les deux existent, dump 11 sept.). Plus de
`hasColumn('poste_budgetaire_id')` : la colonne a été droppée #1492,
le service a été recâblé. Ce filet empêche de **supprimer un poste
dossier** encore utilisé. Il n’exige **jamais** `programme_budget_id`
et ne s’applique pas à l’onglet Budget programme.

**2. Exigence d’enveloppe — JAMAIS écrite.**

`assertRattachementProgrammeBudgetValide` L5301-5436 : si une
enveloppe **est** fournie, elle est contrôlée (existe, active, du
programme, `applicable_budget`, pas de retenue). Si elle **n’est
pas** fournie, aucun 422 enveloppe. Le seul 422 poste hors brouillon
est L5377 : `rattachement_poste_direct` + **type** NULL. Un type
choisi sans enveloppe **passe**.

`assertRattachementBudgetCompletPourEnvoi` L5488-5492 :

`$hasPoste = $posteTypeId !== null || $programmeBudgetId !== null || $rattachementPoste;`

Le drapeau JSON suffit. Appels : `valider` L2196,
`resolveEngagementPayloadPourValiderDejaValide` L2740,
`resolveEngagementPayloadPourValiderEtComptabiliser` L2784,
`validerHistorique` L2871.

Comptabilisation L1114-1128 : filet **compte GL du type**, pas
l’enveloppe. CLASSID APBILL = classe du **programme**
(`ComptabilisationController` L1485-1508), pas du poste.

**3. `ProgrammeBudgetService::estConsomme` L150-156 — INACTIF (stub lot 2).**

Toujours `return false`. Le 422 « Impossible de supprimer cette
enveloppe : elle est déjà consommée » (L137) ne part jamais. Analogie
#1448 la plus proche **côté Budget** : un garde-fou écrit, colonnes
disponibles (`programme_budget_id` sur dépôt et facture), **jamais
branché**. Hors périmètre de ce lot (pas de blocage).

**4. Backfill #1033** — commande `budgets:backfill-ancrage-historique`
toujours présente. C’est un rattrapage **automatique**. Robin l’a
exclu. Ne pas la lancer.

---

## C. L’ONGLET BUDGET

### C.1 Fichiers

| Rôle | Fichier |
|------|---------|
| Onglet nav programme | `resources/js/Layouts/AppShell.vue` L277-282 — clé `budget`, href `/programmes/${id}/budget`, actif si composant `Budget*` |
| Page | `resources/js/Pages/Budget/Index.vue` (412 lignes) |
| Contrôleur | `app/Http/Controllers/ProgrammeBudgetController.php` `index` L24-40 |
| Service | `app/Services/ProgrammeBudgetService.php` `pourProgramme` L165-229 |
| Route | `routes/web.php` L2598-2608 `programmes.budget.index` |
| Lien liste programmes | `Programmes/Index.vue` L617 (hors périmètre UI « À imputer ») |

Ce n’est **pas** un onglet interne d’un `Programmes/Show.vue` : c’est
une page Inertia sœur de Bilan, dans la nav programme.

### C.2 Construction des lignes aujourd’hui

`pourProgramme` L167-174 : `ProgrammeBudget::query()->where('programme_budgets.programme_id', $programme->id)->with(poste, révisions, créateur)->get()`.

Aucune jointure `factures` / `depot_factures`. Agrégats = somme des
`programme_budgets.montant_ht` par rubrique / type, plus
`total_alloue_ht`. Commentaire `estConsomme` L145-155 : consommation
factures **non branchée**.

Sérialisation : `groupes[]` → `postes[]` → `enveloppes[]` (id, libelle,
montant_ht, commentaire, révisions).

### C.3 Jointure factures par `programme_budget_id` uniquement ?

**Sur l’onglet Budget : les factures ne sont pas jointes du tout.**
Confirmé par le code (zéro `Facture` / `DepotFacture` dans
`ProgrammeBudgetService` et `Budget/Index.vue`). L’orpheline n’est
pas filtrée : **l’écran n’a jamais montré de dépense.**

**Sur le Bilan : oui, INNER JOIN enveloppe via id effectif.**
`buildDepotsHorsMarcheKpiParPoste` L1291-1299 :

- `join('programme_budgets as pbg', pbg.id = COALESCE(facture, dépôt))`
- `whereRaw budget effectif IS NOT NULL`
- `whereRaw marché effectif IS NULL`
- `d.deleted_at IS NULL` explicite (Query Builder)

Une facture type-only (enveloppe NULL des deux côtés) n’a pas de clé
de join → absente des colonnes `facture_valide` / `facture_en_attente`
du poste, donc des `totaux_charges` L896-904 / L911-921.

### C.4 Structure réelle du composant — où placer « À imputer »

Template `Budget/Index.vue` L156-411, dans l’ordre :

1. **En-tête** L158-173 : titre « Budget », phrase « Enveloppes
   budgétaires du programme — total alloué {total_alloue_ht} », bouton
   « Nouvelle enveloppe » si `canWriteUi`.
2. **État vide** L175-177 : si `!groupes.length`, cadre pointillé
   « Aucune enveloppe budgétaire pour ce programme. »
3. **Boucle rubriques** L179-241 : `v-for="groupe in groupes"` →
   `v-for="poste in groupe.postes"` → `<ul>` d’enveloppes (libellé,
   montant, Historique / crayon / poubelle).
4. **Dialogs** L243-410 : création, édition, historique révisions.
   Hors flux visuel de la page.

Pas de tableau de factures, pas d’accordéon, pas de pied de page
métrique autre que le total alloué de l’en-tête.

**Emplacement proposé (pas une décision métier sur les totaux) :**
bloc **entre l’en-tête (1) et la boucle / vide (2-3)**. Raisons tirées
de la structure, pas d’une préférence esthétique :

- En **tête de page** (après le titre, avant les enveloppes) : visible
  même si `groupes.length === 0` (l’état vide L175 masquerait un pied).
- **Hors** de la boucle poste/enveloppe : ne pas coller une facture
  sous un type « hydraulique » avant rattachement (gonflement visuel
  d’un poste, contraire à l’imputation humaine).
- Section **repliable, ouverte s’il y a au moins une facture** : la
  page reste un CRUD enveloppes ; la file d’imputation ne doit pas
  scroller les enveloppes hors écran, mais ne doit pas être oubliée.
- **Pas en pied** : après N rubriques, la compta ne la verrait pas.

Ne pas toucher `AppShell.vue` (zone sérialisée, ~10 lots) : pas de
badge nav dans ce chantier.

### C.5 Inclure les « À imputer » dans les totaux ? NE PAS DÉCIDER

Aujourd’hui elles sont **exclues de fait** (Budget : pas de dépense
du tout ; Bilan : INNER JOIN). Deux options, impact = résultat du
SQL A1 impact (à coller en prod). Ordre de grandeur historique
#1523 : 42 800 € HT sur 8 programmes — **non réactualisé**.

| Option | Budget `total_alloue_ht` | Bilan `totaux_charges.facture_valide` / `en_attente` | Lecture compta actuelle |
|--------|--------------------------|------------------------------------------------------|-------------------------|
| **A — garder hors totaux** (recommandation technique #1523, pas un arbitrage Robin) | inchangé (c’est de l’alloué, pas du facturé) | inchangé jusqu’à chaque imputation manuelle | Les chiffres déjà lus restent stables. La zone affiche un total **à part**. |
| **B — inclure dans le facturé** | n/a (alloué ≠ facturé) | + ht_qui_irait_dans_facture_valide / en_attente (SQL A1 impact) | Toute comparaison YoY / reporting déjà produit saute du delta. Si le type est connu, le poste « hydraulique » gonfle **avant** le choix d’enveloppe (N enveloppes possibles par type). Si le type est NULL, il faudrait un bucket inventé. |

Le `total_alloue_ht` de l’onglet Budget **ne doit pas** absorber les
factures à imputer : c’est la somme des enveloppes, pas une
consommation. La question d’inclusion ne concerne que le **facturé**
(Bilan, et un éventuel « consommé » futur sur Budget — aujourd’hui
`estConsomme` = false).

Après imputation manuelle (écriture `programme_budget_id`), la facture
**sort** de « À imputer » et **entre** dans
`buildDepotsHorsMarcheKpiParPoste` : c’est le seul moment où les
totaux poste doivent bouger.

### C.6 Le Bilan a-t-il le même angle mort ?

**Oui.** Chaîne : `BilanFinancierController::index` L22-36 →
`BilanFinancierService::getBilan` → hors marché =
`buildDepotsHorsMarcheKpiParPoste` L1280-1319 (INNER JOIN enveloppe).
Marché = `buildMarchesKpiProgramme` (INNER JOIN `postes_budgetaires`
sur `marches.poste_budgetaire_type_id`) : les orphelines hors marché
n’y sont pas non plus.

`resources/js/Pages/Bilan/Index.vue` : DataTable par rubrique/poste,
`expandedPostes`, totaux en colonnes. Pas de bandeau hors tableau.
Même règle : payload **séparé** (`factures_a_imputer`), **non** versé
dans `totaux_charges`. Ne pas élargir l’INNER JOIN.

`BilanFinancierService.php` est une zone sérialisée (règle
`zones-communes-serialisation`). Un lot Bilan doit être **seul** sur
ce fichier.

---

## D. L’ACTE D’IMPUTATION

### D.1 Comment on rattache aujourd’hui

Il n’existe **aucun écran** « imputer à une enveloppe ».

La saisie dépôt (`DepotFactureSaisieForm.vue`) propose « Pas de
marché, rattacher à un poste » → Select de **types**
(`buildPostesChargeDirecteParProgrammePayload` L6221-6257 : lignes
`postes_budgetaires` du programme dont le type a `applicable_budget`,
**pas** de jointure `programme_budgets`). Le POST n’envoie pas
`programme_budget_id`. Le coerce L155 le force à null.

`FactureMarcheDoubleAncrageService::appliquer` L187-273 **sait**
écrire dépôt + facture + JSON pour une enveloppe. Appelé au PATCH
après engagement (`syncFactureLieeDepuisDepot` L1981) et aux
déplacements marché. **Pas réutilisable tel quel via le PATCH saisie** :
le coerce en amont (L1699) détruit l’enveloppe si
`rattachement_poste_direct` est vrai. Un lot d’imputation doit
**contourner le coerce** (endpoint dédié) et poser aussi le drapeau
JSON `rattachement_poste_direct=true` (`syncOcrRawSaisieMarche` ne le
pose pas, #1523 §5.2).

`MarcheDeplacerFactureService` : exige un marché source (L141-144).
Table `facture_reaffectations` = marché → marché. Hors sujet.

`DepotFactureProjetAncrage` : destination PROJET, hors sujet.

Liste blanche serveur (règle mises à jour partielles) : uniquement
`programme_budget_id`, enveloppe du `programme_id` de la facture,
active, non soft-deleted, type `applicable_budget`. Dériver le type.
Interdire si marché effectif non null.

### D.2 Postes / enveloppes à proposer

« Ceux du programme de la facture uniquement. » Deux requêtes
existantes, **ce n’est pas la même chose** :

**Types de nomenclature du programme** (saisie actuelle, trop large
pour l’imputation) — `buildPostesChargeDirecteParProgrammePayload`
L6223-6231 : `PosteBudgetaire::query()->whereHas(type applicable_budget)`
puis groupé par `programme_id`. Un type peut exister sans enveloppe.

**Enveloppes du programme** (cible de l’imputation — N enveloppes
par type, #1025) :

```
ProgrammeBudget::query()
    ->where('programme_id', $facture->programme_id)  // SoftDeletes implicite
    ->where('actif', true)
    ->with('posteBudgetaireType:id,libelle,applicable_budget')
    ->orderBy('libelle')
    ->get();
```

Équivalent SQL :

```sql
SELECT pb.id, pb.libelle, pb.montant_ht, pb.poste_budgetaire_type_id,
       pbt.libelle AS type_libelle
FROM programme_budgets pb
JOIN postes_budgetaires_types pbt ON pbt.id = pb.poste_budgetaire_type_id
WHERE pb.programme_id = ?          -- programme de la facture
  AND pb.deleted_at IS NULL
  AND pb.actif = 1
  AND pbt.applicable_budget = 1
ORDER BY pb.libelle;
```

`postesOptionsPourProgramme` L284-313 liste les **types de l’enseigne**
pour **créer** une enveloppe. Ne pas s’en servir comme liste
d’imputation (ce serait l’enseigne entière, pas les enveloppes déjà
ouvertes sur CE programme).

Si le programme n’a **aucune** enveloppe, la zone « À imputer »
liste la facture mais le Select d’imputation est vide : la compta
crée d’abord l’enveloppe (bouton déjà sur la page, droit DG). Pas de
création d’enveloppe déduite du marché (exclu par Robin).

### D.3 Imputation après comptabilisation / paiement — verdict par le code

Chaîne Sage à l’envoi APBILL :

- **CLASSID** = classe du **programme** (`ComptabilisationController`
  L1485-1508). Changer d’enveloppe **ne change pas** CLASSID.
- **ACCOUNTNO de charge** = `ProgrammeChargeAccountResolver` L19-41 :
  enveloppe → (sinon) marché → (sinon) type explicite. Les lignes
  `lignes_comptables` / APBILL portent ce numéro **au moment de
  l’envoi**. Aucun replay APBILL à la modification ERP
  (`FactureAdminErpEditOverride` L12-13 : « Aucune resynchronisation
  Sage »).
- Filet analogue déjà écrit pour un **déplacement marché**
  (`MarcheDeplacerFactureService::construireAvertissements` L154-159) :
  « met à jour le rattachement en ERP mais **ne modifie pas**
  l’écriture Sage ».

`applyDepotSaisieFromValidated` L1687-1689 refuse toute modification
post-compta hors `userPeutModifierSaisieStatutAvance` (historique
COMPTA/DG/Admin ERP, ou override Admin ERP Sage). Un endpoint
d’imputation qui réutiliserait ce PATCH hériterait du cran — et du
coerce qui casse l’enveloppe.

`FactureStatutService::estComptabiliseeOuAuDela` : `comptabilisee`,
`mise_en_paiement`, `en_paiement`, `payee` (normalisés).

**Verdict :**

| Situation | Imputer `programme_budget_id` | Sage |
|-----------|-------------------------------|------|
| NV1 / NV2 / à comptabiliser, pas de `intacct_bill_id` | **oui** — ERP seul | rien à sync |
| Saisie historique (`saisie_historique=1`, jamais APBILL) | **oui** | pas d’écriture Sage |
| `intacct_bill_id` / `intacct_record_no` non vide | **désync ACCOUNTNO possible** si le type de la nouvelle enveloppe ≠ type (donc compte) envoyé. CLASSID inchangé. L’APBILL n’est pas mis à jour. | |
| Payée historique sans Sage | oui, comme historique | — |
| Payée + Sage | même risque ACCOUNTNO | |

Frontière proposée pour un lot ultérieur (pas un fix ici) :

- Autoriser tant que **aucun** `intacct_bill_id` (dépôt ET facture).
- Après Sage : **interdire** hors lecture ; pas d’override Admin ERP
  par défaut (Robin testera avec un compte tiers — un Gate
  laisserait passer `GS_ADMIN_ERP` via `Gate::before`
  `AppServiceProvider` L69-74). Si un override est un jour voulu :
  brut `in_array('GS_ADMIN_ERP', m365GroupNames())` + bandeau
  `FactureAdminErpEditOverride::WARNING_BANNER`.
- Ne pas imputer une facture qui a déjà un `marche_id` effectif
  (autre objet : rattachement marché).

### D.4 Droits

Demande Robin : `GS_COMPTABILITE` + `GS_DIRECTION_GENERALE`.
`Gate::before` L69-74 renvoie true pour `GS_ADMIN_ERP` → **tout
contrôle Gate est inutilisable** pour un test compte tiers.

**Modèle de référence** : `SaisieHistoriqueEdition::userPeutModifier`
(`app/Support/SaisieHistoriqueEdition.php` L25-31) =

`count(array_intersect(self::GROUPES, $user->m365GroupNames())) > 0`

avec `GROUPES` listés en dur. Jamais `hasM365Group` /
`hasAnyM365Group` (court-circuit Admin ERP, `User.php` L134-152).

Autre lecture brute déjà en prod : `canEditDepot` L4888-4903
intersecte `$user->groupes_m365` (alias de `m365GroupNames()` L129)
avec `GS_COMPTABILITE` / `GS_ADMIN_ERP`.

**Piège à ne pas copier** : `ProgrammeBudgetController::userCanWrite`
L104-107 = `isAdminERP() || hasAnyM365Group(['GS_DIRECTION_GENERALE',
'GS_ADMIN_ERP'])`. Middleware d’écriture enveloppes
`m365.groups:GS_DIRECTION_GENERALE,GS_ADMIN_ERP` (routes L2601) :
même famille. L’imputation **compta** ne doit pas réutiliser ce
middleware (il exclurait `GS_COMPTABILITE` côté route, et
l’inclurait côté Gate pour Robin seulement).

Proposition lot ultérieur : `in_array` sur `m365GroupNames()` brut,
groupes `GS_COMPTABILITE` et `GS_DIRECTION_GENERALE` uniquement.
`GS_ADMIN_ERP` n’est **pas** dans la demande Robin : un Admin ERP
qui n’est pas aussi COMPTA/DG n’imputera pas — c’est testable.
Accès programme (`ProgrammeAccessService`) **en plus** du groupe.

---

## DÉCOUPAGE EN LOTS (risque)

Aucun rattrapage auto. Aucun blocage à la source. Aucune modification
des totaux existants. Libellé UI : « À imputer ».

| Lot | Contenu | Risque | Zone sérialisée |
|-----|---------|--------|-----------------|
| **1611** | ce diagnostic | — | — |
| **A — Budget lecture** | Payload `factures_a_imputer` (critère A1) + bloc en tête de `Budget/Index.vue` + total HT **à part**. Zéro écriture. Zéro changement `pourProgramme` totaux alloués. | **Faible** | `Budget/Index.vue` (pas dans la liste chaude) ; rebuild Vite Budget. Ne pas toucher `AppShell.vue`. |
| **B — Imputation manuelle** | Endpoint **dédié** (ne pas réutiliser PATCH saisie : coerce L155). Liste blanche `programme_budget_id`. `FactureMarcheDoubleAncrageService::appliquer` + drapeau JSON. Enveloppes du programme (SQL D.2). Droits brut COMPTA+DG. Frontière Sage D.3. `logHistorique`. Tests compte tiers. | **Moyen** | Éviter `DepotFactureController.php` (fichier chaud) : nouveau contrôleur mince. Journal utilisateur. |
| **C — Bilan bandeau** | Même payload, bandeau hors DataTable, **non** additionné à `totaux_charges`. Ne **pas** toucher `buildDepotsHorsMarcheKpiParPoste`. | **Élevé** (fichier chaud `BilanFinancierService.php` + `Bilan/Index.vue`) | Un lot **seul** sur ce service, rebase `origin/main` à jour avant build. |
| **D — (plus tard, pas maintenant)** | Blocage à la source (422 enveloppe à l’envoi) + éventuellement `estConsomme`. | Moyen métier | Quand les équipes sont rodées, décision Robin. |

Ne pas fusionner A et B dans un commit : A livrable seul donne déjà
la visibilité chiffrée en prod. B sans A = imputation à l’aveugle.

Ne pas lancer `budgets:backfill-ancrage-historique`.

---

## POINTS RESTANT À ARBITRER PAR ROBIN

1. **Critère de la zone** : A1 (#1523, recommandé) ou le A0 littéral
   du prompt (noyé sous les factures marché) ?
2. **Inclusion dans les totaux Bilan** : option A hors totaux vs B
   inclusion — coller SQL A1 impact avant de choisir. Ne rien changer
   tant que ce n’est pas tranché.
3. **Afficher aussi le bandeau Bilan (lot C) dès la V1**, ou Budget
   seul (lot A) d’abord ?
4. **Frontière Sage** : interdire dès `intacct_bill_id`, ou autoriser
   si le type (donc ACCOUNTNO) de la nouvelle enveloppe = type déjà
   envoyé ?
5. **GS_ADMIN_ERP** : hors liste d’imputation (demande actuelle) ou
   override post-Sage avec bandeau #658 ?
6. **Facture avec type renseigné + N enveloppes de ce type** : pré-
   filtrer le Select sur ce type, ou lister toutes les enveloppes du
   programme ?
7. **Dépôt PROGRAMMES « à imputer » sans `facture_id` encore**
   (brouillon / avant valider) : dans la zone Budget, ou seulement
   les factures engagées ?
8. Coller les SQL A0/A1 en prod et joindre les résultats (année 2026
   surtout) — **sans ça le coût du blocage différé n’est pas chiffré.**

---

## DÉPLOIEMENT-TEST

```text
git pull origin main
```

Pas de migrate, journal:sync, npm, optimize. Coller les SQL §A en
phpMyAdmin prod (`hectareenvol`). Filtrer `deleted_at IS NULL`.

## LEÇON

Le critère « `factures.programme_budget_id` IS NULL » compte toutes
les factures **marché** (enveloppe volontairement nulle, #1029). La
zone « À imputer » doit s’ancrer sur l’id **effectif**
`COALESCE(facture, dépôt)` + drapeau poste + pas de marché + pas
`sans_marche`. Aucun `Facture::create` ne copie l’enveloppe ; le
coerce L155 la **vide** activement. Le front n’envoie toujours pas le
champ. SIWI ne crée pas de `factures`. L’historique (#1604) oui, sans
Sage. Page Budget = CRUD enveloppes, zéro facture : ajouter un bloc
**au-dessus** des rubriques, pas un LEFT JOIN. Bilan : même angle
mort, INNER JOIN L1291. Imputer après APBILL = désync ACCOUNTNO, pas
CLASSID. Droits : `m365GroupNames()` brut, modèle
`SaisieHistoriqueEdition`. Ne pas réutiliser le PATCH saisie.
)