---
suivi: 1777
date: 2026-10-02
sujet: Diagnostic — retenue CIE effacée avant envoi Sage
chantier: factures / CIE
type: diagnostic
statut: poussé
hash:
fichiers:
  - docs/suivi/SUIVI_1777_diag_cie_efface_compta.md
---

# SUIVI #1777 — retenue CIE effacée avant Sage

Phase 1. Aucun code modifié. Fiche #1775 relue : son hypothèse « case cochée, rien écrit » est contredite par la prod. La facture 1269 a bien eu un prélèvement, puis il a été annulé.

Faits prod déjà connus (non relus ici) :

- Historique 12874, 03/09 : `montant_cie` 408,60 TTC, `cie_lignes` `[{repartition_id:35, montant_ht:340,50}]`. Prélèvement 3 créé.
- Historique 16654, 18/09 15:00:46 local : `montant_cie` 408,60 → 0, `cie_lignes` → `[]`. Prélèvement 3 `annule` + soft-supprimé à 13:00:45 UTC.
- 16662-16665, 18/09 15:08 : envoi Intacct, puis comptabilisée. Payée le 21/09.
- Même journée, prélèvement 8 (facture 1472, dépôt 1783, 252,00 HT) annulé 12:49:18 UTC ; prélèvement 7 (facture 1450, dépôt 1785, 340,50 HT) annulé 12:57:05 UTC.

## 1. Qui a écrit le 18/09

L’historique ne stocke pas le nom de l’écran. `Historisable::logHistorique` (`app/Traits/Historisable.php` l. 27-37) enregistre `user_id`, `impersonator_id`, `action`, `champs_modifies` (`avant` / `apres`), `commentaire`.

Le commentaire, posé par `logHistoriqueIfSaisieChanged` (`DepotFactureController.php` l. 6391-6401), est « Modification après engagement workflow (statut dépôt : …) » dès que `facture_id` est rempli. Le statut dans cette phrase est le seul contexte d’étape stocké.

Les trois appelants qui écrivent `action=modifie` puis passent dans `applyDepotSaisieFromValidated` :

- brouillon : `DepotFactureController` l. 2080-2081, URL `/depot-factures/{uuid}` ;
- fiche déjà engagée : l. 4892-4893 ;
- comptabilisation : `ComptabilisationController::update` l. 299-300, URL `/factures/comptabilisation/{uuid}`.

Dès qu’une facture existe, le formulaire envoie l’enregistrement sur l’URL de comptabilisation (`DepotFactureSaisieForm.vue` l. 7761-7764), que l’hôte soit la page Comptabilisation ou la fiche dépôt. Le nom de la personne est le `user_id` de l’historique 16654 : commande (a) ci-dessous, pas encore exécutée.

## 2. Chemin d’écrasement

### 2.a Initialisation à l’ouverture

Un seul composant sur tous les hôtes : `DepotFactureSaisieForm.vue`.

| Hôte | Fichier | Écrit en base |
|---|---|---|
| Dépôt | `DepotFactures/Index.vue` | oui si enregistrable |
| Fiche NV1 / NV2 | `DepotFactures/Show.vue` | oui ; une fois `facture_id` rempli, le PATCH part vers la comptabilisation |
| Marché | `Marches/Show.vue` | même formulaire |
| Comptabilisation | `Factures/Comptabilisation/Index.vue` l. 709, props `depot: p.depot` l. 335 | oui, `useAjax` |
| Consultation | `SaisieConsultation.vue` | non : `refreshCieDues` sort tout de suite (l. 4562-4564) |

À la création du formulaire, `montant_cie` (TTC) et `cie_lignes` (HT) sont lus dans `props.depot.ocr_raw.saisie` (l. 613-619). Le payload dépôt inclut `ocr_raw` entier (`DepotFactureController.php` l. 4563). Rien n’est lu dans `cie_prelevements` à ce moment. `showCIE` démarre à `false` (l. 2171). L’hydratation tardive recopie les mêmes champs (l. 7404-7411) et ne coche pas `showCIE`.

