---
suivi: 1520
date: 2026-09-11
sujet: Diagnostic — autoriser les retenues sur facture hors marché
chantier: depot-factures / retenues
type: diagnostic
statut: poussé
hash: c777808a
fichiers:
  - docs/suivi/SUIVI_1520_diag_retenues_hors_marche.md
---

# SUIVI #1520 — PHASE 1 diagnostic : retenues hors marché

## PROMPT ENVOYÉ

Diagnostic seul. Aujourd’hui la saisie d’une facture sans marché refuse
toute retenue (« Les retenues (retenue de garantie) sont interdites sur
une facture hors marché… »). Décision métier : toutes les natures
saisissables + prélevées sur le paiement + Sage ; l’ERP ne les suit
pas (pas de théorique, pas de libération, pas d’états). Réserve : si
une nature (CIE / CP) ne peut techniquement pas fonctionner sans
marché, le signaler avec le code. Ne rien modifier aux retenues avec
marché. Aucune migration, aucun code applicatif.

## BASE GIT

HEAD = `origin/main` = `1f865d4b` (chore hash fiche #1519).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1191 LEÇON** — `peutSaisirRetenuesMarche` + `assertAucuneRetenueHorsMarche`
  posés ensemble (UI + 422). CIE volontairement hors du `v-if` retenues
  à l’époque ; poste `sans_marche` ensuite révoqué #1193. Impact : le
  message actuel et le watcher qui remet les % à 0 datent de ce lot.
  Mesure prod 04/08 : 0 retenue enregistrée hors marché.
- **#1188 LEÇON** — dette D (retenues visibles hors marché) mesurée
  avant #1191 ; script `tools/diag/diag_saisie_facture_1188.php` sujet D
  toujours utilisable pour le volume.
- **#1267 / #1268 LEÇON** — CIE n’est plus un % ni un FIFO : une ligne
  cochée = une `cie_repartitions` due fournisseur×marché. Impact :
  impossible d’éteindre une dette CIE sans `marche_id`.
- **#1304 / #1302 LEÇON** — stock facture TTC (`retenues_garantie.montant_retenu`,
  crédits `lignes_comptables`) ; théoriques et pénalités HT ; conversion
  lecture dans MRES. Impact : lever l’interdiction ne change pas les
  unités.
- **#1309 / #1320 LEÇON** — libération RG = `demandes_liberation_rg`
  portée **marché**, pas d’allocation sur `retenues_garantie`, pas
  d’écriture Sage. Impact : une RG hors marché n’a pas de chemin de
  libération ERP (conforme à la décision).
- **#1312 / #1287** — alertes CP et onglet Retenues = `pourMarche`.
- **#1224** — cases RG/CP/OPC masquées si % marché = 0.

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

---

## 1. OÙ EST LA RÈGLE

### 1.1 Serveur — une seule méthode, pas de FormRequest

Pas de `FormRequest`. Règles de champs (`retenue_garantie_pct`, etc.)
dans `DepotFactureController::depotDraftSaisieValidationRules`
(`app/Http/Controllers/DepotFactureController.php` L957-961) :
nullable numeric, **sans** interdiction hors marché.

L’interdiction est un assert métier :

`DepotFactureController::assertAucuneRetenueHorsMarche` L5782-5809 :

- champs testés : `retenue_garantie_pct`, `compte_prorata_pct`,
  `retenue_opc_pct`, `retenue_finitions_montant` ;
- libellés injectés dans le message : « retenue de garantie »,
  « compte prorata », « retenue OPC », « retenue finitions » ;
- seuil `abs((float) $raw) > 0.009` → `ValidationException` 422 ;
- message exact L5803-5806 :
  `Les retenues (%s) sont interdites sur une facture hors marché. Choisissez un marché ou retirez la retenue.`

**CIE n’est pas dans cette liste.** CIE a ses propres 422 (point 2).

Déclenchement L5128-5136 dans
`assertRattachementProgrammeBudgetValide` (L5043) :

```
$horsMarcheEffectif = $sansMarche || $rattachementPoste
    || ($destination === 'PROGRAMMES' && $marcheId === null && $programmeBudgetId === null);

if ($horsMarcheEffectif && $destination === 'PROGRAMMES') {
    $this->assertAucuneRetenueHorsMarche($validated, $depot);
}
```

Trois cas « hors marché » PROGRAMMES :

