---
suivi: 1799
date: 2026-10-07
sujet: Diagnostic plafond facture, détail marché, reste à facturer
chantier: factures
type: diagnostic
statut: poussé
hash: c3c894c6
fichiers:
  - docs/suivi/SUIVI_1799_diag_plafond_reste_a_facturer.md
---

# SUIVI #1799 — diagnostic plafond, détail marché, reste à facturer

Phase 1. Aucun code. Aucune écriture en base. Aucun appel Sage.

Fiches lues : #1295 (le plafond hors retenues est une confirmation, pas un blocage ; le reste « marché − facturé » sans retenues invente de la disponibilité), #1552 (le KPI de plafond n’est calculé que pour le marché demandé, pas pour tout le catalogue), #1344 (la reprise s’ajoute en HT, une fois, après les dépôts). Elles confirment de ne pas inventer un second cumul OCR et de ne pas rebrancher la reprise à côté du SUM déjà existant.

Liste des montants du marché MCH-2026-232 : non obtenue. `php artisan tinker` a résolu la config `mysql` / `erp_immo`, puis la requête a échoué : `SQLSTATE[HY000] [2002]` connexion refusée sur `127.0.0.1:3306`. Aucune ligne d’`id`, de reprise ou de dépôt n’a été lue. Les commandes sont en fin de fiche. Tant qu’elles n’ont pas tourné sur le serveur, l’hypothèse A ou B reste une déduction, pas un listing.

## 1. Plafond hors retenues

### a. Ce qui est additionné

Le message cité (« Cumul après facture : 151 468,80 € HT ») est le texte serveur, `MontantFactureSousTraitantService::messageDepassementPlafond` (`app/Services/MontantFactureSousTraitantService.php:1053-1071`). Le bandeau sous le marché (« Dépassement de … HT ») est le front, `DepotFactureSaisieForm.vue:4948` et `:9256`. Le dialogue front (`:8303-8307`) ne contient pas la phrase « Cumul après facture ».

Les deux comparent le même écart : plafond − déjà facturé hors facture courante − HT de la facture courante.

Déjà facturé, serveur, `kpiAvancementMarche` (`:403-434`) :

- somme des dépôts du marché hors rejet et hors archive, en sautant l’`id` du dépôt courant (`:421-427`) ;
- chaque dépôt vaut `montant_ht`, ou la somme de ses ventilations s’il est multi-fournisseur (`montantHtDepotPourAgregation`, `:294-305`) ;
- puis `htTravailPourAgregat` (`LiberationRgFactureService.php:54-78`), qui rend le HT stocké tel quel quand la facture n’a pas de libération de retenue ;
- plus une fois `SUM(marche_reprise_lignes.montant_facture_ht)` (`MarcheMontantsAgregesService::montantReprisePourFiltreStatuts`, `:71-77`, qui appelle `MarcheRepriseMontantsService::totauxPourMarche`).

HT courant, serveur, `resolveMontantCourantHtPourAvancement` (`:893-910`) : `depot.montant_ht` dès qu’il est supérieur à 0,009. Le montant posté par le formulaire n’est utilisé que si la colonne stockée est vide ou nulle. Ce HT est ajouté dans `kpiAvancementMarchePourFacture` (`:474`) : `avant.total_facture_ht + montantCourantHt`.

HT courant, front : `form.montant_ht`, lui-même la somme des lignes « Base HT à régler » (`totalHt`, `:3475-3477`, recopié par le `watch` `:5269-5276`). `resteApresFactureInfo` (`:3294-3327`) fait `plafond_hors_retenues_ht − total_facture_ht_hors_courant − form.montant_ht`.

Aucun des deux chemins ne lit un champ OCR « cumul » ou « situations précédentes ». Le prompt OCR (`OcrFactureService.php:66-88`) demande un seul `montant_ht`, sans distinguer la ligne « Montant H.T. » (cumul) de la ligne « situation n°11 ».

Le plafond est `plafondHorsRetenues` (`MarcheSyntheseFinanciereService.php:49-70`) : marché + avenants − retenues théoriques. Sans RG, CP, OPC ni CIE, il vaut le marché : 83 168,00.