### 2.b Chargement des dues

`refreshCieDues` (l. 4561) appelle `GET /depot-factures/{uuid}/cie-retenue-dues`. Le serveur passe l’id du dépôt (`cieRetenueDues` l. 5982).

`CieService::repartitionsDuePourFournisseurMarche` (l. 1003-1057) réintègre le prélèvement actif de CE dépôt :

```1033:1037:app/Services/CieService.php
$dejaCeDepot = (float) ($preleveParRep[$repId] ?? 0);
$resteBrut = round((float) $rep->montant_calcule_ht - (float) $rep->cumul_retenu_ht, 2);
$resteAffiche = round($resteBrut + $dejaCeDepot, 2);
if ($resteAffiche <= 0.009 && $dejaCeDepot <= 0.009) {
```

Une répartition entièrement retenue sur cette facture reste donc dans `dues`, tant que le prélèvement est actif et que la répartition est vivante, même fournisseur, même `marche_id`, opération parente vivante. Elle disparaît du dû si le prélèvement est déjà `annule` ou soft-supprimé, ou si la répartition ne matche plus (autre marché, autre fournisseur, soft-delete, opération absente).

Côté écran, la réponse remplace `cieLignes` (l. 4667-4697). Une ligne enregistrée est recochée seulement si son `repartition_id` est dans `dues` (l. 4673-4675), ou via `preleve_sur_depot_ht` (l. 4676-4678). Le montant gardé est `min(sauvé, reste_du_ht)` : un reste à 0 donne un montant 0.

Puis, si un TTC ou des lignes sauvées existent encore, le code appelle quand même `syncMontantCieTtcDepuisLignes` (l. 4709-4711). Cette fonction ne relit pas la saisie stockée. Elle recalcule le TTC depuis les lignes dues cochées, et écrase `form.cie_lignes` (l. 4469-4487). Liste vide ou lignes à 0 : `form.montant_cie` passe à 0 et `form.cie_lignes` à `[]`, sans message.

Échec, timeout 20 s ou annulation de l’appel (l. 4723-4750) : `cieLignes` est vidé, `form.montant_cie` n’est pas mis à 0. Un enregistrement brouillon enverrait encore le TTC hydraté, avec `cie_lignes: []`. Ce n’est pas le cas du 18/09 : l’historique montre le TTC passé à 0, donc le formulaire avait déjà été recalculé à 0 avant le PATCH.

### 2.c Ce que poste la comptabilisation

`Comptabiliser`, `Comptabiliser et payer`, `Rejeter` et `Enregistrer` appellent `saveDraftAjax` (l. 7772, l. 7870, l. 8403, l. 8446, l. 8500). Le corps est `buildDraftPayload` (l. 7101).

Champs CIE postés (l. 7157-7159) :

- `montant_cie` : la valeur courante du formulaire (TTC). Après le recalcul du 2.b, c’est 0.
- `cie_montant_ht` : 0 si `showCIE` est faux, sinon la somme HT des lignes dues cochées.
- `cie_lignes` : `buildCieLignesPayload` (l. 4491-4500). Tableau vide si `showCIE` est faux, ou si aucune ligne due n’est cochée avec un HT > 0,009. Le tableau stocké dans `form.cie_lignes` n’est pas relu ici.

`buildValiderPayload` (l. 7755-7757) force en plus `montant_cie` à 0 quand `showCIE` est faux. Les boutons de comptabilisation n’utilisent pas ce payload : ils passent par le brouillon. Les deux aboutissent au même `applyDepotSaisieFromValidated`.

Le serveur écrit ces valeurs dans `ocr_raw.saisie` (l. 1859-1863), puis synchronise les prélèvements (l. 1986).

### 2.d Annulation dans `syncPrelevementsPourDepot`

`cieLignesDepuisValidated` (l. 5651-5655) : clé absente, `null` ou non-tableau → `[]`. Un tableau vide donne le même `[]`. La synchro ne distingue pas les deux.

