---
suivi: 1521
date: 2026-09-11
sujet: Retenues saisissables sur programme hors ERP uniquement
chantier: depot-factures / retenues / hors-erp
type: feature
statut: poussé
hash: eeed3e46
fichiers:
  - app/Support/ProgrammeHorsErpGuard.php
  - app/Http/Controllers/DepotFactureController.php
  - app/Services/RetenueGarantieService.php
  - app/Services/RetenueHistoriqueCollecteService.php
  - resources/js/Pages/DepotFactures/DepotFactureSaisieForm.vue
  - tests/Unit/DepotFactureRetenuesHorsErp1521SourceTest.php
  - tests/Unit/ProgrammeHorsErp1392SourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1521_retenues_programme_hors_erp.md
---

# SUIVI #1521 — retenues sur programme hors ERP uniquement

## PROMPT ENVOYÉ

Lever l’interdiction de retenue hors marché uniquement si le programme
porte `Programme::STATUT_HORS_ERP`. Natures autorisées : RG, CP, OPC,
finitions. CIE toujours interdit. Miroirs de suivi (`retenues_garantie`,
`depot_facture_retenues_historiques`) non alimentés si marché NULL.
Garde `ProgrammeHorsErpGuard`. Front : carte Retenues visible, %
saisissables à la main, CIE masqué. Aucune migration. Ne pas toucher
libération RG, `computeRetenuesGlobales`, ni le comportement AVEC marché.

## BASE GIT

Rebasé sur `origin/main` avant build Vite (`DepotFactureSaisieForm.vue`
zone commune). Hash HEAD de départ : voir commit feature.

## FICHES LUES