Identités, sans choisir :

- 73 876,00 + 77 592,80 = 151 468,80
- 73 876,00 × 2 + 3 716,80 = 151 468,80
- 151 468,80 − 83 168,00 = 68 300,80

### b. OCR

Stocké : `ocr_raw.montant_ht` (et la saisie qui en découle). Le contrôle lit `depot_factures.montant_ht` (ou les ventilations), pas `ocr_raw`. La valeur OCR de ce dépôt n’a pas été lue (base injoignable).

### c. Reprise

Le code l’ajoute une fois : un `SUM` sur `marche_reprise_lignes`, pas une boucle qui recompte la même ligne. La fiche marché fait le même `SUM` puis l’ajoute aux dépôts (`MarcheSyntheseFinanciereService.php:196-197` et `:207`). Un double comptage n’existe dans le code que si la table contient deux lignes, ou si un dépôt de 73 876 coexiste avec la ligne de reprise. La fiche citée (facturé 73 876, reste 9 292 = 83 168 − 73 876) décrit un total qui ne contient qu’une fois 73 876. Le même `SUM` alimente la fiche et le plafond. Un second 73 876 dans ce `SUM` se verrait aussi sur la fiche.

### d. Liste — non exécutée

Commande en bas. Sortie : connexion refusée. Ne pas trancher A ou B sur une ligne qui n’a pas été lue.

Déduction, à confirmer par cette commande : la fiche et le plafond partagent le même déjà-facturé hors dépôt courant. Pour que le cumul après facture vaille 151 468,80 pendant que la fiche reste à 73 876, le HT courant utilisé par le contrôle est 77 592,80 (le cumul du PDF), pas 3 716,80. L’autre assemblage (73 876 compté deux fois + 3 716,80) mettrait 147 752 sur la fiche.

Le serveur peut utiliser 77 592,80 même si l’écran affiche 3 716,80 : il prend la colonne `montant_ht` déjà enregistrée et ignore le montant posté (`:898-906`). Dans ce cas le bandeau front (qui suit la saisie) et le message serveur divergent. Les deux ont été décrits à 68 300,80, donc au moment du message les deux chemins additionnaient le même HT courant.

### e. Autres dépôts

Même mécanisme pour tout dépôt : le plafond prend `montant_ht` (ou la somme des ventilations), pas un cumul OCR séparé. L’écart visible est un dépôt dont `ocr_raw.montant_ht` ou la somme des lignes de saisie diffère de `montant_ht`. Commande en bas. Non exécutée ici.

### f. CIE

Le texte « Retenues CIE disponibles non chargées » est posé quand l’appel HTTP échoue (`restaurerCieEnregistreeApresEchecDues`, `DepotFactureSaisieForm.vue:4653-4662`, motif recopié dans le `catch` `:4851-4857`). Une réponse vide et réussie affiche « aucun CIE dû » (`:4755-4756`), pas ce texte.

L’appel part dès que le dépôt a un marché et un fournisseur (`refreshCieDues`, `:4665-4741`), y compris si le poste n’a pas de CIE. URL : `GET /depot-factures/{uuid}/cie-retenue-dues?marche_id=&fournisseur_id=` (`routes/web.php:472`). Le contrôleur `cieRetenueDues` (`DepotFactureController.php:6028-6052`) exige `authorizeCieRead` (`AuthorizesCieAccess.php:29-37`) : hors des groupes de lecture, la réponse est 403 et le front affiche le même texte d’échec. `applicable_cie` absent est traité comme vrai (`DepotFactureController.php:4466`).

Logs locaux : aucune ligne `depassement_plafond`, `cie-retenue-dues` ou `MCH-2026-232`. Grep à lancer sur le serveur, en bas.

### g. Confirmation

