---
suivi: 1032
date: 2026-07-29
sujet: Diagnostic calcul BILAN — engagé marchés+budgets / hors marché / Facturé NV1
chantier: budgets-programme
type: diagnostic
statut: déployé, script exécuté en prod le 29/07
hash: 2a95008d
fichiers:
  - docs/suivi/SUIVI_1032_diag_bilan_engage.md
  - tools/diag/diag_bilan_1032.php
---

## PROMPT ENVOYÉ

SUIVI #1032 — PHASE 1 DIAGNOSTIC UNIQUEMENT. INTERDICTION de modifier le code applicatif.
Chantier BUDGETS PROGRAMME, lot 3 (BILAN). Préalable #1026. HEAD prod = bf5c9d9a.
Constat prod 29/07/2026 programme 2 LES TRITONS : PUBLICITE (étude 66 666 €, reste à 0
malgré enveloppe + facture NV1) ; ASSURANCE (142 113 € sur ligne marché, Bilan réel poste = 0).
Livrable : fiche docs/suivi + script tools/diag lecture seule + commit/push.

## SYNTHÈSE

### Pré-requis git
- `git log --oneline -1 origin/main` avant travail : `bf5c9d9a chore(suivi): renseigne hash #1031 (d248aada)`
- Aucun fichier app/resources/database/routes modifié. Seuls docs/suivi + tools/diag.

### A1 — Point d’entrée payload BILAN

| Rôle | Fichier | Méthode | Lignes |
|------|---------|---------|--------|
| Contrôleur Inertia | `app/Http/Controllers/BilanFinancierController.php` | `index()` | L21-33 : `initBilanProgramme` → `getBilan` → `Inertia::render('Bilan/Index', …)` |
| Service | `app/Services/BilanFinancierService.php` | `getBilan()` | L51-55 → délègue à `buildBilanPayload()` L737-921 |
| Agrégats marché / factures | idem | `buildMarchesKpiProgramme()` | L1073-1158 |
| Lignes par poste | idem | `buildRubriqueSection()` | L928-1011 |
| Front | `resources/js/Pages/Bilan/Index.vue` | colonnes charges | L1060-1214 |

Pas d’autre builder parallèle pour l’onglet BILAN programme (le dashboard réutilise le même `getBilan` via `ProgrammeDashboardPresenter`).

### A2 — Sources des 6 colonnes charges (ligne POSTE)

Toutes les montants charges sont en HT. Preuves = code cité, pas un résumé libre.

#### 1. Étude fi
| Élément | Valeur |
|---------|--------|
| Champ payload | `montant_etude_fi` |
| Source | `postes_budgetaires.montant_etude_fi` (éditable UI) |
| Code | `buildRubriqueSection` L949 : `$etude = (float) $poste->montant_etude_fi;` ; exposé L979 |
| Front | `Index.vue` L1060-1072 |

#### 2. Marchés & Avenants signés
| Élément | Valeur |
|---------|--------|
| Champ payload ligne poste | `montant_marche_signe` |
| Source dénorm | `postes_budgetaires.montant_marche_signe` (rafraîchi par `refreshMontantsAgreges` L718-734 via `buildMarchesByPoste` L1032-1061) |
| Formule live dénorm | `SUM(marches.montant_marche_ht + Σ avenants.montant_ht WHERE statut IN ('signe','en_cours'))` groupé par `postes_budgetaires.id` via join `m.poste_budgetaire_type_id = pb.poste_type_id` (L1037-1045, `sqlMontantMarcheSigneHt` L1016-1027) |
| Code ligne | L950 : `$signe = (float) $poste->montant_marche_signe;` |
| Détail marché déplié | `marche.montant_total_ht` / `montant_marche_ht` (même expression SQL, L1094-1134) |
| Front poste | `Index.vue` L1112 ; front marché L1105-1110 |
| **Budgets / enveloppes** | **ABSENTS** de cette colonne aujourd’hui |