`assertCieRetenueServeur` sort sans erreur si les lignes sont vides et que le HT posté et le TTC posté sont ~0 (l. 5844-5846). Deuxième sortie, l. 5863-5865 : lignes vides et `cie_montant_ht` posté à 0, même si le TTC stocké en base est encore positif. Pas de 422.

`syncPrelevementsPourDepot` (l. 1110-1126) : tableau normalisé vide → chaque prélèvement actif du dépôt passe à `annule`, puis `delete()` (soft-delete), puis recalcul du cumul HT.

À l’écriture de la saisie, la clé absente garderait les anciennes `cie_lignes` (l. 1861-1863). Le formulaire envoie toujours la clé. Le 18/09, clé présente et vide, et TTC à 0 : saisie et prélèvement sont effacés ensemble. RG et CP ne sont pas touchés par cette synchro : ce sont des pourcentages dans la saisie, sans table de prélèvement annulée quand le tableau est vide.

## 3. Portée

Commandes lecture seule, à coller sur OVH. `withTrashed`. Montant des prélèvements : HT. `montant_cie` dans l’historique : TTC. `champs_modifies` est le diff complet `avant` / `apres`. Le commentaire porte le statut du dépôt au moment de l’écriture, pas le nom de l’écran.

RG et compte prorata : pas de table de prélèvement synchronisée par un tableau vide. Aucune requête.

```
php artisan tinker --execute="\$ids = [16654]; foreach (\$ids as \$id) { \$h = App\Models\FactureHistorique::query()->find(\$id); if (\$h === null) { echo 'hist='.\$id.' ABSENT'.PHP_EOL; continue; } \$u = \$h->user_id ? App\Models\User::query()->find(\$h->user_id) : null; \$imp = \$h->impersonator_id ? App\Models\User::query()->find(\$h->impersonator_id) : null; echo 'hist='.\$h->id.' depot='.\$h->depot_facture_id.' facture='.(\$h->facture_id ?? '-').' action='.\$h->action.' user='.(\$h->user_id ?? '-').' nom='.(\$u->name ?? '-').' impersonator='.(\$h->impersonator_id ?? '-').' nom_imp='.(\$imp->name ?? '-').' created='.\$h->created_at.' commentaire='.(\$h->commentaire ?? '-').PHP_EOL; echo 'champs='.json_encode(\$h->champs_modifies).PHP_EOL; } foreach (App\Models\FactureHistorique::query()->whereIn('depot_facture_id', [1783, 1785])->where('action', 'modifie')->whereDate('created_at', '2026-09-18')->orderBy('id')->get() as \$h) { \$u = \$h->user_id ? App\Models\User::query()->find(\$h->user_id) : null; \$imp = \$h->impersonator_id ? App\Models\User::query()->find(\$h->impersonator_id) : null; echo 'hist='.\$h->id.' depot='.\$h->depot_facture_id.' facture='.(\$h->facture_id ?? '-').' action='.\$h->action.' user='.(\$h->user_id ?? '-').' nom='.(\$u->name ?? '-').' impersonator='.(\$h->impersonator_id ?? '-').' nom_imp='.(\$imp->name ?? '-').' created='.\$h->created_at.' commentaire='.(\$h->commentaire ?? '-').PHP_EOL; echo 'champs='.json_encode(\$h->champs_modifies).PHP_EOL; } echo 'FIN_A'.PHP_EOL;"

php artisan tinker --execute="\$rows = App\Models\CiePrelevement::withTrashed()->where('statut', 'annule')->orderBy('id')->get(); echo 'annules='.\$rows->count().PHP_EOL; foreach (\$rows as \$p) { \$d = \$p->depotFacture()->withTrashed()->first(); \$bill = \$d ? trim((string) \$d->intacct_bill_id) : ''; if (\$bill === '') { continue; } \$fac = \$d->facture_id ? App\Models\Facture::withTrashed()->find(\$d->facture_id) : null; \$fou = \$d->fournisseur_id ? App\Models\Fournisseur::query()->find(\$d->fournisseur_id) : null; \$prog = \$d->programme_id ? App\Models\Programme::query()->find(\$d->programme_id) : null; \$bestId = '-'; \$bestUser = '-'; \$bestAt = '-'; \$bestSec = 999999999; foreach (App\Models\FactureHistorique::query()->where('depot_facture_id', \$d->id)->where('action', 'modifie')->orderBy('id')->get() as \$h) { \$cm = (array) \$h->champs_modifies; \$av = (array) (\$cm['avant'] ?? []); \$ap = (array) (\$cm['apres'] ?? []); \$touch = array_key_exists('montant_cie', \$av) || array_key_exists('montant_cie', \$ap) || array_key_exists('cie_lignes', \$av) || array_key_exists('cie_lignes', \$ap); if (\$touch === false) { continue; } \$sec = abs(strtotime((string) \$h->created_at) - strtotime((string) (\$p->deleted_at ?: \$p->updated_at))); if (\$sec < \$bestSec) { \$bestSec = \$sec; \$bestId = \$h->id; \$u = \$h->user_id ? App\Models\User::query()->find(\$h->user_id) : null; \$bestUser = (\$u->name ?? '-').'#'.(\$h->user_id ?? '-'); \$bestAt = (string) \$h->created_at; } } echo 'prelevement='.\$p->id.' facture='.(\$d->facture_id ?? '-').' numero='.\$d->numero_facture.' fournisseur='.(\$fou->intacct_id ?? '-').' '.(\$fou->nom ?? '-').' programme='.(\$d->programme_id ?? '-').' '.(\$prog->libelle ?? '-').' montant_ht='.\$p->montant_retenu_ht.' annule_at='.(\$p->deleted_at ?: '-').' statut_facture='.(\$fac->statut ?? '-').' bill='.\$bill.' hist='.\$bestId.' hist_at='.\$bestAt.' user='.\$bestUser.PHP_EOL; } echo 'FIN_B'.PHP_EOL;"
```