`bloque` reste faux (`evaluationDepassementDepuisKpiApres`, `:1034-1036`). Sans le booléen `confirmation_depassement_plafond`, envoi rejeté en 422 sur ce champ (`DepotFactureController.php:5221-5232`). Avec le booléen : un `Log::warning` `depot.envoi_validation.depassement_plafond_confirme` (`:5235-5244`) avec l’id du dépôt, le marché, l’utilisateur, le plafond, le cumul, le dépassement et le `montant_ht` passé au contrôle. Aucune colonne, aucun statut, aucune alerte enregistrée. Le montant de la facture n’est pas modifié.

Conséquence : nulle sur le montant si le HT enregistré est bien 3 716,80. Réelle si le HT enregistré est le cumul 77 592,80 : la facture part à ce montant, et la situation suivante sera comparée à un déjà-facturé trop haut.

## 2. Fenêtre « Détail du marché »

Le dialogue (`DepotFactureSaisieForm.vue:11647-11670`) lit :

| Affiché | Champ | Source |
|---|---|---|
| Montant marché HT | `montant_marche_ht` | colonne |
| Montant total HT / TTC | `montant_total_ht`, `montant_total_ttc` | colonnes |
| Total facturé HT | `total_facture_ht` | colonne, pas le KPI |
| Reste à régler HT | `reste_a_regler_ht` | colonne |
| % avancement | `pct_avancement` | colonne |
| Nb situations | `nb_situations` | `situations_count` |

Le payload (`mapMarchePourSaisieDepotPayload`, `DepotFactureController.php:4471-4494`) copie ces colonnes. Le KPI vivant (dépôts + reprise), calculé seulement si `kpi_marche_id` est le marché choisi (`:4426-4430`, `:4487-4494`), est écrit dans `total_facture_ht_hors_courant`, `plafond_hors_retenues_ht`, `reste_a_facturer_sous_plafond_ht`. Le dialogue ne lit aucun de ces trois champs.

`nb_situations` compte la relation `situations`. Une ligne `marche_reprise_lignes` n’est pas une situation : le compteur reste 0 même quand la reprise existe.

`Marche::recalculer` (`Marche.php:329`) écrit le payé dans la colonne `total_facture_ht`, et le facturé dans `pct_avancement` et `reste_a_regler_ht` (`:321-331`). L’écran cité (facturé 0, reste 83 168, avancement 0 %) est l’état des colonnes avant recalcul : un recalcul avec 73 876 facturés et payés aurait bougé les trois. La reprise est ignorée parce que la fenêtre affiche ce cache, pas l’agrégat vivant.

## 3. Reste à facturer

Tableau des marchés du programme, `Marches/Index.vue:100-102` :

```php
return toMontant(marche?.montant_total_ht) - toMontant(marche?.total_facture_ht);
```

Le commentaire au-dessus dit « Pas de plafond à 0 ». Un marché à 0 € avec 1 640 € facturés affiche −1 640. Le total du tableau (`:131`) est la somme de ces écarts : les lignes à 0 € tirent le reste global vers le bas. `total_facture_ht` de cette liste inclut la reprise (`totauxHtDepotsListeMarches`, `:140-191`, puis `enrichirTotauxListeMarches`).

Même formule côté synthèse marché : `plafond − facturé` (`MarcheSyntheseFinanciereService.php:207`). Sans retenues, le plafond d’un marché à 0 € est 0, donc le reste est aussi l’opposé du facturé. `composeKpiAvancementMarche` (`:740-753`) met le reste à `null` quand le marché est ~0 ; le tableau, lui, affiche le négatif.

Écrans qui reprennent ce reste :

- liste des marchés du programme (`Marches/Index.vue:605`) et son total ;
- carte « Reste à facturer HT » de la fiche marché (`Marches/Show.vue:3320-3321`, source `totaux.reste_a_facturer_ht`) ;
- bloc synthèse marché (`MarcheSyntheseFinanciere.vue:428`) ;
- synthèse du programme, somme des restes marché (`ProgrammeSyntheseFinanciere.vue:198` et `:260-262`) ;
- panneau latéral de la fiche facture (`Factures/Show.vue:2009-2014`).