#### 3. Facturé Payé
| Élément | Valeur |
|---------|--------|
| Champ payload | `montant_facture_valide` (nom historique « valide » = payé KPI) |
| Agrégation | `buildMarchesKpiProgramme` : pour chaque marché du poste, `repartitionKpiMarche($marcheId, $st, $statutsPaye)['total_ht']` (L1111-1122) puis somme par `poste_budgetaire_id` |
| Filtres statut dépôt | `statutsDepotPayesMarcheKpi()` L1181-1187 = `DepotFacture::STATUT_PAYE` (`paye`) + `STATUT_VALIDE` (`valide`) |
| Tables | `depot_factures` via `MontantFactureSousTraitantService::totalHtDepotsMarche` → `depotsPourAgregation` = `DepotFacture::pourMarche($marcheId)` (+ reprise SIWI si filtre compatible) |
| Ancrage | **UNIQUEMENT marchés** du programme (join `marches` ↔ `postes_budgetaires`) — pas d’enveloppe |
| Front | tooltip L1122 ; body L1126-1144 |

#### 4. Facturé
| Élément | Valeur |
|---------|--------|
| Champ payload | `montant_facture_en_attente` (clé trompeuse : ce n’est PAS « en attente ») |
| Agrégation | même boucle L1116-1123 avec `$statutsIn = null` → **tous** les dépôts du marché hors rejet/archive |
| Code filtre | `depotsPourAgregation` : `horsRejetEtArchive()` si `$statutsIn` null (pas de `whereIn statut`) — `MontantFactureSousTraitantService` L575-589 |
| Tooltip UI | L1156 : « Tous les dépôts hors rejet ou archive… » |
| Front | L1161-1175 affiche `en_attente_ht` / `montant_facture_en_attente` |

#### 5. Bilan réel
| Élément | Valeur |
|---------|--------|
| Champ payload poste | `montant_bilan_reel` |
| Calcul back | L953 : `$reel = $factureValide;` (= copie de Facturé Payé, pas une 3e source) |
| Totaux | `totaux.reel` = somme des `$reel` (L966, L1002) |
| Front ligne **poste** | L1193 : `data.montant_bilan_reel` |
| Front ligne **marché** | L1187-1191 : **`data.marche.montant_total_ht`** (= marché+avenants signés) — **pas** les factures |

#### 6. Prévisionnel
| Élément | Valeur |
|---------|--------|
| Champ payload | `montant_previsionnel` |
| Calcul | L954 : `$previsionnel = $signe;` (= Marchés & Avenants signés) |
| Totaux charges | `previsionnel` = `totauxChargesSigne` (L885) |
| Front | L1207-1209 ; tooltip L1203 = même libellé que marchés signés |

### A3 — Filtre exact colonne « Facturé » vs règle NV1

- UI « Facturé » = agrégat `en_attente` KPI = **tous dépôts marché hors `rejete` / `archive`**, **sans** filtre sur un enum NV1.
- UI « Facturé Payé » = `whereIn(statut, ['paye', 'valide'])` uniquement (constantes `STATUT_PAYE`, `STATUT_VALIDE`).
- **Incompatible avec la règle Robin** « NV1 validée = Facturé » :
  - « Facturé » est **trop large** (inclut `en_validation_nv1`, `en_cours_saisie`, etc. liés au marché).
  - « Facturé Payé » est **trop étroit** (exclut p.ex. `a_comptabiliser`, `en_validation_nv2`, `comptabilisee` si le statut dépôt n’est pas `valide`/`paye`).
- Existe déjà côté modèle une sémantique proche NV1 : `DepotFacture::compteDansFactureApresNv1Valide()` (L481-509) + `statutsNormalisesApresNv1Valide()` — **non branchée** dans `BilanFinancierService`.

### A4 — Dépôt SANS marché : contribution aux colonnes ?

**NON.** Preuve :
1. `buildBilanPayload` ne charge les factures/KPI que via `buildMarchesKpiProgramme` (L790-792), qui itère `FROM marches m JOIN postes_budgetaires …` (L1077-1083).
2. Aucun appel à un agrégat `programme_budget_id` / `poste_budgetaire_type_id` hors marché dans ce service.
3. `buildMarchesByPoste` (signe) = marchés uniquement.
4. Conséquence PUBLICITE prog 2 : enveloppe 66 666 € + facture hors marché NV1 → Étude fi seule remplie ; Marchés/Facturé/Bilan/Prévisionnel = 0.

### A5 — `buildDepotsHorsMarcheParPoste()`

