---
suivi: 1605
date: 2026-09-17
sujet: Diagnostic — marché soft-deleted encore compté dans le BILAN
chantier: bilan / marches
type: diagnostic
statut: poussé
hash: d9607c06
fichiers:
  - docs/suivi/SUIVI_1605_diag_marche_supprime_bilan.md
---

# SUIVI #1605 — PHASE 1 diagnostic (aucun fix)

## PROMPT ENVOYÉ

Constat prod ARBOREA (programme id 1) : BILAN poste « 1 — FONCIER /
APPORTEUR AFFAIRE » affiche MCH-2026-018 (deleted_at 2026-07-28) et
MCH-2026-165 (vivant), 400 000 € engagés au lieu de 200 000 €.
CHANTIER n’affiche que 165. Diagnostic sans code, sans donnée, sans
restauration de 018.

## FICHES LUES

- **#1032** — point d’entrée BILAN = `BilanFinancierController::index`
  → `getBilan` → `buildMarchesByPoste` / `buildMarchesKpiProgramme`.
  Impact : on ne cherche pas un second builder BILAN.
- **#1035** — `montant_engage` = marchés signés + enveloppes ;
  `previsionnel` = engagé. Impact : un marché fantôme gonfle aussi
  prévisionnel, Δ Étude/Engagé, marges prévisionnelles.
- **#1035 / #1041** — `getBilan()` **écrit** via
  `refreshMontantsAgreges`. Les scripts diag évitaient `getBilan`.
  Impact : ouvrir le BILAN réécrit `postes_budgetaires.montant_marche_signe`
  depuis la requête fautive.
- **#334** (commit `b47543cb`, 2026-06-22) — soft-delete marché +
  éligibilité (factures / dépôts / avenants / documents / ST).
  Impact : au 28/07/2026 le garde-fou factures existait déjà.

## 1. CAUSE EXACTE

### Chemin BILAN (engagé par poste)

| Étape | Fichier | Lignes |
|---|---|---|
| Page | `BilanFinancierController::index` | `app/Http/Controllers/BilanFinancierController.php:22-36` |
| Calcul + écriture dénorm | `BilanFinancierService::getBilan` | `:53-57` appelle `refreshMontantsAgreges` **puis** `buildBilanPayload` |
| Somme engagé → colonne poste | `refreshMontantsAgreges` → `buildMarchesByPoste` | `:720-736` / `:1072-1101` |
| Liste dépliée des marchés | `buildBilanPayload` → `buildMarchesKpiProgramme` | `:792-794` / `:1112-1135` |
| Ligne poste (engagé + détails) | `buildRubriqueSection` | `:972-1018` (`montant_engage` = dénorm + enveloppes) |
| Affichage | `resources/js/Pages/Bilan/Index.vue` | `poste.marches` + `montant_engage` |

Formule engagé marché (identique dans les deux requêtes) :
`sqlMontantMarcheSigneHt` `:1056-1066` =
`marches.montant_marche_ht` + Σ `avenants.montant_ht` (`statut IN ('signe','en_cours')`).

Colonne poste : `montant_engage` = `montant_marche_signe` (dénorm,
rafraîchie à chaque ouverture) + enveloppes budget (`#1035`).

### La requête réellement exécutée n’exclut pas `deleted_at`

`Marche` **utilise** `SoftDeletes` (`app/Models/Marche.php:8-12`).
Colonne depuis la création de table
(`database/migrations/2026_04_20_100100_create_marches_table.php`).
Attendu confirmé : `deleted_at` renseigné sur id 21.

**Mais le BILAN ne passe pas par Eloquent.** Les deux agrégats sont
`DB::table('marches as m')` — **aucun global scope SoftDeletes**,
**aucun** `whereNull('m.deleted_at')`.

`buildMarchesByPoste` — requête réellement exécutée :

```
DB::table('marches as m')
  ->join('postes_budgetaires as pb', …)
  ->where('m.programme_id', $programmeId)
  ->whereNotNull('m.poste_budgetaire_type_id')
  ->groupBy('pb.id')
  ->selectRaw("pb.id, SUM(montant_marche_ht + avenants) …")
```