## 4. Conclusion

Cause : un enregistrement du formulaire de saisie, le 18/09, a posté `montant_cie = 0` (TTC) et `cie_lignes = []`. `assertCieRetenueServeur` l’a accepté sans message (l. 5844 ou l. 5863). `syncPrelevementsPourDepot` l. 1110-1126 a annulé et soft-supprimé le prélèvement. Une seconde plus tard, l’historique 16654 décrit exactement ce diff. L’envoi Sage (15:08) est un geste distinct, huit minutes après.

Le formulaire produit ce payload sans décoche manuelle quand `refreshCieDues` ne restitue pas de ligne cochée à montant HT > 0, puis que `syncMontantCieTtcDepuisLignes` (l. 4709-4711 et l. 4480-4487) écrase le TTC et les lignes déjà lus dans `ocr_raw.saisie`. `buildCieLignesPayload` (l. 4491-4500) poste alors le vide. Le serveur, lui, réintègre bien le prélèvement de cette facture dans le dû (l. 1033-1037) : « solde déjà consommé » ne retire la ligne que si ce prélèvement n’est plus actif ou si la répartition n’est plus sur ce fournisseur et ce marché.

Le nom de l’utilisateur et la liste complète des factures sortent des commandes (a) et (b).

Correctif envisagé, non codé : ne pas recalculer le CIE à 0 quand les dues ne réaffichent pas la ligne déjà enregistrée ; ne poster l’effacement que sur une décoche explicite. Côté serveur, ne pas appeler la synchro sur un tableau vide s’il existe un prélèvement actif, et traiter une clé absente comme « ne pas toucher ». Les factures déjà payées ne sont pas à réécrire : la retenue reste due et sera reprise sur les situations suivantes.

## LEÇON

Le #1775 a vu l’état final (saisie à 0, pas de ligne au décompte) et en a déduit que la case n’avait rien écrit. L’historique montre l’écriture, puis l’effacement. Une synchro qui traite « rien dans le payload » comme « l’utilisateur a annulé » efface une retenue valide dès qu’un écran enregistre avant d’avoir reposé les lignes.