| Question | Réponse |
|----------|---------|
| Existe dans le code ? | **NON** (0 occurrence sous `app/` / `resources/`). Uniquement mentionné comme TODO dans `PROJECT.md` (chantier charges directes / axe BILAN). |
| Appelée ? | Non |
| Injectée dans le payload ? | Non — code mort / jamais écrit |

### A6 — Écart ASSURANCE (poste Bilan réel = 0, ligne marché = 142 113)

**Deux causes cumulées, défaut principal = FRONT.**

1. **Front (`Index.vue` L1185-1191)** : sur une ligne marché (`__is_marche` / `__is_marche_consolide`), la colonne « Bilan réel » affiche `marche.montant_total_ht` (= engagé marché+avenants), **pas** `facture_valide_ht` ni un réel facturé. Donc 142 113 € vu au dépli = le montant **signé** du marché SMABTP, réaffiché sous le mauvais en-tête.
2. **Back (L953)** : au niveau poste, `montant_bilan_reel = montant_facture_valide` = Σ dépôts `paye`/`valide` du/des marché(s). Si aucune facture n’est encore `paye`/`valide`, le poste affiche 0 — alors même que « Marchés & Avenants signés » peut afficher 142 113 via `montant_marche_signe`.

Ce n’est **pas** une agrégation manquante marché→poste sur le signé (le signé remonte) ; c’est une **définition différente** (signé vs facturé payé) + un **mauvais mapping Vue** sur la colonne Bilan réel des sous-lignes marché.

### B — État données (script prod)

Script : `tools/diag/diag_bilan_1032.php` (lecture seule).
Commande OVH (racine projet) :

```
php tools/diag/diag_bilan_1032.php
```