Fichier : `app/Services/BilanFinancierService.php:1076-1085`.

`buildMarchesKpiProgramme` (les deux lignes TORRES) : **même oubli**,
`:1116-1135`.

Ce n’est **pas** `withTrashed()`. C’est une table intermédiaire SQL
brute. Laravel n’injecte `deleted_at IS NULL` que sur `Marche::query()`
/ `$programme->marches()`.

### Piège : un `whereNull('deleted_at')` existe dans le même fichier

`BilanFinancierService.php:70-73` filtre `LotCommercial` (recettes),
**pas** les marchés.

À l’inverse, les enveloppes du même bilan filtrent bien le soft-delete
(`buildBudgetsAllouesByPoste` `:1332` `whereNull('pbg.deleted_at')`).
Le pattern était connu pour `programme_budgets` ; pas appliqué à
`marches`.

### Pourquoi le dénorm reste faux

`getBilan` réécrit `postes_budgetaires.montant_marche_signe` à chaque
ouverture (`:732-734`) **depuis** `buildMarchesByPoste`. Ce n’est pas
un reliquat figé du 28/07 : c’est recalculé, faux, à chaque visite
BILAN **et** à chaque ouverture du dashboard programme
(`app/Support/ProgrammeDashboardPresenter.php:65-70` → `getBilan`).

`MarcheSuppressionService::supprimer` (`:36`) ne rappelle **pas**
`refreshMontantsAgreges`. Même si on ajoutait ce rappel demain, tant
que `buildMarchesByPoste` ignore `deleted_at`, le dénorm redeviendrait
faux au prochain BILAN.

### Verdict Q1

Cause **établie**, pas une hypothèse. Code fautif :
`BilanFinancierService.php:1076-1085` et `:1116-1135`.

## 2. AMPLEUR

Non exécuté depuis ce poste (pas d’accès prod). Les requêtes **Q2** et
**Q2t** en fin de fiche donnent, pour tous les programmes, chaque
marché supprimé encore « engagé » au sens BILAN, et le total fantôme.

ARBOREA (fait Robin) : **au moins** 1 programme, **200 000 €**
(MCH-2026-018). Le total national = sortie Q2t.

## 3. AUTRES ÉCRANS

### CHANTIER vs BILAN (les deux chemins)

| | CHANTIER | BILAN |
|---|---|---|
| Entrée | `MarcheController::index` `:85-86` → `SuiviChantierController::index` `:32-36` → `MarcheController::indexProps` `:93-111` | `BilanFinancierController::index` `:22-28` → `getBilan` |
| Source marchés | `$programme->load(['marches' => …])` Eloquent | `DB::table('marches as m')` |
| SoftDeletes | **oui** (scope modèle) | **non** |
| KPI engagé | `computeKpis` `:3005-3009` somme `montant_total_ht` sur la collection déjà filtrée | `sqlMontantMarcheSigneHt` sur toutes les lignes `marches` du programme |
| Affichage 018 | absent | présent |

C’est exactement le constat prod.

### Inventaire (exclut-il les marchés `deleted_at` ?)

