---
suivi: 1606
date: 2026-09-17
sujet: Fix — marchés soft-deleted exclus du BILAN
chantier: bilan / marches
type: fix
statut: poussé
hash: 803d00eb
fichiers:
  - app/Services/BilanFinancierService.php
  - tests/Unit/BilanMarcheSupprime1606SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - docs/suivi/SUIVI_1606_fix_marche_supprime_bilan.md
---

# SUIVI #1606 — PHASE 2 fix marchés supprimés au BILAN

## PROMPT ENVOYÉ

Suite #1605. Ajouter `whereNull('m.deleted_at')` aux deux agrégats
Query Builder de `BilanFinancierService`. Vérifier le reste du
service et les `DB::table('marches')` hors bilan. Ne pas toucher
aux données (018, TEST, LOUIS PASTEUR). Pas de migration. Diff
minimal. Dashboard = même `getBilan`, donc corrigé par le même
filtre.

## FICHES LUES

- **#1605** — cause, deux chemins, dénorm réécrit par `getBilan`,
  CHANTIER Eloquent OK. Impact : correctif uniquement sur les deux
  requêtes BILAN ; pas de cascade suppression ; pas de purge 018.
- **#1043 LEÇON** — ne pas avaler une panne KPI en zéros. Impact :
  on n’ajoute pas de `catch` ; on ne touche pas au
  `catch`/`return []` de `buildMarchesByPoste` (hors lot).
- **#1035 LEÇON** — engagé = marchés signés + enveloppes. Impact :
  filtrer les marchés ne change pas les enveloppes (`pbg.deleted_at`
  déjà filtré).
- **#1032** — un seul builder BILAN ; dashboard réutilise `getBilan`.
  Impact : pas de second correctif dashboard.

## 1. CORRECTIF

`whereNull('m.deleted_at')` ajouté sur :

- `buildMarchesByPoste` — dénorm `montant_marche_signe` / engagé poste
- `buildMarchesKpiProgramme` — liste dépliée + ids pour facturé/payé marché

Dashboard : `ProgrammeDashboardPresenter::buildBilanPrevisionnelSlice`
appelle `getBilan` — **aucun fichier dashboard modifié**.

## 2. REQUÊTES VÉRIFIÉES DANS `BilanFinancierService`

| Méthode | Table (QB) | Soft-delete | Sort #1606 |
|---|---|---|---|
| `buildMarchesByPoste` | `marches as m` | `Marche` SoftDeletes | **corrigé** `whereNull('m.deleted_at')` |
| `buildMarchesKpiProgramme` | `marches as m` | idem | **corrigé** idem |
| `calculerRecettes` | Eloquent `LotCommercial` | SoftDeletes | déjà `whereNull('deleted_at')` L72 — inchangé |
| `buildFactureNv1ByMarche` | `depot_factures as d` + LEFT JOIN `factures` | SoftDeletes dépôts/factures | **non corrigé** : pas de `d.deleted_at`. Hors périmètre (facturé). Après le filtre marchés, les ids d’un marché corbeille ne sont plus dans le `IN`. Signalé, pas touché. |
| `buildDepotsHorsMarcheKpiParPoste` | `depot_factures as d` + `programme_budgets` | SoftDeletes | déjà `d.deleted_at IS NULL` L1298 — inchangé |
| `buildBudgetsAllouesByPoste` | `programme_budgets as pbg` | SoftDeletes | déjà `whereNull('pbg.deleted_at')` L1332 — inchangé |
| `buildAvenantsDetailsParMarche` | `avenants` | **pas** de SoftDeletes | n/a ; borné aux ids marchés vivants après le fix |
| `sqlMontantMarcheSigneHt` | sous-requête `avenants` | pas de SoftDeletes | n/a (avenants d’un marché déjà filtré) |

Aucune autre `DB::table('marches')` dans ce service.

## 3. MÊME DÉFAUT AILLEURS (`app/`)

Occurrences Query Builder / jointure SQL brute sur `marches` :

| Fichier | Filtre `deleted_at` | Action |
|---|---|---|
| `BilanFinancierService` (2) | **non** avant ce lot | **corrigé** |
| `MarcheRepriseMontantsService::totauxPourProgramme` | `whereNull('marches.deleted_at')` | aucune |
| `DemandeLiberationRgService` (2 joins filtres) | `whereNull('marches.deleted_at')` | aucune |
| `SharePointChantierExplorerService` (2) | `whereNull('m.deleted_at')` | aucune |
| `MarcheDocumentSharePointResyncService::eligibleQuery` | `whereNull('marches.deleted_at')` | aucune |

Hors `app/` : scripts `tools/diag/*` et migrations historiques —
pas du runtime bilan. Non corrigés.

**Aucun autre écran métier** n’agrège l’engagé via `DB::table('marches')`
sans filtre. CHANTIER Eloquent non touché.

`MarcheRepriseMontantsService::totauxPourMarches` ne joint pas `marches`
(filtre par ids fournis). Après le fix, les ids viennent de marchés
vivants.

## 4. DÉNORMALISÉ

`getBilan` : `refreshMontantsAgreges` **puis** `buildBilanPayload`.
`refreshMontantsAgreges` écrit `postes_budgetaires.montant_marche_signe`
depuis `buildMarchesByPoste`.

**Confirmé** : après ce correctif, la **première** ouverture BILAN
(ou dashboard programme, même `getBilan`) réécrit le dénorm juste.
Pas de commande artisan. Pas d’exécution. Pas de script.

ARBOREA : ouvrir l’onglet BILAN une fois après déploiement.
TEST (1 500 000 € fantômes) et LOUIS PASTEUR (0 €) : même auto-guérison
à la consultation ; montants LOUIS PASTEUR inchangés (fantômes à 0).

## 5. DONNÉES

Aucune. 018 non restauré, non purgé. TEST et LOUIS PASTEUR intacts.

## RISQUE DIRECTION

Après correctif + consultation, ARBOREA FONCIER : 1 572 334 € →
1 372 334 €. APPORTEUR AFFAIRE : 400 000 → 200 000 €. Marge
prévisionnelle et Δ Étude/Engagé changent. Chiffres déjà communiqués
à la direction étaient faux de 200 000 € d’engagé. Facturé et marge
réelle : inchangés (018 sans facture vivante, #1605).

## DÉPLOIEMENT-TEST

OVH :

```
git pull origin main
php artisan optimize:clear
php artisan view:clear
php artisan journal:sync
```

Pas de migrate. Pas de npm. Pas de config:cache.

Tests manuels (prod, après pull) : voir synthèse.

## LEÇON

Un `whereNull('deleted_at')` sur une autre table du même service
(enveloppes, lots) ne protège pas `DB::table('marches')`. SoftDeletes
n’existe que sur Eloquent. Le dashboard n’a pas de second calculateur :
corriger `getBilan` suffit pour les deux écrans.