Les chiffres B1–B5 sont **à lire sur la sortie prod** (base locale sans tables métier = non significative). Le script couvre :
- B1 PUBLICITE (ancrage poste dépôt ∪ marché ∪ enveloppe)
- B2 hors marché par (programme, poste) : `poste_seul` vs `avec_enveloppe` + brut
- B3 surcharge `poste_budgetaire_type_id` + marche effectif (volume courant, pas le « 12 » figé #1026)
- B4 postes marchés ∩ hors-marché
- B5 enveloppes + Σ consommation SQL + flag conso nulle
- Focus prog 2 PUBLICITE/ASSURANCE (montants dénorm postes)

Note : `ProgrammeBudgetService::estConsomme()` retourne encore `false` en dur (L150-156) — la conso B5 est calculée en SQL diag, pas via le service.

### C — Risque de double comptage

#### C1 — Scénario
Un dépôt historique avec `marche_id` effectif **et** `poste_budgetaire_type_id` (population surcharge B3 / #1026) : lot 3 pourrait l’agréger une fois via l’axe marché **et** une fois via l’axe poste/enveloppe si l’agrégat hors marché se base naïvement sur `poste_budgetaire_type_id IS NOT NULL` sans exclure `marche_effectif IS NOT NULL`.  
Variante post-#1029 : dépôt avec `programme_budget_id` **et** marché (interdit en écriture actuelle, mais pas de CHECK SQL) — même risque.

#### C2 — Invariant
> Un dépôt a **un** ancrage et un seul : **marché XOR enveloppe** (`programme_budget_id`). La somme engagé (marchés + budgets) et la somme facturé (marchés + budgets) bouclent sur le total du poste, sans overlap.

Le code actuel **ne garantit pas** l’invariant en base :
- Exclusivité marché ↔ enveloppe = validation applicative (#1029 `FactureMarcheDoubleAncrageService` / asserts controller), **pas** contrainte MySQL.
- `poste_budgetaire_type_id` reste **polysémique** (surcharge marché + charge directe) — #1026.
- Le bilan ignore totalement l’axe enveloppe → pas de double comptage **aujourd’hui** dans le BILAN, mais le risque apparaît dès l’écriture du lot 3.

#### C3 — Requête de contrôle (après lot 3)
Fournie aussi en fin de script diag :

```sql
-- Overlap interdit (doit être 0)
SELECT d.id, d.programme_id,
  COALESCE((SELECT f.marche_id FROM factures f WHERE f.id = d.facture_id LIMIT 1), d.marche_id) AS marche_eff,
  COALESCE((SELECT f.programme_budget_id FROM factures f WHERE f.id = d.facture_id LIMIT 1), d.programme_budget_id) AS budget_eff
FROM depot_factures d
WHERE COALESCE((SELECT f.marche_id FROM factures f WHERE f.id = d.facture_id LIMIT 1), d.marche_id) IS NOT NULL
  AND COALESCE((SELECT f.programme_budget_id FROM factures f WHERE f.id = d.facture_id LIMIT 1), d.programme_budget_id) IS NOT NULL;
```

Bouclage facturé poste (à brancher sur le filtre NV1 retenu) :  
`facturé_poste = Σ dépôts(marché_eff → poste) filtrés + Σ dépôts(budget_eff → poste) filtrés` avec les deux ensembles disjoints.

### D — Recommandation architecture lot 3 (sans code)

#### D1 — Cible
- Renommer / requalifier « Marchés & Avenants signés » → **« Engagé (marchés + budgets) »**.
- Au dépli poste : sous-lignes marchés **et** sous-lignes enveloppes (`programme_budgets`).
- Δ Étude/Signé = étude − (Σ marchés signés + Σ montants enveloppes actives).
- Colonne « Facturé » : aligner sur **NV1 validée** (`compteDansFactureApresNv1Valide` ou équivalent SQL), pas sur « tous hors rejet » ni sur `paye`/`valide` seuls.
- Corriger le mapping Vue Bilan réel ligne marché (ne plus binder `montant_total_ht`).

#### D2 — Ancrage hors marché pour agrégation
**Faire foi : `programme_budget_id` effectif** (facture prioritaire, sinon dépôt — même règle que #1029).  
**Ne pas** agréger le hors-marché sur `poste_budgetaire_type_id` seul : polysémie (12+ surcharges marché).  
Priorité : si `marche_effectif` → axe marché (ignorer poste_type dépôt) ; sinon si `programme_budget_effectif` → axe enveloppe → poste dérivé de l’enveloppe ; sinon (legacy poste_type sans enveloppe) → filet temporaire jusqu’à fin 2c, **hors** des dépôts déjà marché.

#### D3 — Obstacles avant lot 3
1. **Lot 2c migration données** : **OUI, doit précéder** (ou être couplé) — backfill `programme_budget_id` sur les charges directes encore ancrées seulement via `poste_budgetaire_type_id` ; sinon le BILAN engagé sous-comptera ou devra dual-path legacy.
2. Corriger / figer le filtre Facturé = NV1 (sinon lot 3 perpétue le décalage métier).
3. Bug Vue ASSURANCE (Bilan réel = montant_total_ht) : correctif front indépendant, idéalement avant ou dans le même lot UI.
4. `ProgrammeBudgetService::estConsomme` encore stub `false` — à brancher pour l’onglet BUDGET, orthogonal mais cohérent.
5. Pas de rebuild bundle tant qu’aucun `.vue` n’est touché (ce diag n’en touche aucun).

### Vérifs techniques
```
php -l tools/diag/diag_bilan_1032.php
→ No syntax errors detected in tools/diag/diag_bilan_1032.php
head (3 premières lignes) : <?php / /** / * SUIVI #1032 — Diagnostic BILAN engagé…
wc-l : 620 lignes
```

## DÉPLOIEMENT / TEST

1. `git pull origin main` sur OVH
2. `php tools/diag/diag_bilan_1032.php` (lecture seule) — coller B1–B5 dans le suivi / ticket
3. Aucune migration, aucun `optimize`, aucun npm
4. Contrôle manuel UI (sans fix) : prog 2 → BILAN → PUBLICITE (zéros attendus hors étude) ; ASSURANCE dépli → Bilan réel ligne marché = montant signé (bug front confirmé)

## LEÇON

Le BILAN charges est encore un **miroir KPI marchés** (`buildMarchesKpiProgramme`). Les enveloppes #1025/#1029 et les dépôts hors marché n’y entrent pas — d’où PUBLICITE à 0. La colonne « Facturé » n’est pas NV1. Sur les sous-lignes marché, « Bilan réel » affiche par erreur le montantsigné. Lot 3 = brancher l’axe enveloppe + filtre NV1 + invariant XOR, après (ou avec) migration 2c.