| Cas | Source | Sens métier (#1191) |
|-----|--------|---------------------|
| `sans_marche` | colonne `depot_factures.sans_marche` + JSON | « Marché non encore créé » |
| `rattachement_poste_direct` | **JSON seulement** (`ocr_raw.saisie`) — pas de colonne | Charge directe hors marché |
| `marche_id` NULL sans enveloppe | dépôt / JSON / facture | Brouillon PROGRAMMES incomplet |

`destination !== 'PROGRAMMES'` (FRAIS_GENERAUX, PROJET) : **cet** assert
ne tourne pas. L’enveloppe budgétaire réutilise le même assert **plus**
CIE interdit (`assertAucuneRetenueSiEnveloppeBudget` L5815-5826, appelé
L5116).

`brouillonPermissif` (L5119) n’exempte **pas** l’assert retenues : un
Enregistrer brouillon 422 aussi bien qu’un Valider.

Appels de `assertRattachementProgrammeBudgetValide` (donc de l’assert
retenues si hors marché PROGRAMMES) :

| Ligne | Chemin |
|-------|--------|
| 1574 | `applyDepotSaisieFromValidated` (Enregistrer saisie **et** PATCH compta `ComptabilisationController::update` L282) |
| 1902 | `update` dépôt, brouillon permissif |
| 2054 | `valider` |
| 2589 | Valider déjà validé |
| 2642 | Valider et comptabiliser |
| 2729 | Valider historique |

Duplication : **aucune** autre copie du message dans le repo (grep
unique sur `DepotFactureController.php` L5804).

### 1.2 Front — blocage client, plus large que le 422

`resources/js/Pages/DepotFactures/DepotFactureSaisieForm.vue` :

- L232-234 `horsMarcheProgrammes` = `sans_marche \|\| rattachement_poste_direct`
- L236-241 `peutSaisirRetenuesMarche` = destination PROGRAMMES **et**
  `marche_id` réel **et** pas `horsMarcheProgrammes`
- L9598-9601 : carte « Retenues » entière derrière `v-if="peutSaisirRetenuesMarche"`
- L4441-4448 : cases RG / CP / OPC en plus derrière `% marché > 0`
  (`marcheAutoriseRg/Cp/Opc` L3998-4001, qui lit `marcheSelectionne` —
  **false si pas de marché**)
- L4092-4111 : case CIE inactivable hors marché (`cieCaseMotif` =
  « facture hors marché — aucune retenue CIE »)
- L4980-4994 : **watcher destructif** — dès que `peutSaisirRetenuesMarche`
  passe à false, `showRG/CP/Finitions/OPC` = false et
  `form.retenue_garantie_pct` / `compte_prorata_pct` /
  `retenue_finitions_montant` / `retenue_opc_pct` = 0
- L5306-5318 : passage destination → FRAIS_GENERAUX remet aussi CIE à 0

Conséquence PHASE 2 : lever **seulement** l’assert PHP ne change rien
à l’écran. Il faut aussi ouvrir la carte Vue **et** ne plus zéroter,
**et** ne plus exiger un % marché pour cocher RG/CP/OPC (sinon les
cases restent invisibles faute de `marcheSelectionne`).

Finitions : pas de filtre `% marché` (L9634-9636) — visibles dès que
la carte l’est.

---

## 2. QUELLES RETENUES SONT CONCERNÉES

Inventaire des natures gérées aujourd’hui (hors pénalités, voir 2.7).

| Nature | Nom code | Stock principal | Même interdiction hors marché ? | Sage distinct ? |
|--------|----------|-----------------|---------------------------------|-----------------|
| Retenue de garantie | `retenue_garantie_pct` (saisie) ; rôle `rg` | JSON `ocr_raw.saisie` ; miroir `retenues_garantie.montant_retenu` (TTC) | **Oui** (assert L5785 + Vue) | Compte settings `comptes_comptables.retenue_garantie` défaut **401700** (`ErpComptesComptablesService.php` L33) |
| Compte prorata | `compte_prorata_pct` ; rôle `cp` | JSON saisie ; crédits lignes ; historique `depot_facture_retenues_historiques` type `cp` | **Oui** (assert L5786 + Vue) | `compte_prorata` défaut **401800** (L34) |
| OPC | `retenue_opc_pct` ; rôle `opc` ; % marché `taux_opc_pct` | JSON saisie + crédits lignes. **Pas de table de suivi dédiée.** | **Oui** (assert L5787 + Vue) | `retenue_opc` défaut **401500** (L36) |
| Finitions | `retenue_finitions_montant` (signé TTC) + `retenue_finitions_mode` `prelevement`/`liberation` | JSON saisie + crédits/débits lignes. **Pas de table de suivi dédiée.** | **Oui** (assert L5788 + Vue) | `retenue_finitions` défaut **401400** (L35) |
| CIE | `montant_cie` (TTC calculateur) + `cie_montant_ht` + `cie_lignes[]` | JSON saisie ; `cie_prelevements` (HT) liés à `cie_repartitions.marche_id` | **Pas le même assert.** Interdit enveloppe (L5822-5825). Interdit sans marché **et** fournisseur (L5612-5617, L5668-5671). Front : `peutSaisirRetenuesMarche` | `cie` défaut **401600** (L32) |

Calculateur unique, **sans `marche_id`** :
`EcrituresComptablesCalculator::computeRetenuesGlobales`
(`app/Services/DepotFacture/EcrituresComptablesCalculator.php` L282-325)
et rôles L17-25 (`ROLE_RG` … `ROLE_CIE`). Les montants d’écriture sont
du TTC reconstituté. Le marché n’entre pas dans la formule.

### 2.1 RG

Miroir écrit **après** comptabilisation Sage :
`RetenueGarantieService::upsertDepuisComptabilisation` L34-84 —
`marche_id` = `FactureMarcheDoubleAncrageService::resolveMarcheIdEffectif`
(nullable). FK `retenues_garantie.marche_id` **nullable**
(migration `2026_06_16_120000` L14).

### 2.2 CP

Deux mécanismes distincts :

1. **Prélèvement** sur facture = crédit 401800 (comme RG). Pas d’INSERT
   dédié à la comptabilisation : lecture via
   `LignesComptablesRetenueExtractor` (`settingKey` `compte_prorata`).
2. **Cagnotte / sorties** = `compte_prorata_sorties` via
   « Déduire de la cagnotte CP » (`CompteProrataSortieService`) —
   exige `programme_id` (L121-124, L158-161), pas un marché pour créer
   la sortie. Le **pilotage** (prévisionnel, en appro, provisionné)
   est 100 % par marché (`CompteProrataService::previsionnelMarche` L44-50
   = taux × `montant_marche_ht` ; `collecteMarche` L58-80).

### 2.3 CIE — RÉSERVE TECHNIQUE

Le module CIE **ne peut pas** retenir « comme les autres » sans marché.

Preuves :

- `assertCieRetenueServeur` L5612-5617 : `marcheId <= 0 \|\| fournisseurId <= 0`
  → « Retenue CIE impossible sans marché et fournisseur. »
- Si `montant_cie > 0` sans `cie_lignes` : L5620-5624 (FIFO retiré #1268).
- `cieRetenueDues` L5705-5710 : `marcheId > 0 && fournisseurId > 0` sinon
  `dues = []`.
- `CieService::repartitionsDuePourFournisseurMarche` L1003-1007 : retour
  `[]` si `marcheId <= 0` ; requête L1009-1011
  `where('fournisseur_id')` + `where('marche_id')`.
- `syncPrelevementsPourDepot` écrit `cie_prelevements` sur une
  `cie_repartitions` qui porte `marche_id` (modèle L17).

Un crédit Sage ROLE_CIE **pourrait** naître du seul `montant_cie` TTC
dans le calculateur (L270, L304, L590) — le GL n’a pas besoin du
marché. Mais le serveur refuse ce chemin depuis #1268 : il faut
cocher des dues. Autoriser un CIE « montant libre hors module » serait
une **nouvelle règle produit**, pas un simple levage d’interdiction.

**Périmètre PHASE 2 recommandé :** RG, CP, OPC, finitions.
CIE hors marché : **hors lot** (rester 422 + case grise), sauf
arbitrage contraire explicite.

### 2.4 OPC / finitions

Pas d’état de suivi dédié. MRES les ventile uniquement via
`DepotFacture::pourMarche` (L66-84 de `MarcheRetenuesEtatsService.php`).
Hors marché ils n’apparaissent déjà nulle part dans les onglets marché.

### 2.5 `autre_retenue_ht`

Migration `2026_05_10_120000` ; drop conditionnel `2026_05_12_140000`
si aucune donnée. Plus dans le fillable dépôt. Nature morte.

### 2.6 `rg_complementaire_pct`

Commentaire L5727 : caution marché, **pas** une retenue dépôt.
Hors sujet.

### 2.7 Pénalités (adjacent, pas dans l’assert)

`penalite_ids` forcés à `[]` hors marché à la persistance
(`DepotFactureController` L1687-1695, L2139, L2617, L2679).
`synchroniserPenalitesImputees` L1521-1524 no-op si `marche_id <= 0`.
Ce n’est pas une « retenue saisie » ; ne pas l’ouvrir dans #1520
(garde-fou : ne pas toucher aux mécanismes existants).

---

## 3. CE QUI DÉPEND DU MARCHÉ EN AVAL

Comportement si `marche_id` effectif NULL **après** saisie d’une retenue
(vérifié dans le code, pas supposé).

### 3.1 Montants théoriques

| Objet | Fichier | Si marche NULL |
|-------|---------|----------------|
| RG théorique = `montant_total_ht × retenue_garantie_pct` | `MarcheRetenuesEtatsService::pourMarche` L51-60 | Méthode exige un `Marche`. Jamais appelée sans. **Pas d’erreur.** |
| CP prévisionnel = `montant_marche_ht × compte_prorata_pct` | `CompteProrataService::previsionnelMarche` L44-50 | Idem, paramètre `Marche`. |
| OPC théorique | MRES L53, L62 | Idem. |
| CIE théorique = SUM `cie_repartitions.montant_calcule_ht` WHERE `marche_id` | MRES `cieTheoriqueHt` L515-525 | Filtre marché. Hors marché : 0, pas de division. |

Pas de division par zéro liée à l’absence de marché. Le dénominateur
`1 − (RG+CP+OPC)%` (`computeRetenuesGlobales` L301-308) est indépendant
du marché (filet si % = 100).

### 3.2 États de suivi (onglet Retenues & Pénalités, Synthèse)

`MarcheRetenuesEtatsService::pourMarche` L38+ :

- dépôts : `DepotFacture::pourMarche($marcheId)` L66-68 (facture.marche_id
  prioritaire, sinon dépôt.marche_id si pas de facture — modèle L407-415) ;
- RG : `RetenueGarantie::where('marche_id', $marcheId)` L87-88 ;
- CP Sage : `collecteMarche` (dépôts `intacct_bill_id` + marche effectif
  L565-588) ;
- OPC/CIE/finitions : ventilation des **mêmes** dépôts `pourMarche` L220-222.

Une facture `marche_id` NULL **n’entre pas** dans ces listes. Pas de
ligne orpheline, pas de 500. Les totaux marché restent ceux du marché.

Widgets Synthèse (`MarcheResumeWidgets.vue`) consomment le payload
marché — même isolation.

Frise couverture : `MarcheCouvertureKpiService` L90-105 — SUM RG
`where('marche_id', $marcheId)` ; dépôts `where('marche_id', $marcheId)`.
Hors marché ignoré. (Unité : SUM `montant_retenu` TTC nommé `rgHt` —
dette d’unité #1304, **hors** ce lot.)

### 3.3 `retenues_garantie`

`upsertDepuisComptabilisation` L62-71 **écrit** `marche_id` null si
l’effectif est null. Ligne persistée, unique par `facture_id`.

Lecteurs tous filtrés `where('marche_id', $marcheId)` :

- MRES L87-88
- `MarcheController` L1077-1079 (avenant)
- `MarcheCouvertureKpiService` L92-95

Aucun écran listant les RG `marche_id IS NULL` trouvé.

Risque différé : `MarcheRapprocherFactureService::rapprocher` L120
appelle `retenueGarantieService->resynchroniserMarche` (L271-296) qui
**recopie** le `marche_id` de la facture sur la ligne RG existante.
À ce moment la RG hors suivi **entre** dans MRES / KPI / plafond
libération. Aucun avertissement « cette facture a des retenues »
dans `construireAvertissements` L244-266.

Même motif : `MarcheDeplacerFactureService.php` L106.

### 3.4 `demandes_liberation_rg`

Modèle L11-12, L47 : `marche_id` obligatoire métier. Création depuis
l’onglet marché (`DemandeLiberationRgController`). Page transverse
`LiberationRg/Index.vue` L6 : création interdite (liste des demandes
déjà liées à un marché). Plafond =
`etats.rg.montant_retenu` MRES (`DemandeLiberationRgService` L21, L47-64).

Hors marché : **aucun** bouton, **aucune** ligne. Conforme « libération
hors ERP ». Ne pas toucher.

### 3.5 `compte_prorata_sorties`

Sortie = dépense de cagnotte, pas le prélèvement facture.
`upsertDepuisComptabilisation` L140-171 : si « déduire cagnotte » +
`programme_id`. `marche_id` nullable, resync L205-219.

Écran cagnotte : `CompteProrataController` L43 →
`CompteProrataService::kpisProgramme`.

**Point réel de totaux faussés (historique, pas Sage) :**
`RetenueHistoriqueCollecteService::sommeMontantHtCpProgramme` L168-177
inclut **explicitement** `whereNull('marche_id')` OR `marche_id IN
(éligibles)`. Appelé par `kpisProgramme` L385-386 et L394-395.

Donc : valider **historique** une facture hors marché avec CP
(`DepotFactureController` L2882-2883 `collecter($depot, 'cp')`)
insère une ligne `depot_facture_retenues_historiques` (`marche_id`
null, `programme_id` rempli) qui **gonfle la cagnotte programme**.

Chemin Sage (comptabilisé, `intacct_bill_id` non vide) : `collecteMarche`
filtre les dépôts par marché effectif → hors marché **exclu**. Les
KPI programme n’additionnent que les marchés. CP Sage hors marché
n’entre pas dans la cagnotte. C’est le comportement voulu (« ERP ne
suit pas ») **sauf** le trou historique ci-dessus.

### 3.6 CIE `operations` / `prelevements` / `repartitions`

Sans marché sur le dépôt, `assertCieRetenueServeur` empêche
`cie_prelevements`. Les anomalies `repartitions_sans_marche`
(`CieService` L641-643, UI `Cie/Index.vue`) concernent des
**répartitions d’opération** sans marché, pas des factures hors marché.
Hors périmètre si CIE reste interdit.

### 3.7 Recalcul marché / bilan

`mettreAJourCalculsMarche` L2929-2935 : `if (! $marcheId) return;` —
no-op, pas d’erreur.

`BilanFinancierService` : aucun grep retenues.

### 3.8 Tableaux de bord / alertes CP

Alertes `cp_depassement_*` : MRES #1312, par marché. Hors marché
invisible.

---

## 4. CHAÎNE DE PAIEMENT ET SAGE

### 4.1 Matérialisation

1. Saisie → JSON `ocr_raw.saisie` (L1721-1730) + `lignes_comptables`
   (calculateur TTC, rôles rg/cp/opc/finitions/cie).
2. Comptabilisation → APBILL. Après succès :
   `ComptabilisationController` L883-884
   `retenueGarantieService->upsertDepuisComptabilisation` +
   `compteProrataSortieService->upsertDepuisComptabilisation`
   (sortie seulement si case cagnotte).
3. Paiement APPYMT : montant = crédit **401000** net
   (`Net401DepuisLignesComptables`, `ComptabilisationPaiementApercuService`
   L78, L102-104). Les crédits 401400/500/600/700/800 restent dettes
   AP Sage, **non payées** par l’APPYMT du net.

### 4.2 Le marché dans l’APBILL ?

**Non pour les lignes de retenue.**
`appendApbillRetenueLigneSpecsFromLignesComptables` L2886-2924 :
compte GL settings + crédit des `lignes_comptables` + `classid` /
`location_id` / `department_id`. Pas de `marche_id`.

`classIdForBill` L1478-1500 : destination PROGRAMMES → **classe du
programme** (`resolveProgrammeClassIdForApbill`), pas du marché.
Location = établissement saisie. Absent de marché : **rien à
remplacer** sur les lignes retenues.

Fallback `appendApbillRetenueLigneSpecs` L2932 : **zéro appelant**
(mort).

Noms `rg_ht` / `cp_ht` dans `sommesRetenuesApbillDepuisLignesComptables`
L2817-2874 : ce sont les **crédits TTC** des lignes, mal nommés `_ht`.
Cohérent avec le stock TTC. L’APPYMT n’utilise pas ces clés, il
utilise le 401000.

### 4.3 TTC vs HT

Confirmé, **non impacté** par l’absence de marché :

- Calculateur / lignes / `retenues_garantie.montant_retenu` / crédits
  APBILL = **TTC**.
- Théoriques marché et pénalités = **HT**.
- MRES convertit TTC→HT à la lecture (`ttcVersHt`, #1304).
- CIE module = HT (`cie_prelevements.montant_retenu_ht`) ;
  `form.montant_cie` = TTC (#1267).

Lever l’interdiction ne change aucune formule d’unité.

---

## 5. COMMENT EXCLURE PROPREMENT DU SUIVI

### 5.1 `marche_id` NULL suffit-il aujourd’hui ?

**Pour les écrans marché existants : oui.** Tous les lecteurs RG / MRES /
KPI / libération / `collecteMarche` filtrent `marche_id = :marché`.
Pas de requête « toutes les RG du programme » sur `retenues_garantie`.

**Ce n’est pas un critère durable** si la facture est ensuite rapprochée
(`MarcheRapprocherFactureService`) : `sans_marche` veut dire « à relier
ultérieurement » (#1191). Le resync recopierait le marché sur la RG.

**Trou CP historique :** `sommeMontantHtCpProgramme` **veut** les
`marche_id` NULL (L175-176). NULL ne suffit pas pour la cagnotte
programme.

### 5.2 Drapeau explicite ?

Pas nécessaire pour un premier fix **si** PHASE 2 :

1. n’écrit **pas** `retenues_garantie` ni `depot_facture_retenues_historiques`
   quand l’effectif marché est null (skip upsert / collecter) ;
2. laisse les % uniquement dans `ocr_raw.saisie` + `lignes_comptables`
   (source Sage) ;
3. **refuse ou ignore** le resync RG au rapprochement si on veut que
   « ne pas suivre » survive à une liaison ultérieure.

Un drapeau (`retenue_hors_suivi`) n’est justifié que si l’on veut
rapprocher la facture **et** continuer à l’exclure des états. Ce n’est
pas requis pour lever l’interdiction de saisie. Ne pas le poser
« au cas où ».

Critère opérationnel recommandé PHASE 2, sans migration :

- saisie autorisée si PROGRAMMES hors marché (sauf enveloppe) ;
- suivi = uniquement `marche_id` non null, comme aujourd’hui ;
- **ne pas** matérialiser le miroir RG/CP historique sans marché.

### 5.3 Écrans / requêtes à exclure (si un miroir était quand même écrit)

| Surface | Fichier | Action |
|---------|---------|--------|
| Onglet Retenues marché | `MarcheRetenuesEtatsService::pourMarche` | Déjà exclu |
| Synthèse / widgets | `MarcheSyntheseFinanciereService`, `MarcheResumeWidgets.vue` | Déjà exclu |
| Frise couverture | `MarcheCouvertureKpiService` | Déjà exclu |
| Libération RG | `DemandeLiberationRgService` | Déjà exclu (création marché) |
| Cagnotte CP programme | `CompteProrataService::kpisProgramme` + `sommeMontantHtCpProgramme` | **À corriger** : ne plus additionner `marche_id` NULL issus de factures hors suivi |
| Rapprochement / déplacement | `MarcheRapprocherFactureService` L120, `MarcheDeplacerFactureService` L106 | Ne pas propager `marche_id` sur une RG « hors suivi », ou avertir |

---

## 6. RISQUES SI ON LÈVE SEULEMENT L’INTERDICTION

Sans autre changement (assert PHP retiré, Vue inchangée) : **aucun
changement utilisateur** — la carte reste masquée, le watcher remet à 0.

Si PHP **et** Vue ouverts sans filets suivi / CIE / historique :

| Risque | Gravité | Preuve |
|--------|---------|--------|
| CIE 422 ou dues vides | Bloquant si on affiche la case | L5612-5624, L5708-5710 |
| Cases RG/CP/OPC invisibles même carte ouverte | UX | `marcheAutorise*` L3998-4001 false sans marché |
| Watcher remet à 0 au toggle sentinelle | Perte saisie | L4980-4994 |
| Ligne `retenues_garantie` `marche_id` NULL | Dormante | upsert L62-71 ; lecteurs OK tant que pas rapproché |
| Rapprochement → RG soudain dans MRES + plafond libération | **Totaux marché faussés** | rapprocher L120 + MRES L87 |
| CP historique → cagnotte programme | **Totaux cagnotte faussés** | `sommeMontantHtCpProgramme` L175-176 |
| Écriture Sage incomplète | **Non** si `lignes_comptables` équilibrées | APBILL L1631, classid programme L1478-1500 |
| 500 / division par zéro écrans marché | **Non** | `pourMarche` / `if (! $marcheId) return` |
| Enveloppe + retenues | Interdit inchangé si on ne touche pas L5116 | À conserver |
| Retenues **avec** marché | Inchangées si on ne touche qu’au garde hors marché | Garde-fou |

Écrans en **erreur** (500) : aucun identifié.
Écritures Sage incomplètes : non, le marché n’alimente pas les lignes
401x. Totaux faussés : **oui** si miroir RG rapproché ou CP historique
programme.

---

## 7. DONNÉES — requêtes SQL (à coller en prod)

Pas d’accès MySQL ici. #1191 (04/08/2026) : 0 retenue hors marché
enregistrée — à rejouer, le stock a bougé.

`rattachement_poste_direct` n’est **pas** une colonne (JSON saisie).

---

## PÉRIMÈTRE PHASE 2 (proposition, pas un fix)

À arbitrer sur ce rapport :

1. **Ouvrir** RG, CP, OPC, finitions hors marché PROGRAMMES
   (`sans_marche` / rattachement poste / marche_id NULL).
2. **Garder** l’interdiction enveloppe (`assertAucuneRetenueSiEnveloppeBudget`).
3. **Garder** CIE module (422 + case inactive) — réserve §2.3.
4. **Garder** pénalités hors marché à zéro.
5. Vue : nouvelle condition d’affichage (ne plus exiger `% marché` hors
   marché) ; **neutraliser le watcher** L4980-4994 hors marché.
6. Ne pas upsert `retenues_garantie` / collecter CP historique si
   marché effectif null.
7. Ne pas modifier MRES, libération, cagnotte des **marchés existants**
   autrement qu’en évitant d’y faire entrer ces lignes.
8. Signal rapprochement : aujourd’hui silencieux — à trancher (laisser
   entrer / refuser / rester hors suivi).

FRAIS_GENERAUX / PROJET : l’assert PHP ne s’y applique pas ; la Vue
exige PROGRAMMES. Hors besoin exprimé (message = hors marché
programme). Ne pas élargir sans demande.

Aucun commit applicatif dans ce lot.

---

## DÉPLOIEMENT-TEST

```text
git pull origin main
```

Pas de migrate, journal:sync, npm, optimize. Coller les SQL du §7.

## LEÇON

Une interdiction « hors marché » a deux couches (#1191) : 422 serveur
**et** `v-if` + watcher qui **efface** les %. Lever l’une sans l’autre
ne sert à rien. CIE n’est pas une retenue-% : depuis #1268 c’est
l’extinction d’une dette `cie_repartitions` clé fournisseur×marché —
le GL 401600 n’y change rien. `marche_id` NULL isole les écrans
marché d’aujourd’hui, mais `sans_marche` est fait pour être rapproché
et `sommeMontantHtCpProgramme` **cherche** les NULL : l’exclusion
« ERP ne suit pas » n’est pas gratuite, elle se joue à l’écriture du
miroir, pas au SELECT des onglets.

---

```markdown
## SUIVI #1520 — SYNTHÈSE diagnostic retenues hors marché

### Verdict
La règle est unique, dans DepotFactureController::assertAucuneRetenueHorsMarche
(L5782-5809), message L5803-5806. Pas de FormRequest. Déclenchée pour
destination PROGRAMMES si sans_marche OU rattachement_poste_direct (JSON)
OU marche_id NULL sans enveloppe (L5131-5136). Appelée depuis
applyDepotSaisieFromValidated / valider / historique / PATCH compta.
CIE n’est pas dans cette liste.

Front : DepotFactureSaisieForm.vue peutSaisirRetenuesMarche L236-241
masque la carte (L9598) ; watcher L4980-4994 remet RG/CP/OPC/finitions
à 0 ; cases RG/CP/OPC exigent en plus un % marché (L4441-4448) donc
restent invisibles sans marché même si on ouvre la carte.

### Natures
- RG (retenue_garantie_pct, 401700, miroir retenues_garantie TTC)
- CP (compte_prorata_pct, 401800, cagnotte par marché)
- OPC (retenue_opc_pct, 401500, pas de table suivi)
- Finitions (retenue_finitions_montant signé TTC, 401400, pas de table)
- CIE (montant_cie TTC + cie_lignes HT, 401600, cie_prelevements)

Toutes sauf CIE : même interdiction hors marché. Calculateur
computeRetenuesGlobales : pas de marche_id (TTC).

### RÉSERVE CIE (et CP suivi)
CIE module : ne peut pas fonctionner sans marché.
assertCieRetenueServeur L5612-5617 ; dues L5708-5710 et
CieService::repartitionsDuePourFournisseurMarche L1003-1011.
FIFO retiré #1268 : pas de montant libre. Périmètre PHASE 2 :
RG + CP (prélèvement Sage) + OPC + finitions. CIE : rester interdit.

CP prélèvement Sage : OK sans marché (crédit 401800). CP théorique /
cagnotte / alertes : par marché, hors marché ignoré — voulu.
Trou : collecte historique CP + sommeMontantHtCpProgramme L175-176
inclut marche_id NULL → totaux cagnotte programme faussés si on
collecte sans skip.

### Aval marché_id NULL
Pas de 500, pas de division par zéro. MRES / KPI / libération RG /
collecteMarche filtrent marche_id. mettreAJourCalculsMarche L2933
return. APBILL retenues = lignes_comptables + classid PROGRAMME
(ComptabilisationController L1478-1500, L1631) — marché absent des
lignes 401x. APPYMT = net 401000 (Net401DepuisLignesComptables).
TTC stock / HT théorique : inchangé.

Risque réel : rapprocher (MarcheRapprocherFactureService L120) recopie
marche_id sur retenues_garantie → la RG « hors suivi » entre dans
MRES et le plafond demandes_liberation_rg.

### Exclusion suivi
marche_id NULL suffit aux écrans actuels. Insuffisant si rapprochement
ou si collecter() historique CP. Recommandé sans drapeau / sans
migration : ne pas upsert retenues_garantie ni
depot_facture_retenues_historiques quand marché effectif null.
Drapeau seulement si on veut rapprocher ET rester hors états.

### Si on lève l’interdiction sans le reste
Vue inchangée = rien ne change. Vue+PHP sans filet = pas d’écriture
Sage cassée ; totaux marché OK tant que pas rapproché ; totaux
cagnotte OK en Sage, faux en historique CP. CIE 422. Enveloppe :
garder l’interdit. Ne pas toucher retenues avec marché ni libération.

### PHASE 2 (proposition)
Ouvrir RG/CP/OPC/finitions hors marché PROGRAMMES. Garder enveloppe
+ CIE + pénalités. Ajuster Vue (carte + watcher + % marché). Skip
miroirs suivi si marche null. Trancher le rapprochement. Un lot.

### SQL 1 — dépôts hors marché avec retenues JSON (saisie)
SELECT d.id, d.uuid, d.statut, d.sans_marche, d.marche_id, d.facture_id,
       d.programme_id, d.intacct_bill_id,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) AS destination,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) AS rattachement,
       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.retenue_opc_pct')) AS opc_pct,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_finitions_montant')) AS finitions,
       JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.montant_cie')) AS cie_ttc
FROM depot_factures d
WHERE d.deleted_at IS NULL
  AND (
    d.sans_marche = 1
    OR JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
    OR (
      JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.destination')) = 'PROGRAMMES'
      AND d.marche_id IS NULL
      AND d.programme_budget_id IS NULL
    )
  )
  AND (
    CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_garantie_pct')) AS DECIMAL(10,4)) > 0.009
    OR CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.compte_prorata_pct')) AS DECIMAL(10,4)) > 0.009
    OR CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_opc_pct')) AS DECIMAL(10,4)) > 0.009
    OR ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.retenue_finitions_montant')) AS DECIMAL(15,2))) > 0.009
    OR CAST(JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.montant_cie')) AS DECIMAL(15,2)) > 0.009
  )
ORDER BY d.id DESC;

### SQL 2 — volumes hors marché vs avec retenue JSON
SELECT
  SUM(d.sans_marche = 1) AS nb_sans_marche,
  SUM(d.marche_id IS NULL) AS nb_marche_id_null,
  SUM(
    JSON_UNQUOTE(JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct')) IN ('true', '1')
    OR JSON_EXTRACT(d.ocr_raw, '$.saisie.rattachement_poste_direct') = true
  ) AS nb_rattachement_json
FROM depot_factures d
WHERE d.deleted_at IS NULL;

### SQL 3 — miroir RG sans marché
SELECT rg.id, rg.facture_id, rg.depot_facture_id, rg.marche_id,
       rg.montant_retenu, rg.retenue_garantie_pct, rg.statut, rg.source,
       rg.intacct_bill_id, d.sans_marche, d.statut AS depot_statut
FROM retenues_garantie rg
LEFT JOIN depot_factures d ON d.id = rg.depot_facture_id
WHERE rg.marche_id IS NULL
ORDER BY rg.id DESC;

### SQL 4 — historique CP/RG/CIE/finitions sans marché
SELECT h.id, h.depot_facture_id, h.facture_id, h.programme_id, h.marche_id,
       h.type_retenue, h.montant_ttc_source, h.montant_ht, h.source, h.collected_at
FROM depot_facture_retenues_historiques h
WHERE h.marche_id IS NULL
  AND h.montant_ht > 0.009
ORDER BY h.id DESC;

### SQL 5 — prélèvements CIE sur dépôt sans marché
SELECT p.id, p.depot_facture_id, p.cie_repartition_id, p.montant_retenu_ht,
       p.statut, r.marche_id AS repartition_marche_id,
       d.marche_id AS depot_marche_id, d.sans_marche
FROM cie_prelevements p
JOIN depot_factures d ON d.id = p.depot_facture_id
LEFT JOIN cie_repartitions r ON r.id = p.cie_repartition_id
WHERE p.deleted_at IS NULL
  AND p.statut <> 'annule'
  AND (d.marche_id IS NULL OR d.sans_marche = 1)
ORDER BY p.id DESC;
```
