---
suivi: 1522
date: 2026-09-11
sujet: Diagnostic — ajustement manuel des retenues historiques (RG, CIE, finitions)
chantier: depot-factures / retenues / historique
type: diagnostic
statut: poussé
hash: 50f81efa
fichiers:
  - docs/suivi/SUIVI_1522_diag_ajustement_manuel_retenues.md
---

# SUIVI #1522 — PHASE 1 diagnostic : ajustement manuel des retenues historiques

## PROMPT ENVOYÉ

Diagnostic seul. Constat MCH-2026-069 : le CP historique (manuel + auto)
apparaît, pas la RG. Décision métier : PAS de collecte automatique des
autres natures ; AJUSTEMENT MANUEL par marché et par nature (RG, CIE,
finitions). Le CP ne change pas. La RG ajustée entre dans le plafond de
libération. Aucune migration, aucun code applicatif.

## BASE GIT

HEAD = `origin/main` = `3a8c6eba` (chore hash fiche #1521).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1521 LEÇON** — skips `marche_id` NULL dans
  `RetenueHistoriqueCollecteService` L39-42 / L114-116 et
  `RetenueGarantieService` L62-66 / L117-120 / L205-207. Impact : tout
  stockage d’ajustement doit exiger un `marche_id` NOT NULL ; ne pas
  réutiliser la table auto liée à un dépôt.
- **#1520 LEÇON** — CIE sans marché impossible ; miroirs ancrés marché.
  Hors périmètre (on reste SUR marché).
- **#1308 LEÇON** — page Retenues CP = Sage TTC→HT + historique HT natif
  (`MarcheCpHistorique` + `DepotFactureRetenueHistorique` type `cp`).
  Ne pas reconvertir l’historique. Impact : l’ajustement doit naître en
  HT, comme le manuel CP et la reprise SIWI (#1344).
- **#1309 / #1320 LEÇON** — `retenues_garantie.statut/date_liberation`
  inutilisables ; plafond = `etats.rg.montant_retenu` (MRES, HT) −
  Demandée+Payée. Aucune allocation FIFO sur les lignes RG. Impact :
  brancher l’ajustement DANS MRES suffit à le rendre libérable, sans
  écrire `retenues_garantie`.
- **#1322 LEÇON** — `Gate::before` + `hasAnyM365Group` faussent les
  tests admin ; `m365GroupNames()` brut + `abort_unless`.
- **#1344 LEÇON** — `marche_reprise_lignes.montant_*_reprise_ht` déjà HT,
  addition APRÈS `ttcVersHt`. Pattern d’injection à recopier, table à
  NE PAS réutiliser (sémantique SIWI ≠ factures historiques).

Aucune fiche #1518/#1519 n’a changé l’approche retenues.

---

## 1. LE MÉCANISME MANUEL DU CP

Il existe. Il est **spécifique au CP**. Ce n’est pas un point d’accroche
générique.

### 1.1 Écran

Page Compte prorata du programme (`resources/js/Pages/CompteProrata/Index.vue`) :

- droit `can_manage_historique` L259-267, clic cellule provisionné L287-290 ;
- dialog L647-650, titre `Historique CP — {référence}` ;
- champ **Montant historique HT** (`historiqueForm.montant_ht`) L661-668 ;
- commentaire optionnel ;
- PUT `/programmes/{programme}/compte-prorata/marches/{marche}/historique`
  (`routes/web.php` L2183).

La page Retenues du marché (`Marches/Show.vue`) **n’offre aucune saisie**
de cet historique. Elle n’affiche qu’une ligne de synthèse une fois le
montant non nul.

### 1.2 Table — `marche_cp_historiques`

Migration `database/migrations/2026_07_01_140000_create_marche_cp_historiques_table.php`
L11-19 :

| Colonne | Contrainte |
|---------|------------|
| `programme_id` | FK programmes, NOT NULL |
| `marche_id` | FK marches, **UNIQUE** (une ligne par marché) |
| `montant_ht` | decimal 14,2, défaut 0 — **HT natif** |
| `commentaire` | text nullable |
| `created_by` / `updated_by` | FK users nullable |

Modèle `app/Models/MarcheCpHistorique.php` `$table = 'marche_cp_historiques'` L10.

Écriture : `CompteProrataController::upsertHistorique` L96-130 —
`firstOrNew(['marche_id'])`, **écrase** `montant_ht` (pas d’historique
des valeurs précédentes). Traçabilité = `created_by` / `updated_by` +
commentaire + timestamps. Pas de table d’audit.

### 1.3 Droits actuels (piège)

`AuthorizesCompteProrataAccess::userCanManageCpHistorique` L68-84 :

- `hasAnyM365Group(['GS_ADMIN_ERP', 'GS_DSI', 'GS_DIRECTION_GENERALE'])`
  **ou** responsable programme.

`hasAnyM365Group` (`User.php` L146-152) short-circuite si
`isAdminERP()` : **ne pas recopier ce test** pour un montant libérable.

### 1.4 Remontée affichage Retenues

`CompteProrataService::montantHistoriqueMarche` L106-111 =

`montantHistoriqueManuelMarche` (`MarcheCpHistorique.montant_ht`)
+ `RetenueHistoriqueCollecteService::sommeMontantHtCpMarche` (auto, type `cp`).

`MarcheRetenuesEtatsService` L161-166 / L239-241 : historique **déjà HT**,
additionné après conversion Sage, jamais via `$ttc`.

Ligne UI L314-330 :

`facture_reference` = **`Histororique CP (manuel + auto)`**
(typo « Histororique » dans le source L320).

`source` = `historique`. Montant = somme manuel + auto, **une seule
ligne agrégée** (on ne distingue pas les deux parts à l’écran Retenues).

### 1.5 Collecte auto CP (rappel, hors décision)

Déclencheurs qui écrivent `depot_facture_retenues_historiques` type `cp`
uniquement :

- artisan `compte-prorata:collecter-historique`
  (`CollecterCompteProrataHistoriqueCommand.php` L15 / `collecter($depot, 'cp')` L74) ;
- `DepotFactureController` après saisie historique L2883
  `collecter($depot, 'cp')`.

**Verdict §1** : le manuel CP n’est pas générique. PHASE 2 doit
**calquer le pattern** (1 ligne / marché, HT, upsert, injection MRES
après conversion), **sans étendre** `marche_cp_historiques`.

---

## 2. OÙ STOCKER L’AJUSTEMENT

### 2.1 `depot_facture_retenues_historiques` — conception générique, usage CP

Modèle `DepotFactureRetenueHistorique.php` L10 / L14-28 : constantes
`TYPE_CP|RG|CIE|FINITIONS` (`cp|rg|cie|finitions`). **OPC absent.**
Commentaire L10 : « seul cp branché pour l’instant ».

Valeurs **écrites aujourd’hui** : uniquement `cp`.

Preuve : les seuls `collecter(...)` applicatifs passent `'cp'`
(`CollecterCompteProrataHistoriqueCommand.php` L51/L74,
`DepotFactureController.php` L2883). Aucun `collecter($depot, 'rg'|…')`.

Le service **sait** calculer RG/CIE/finitions
(`calculerMontants` L271-277, garde `pct_rg_nul` L130-132) mais
**personne ne l’appelle** pour ces types.

### 2.2 Contraintes : pas d’ajustement sans facture

Migration `2026_07_24_170000_create_depot_facture_retenues_historiques_table.php` :

- `depot_facture_id` **NOT NULL**, FK `cascadeOnDelete` L13-15 ;
- unique `(depot_facture_id, type_retenue)` L32 ;
- `facture_id` nullable L16-19 ;
- `marche_id` **nullable** L21-24.

Un ajustement manuel « marché, pas de facture » **ne rentre pas** :
il n’y a pas de dépôt porteur, et #1521 refuse `marche_id` NULL à
l’écriture (L39-42).

### 2.3 `retenues_garantie` — encore plus contraignant

Migration `2026_06_16_120000_create_retenues_garantie_table.php` L13 :
`facture_id` **unique NOT NULL**. Impossible d’y poser un lump marché.

### 2.4 `marche_reprise_lignes` — ne pas réutiliser

Montants HT par nature (`montant_rg_reprise_ht`, etc.,
`MarcheRepriseMontantsService.php` L11-16). Sémantique SIWI / reprise
(#1344). Mélanger historique factures et reprise SIWI fausserait les
deux états.

### 2.5 Structure à proposer (PHASE 2)

Nouvelle table, calquée sur `marche_cp_historiques`, **sans toucher au CP** :

Proposition `marche_retenues_ajustements_historiques` :

- `marche_id` NOT NULL, FK ;
- `type_retenue` string (`rg` | `cie` | `finitions`) ;
- unique `(marche_id, type_retenue)` ;
- `montant_ht` decimal (saisie utilisateur **HT**) ;
- `commentaire` NOT NULL (motif obligatoire — argent libérable) ;
- `created_by` / `updated_by` ;
- timestamps.

Option recommandée en plus : table d’audit
`marche_retenues_ajustements_historiques_log`
(`montant_avant`, `montant_apres`, `user_id`, `motif`, `created_at`)
parce que l’upsert CP **perd** la valeur précédente (L123).

Ne pas étendre `depot_facture_retenues_historiques`.

---

## 3. COMMENT L’AJUSTEMENT REMONTE DANS LES ÉTATS

Source unique des cumuls page Retenues + Synthèse + widgets :
`MarcheRetenuesEtatsService::pourMarche`
(`MarcheController.php` L780-791, L891-896).

Point d’injection analogue à la reprise #1344 : **après** `ttcVersHt`,
L259-270 (aujourd’hui `MarcheRepriseMontantsService::totauxPourMarche`).
Y ajouter `montant_ht` de l’ajustement, puis une ligne de détail
(comme L272-330 pour reprise / historique CP).

### 3.1 Par nature — lecture actuelle

| Nature | Retenu page Retenues | Détail |
|--------|----------------------|--------|
| RG | Σ `retenues_garantie.montant_retenu` TTC → HT L87-122, L235 | `detail_rg` (factures) + reprise L272-289 |
| CP | Sage 401800 TTC→HT + `montantHistoriqueMarche` HT L164-166, L239-241 | prélevements Sage + ligne « Histororique CP (manuel + auto) » L314-330 |
| CIE | `ventilerCle(..., 'cie')` = saisie `computeRetenuesGlobales` TTC L221, L248 | `detailCie` L667 : lignes saisie + dettes `cie_repartitions` (non déduites du cumul retenu) |
| Finitions | `ventilerCle(..., 'finitions')` L222, L251 | pas de table de suivi |
| OPC | `ventilerCle` L220 — **hors périmètre** | |

Théorique CIE : `cie_repartitions.montant_calcule_ht` (`cieTheoriqueHt`,
note L457). Distinct du retenu.

### 3.2 Surfaces qui suivent MRES (un seul branchement)

- Page Retenues `Marches/Show.vue` : `props.retenues.*` L2018-2041, L4820.
- Widgets résumé `MarcheResumeWidgets.vue` L113-123.
- Synthèse `MarcheSyntheseFinanciereService::cumulsRetenusEnHt` L798-807
  lit `etats['rg']['montant_retenu']`.
- **Plafond libération** `DemandeLiberationRgService::plafondPourMarche`
  L46-64 : même clé.
- Payload `liberation_rg` Show L898-899.

### 3.3 Surface qui NE suit PAS MRES — fiche facture

`FactureController.php` L825-839 →
`MontantFactureSousTraitantService::kpiAvancementMarchePourFacture`
L440-486.

`retenues_cumulees` = Σ par dépôt de
`MarcheSyntheseFinanciereService::historiqueFactures` L550-611 =
`computeRetenuesGlobales` sur **la saisie de chaque facture**, TTC→HT.
Pas de lump marché.

Conséquence constatée FAC-2026-736 : le CP de **cette** facture est dans
la saisie → l’avancement « après » augmente. La RG de cette facture est
à 0 dans la saisie (`retenue_garantie_pct` explicite 0, le `??` taux
marché L591 ne s’applique pas) → RG avant = RG après = 5 141,07.

Un ajustement **marché** n’apparaîtra pas dans ce bloc, sauf PHASE 2
y injecte une ligne synthétique. Ce n’est pas bloquant : le mal métier
est le **reste à libérer** (MRES + plafond), pas l’avancement facture.

### 3.4 Surface secondaire, UI masquée

`MarcheCouvertureKpiService.php` L92-95 : Σ
`retenues_garantie.montant_retenu` **sans** MRES (et sans `ttcVersHt`).
Bandeau coupé `SHOW_MARCHE_KPI_COUVERTURE_HEADER = false`
(`Marches/Show.vue` L169). Hors PHASE 2 ; le noter.

### 3.5 Double comptage à éviter

- Ne pas écrire l’ajustement **et** une ligne `retenues_garantie` /
  `depot_facture_retenues_historiques` pour le même montant.
- Ne pas additionner l’ajustement **avant** `$ttc` (sinon ÷ TVA d’un HT).
- Ne pas additionner dans MRES **et** dans `cumulsRetenusEnHt` (ce
  dernier recopie MRES).

---

## 4. RG ET CIRCUIT DE LIBÉRATION

### 4.1 Plafond « Disponible »

`DemandeLiberationRgService.php` L19-21, L46-64 :

Disponible HT = `etats.rg.montant_retenu` − Σ demandes
`demandee` + `payee` (`STATUTS_CONSOMMENT_PLAFOND` L41-44).

UI `Marches/Show.vue` L157 `plafond_source: 'etats.rg.montant_retenu'`,
L4931-4937.

**Aucune lecture directe de `retenues_garantie` pour le plafond.**
`retenues_garantie` n’alimente le plafond **qu’indirectement**, parce
que MRES L87-114 en fait la somme.

Créer une demande : contrôle L123-137 au **moment de créer**. Une
demande déjà `demandee` n’est pas recalculée si le plafond bouge.

Payer / refuser : change le statut, **n’écrit pas**
`retenues_garantie` (doc service L21, modèle L12-13).

### 4.2 Branche A — créer une ligne `retenues_garantie`

Impossible sans `facture_id` unique (migration L13) et
`fournisseur_id` (upsertHistorique L112-114 skip si absent).

Il faudrait une facture fantôme, ou une migration (interdire : le
prompt dit pas de refonte du suivi). Même avec une ligne : MRES
convertirait TTC→HT (`montant_retenu` est **TTC**,
`RetenueGarantieService.php` L17-20, L92-93). L’utilisateur saisirait
du HT → stock TTC → relecture HT : mélange garanti.

De plus `upsertDepuisComptabilisation` **reset**
`statut`/`date_liberation` (constaté #1309) : une RG « ajustement »
posée ici serait fragile.

**A rejetée.**

### 4.3 Branche B — ajuster l’agrégat MRES seulement (recommandée)

Table dédiée HT + addition L259-270 + ligne détail
« Ajustement historique RG ».

Conséquences :

- Plafond libération augmente du même HT **sans autre branchement**.
- `retenues_garantie` inchangée (FAC-310 / FAC-311 restent les seules
  sources facture).
- Une libération Payée continue de vivre dans `demandes_liberation_rg`
  (trace cohérente : demande + justificatifs SP, pas de miroir RG).
- Idempotence upsert `(marche_id, type=rg)` : rejouer = écraser, pas
  doubler, **si** on n’écrit pas aussi dans `retenues_garantie`.

C’est le miroir du CP manuel, avec en plus l’effet libérable via MRES.

### 4.4 Demande déjà en cours si l’ajustement change après coup

- **Hausse** : Disponible augmente ; Demandée/Payée inchangées ;
  on peut demander le delta.
- **Baisse** sous le consommé (Demandée+Payée) : `disponible_ht =
  max(0, retenu − consomme)` L56. Les demandes existantes **restent**.
  On pourrait alors avoir consommé > retenu affiché (plafond « déjà
  trop libéré »). `creer()` ne s’en occupe pas.

Garde-fou PHASE 2 : **refuser** une baisse qui rend
`nouveau_retenu < somme Demandée+Payée`, message explicite. Ne pas
annuler silencieusement des demandes.

### 4.5 RG ajustée puis libérée

Trace = ligne d’ajustement (montant HT, auteur, motif, dates) +
demande(s) `demandes_liberation_rg` (montant HT, justificatifs,
Payée). Pas d’écriture Sage (#1320 porte STOP). Cohérent avec le
circuit actuel (on ne libère pas une ligne `retenues_garantie`).

---

## 5. GARDE-FOUS À CONCEVOIR

### 5.1 Droits — `m365GroupNames()` EN BRUT

Pièges :

- `AppServiceProvider.php` L69-71 `Gate::before` → true si
  `isAdminERP()` ;
- `User::hasAnyM365Group` L146-152 / `hasM365Group` L134-140 : même
  short-circuit ;
- le manuel CP utilise déjà `hasAnyM365Group` L74 : **ne pas copier**.

Modèle à copier : `DemandeLiberationRgPolicy::userEstComptaBrut` L50-59 :

```
$groups = $user->m365GroupNames();
in_array('GS_COMPTABILITE', $groups, true)
|| in_array('GS_ADMIN_ERP', $groups, true)
```

+ `abort_unless` serveur, **pas** `$this->authorize()` seul.

**Proposition groupes** (argent libérable, plus strict que le CP
historique) :

| Action | Groupes (brut) |
|--------|----------------|
| Saisir / modifier l’ajustement | `GS_COMPTABILITE`, `GS_ADMIN_ERP` |
| Lecture du montant | même visibilité que la fiche marché |
| Optionnel | `GS_DIRECTION_GENERALE` (aligné CP, pas le responsable programme) |

Ne **pas** donner le droit au seul responsable programme : le CP
manuel l’autorise (L78-83) ; ici le montant devient libérable.

UI : flag `can_ajuster_historique` calculé avec la **même** fonction
brute, pas un Gate.

### 5.2 Plafond vs théorique

MRES alerte déjà `rg_depassement_retenu` L345-353 (retenu > effective)
sans bloquer. Cas connu marché 32 / MCH-2026-029.

**Ne pas refuser** un ajustement qui fait dépasser le théorique :
alerte existante + warning UI. Refus seulement si `montant_ht < 0`.

### 5.3 Traçabilité

Minimum = colonnes CP (`updated_by`, `commentaire`, timestamps).
Pour la RG : **motif obligatoire** + journal d’audit des écrasements
(montant avant / après). Le CP perd l’avant (L123) : inacceptable ici.

### 5.4 Modifiable après coup

Oui, **upsert** (une valeur courante par marché × nature), comme le CP.
Correction = nouvelle saisie qui écrase, pas une pile d’ajustements
qui s’additionneraient (doublon). L’audit garde l’historique.

Garde baisse vs demandes : §4.4.

---

## 6. CIE ET FINITIONS

### 6.1 CIE — représentable en affichage, pas dans la matrice de dettes

Théorique : `cie_repartitions` (`MarcheRetenuesEtatsService` L64, L457).
Retenu affiché : saisie dépôts `ventilerCle('cie')` L221, **pas**
`cie_prelevements` (note L438 : `statut=encaisse` jamais écrit).
Détail : lignes saisie + dettes ouvertes `montant_calcule_ht −
cumul_retenu_ht` L709-742.

Un lump HT dans MRES (comme la reprise CIE L263-267) ferait monter
« Retenu » et baisser « Restant », **sans** diminuer
`cie_repartitions.cumul_retenu_ht`. Les lignes grises « Dette ouverte »
resteraient. Incohérence d’écran.

Créer un `cie_prelevement` / une répartition sans opération +
fournisseur + facture : hors « pas de refonte ».

**Verdict CIE** : ne pas forcer en PHASE 2. Possible plus tard si le
métier accepte une ligne « Ajustement historique CIE » **purement
affichage**, dettes inchangées — à faire dire explicitement.

### 6.2 Finitions — ajustement propre

Pas de table de suivi, pas de théorique, pas de libération. Uniquement
`ventilerCle('finitions')` L222 / payload L440-443.

Lump HT + ligne détail = même schéma que la reprise finitions L264-268.
**Inclure en PHASE 2** (même table, `type_retenue=finitions`).

### 6.3 OPC

Hors décision. Pas dans `DepotFactureRetenueHistorique::TYPES`. 0,00
sur MCH-2026-069 : ne pas ouvrir.

---

## 7. DISCIPLINE HT / TTC

| Grandeur | Unité | Preuve |
|----------|-------|--------|
| Théoriques marché | HT | MRES L51-62 taux × actualisé HT |
| `retenues_garantie.montant_retenu` | **TTC** | `RetenueGarantieService.php` L17-20 |
| Crédits Sage CP/RG | TTC | MRES L163, conversion L227-235 |
| `marche_cp_historiques.montant_ht` | **HT** saisi | Controller L103, dialog « Montant historique HT » Index.vue L661 |
| `depot_facture_retenues_historiques.montant_ht` | HT = TTC/1,2 | `DIVISEUR_TVA_AFFICHAGE` L16-17, L359-361 |
| Page Retenues | HT | Show.vue L4787 |
| Demandes libération | HT | `demandes_liberation_rg.montant_ht` |
| Reprise SIWI | HT, jamais `$ttc` | MRES L259 |

**L’utilisateur doit saisir l’ajustement en HT** (comme le manuel CP).
Stockage HT. Injection MRES **après** `$ttc`, drapeau
`montant_deja_ht` sur la ligne détail (déjà L300/L324 pour CP).

Risque de mélange : écrire TTC dans une colonne lue comme HT, ou
passer un HT dans `$ttc()`. Erreur invisible : un ÷1,20 trop ou pas
assez, restant faux dans le même sens que le bug actuel.

Ne **pas** réutiliser `RetenueHistoriqueCollecteService::ttcVersHt`
(÷1,2 fixe L16) : MRES utilise `tauxTvaMarchePct` L224-228.

---

## 8. VOLUME — requêtes SQL (à jouer en prod)

Hypothèse FAC-2026-736 : `compte_prorata_pct` rempli,
`retenue_garantie_pct` = 0 en JSON (le `??` taux marché ne s’applique
pas). Les requêtes mesurent **les deux** écarts : saisie réelle vs
taux marché × HT.

Colonnes vérifiées : `depot_factures.saisie_historique`, `type_source`,
`deja_reglee`, `intacct_bill_id`, `marche_id`, `ocr_raw`, `montant_ht`,
`statut`, `deleted_at` ; `factures.reference` ; `marches.reference`,
`retenue_garantie_pct`, `intitule` ; `retenues_garantie.facture_id`,
`montant_retenu`, `marche_id` ; `demandes_liberation_rg.montant_ht`,
`statut`.

### SQL 1 — factures historiques avec RG saisie > 0 absente de `retenues_garantie`

```
SELECT m.id AS marche_id, m.reference,
       d.id AS depot_id, f.reference AS facture_ref,
       d.montant_ht,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_garantie_pct')) AS rg_pct_saisie,
       rg.id AS rg_id
FROM depot_factures d
JOIN marches m ON m.id = d.marche_id AND m.deleted_at IS NULL
LEFT JOIN factures f ON f.id = d.facture_id AND f.deleted_at IS NULL
LEFT JOIN retenues_garantie rg ON rg.facture_id = d.facture_id
WHERE d.deleted_at IS NULL
  AND d.statut = 'paye'
  AND (d.intacct_bill_id IS NULL OR d.intacct_bill_id = '')
  AND (
        d.saisie_historique = 1
     OR d.type_source = 'historique'
     OR d.deja_reglee = 1
  )
  AND CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_garantie_pct')) AS DECIMAL(10,4)) > 0.0001
  AND rg.id IS NULL
ORDER BY m.reference, d.id;
```

### SQL 2 — écart par marché : retenu MRES (miroir TTC/1,2) vs RG « théorique facture » des historiques

Approximation lecture HT = `montant_retenu / (1 + marches.taux_tva/100)`
si `taux_tva` NULL → 20. Ne remplace pas MRES ; ordre de grandeur.

```
SELECT m.id, m.reference,
       ROUND(m.montant_total_ht * m.retenue_garantie_pct / 100, 2) AS rg_theorique_ht,
       ROUND(COALESCE(SUM(rg.montant_retenu), 0) / (1 + COALESCE(m.taux_tva, 20) / 100), 2) AS rg_retenu_miroir_ht_approx,
       ROUND(SUM(
         CASE WHEN (d.saisie_historique = 1 OR d.type_source = 'historique' OR d.deja_reglee = 1)
               AND (d.intacct_bill_id IS NULL OR d.intacct_bill_id = '')
               AND d.statut = 'paye'
              THEN d.montant_ht * m.retenue_garantie_pct / 100
              ELSE 0 END
       ), 2) AS rg_historique_taux_marche_ht,
       COUNT(DISTINCT CASE
         WHEN (d.saisie_historique = 1 OR d.type_source = 'historique' OR d.deja_reglee = 1)
          AND (d.intacct_bill_id IS NULL OR d.intacct_bill_id = '')
          AND d.statut = 'paye' THEN d.id END) AS nb_depots_hist
FROM marches m
LEFT JOIN retenues_garantie rg ON rg.marche_id = m.id
LEFT JOIN depot_factures d ON d.marche_id = m.id AND d.deleted_at IS NULL
WHERE m.deleted_at IS NULL
GROUP BY m.id, m.reference, m.montant_total_ht, m.retenue_garantie_pct, m.taux_tva
HAVING rg_historique_taux_marche_ht > 0.009
ORDER BY rg_historique_taux_marche_ht DESC;
```

### SQL 3 — CIE / finitions sur dépôts historiques (saisie)

```
SELECT m.reference,
       SUM(CASE WHEN CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.montant_cie')) AS DECIMAL(15,2)) > 0.009
                THEN 1 ELSE 0 END) AS nb_hist_cie,
       SUM(CASE WHEN ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_finitions_montant')) AS DECIMAL(15,2))) > 0.009
                THEN 1 ELSE 0 END) AS nb_hist_finitions
FROM depot_factures d
JOIN marches m ON m.id = d.marche_id AND m.deleted_at IS NULL
WHERE d.deleted_at IS NULL
  AND d.statut = 'paye'
  AND (d.intacct_bill_id IS NULL OR d.intacct_bill_id = '')
  AND (d.saisie_historique = 1 OR d.type_source = 'historique' OR d.deja_reglee = 1)
GROUP BY m.id, m.reference
HAVING nb_hist_cie > 0 OR nb_hist_finitions > 0
ORDER BY m.reference;
```

### SQL 4 — demandes de libération dont le plafond bougerait (marchés avec historique)

Tout marché du SQL 2 avec au moins une demande `demandee` ou `payee` :

```
SELECT m.reference, dl.statut, dl.montant_ht, dl.date_demande, u.name AS demandeur
FROM demandes_liberation_rg dl
JOIN marches m ON m.id = dl.marche_id AND m.deleted_at IS NULL
JOIN users u ON u.id = dl.demandeur_id
WHERE dl.deleted_at IS NULL
  AND dl.statut IN ('demandee', 'payee')
  AND m.id IN (
    SELECT d.marche_id FROM depot_factures d
    WHERE d.deleted_at IS NULL AND d.statut = 'paye'
      AND (d.intacct_bill_id IS NULL OR d.intacct_bill_id = '')
      AND (d.saisie_historique = 1 OR d.type_source = 'historique' OR d.deja_reglee = 1)
      AND d.marche_id IS NOT NULL
  )
ORDER BY m.reference, dl.date_demande;
```

### SQL 5 — zoom MCH-2026-069 / FAC-2026-736 (preuve saisie)

```
SELECT d.id, f.reference,
       d.saisie_historique, d.type_source, d.deja_reglee, d.statut, d.intacct_bill_id,
       d.montant_ht,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_garantie_pct')) AS rg_pct,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.compte_prorata_pct')) AS cp_pct,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.montant_cie')) AS cie,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_finitions_montant')) AS finitions
FROM depot_factures d
LEFT JOIN factures f ON f.id = d.facture_id
JOIN marches m ON m.id = d.marche_id
WHERE m.reference = 'MCH-2026-069'
  AND d.deleted_at IS NULL
ORDER BY d.id;
```

---

## 9. OÙ PLACER L’ÉCRAN

Page marché, onglet Retenues (`Marches/Show.vue`) — pas la page Compte
prorata (spécifique CP).

**RG** — carte L4802-4955, **dans** le bandeau du bloc « Demandes de
libération » L4910-4921, **à gauche** du bouton « Demander une
libération » (L4914-4920) : bouton du type « Ajuster l’historique RG ».
Le montant saisi est le même agrégat que le « Disponible » L4931-4933 :
les deux actions se parlent.

Ne pas le coller à « + Facturer le reversement CP » (L5003-5008) : autre
nature, autre droit.

**Finitions** — même logique en tête de carte finitions (s’il n’y a pas
de bouton aujourd’hui : un seul bouton « Ajuster l’historique »).

**CIE** — ne pas poser de bouton tant que le §6.1 n’est pas tranché.

Dialog (mockup PHASE 2) : montant HT, motif obligatoire, montant
courant, théorique, retenu hors ajustement, warning si dépassement
théorique, blocage si baisse < consommé libération.

Le manuel CP **reste** sur Compte prorata / Index.vue. Ne pas y ajouter
RG.

---

## COLLECTE AUTO RG DÉJÀ PRÉSENTE — NE PAS L’ACTIVER

`RetenueGarantieService::upsertHistorique` L95-137 + commande
`retenues-garantie:collecter-historique`
(`CollecterRetenuesGarantieHistoriqueCommand.php` L13) + appel live
`DepotFactureController.php` L2888.

Écrit `retenues_garantie` source `auto_historique`, TTC, exige
facture + fournisseur + `retenue_garantie_pct` > 0.

Décision métier : **ne pas** s’en servir, **ne pas** lancer la
commande en reprise. Si FAC-2026-736 a `rg_pct = 0`, cette collecte
aurait skip `pct_rg_nul` (L216-218) de toute façon.

PHASE 2 ne doit ni l’appeler, ni la « généraliser ».

---

## PROPOSITION PHASE 2

Périmètre :

1. Migration table `marche_retenues_ajustements_historiques` (+ log
   d’audit). Pas d’alter sur CP / `retenues_garantie` /
   `depot_facture_retenues_historiques`.
2. Upsert serveur, `m365GroupNames()` brut
   `GS_COMPTABILITE|GS_ADMIN_ERP`, `abort_unless`, motif obligatoire,
   HT ≥ 0, refus si baisse sous consommé libération RG.
3. Injection **uniquement** dans `MarcheRetenuesEtatsService` après
   `$ttc` (pattern #1344) + ligne détail. Plafond libération et
   Synthèse suivent tout seuls.
4. UI : bouton sur la carte RG (et finitions) de `Marches/Show.vue`.
5. Natures : **RG + finitions**. CIE reporté. OPC hors. **CP inchangé.**
6. Pas de commande de reprise, pas de backfill auto. L’utilisateur
   saisit. (Si une commande d’import apparaissait plus tard : `--dry-run`
   par défaut.)
7. Journal utilisateur. Bundle Vite (Vue touchée).

Hors PHASE 2 : fiche facture `retenues_cumulees`, KPI couverture
masqué, matrice `cie_repartitions`, collecte `retenues-garantie:collecter-historique`.

---

## DÉPLOIEMENT-TEST

Aucune. Fiche seule. SQL §8 à jouer en prod pour calibrer PHASE 2.

## LEÇON

Le libellé « Historique CP (manuel + auto) » cache **deux** mécanismes
étanches : un upsert HT par marché (`marche_cp_historiques`) et une
collecte par dépôt (`depot_facture_retenues_historiques`, CP only).
La colonne `type_retenue` n’a jamais été un écran générique, et cette
table exige un `depot_facture_id`. Pour de l’argent libérable, le
plafond lit déjà MRES : une ligne HT marché injectée après `ttcVersHt`
suffit ; écrire `retenues_garantie` sans facture est impossible et
mélangerait TTC/HT. Ne pas recopier `hasAnyM365Group` du manuel CP.