Le bilan (`Bilan/Index.vue`) affiche « Facturé » et « Facturé Payé », pas cette colonne. Son `reste_ht` de détail est la colonne `reste_a_regler_ht` (`BilanFinancierService.php:1204`), un autre cache. Pas d’écran trésorerie qui recalcule ce reste : la synthèse programme est le total d’opération qui le somme.

Un marché dont le montant est positif et le facturé le dépasse garde un négatif réel. Le cas cité est un montant de marché à 0.

## 4. Reste à payer

`total_paye_ht` est déjà sur la liste (`Marches/Index.vue:616`, alimenté par les dépôts payés ou validés + reprise payée). La fiche marché a déjà « Reste à payer » = plafond hors retenues − payé (`MarcheSyntheseFinanciereService.php:202-205`, affiché `MarcheSyntheseFinanciere.vue:411`). Ce n’est pas facturé − payé. Les deux coïncident quand le facturé égale le plafond (cas 457 001 − 339 057 = 117 944).

Colonne absente de la liste : facturé − payé. Écran où l’ajouter : le tableau `Marches/Index.vue` (colonnes actuelles : Marché + Avenant, Facturé, Reste à facturer, Payé). La synthèse programme somme déjà le « Reste à payer » plafond − payé (`ProgrammeSyntheseFinanciere.vue:247`). Le bilan n’a pas cet écart.

## 5. Conclusion

1. Faux dépassement. Mécanisme prouvé : déjà facturé = dépôts hors rejet hors dépôt courant + `SUM` reprise une fois, plus le HT courant. Le HT courant serveur est la colonne `montant_ht` si elle est positive. Le front suit la base HT saisie. Personne n’ajoute un cumul OCR en plus. Déduction, liste non lue : 151 468,80 avec une fiche à 73 876 correspond à un HT courant de 77 592,80 (cumul du PDF). Le double 73 876 se verrait sur la fiche. À confirmer par la commande : `montant_ht` du dépôt, `ocr_raw.montant_ht`, lignes de reprise, autres dépôts.
2. Détail du marché. Prouvé : la fenêtre lit les colonnes en cache et le nombre de situations. L’agrégat vivant (reprise comprise) est dans d’autres champs, non affichés. `recalculer` écrit en plus le payé dans `total_facture_ht`.
3. Reste négatif sans marché. Prouvé : `montant_total_ht − total_facture_ht`, sans ramener à 0 quand le marché vaut 0. La somme du tableau et la synthèse programme reprennent ces négatifs.
4. Reste à payer. Prouvé : le payé est déjà là. La liste n’a pas la colonne facturé − payé. La fiche a un autre « Reste à payer » (plafond − payé).

Source unique proposée : le déjà facturé d’un marché = dépôts hors rejet et hors archive + `SUM(marche_reprise_lignes.montant_facture_ht)` une fois (`totauxPourMarche` / `composerTotaux`). Le plafond, la fenêtre de détail, la fiche, la liste et le bilan lisent cet agrégat. Les colonnes `marches.total_facture_ht`, `pct_avancement`, `reste_a_regler_ht` ne servent plus d’affichage.

Correctifs envisagés, sans les coder ici :

1. Plafond : le HT courant est le montant de la situation saisi (base HT à régler), jamais le cumul OCR si la saisie l’a remplacé ; le serveur ne reprend pas un ancien `montant_ht` par-dessus le montant posté. L’OCR d’une situation doit viser la ligne de situation, pas « Montant H.T. ».
2. Détail : afficher `total_facture_ht_hors_courant` (ou l’agrégat fiche), le reste sous plafond et l’avancement calculés avec la reprise. Ne plus afficher les colonnes cache. Le nombre de situations ne compte pas la reprise : libellé à part, ou l’inclure.
3. Reste à facturer : si le marché (marché + avenants) vaut 0, afficher 0. Si le marché est positif et dépassé, garder le négatif. Même règle sur le total du tableau et la somme de la synthèse programme.
4. Liste des marchés : colonne facturé − payé, à côté de Payé. Ne pas la confondre avec le « Reste à payer » de la fiche (plafond − payé).

## Commandes (lecture seule, non lancées ici)

