---
suivi: 1041
date: 2026-07-29
sujet: Diagnostic régression Facturé / dépli axe marché (Honoraires = 0)
chantier: budgets-programme
type: diagnostic
statut: poussé
hash: 20719731
fichiers:
  - tools/diag/diag_bilan_1041.php
  - docs/suivi/SUIVI_1041_diag_regression_facture_axe_marche.md
---

## PROMPT ENVOYÉ

SUIVI #1041 — PHASE 1 DIAGNOSTIC UNIQUEMENT. INTERDICTION de modifier le code applicatif.
Régression constatée en prod : programme 1 ARBOREA, groupe « 5 — HONORAIRES TECHNIQUES »,
colonnes Facturé Payé / Facturé / Bilan réel à 0 € et plus aucun chevron de dépli marché,
alors que Engagé reste correct et que le SQL NV1 (#1039) boucle. Branche enveloppe (prog 2
PUBLICITE) fonctionne. Livrable : script `tools/diag/diag_bilan_1041.php` lecture seule +
fiche suivi. Aucun `.vue`, pas de journal, pas de rebuild.

## SYNTHÈSE

### Pré-requis git

- `origin/main` au démarrage du lot : `f0f4e0aa` (#1040 déjà poussé ; HEAD métier bilan = #1039 `4f644ee0` + docs).
- Aucun fichier applicatif modifié. Pas de toucher à `AssistantPropositionController` (#1040).

### Méthode

- Lecture de `BilanFinancierService`, `Index.vue`, diffs `ca291c9c` (#1035), `fe50bea7` (#1036).
- Script prod lecture seule : appelle `buildBilanPayload` (pas `getBilan`, qui écrit via
  `refreshMontantsAgreges`), plus KPI marché / NV1 / hors-marché via Reflection.
- DB locale Cursor DOWN : aucun chiffre local n’est une preuve. Chiffres payload = sortie
  prod du script.

### A — Payload

**A4 (prouvé par le code, sans DB)**  
Les totaux poste `montant_facture_valide` / `montant_facture_en_attente` / `montant_bilan_reel`
sont calculés dans `buildRubriqueSection` à partir des maps `par_poste` (marché) + hors-marché,
**pas** par somme des sous-lignes Vue. Les sous-lignes `marches[]` / `budgets[]` sont attachées
à part pour le dépli. Mais maps **et** `details_by_poste` sortent de la **même** méthode
`buildMarchesKpiProgramme`. Si celle-ci catch et renvoie des structures vides → **0 € au poste
ET chevron absent** (une cause, deux symptômes).

**A1–A3** : dump réel = exécution prod de `php tools/diag/diag_bilan_1041.php` (sections A1–A3).
Attendu si hypothèse catch confirmée :
- HONORAIRES : chaque poste `marches=[]`, facturé*=0, `hasDetails=NON` (sauf éventuel budget) ;
- MOE : pas 279348 / 279903 ;
- PUBLICITE : `budgets` non vide, facturé > 0 — même clés de ligne, différence = présence
  `budgets` / montants hors-marché vs `marches` vides.

### B — Jointure marché → poste

**B1.** Code actuel : `m.poste_budgetaire_type_id` → `pb.poste_type_id`  
(`buildMarchesKpiProgramme` ≈L1116-1120, `buildMarchesByPoste` ≈L1076-1081).  
`marches.poste_budgetaire_id` existe en modèle/base mais **n’est pas** utilisé pour ce join.

**B2.** Jointure **inchangée** au #1035/#1036/#1039 (diff `ca291c9c` : pas de modification de
cette clause ON). Donc une jointure « mauvaise colonne » n’explique pas à elle seule la
*régression* temporelle — sauf si les données `type_id` avaient disparu (à vérifier B3 en prod).

**B3.** Comptages = section B3 du script en prod.

### C — Front

**C1.** Clés lues :

| Niveau | Facturé Payé | Facturé | Bilan réel |
|---|---|---|---|
| Poste | `montant_facture_valide` | `montant_facture_en_attente` | `montant_bilan_reel` |
| Sous-ligne marché | `marche.facture_valide_ht` | `marche.en_attente_ht` | `marche.facture_valide_ht` |

Engagé poste : `montant_engage` (#1035).

**C2.** Les clés émises par `buildRubriqueSection` matchent ces lectures. Pas de rename orphelin
identifié sur les colonnes facturé. Le rename interne `par_poste.en_attente` → `facture` est
consommé uniquement en PHP (jamais envoyé tel quel au Vue).

**C3.** Chevron : `hasDetails(poste) = hasMarches \|\| hasBudgets` (#1035, avant : `hasMarches`
seul). Ne dépend **pas** du montant facturé — uniquement de `poste.marches.length` /
`poste.budgets.length`.

**C4.** `public/build/manifest.json` : `resources/js/Pages/Bilan/Index.vue` →
`assets/Index-lNoTTTL6.js` (bundle #1035). #1036/#1039 n’ont pas rebuild. Hypothèse bundle
périmé **écartée** au niveau repo ; confirmation hash fichier = C4 du script prod.

### D — Périmètre

**D1.** Script section D : pour chaque programme, détecte `AXE_MARCHE_MORT` si
`buildMarchesByPoste` Σ > 0 et `buildMarchesKpiProgramme` details=0 + facture/payé=0.
Chiffres = sortie prod.

**D2.** Dashboard : `DepotFacture::sommeMontantHtFactureApresNv1PourProgramme` — **hors**
`buildMarchesKpiProgramme`. Si dashboard > 0 et bilan marché = 0 → défaut localisé bilan KPI.

### Hypothèse principale (à confirmer / infirmer en prod)

`buildMarchesKpiProgramme` enveloppe tout (y compris `buildFactureNv1ByMarche` ajouté #1035,
SQL-ifié #1036) dans `try/catch (\Throwable)` qui :

1. logue un `Log::warning('… buildMarchesKpiProgramme error')` (pas une exception fatale —
   d’où « aucune erreur » si on cherche un stack 500) ;
2. retourne `details_by_poste => []` et `par_poste => [valide=>[], facture=>[]]`.

Effets :
- plus de sous-lignes marché → plus de chevron (`hasMarches` faux) ; ASSURANCE sans dépôt
  non plus (pas d’enveloppe) ;
- Facturé Payé / Facturé marché à 0 ; `refreshMontantsAgreges` peut en plus écrire
  `montant_bilan_reel = 0` (fallback du payload) ;
- Engagé intact (`montant_marche_signe` via `buildMarchesByPoste`, catch séparé) ;
- Enveloppes intactes (`buildDepotsHorsMarcheKpiParPoste` / `buildBudgetsAllouesByPoste`
  hors de ce catch).

Le script A-bis isole `buildFactureNv1ByMarche` + rejoue le SQL brut pour faire apparaître
l’exception réelle si le catch la masque à l’écran.

### E — Recommandation (sans coder)

**E1.** Cause racine candidate : exception avalée dans `buildMarchesKpiProgramme` (très
probablement autour de `buildFactureNv1ByMarche`), vidant à la fois maps facturé et sous-lignes
marché — preuve attendue = A-bis + warning log.

**E2.** Fix minimal **back** : (a) ne pas vider `details_by_poste` / payé si seul le calcul NV1
échoue ; (b) corriger la requête NV1 par marché qui plante ; (c) logger la trace complète.
Pas de fix front requis si le payload redevient correct.

**E3.** Ne pas revert #1035/#1036/#1039 en bloc (enveloppes + preuve NV1). Ne jamais annuler
le backfill données #1033. Fix localisé.

**E4.** Contrôle manquant : assert payload (`details_by_poste` non vide + montant MOE > 0), pas
seulement le SQL agrégé global du diag #1039.

### Closures script (anti-#1038)

| Closure | use() | Notes |
|---|---|---|
| `$qAll` | — | DB facade |
| `$qOne` | `$qAll` | |
| `$money` | — | |
| `$dumpLignePoste` | `$money` | |
| `$findLigne` / `$findRubrique` / `$callPrivate` | — | |
| boucle programmes | pas de closure | variables du scope principal |

### Qualité

```
php -l tools/diag/diag_bilan_1041.php
→ No syntax errors detected in tools/diag/diag_bilan_1041.php
```

`head -3` + `wc -l` : voir synthèse commit. Aucun chiffre local présenté comme vérifié.

## DÉPLOIEMENT / TEST

```bash
git pull origin main
# pas de migrate / pas de npm / pas de journal:sync
php tools/diag/diag_bilan_1041.php
```

Coller les sections A1–A3, A-bis, B3, D1–D2, LOG dans le ticket. Vérifier aussi
`storage/logs/laravel.log` pour `buildMarchesKpiProgramme error`.

## LEÇON

Un `catch (\Throwable)` qui renvoie des structures vides transforme une erreur SQL ciblée en
régression métier silencieuse (0 € + UI amputée), alors que les diagnostics SQL globaux
restent verts — il faut tester le **payload** assemblé, pas seulement la règle métier SQL.