| Écran / calcul | Chemin | Exclut soft-delete ? | Preuve |
|---|---|---|---|
| BILAN engagé + liste marchés | `buildMarchesByPoste` / `buildMarchesKpiProgramme` | **NON** | `DB::table` sans `deleted_at` `:1076` `:1116` |
| BILAN facturé / payé marché | même `buildMarchesKpiProgramme` (somme dépôts des ids retournés) | **NON** (ids incluent les supprimés) | `:1137-1160` |
| BILAN enveloppes | `buildBudgetsAllouesByPoste` | **oui** | `:1332` |
| BILAN hors marché | `buildDepotsHorsMarcheKpiParPoste` | n/a (pas de join marches) ; `d.deleted_at IS NULL` | `:1298` |
| Dénorm `postes_budgetaires.montant_marche_signe` | `refreshMontantsAgreges` | **NON** (alimenté par la requête fautive) | `:720-734` |
| Dashboard slice « charges engagées / marge prév. » | `app/Support/ProgrammeDashboardPresenter.php` → `getBilan` | **NON** | `:65-83` |
| Dashboard KPI `total_marches_ht` | `ProgrammeDashboardController` `$programme->marches` Eloquent | **oui** | `:48-73` SoftDeletes |
| CHANTIER liste + KPI engagé | `indexProps` Eloquent | **oui** | `:96-111` `:3005-3009` |
| Synthèse financière programme | `MarcheSyntheseFinanciereService::pourProgramme` `Marche::query()` | **oui** | `:353-357` |
| Fiche marché / retenues / RG états | `Marche` bound route (404 si trashed) + `MarcheRetenuesEtatsService` | **oui** (pas d’entrée UI) | Eloquent |
| Reprises SIWI totaux programme | `MarcheRepriseMontantsService::totauxPourProgramme` | **oui** | `:101-103` `whereNull('marches.deleted_at')` |
| Demandes libération RG (filtres) | `DemandeLiberationRgService` | **oui** | `:530` `:548` |
| Explorer SharePoint chantier | `SharePointChantierExplorerService` | **oui** | `:323` `:341` |
| Resync docs SharePoint | `MarcheDocumentSharePointResyncService::eligibleQuery` | **oui** | `:66-68` |
| Compte prorata programme | `CompteProrataService::marchesCpEligiblesProgramme` / `marchesProgramme` `Marche::query()` | **oui** | `:502-522` |
| CIE titulaires | `CieService::titulaireIds` `Marche::query()` | **oui** | `:41-49` |
| TMA (select marchés) | `TMAController` `Marche::query()` | **oui** | `:90-95` |
| Concessionnaires | `ConcessionnairesController` `Marche::query()` | **oui** | `:18-25` |
| Saisie dépôt (liste marchés) | `DepotFactureController` `Marche::query()` | **oui** | `:4333` |
| Liste factures globale (filtre marché) | `app/Services/FactureGlobaleListeQuery.php` `Marche::query()` | **oui** | `:461-464` |
| Budget programme | `ProgrammeBudgetService` | n/a | pas de lecture `marches` |
| Pénalités solde programme | `MarchePenalitesSoldeService` | n/a | `penalites.programme_id`, pas join marché |
| Espace admin marchés supprimés | `Admin/MarchesSupprimesController` `onlyTrashed()` | volontairement les supprimés | `:24` |
| Alertes | `AlerteService` | lien vers fiche ; pas d’agrégat engagé | — |

Seul `withTrashed()` métier hors corbeille admin : `Marche::nextReference`
(`app/Models/Marche.php:226`) pour ne pas réattribuer un numéro — **pas**
le BILAN.

**Périmètre du bug d’affichage engagé** : BILAN (liste + totaux +
marges/prévisionnel qui en dérivent) et le **slice dashboard** qui
réutilise `getBilan`. Pas le CHANTIER, pas la synthèse marché
Eloquent, pas RG/CIE/CP/TMA/dépôt.

**Nuance dashboard** : deux totaux coexistants — KPI en-tête
(Eloquent, juste) vs widget bilan prévisionnel (`getBilan`, faux).

## 4. FACTURES RATTACHÉES À UN MARCHÉ SUPPRIMÉ

Suppression actuelle = **soft-delete du marché seul**. Aucun
`marche_id = NULL`, aucun déplacement. Les FK `factures.marche_id` et
`depot_factures.marche_id` restent.

Au 28/07, `MarcheSuppressionEligibiliteService` (`b47543cb`, 22/06)
**bloquait** déjà si `Facture::query()->where('marche_id')` ou
`DepotFacture::pourMarche` renvoyait > 0. Donc **018 n’avait, au
moment de la suppression, aucune facture/dépôt vivant** (sinon le
delete admin aurait échoué). À confirmer par **Q4a** (vivants) et
**Q4b** (y compris corbeille).

Si Q4a = 0 : le **facturé** du poste n’est pas doublé ; seul
**l’engagé** l’est. La marge réelle (`acte − facturé payé`,
`calculerMarges` `:298`) n’est alors **pas** touchée par 018. La
marge / le taux **prévisionnels** et le Δ Étude/Engagé **le sont**
(`chargesPrevisionnel` = engagé, `:293-297` ; `previsionnel` =
`totauxChargesSigne`, `:901`).