- **#1520 LEÇON** — la règle unique est `assertAucuneRetenueHorsMarche` ;
  tous les appelants passent par `assertRattachementProgrammeBudgetValide`
  (apply / Enregistrer / valider / déjà-validé / valider+compta /
  historique / PATCH compta via apply). CIE sans marché impossible
  (`CieService` L1003, FIFO retiré #1268). Impact : un seul if hors ERP
  dans l’assert, plus `assertAucuneRetenueCieSansMarche`. Message ERP
  géré **inchangé**.
- **#1191 LEÇON** — `peutSaisirRetenuesMarche` + watcher qui remet les %
  à 0. Impact : ouvrir la carte hors ERP et early-return du watcher
  #1224, sinon les cases restent à 0.
- **#1224 LEÇON** — RG/CP/OPC masqués si % marché = 0. Impact :
  `peutAfficherOption*` aussi vrai si `programmeEstHorsErp` (saisie
  manuelle).
- **#1268 LEÇON** — CIE = ligne due fournisseur×marché. Impact : CIE
  masqué + 422 `Retenue CIE impossible sans marché et fournisseur.`
- **#1391 / #1392 LEÇON** — `ProgrammeHorsErpGuard` coerce `sans_marche`,
  refuse marché / poste / enveloppe. Impact : ne pas retester
  `statut ===` ; enveloppe hors ERP déjà 422, cas enveloppe inchangé.
- **#1309 / #1320** — libération RG portée marché. Non touchée.

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

## BACKEND

`ProgrammeHorsErpGuard::isProgrammeHorsErp(?int $programmeId)` :
délègue à `Programme::isHorsErp()`. `coerceDepotFactureValidated` et
`assertDepotFactureRattachement` passent par ce helper.

`DepotFactureController::assertAucuneRetenueHorsMarche` L5782 :
si hors ERP → `assertAucuneRetenueCieSansMarche` puis return ;
sinon boucle RG/CP/OPC/finitions inchangée, message L5816-5818
identique.

`assertAucuneRetenueCieSansMarche` L5847 : refuse `montant_cie`,
`cie_montant_ht`, `cie_lignes` avec le message existant
`Retenue CIE impossible sans marché et fournisseur.`

Appelants inchangés (tous via `assertRattachementProgrammeBudgetValide`) :
apply L1574, Enregistrer L1902, valider L2054, déjà-validé L2589,
valider+compta L2642, historique L2729. Enveloppe L5116 toujours
`assertAucuneRetenueSiEnveloppeBudget` (interdit, inchangé). Hors ERP
ne peut pas porter d’enveloppe (#1392).

## FRONT — DepotFactureSaisieForm.vue

- `peutSaisirRetenuesMarche` L237 : PROGRAMMES et (hors ERP **ou**
  marché réel). Carte Retenues L9619.
- Watcher `peutSaisirRetenuesMarche` L4996 : no-op si `ok` (hors ERP
  reste true, % non remis à 0).
- Watcher #1224 L5013 : early-return si `programmeEstHorsErp`.
- `peutAfficherOptionRg/Cp/Opc` L4451 : vrai si hors ERP (pas de %
  marché).
- `applyHorsErpProgrammeContraintes` L4926 : zéro CIE seulement
  (`montant_cie`, `cie_lignes`, `showCIE`) — RG/CP/OPC/finitions
  conservés.
- Case CIE `v-if="!programmeEstHorsErp"` L9672. Fetch dues CIE
  sauté L4253.

### Mapping UI ↔ back (composants de choix touchés)

Libellé affiché | Valeur postée | Colonne / JSON | Libellé back
--- | --- | --- | ---
Retenue de garantie (case) | `showRG` local ; si cochée, `retenue_garantie_pct` | `ocr_raw.saisie.retenue_garantie_pct` | retenue de garantie
Compte prorata (case) | `showCP` local ; si cochée, `compte_prorata_pct` | `ocr_raw.saisie.compte_prorata_pct` | compte prorata
OPC (case) | `showOpc` local ; si cochée, `retenue_opc_pct` | `ocr_raw.saisie.retenue_opc_pct` | retenue OPC
Finitions (case) | `showFinitions` local ; si cochée, `retenue_finitions_montant` | `ocr_raw.saisie.retenue_finitions_montant` | retenue finitions
Prélèvement (radio finitions) | `retenue_finitions_mode` = `prelevement` | `ocr_raw.saisie.retenue_finitions_mode` | prélèvement (montant positif)
Libération (radio finitions) | `retenue_finitions_mode` = `liberation` | `ocr_raw.saisie.retenue_finitions_mode` | libération (montant négatif)
CIE (case, masquée hors ERP) | `showCIE` / `montant_cie` / `cie_lignes` | refusé serveur | CIE
Pénalités (case, inchangée) | `penalite_ids` — disabled sans `marche_id` | hors périmètre | —

Les pourcentages hors ERP sont saisis à la main (aucun marché ne les
fournit). Pas de select de nature : cases binaires + champs numériques.

## MIROIRS DE SUIVI — lignes exactes

Ne rien écrire si marché effectif NULL.

- `RetenueGarantieService::upsertDepuisComptabilisation` L62-66 :
  après `resolveMarcheIdEffectif`, `if ($marcheId === null) return null;`
  — pas de `updateOrCreate` sur `retenues_garantie`.
- `RetenueGarantieService::upsertHistorique` L117-120 : skip si
  `marche_id` null / <= 0.
- `RetenueGarantieService::predireHistorique` L205-207 :
  `raison: marche_id_absent`.
- `RetenueHistoriqueCollecteService::collecter` L39-42 : skip si
  marché null / <= 0 — pas d’écriture `depot_facture_retenues_historiques`.
- `RetenueHistoriqueCollecteService::predire` L114-116 :
  `raison: marche_id_absent`.

Les lignes comptables Sage (401700 / 401800 / 401500 / 401400 + CLASSID
programme) restent produites par le chemin compta existant. Non touché.

## JOURNAL

`depot-retenues-programme-hors-erp-1521-2026-09-11` — visible saisie
facture (groupes Envol, Hectare, compta, admin, DSI).

## TESTS (source ; sqlite skipped ici)

`DepotFactureRetenuesHorsErp1521SourceTest` + assertions
`isProgrammeHorsErp` dans `ProgrammeHorsErp1392SourceTest`.

Validation manuelle prod :

1. Programme géré ERP, sans marché, RG > 0 → REFUS, message
   `Les retenues (retenue de garantie) sont interdites sur une facture hors marché. Choisissez un marché ou retirez la retenue.`
2. Programme hors ERP, sans marché, RG > 0 → ACCEPTÉ
3. Programme hors ERP, CIE > 0 → REFUS
   `Retenue CIE impossible sans marché et fournisseur.`
4. Programme hors ERP, RG saisie → aucune ligne `retenues_garantie`
5. Programme avec marché → comportement strictement inchangé
6. Cas 2 → validation → compta : APBILL 401700, CLASSID programme présent

## DÉPLOIEMENT-TEST

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

Pas de migration. Rebuild Vite committé dans le même commit.

## LEÇON

L’interdiction #1191 reste la règle pour tout programme géré dans
l’ERP. Hors ERP n’a pas de marché possible : lever RG/CP/OPC/finitions
là seulement, via `ProgrammeHorsErpGuard::isProgrammeHorsErp`, jamais
un second test de statut. CIE sans marché n’a pas de base de répartition
(#1268) : masquer + 422 explicite. Les miroirs de suivi sont ancrés
marché — skip si NULL, sinon des théoriques / libérations fantômes.
Ne pas ouvrir FRAIS_GENERAUX / PROJET. Watcher #1224 remet les % à 0
dès que le % marché est absent : hors ERP il faut early-return, sinon
la carte ouverte reste inutilisable.