Une ligne, sans point d’exclamation. À lancer en SSH sur le serveur, pas en CLI si elle devait appeler Sage : celle-ci ne fait que des lectures.

```text
php artisan tinker --execute='$m=App\Models\Marche::query()->where("reference","MCH-2026-232")->first(); if(!$m){echo "ABSENT"; return;} $svc=app(App\Services\MontantFactureSousTraitantService::class); $reprises=App\Models\MarcheRepriseLigne::query()->where("marche_id",$m->id)->get(["id","libelle","fournisseur_id","montant_facture_ht","montant_paye_ht"]); $depots=App\Models\DepotFacture::query()->where("marche_id",$m->id)->get(["id","numero_facture","statut","montant_ht","montant_ttc","ocr_raw"]); $lignes=[]; foreach($depots as $d){ $ocr=data_get($d->ocr_raw,"montant_ht"); $saisie=data_get($d->ocr_raw,"saisie.montant_ht"); $lignes[]=["id"=>$d->id,"type"=>"depot","statut"=>$d->statut,"numero"=>$d->numero_facture,"montant_ht"=>$d->montant_ht,"ocr_montant_ht"=>$ocr,"saisie_montant_ht"=>$saisie,"source"=>"depot_factures.montant_ht"]; } foreach($reprises as $r){ $lignes[]=["id"=>$r->id,"type"=>"reprise","statut"=>null,"numero"=>$r->libelle,"montant_ht"=>$r->montant_facture_ht,"ocr_montant_ht"=>null,"saisie_montant_ht"=>null,"source"=>"marche_reprise_lignes.montant_facture_ht"]; } $rt=App\Models\DepotFacture::query()->where("numero_facture","2026-6192")->orderByDesc("id")->first(); $eval=$rt? $svc->evaluationDepassementMarchePourValidation($m,$rt,(float)$rt->montant_ht):null; echo json_encode(["marche"=>["id"=>$m->id,"montant_marche_ht"=>$m->montant_marche_ht,"total_facture_ht_colonne"=>$m->total_facture_ht,"pct_avancement"=>$m->pct_avancement,"reste_a_regler_ht"=>$m->reste_a_regler_ht],"lignes"=>$lignes,"evaluation_si_depot_2026_6192"=>$eval], JSON_UNESCAPED_UNICODE);'
```

Dépôts dont le HT stocké diffère du HT OCR ou du HT de saisie :

```text
php artisan tinker --execute='$rows=App\Models\DepotFacture::query()->whereNotNull("ocr_raw")->orderByDesc("id")->limit(400)->get(["id","numero_facture","statut","montant_ht","ocr_raw"]); $out=[]; foreach($rows as $d){ $ocr=data_get($d->ocr_raw,"montant_ht"); $saisie=data_get($d->ocr_raw,"saisie.montant_ht"); $ht=(float)$d->montant_ht; $diff=(is_numeric($ocr)&&abs($ht-(float)$ocr)>0.009)||(is_numeric($saisie)&&abs($ht-(float)$saisie)>0.009); if($diff){ $out[]=["id"=>$d->id,"numero"=>$d->numero_facture,"statut"=>$d->statut,"montant_ht"=>$ht,"ocr_montant_ht"=>$ocr,"saisie_montant_ht"=>$saisie]; } } echo json_encode($out, JSON_UNESCAPED_UNICODE);'
```

Logs CIE et confirmation (grep, pas une écriture) :

```text
grep -n "cie-retenue-dues\|Retenues CIE\|depassement_plafond_confirme\|MCH-2026-232" storage/logs/laravel.log | tail -n 80
```

## LEÇON

Le plafond, la fenêtre œil et la fiche ne lisent pas le même « déjà facturé ». L’œil lit des colonnes ; `recalculer` y met le payé sous l’étiquette facturé. Le plafond et la fiche lisent l’agrégat vivant, et le serveur préfère le `montant_ht` déjà enregistré au montant que l’utilisateur vient de saisir. Une situation SIWI a deux HT sur le PDF : le cumul et la situation. L’OCR n’en demande qu’un.