Si Q4a > 0 (données postérieures, ou le garde-fou contourné) : le
BILAN additionnerait aussi leurs KPI via les ids de
`buildMarchesKpiProgramme`. Après un fix `deleted_at`, ces factures
disparaîtraient du BILAN marché (orphelines côté agrégat, toujours
liées en FK). **Q4c** liste le stock national.

Visibilité hors BILAN : `Marche::query()->find(21)` = null → fiche
marché 404. Une facture vivante avec `marche_id = 21` reste
ouvrable ; le select marchés du dépôt ne proposera plus 018
(Eloquent).

## 5. SUPPRESSION D’UN MARCHÉ AUJOURD’HUI

`MarcheController::destroy` `:944-956` : admin ERP seulement →
`MarcheSuppressionService::supprimer`.

Séquence (`MarcheSuppressionService.php:19-38`) :

1. `analyser()` éligibilité.
2. SharePoint : `supprimerDossierMarcheSiPropre` (dossier vide).
3. `$marche->delete()` — **SoftDeletes uniquement**.
4. Journal `marche_historique` type `suppression`.

**Pas** de traitement factures, retenues, avenants, situations,
sous-traitants, reprise SIWI. Les lignes filles restent.

Blocages actuels (`MarcheSuppressionEligibiliteService.php:27-68`) :
factures **vivantes**, dépôts **vivants** (`pourMarche`), avenants,
documents marché/avenant, sous-traitants. **Pas** les situations.
**Pas** les factures/dépôts déjà en corbeille.

018 au 28/07 : éligibilité déjà en place → très probable **zéro**
facture/dépôt/avenant/doc/ST vivant. **Q5** pour l’état actuel des
satellites (y compris situations, hors garde-fou).

## 6. VERDICT

**Cause établie.** `DB::table('marches')` dans
`BilanFinancierService` ignore `deleted_at`. CHANTIER Eloquent le
respecte. Même fichier : les budgets filtrent `deleted_at`, les
marchés non.

**Périmètre.** Bug d’**engagé / liste marchés / prévisionnel / Δ /
marges prévisionnelles / slice dashboard**. Pas le CHANTIER. Marge
réelle / facturé : seulement si Q4a > 0.

**CODE à corriger (phase 2, pas ce lot)** : `whereNull('m.deleted_at')`
sur `buildMarchesByPoste` **et** `buildMarchesKpiProgramme`. Option :
`refreshMontantsAgreges` après `supprimer` (inutile tant que la
requête est fausse ; utile ensuite). Ne pas toucher à `estApBillPaye`
ni à la compta.

**DONNÉES.** Pas de purge ni restauration de 018 (pièce à conviction).
Si le code est corrigé, le dénorm **se recalcule** au prochain
`getBilan` : pas de script de rattrapage nécessaire pour
`montant_marche_signe`. Factures encore collées à un marché
supprimé (Q4) : arbitrage Robin (laisser / rattacher à 165).

**Risque direction.** Oui. ARBOREA FONCIER passerait de 1 572 334 €
à 1 372 334 € à l’affichage. Tout programme de Q2 verrait son engagé
et ses marges **prévisionnelles** baisser. Des chiffres déjà montrés
à la direction seraient révisés à la baisse. Ne pas déployer le fix
sans ce signal. Le facturé (si Q4a = 0) ne bouge pas.

## DÉPLOIEMENT-TEST

Aucun. Diagnostic uniquement. Exécuter le bloc SQL en **lecture**
prod (phpMyAdmin / mysql), coller les résultats pour l’arbitrage.

JAMAIS npm / composer / optimize / config:cache sur OVH.

## LEÇON

`SoftDeletes` sur le modèle ne protège pas `DB::table('marches')`.
Un `whereNull('deleted_at')` ailleurs dans le même service (lots,
budgets) ne prouve rien pour cette requête. CHANTIER juste + BILAN
faux = deux chemins ; les identifier par Eloquent vs Query Builder.

## REQUÊTES SQL (bloc unique, lecture seule)

```
-- ============================================================
-- SUIVI #1605 — lecture seule. Ne pas UPDATE / DELETE / restore.
-- Formule engagé = même que BilanFinancierService::sqlMontantMarcheSigneHt
-- ============================================================

-- Q1 — ARBOREA : les deux TORRES (pièce à conviction, ne pas restaurer 018)
SELECT
  m.id,
  m.reference,
  m.intitule,
  m.programme_id,
  p.libelle AS programme,
  p.code_envol,
  m.poste_budgetaire_type_id,
  pbt.libelle AS poste,
  m.montant_marche_ht,
  m.montant_total_ht,
  m.deleted_at,
  (
    COALESCE(m.montant_marche_ht, 0)
    + COALESCE((
        SELECT SUM(av.montant_ht)
        FROM avenants av
        WHERE av.marche_id = m.id
          AND av.statut IN ('signe', 'en_cours')
      ), 0)
  ) AS engage_bilan_ht
FROM marches m
JOIN programmes p ON p.id = m.programme_id
LEFT JOIN postes_budgetaires_types pbt ON pbt.id = m.poste_budgetaire_type_id
WHERE m.programme_id = 1
  AND m.reference IN ('MCH-2026-018', 'MCH-2026-165');

-- Q2 — TOUS les programmes : marchés supprimés encore « engagés » au sens BILAN
SELECT
  p.id AS programme_id,
  p.libelle AS programme,
  p.code_envol,
  m.id AS marche_id,
  m.reference,
  m.intitule,
  pbt.libelle AS poste,
  m.montant_marche_ht,
  (
    COALESCE(m.montant_marche_ht, 0)
    + COALESCE((
        SELECT SUM(av.montant_ht)
        FROM avenants av
        WHERE av.marche_id = m.id
          AND av.statut IN ('signe', 'en_cours')
      ), 0)
  ) AS engage_fantome_ht,
  m.deleted_at
FROM marches m
JOIN programmes p ON p.id = m.programme_id
LEFT JOIN postes_budgetaires_types pbt ON pbt.id = m.poste_budgetaire_type_id
WHERE m.deleted_at IS NOT NULL
  AND m.poste_budgetaire_type_id IS NOT NULL
  AND (
    COALESCE(m.montant_marche_ht, 0)
    + COALESCE((
        SELECT SUM(av.montant_ht)
        FROM avenants av
        WHERE av.marche_id = m.id
          AND av.statut IN ('signe', 'en_cours')
      ), 0)
  ) <> 0
ORDER BY p.libelle, m.reference;

-- Q2t — total fantôme + nombre de programmes (même filtre que Q2)
SELECT
  COUNT(*) AS nb_marches_fantomes,
  COUNT(DISTINCT x.programme_id) AS nb_programmes,
  ROUND(SUM(x.engage_fantome_ht), 2) AS total_engage_fantome_ht
FROM (
  SELECT
    m.programme_id,
    (
      COALESCE(m.montant_marche_ht, 0)
      + COALESCE((
          SELECT SUM(av.montant_ht)
          FROM avenants av
          WHERE av.marche_id = m.id
            AND av.statut IN ('signe', 'en_cours')
        ), 0)
    ) AS engage_fantome_ht
  FROM marches m
  WHERE m.deleted_at IS NOT NULL
    AND m.poste_budgetaire_type_id IS NOT NULL
) x
WHERE x.engage_fantome_ht <> 0;

-- Q3 — contrôle CHANTIER (Eloquent) : marchés VIVANTS ARBOREA poste foncier
SELECT m.id, m.reference, m.deleted_at, m.montant_marche_ht
FROM marches m
WHERE m.programme_id = 1
  AND m.deleted_at IS NULL
  AND m.poste_budgetaire_type_id = (
    SELECT poste_budgetaire_type_id FROM marches WHERE id = 168
  );

-- Q4a — factures / dépôts VIVANTS encore collés à 018 (id 21)
SELECT 'facture' AS type, f.id, f.reference, f.statut, f.montant_ht, f.deleted_at, f.marche_id
FROM factures f
WHERE f.marche_id = 21 AND f.deleted_at IS NULL
UNION ALL
SELECT 'depot', d.id, d.uuid, d.statut, d.montant_ht, d.deleted_at, d.marche_id
FROM depot_factures d
WHERE d.marche_id = 21 AND d.deleted_at IS NULL;

-- Q4b — y compris corbeille (018)
SELECT 'facture' AS type, f.id, f.reference, f.statut, f.montant_ht, f.deleted_at, f.marche_id
FROM factures f
WHERE f.marche_id = 21
UNION ALL
SELECT 'depot', d.id, d.uuid, d.statut, d.montant_ht, d.deleted_at, d.marche_id
FROM depot_factures d
WHERE d.marche_id = 21;

-- Q4c — national : factures/dépôts vivants dont le marché est supprimé
SELECT
  p.id AS programme_id,
  p.libelle,
  m.id AS marche_id,
  m.reference,
  m.deleted_at,
  COUNT(DISTINCT f.id) AS nb_factures_vivantes,
  COUNT(DISTINCT d.id) AS nb_depots_vivants
FROM marches m
JOIN programmes p ON p.id = m.programme_id
LEFT JOIN factures f ON f.marche_id = m.id AND f.deleted_at IS NULL
LEFT JOIN depot_factures d ON d.marche_id = m.id AND d.deleted_at IS NULL
WHERE m.deleted_at IS NOT NULL
GROUP BY p.id, p.libelle, m.id, m.reference, m.deleted_at
HAVING nb_factures_vivantes > 0 OR nb_depots_vivants > 0
ORDER BY p.libelle, m.reference;

-- Q5 — satellites actuels de 018 (y compris ce que l’éligibilité ne bloque pas)
SELECT 'avenant' AS type, COUNT(*) AS n FROM avenants WHERE marche_id = 21
UNION ALL SELECT 'situation', COUNT(*) FROM situations WHERE marche_id = 21
UNION ALL SELECT 'document', COUNT(*) FROM marche_documents WHERE marche_id = 21
UNION ALL SELECT 'sous_traitant', COUNT(*) FROM marche_sous_traitants WHERE marche_id = 21
UNION ALL SELECT 'reprise_ligne', COUNT(*) FROM marche_reprise_lignes WHERE marche_id = 21
UNION ALL SELECT 'historique_suppression', COUNT(*) FROM marche_historique WHERE marche_id = 21 AND type = 'suppression';

-- Q5b — journal de suppression 018
SELECT id, marche_id, user_id, type, description, created_at
FROM marche_historique
WHERE marche_id = 21
ORDER BY created_at;

-- Q6 — dénorm poste vs somme live (ARBOREA) : écart = preuve que refresh réécrit le faux
SELECT
  pb.id AS poste_id,
  pbt.libelle,
  pb.montant_etude_fi,
  pb.montant_marche_signe AS denorm_signe,
  (
    SELECT ROUND(SUM(
      COALESCE(m.montant_marche_ht, 0)
      + COALESCE((
          SELECT SUM(av.montant_ht) FROM avenants av
          WHERE av.marche_id = m.id AND av.statut IN ('signe', 'en_cours')
        ), 0)
    ), 2)
    FROM marches m
    WHERE m.programme_id = pb.programme_id
      AND m.poste_budgetaire_type_id = pb.poste_type_id
  ) AS somme_sql_brute_y_compris_supprimes,
  (
    SELECT ROUND(SUM(
      COALESCE(m.montant_marche_ht, 0)
      + COALESCE((
          SELECT SUM(av.montant_ht) FROM avenants av
          WHERE av.marche_id = m.id AND av.statut IN ('signe', 'en_cours')
        ), 0)
    ), 2)
    FROM marches m
    WHERE m.programme_id = pb.programme_id
      AND m.poste_budgetaire_type_id = pb.poste_type_id
      AND m.deleted_at IS NULL
  ) AS somme_si_filtre_deleted_at
FROM postes_budgetaires pb
JOIN postes_budgetaires_types pbt ON pbt.id = pb.poste_type_id
WHERE pb.programme_id = 1
  AND (
    pbt.libelle LIKE '%FONCIER%'
    OR pbt.libelle LIKE '%APPORTEUR%'
  );
```
