---
suivi: 1570
date: 2026-09-16
sujet: Diagnostic — facture ERP payée alors que la fiche Sage est extournée
chantier: factures / paiement / sage
type: diagnostic
statut: poussé
hash: 8f1eb0b1
fichiers:
  - docs/suivi/SUIVI_1570_diag_facture_payee_extournee.md
---

# SUIVI #1570 — PHASE 1 diagnostic : facture marquée payée sur une fiche Sage extournée

## PROMPT ENVOYÉ

PHASE 1 lecture seule. Cas prod FAC-2026-1404 / dépôt
`cf67355e-f1b8-4794-99ba-9faa05ab34ac` / Bill Sage `691359` :
ERP `payee`/`paye`, workflow `paiement_effectif` validé SANS
validateur, Sage état Extourné / statut brut `V`. Robin confirme
qu’aucun paiement n’est parti. Aucune migration, aucun fix, aucun
correctif de données. Requêtes SQL à coller. Commit + push main.
Pas de Vite. Pas d’appel Sage CLI.

## BASE GIT

HEAD local = `origin/main` = `790f982f` (#1568 destination parcelle).
Aucun rebase. Aucun fichier applicatif modifié. Aucun MySQL. Aucun
journal (#412 : diagnostic sans impact utilisateur visible). Working
tree sale (WIP foncier hors lot) : non committé.

## FICHES LUES

- **#1548 LEÇON** — un TTC persisté (OCR / JSON `saisie.montant_ttc`)
  n’est pas le TTC du décompte. Impact : le « lot de 50 » cité dans
  le prompt n’est **pas** chiffré dans cette fiche ; l’appartenance
  du 1404 se mesure par SQL (Q7.d), pas par le code.
- **#1489 LEÇON** — `deja_reglee` clôture des WV en `sans_objet`.
  Impact : ce n’est **pas** le chemin `paiement_effectif` `valide`
  sans validateur (le 1404 est `valide`, pas `sans_objet`).
- **#1485** — badges déjà réglée / historique. Hors cause du passage
  à `payee`.
- **#1479 LEÇON** — crontab SSH absent n’exclut pas un cron panel OVH
  ou un hit HTTP. Impact : le `Schedule` Laravel existe, mais son
  exécution prod n’est **pas** prouvée.
- **#1534 LEÇON** — un drapeau `payee` n’extourne rien dans Sage.
  Impact : l’inverse est vrai aussi : une extourne Sage n’écrit rien
  dans l’ERP, sauf si un **autre** chemin relit Sage.

Aucune fiche lue n’instruit le mapping `WHENPAID` / `Reversed` vers
`payee`. C’est le cœur de ce lot.

---

## 1. LA CHAÎNE DE STATUTS

### 1.1 Facture ERP — `factures.statut`

Définition PHP : `Facture::statutsAutorisesColonne()`
(`app/Models/Facture.php:36-47`). ENUM MySQL aligné par
`database/migrations/2026_05_29_140000_add_comptabilisee_to_factures_statut_enum.php:21-30`.

| Valeur DB | Constante | Rôle |
|-----------|-----------|------|
| `recue` | `STATUT_RECUE` | dépôt / OCR |
| `en_validation_nv1` | `STATUT_EN_VALIDATION_NV1` | NV1 |
| `en_validation_nv2` | `STATUT_EN_VALIDATION_NV2` | NV2 (enseigne ENVOL) |
| `a_comptabiliser` | `STATUT_A_COMPTABILISER` | file compta |
| `comptabilisee` | `STATUT_COMPTABILISEE` | APBILL envoyé |
| `en_paiement` | `STATUT_EN_PAIEMENT` | file paiement / APPYMT en cours |
| `payee` | `STATUT_PAYEE` | **payée** |
| `rejetee` | `STATUT_REJETEE` | rejet |

Sous-étapes Sage **distinctes** de `statut`, colonne
`factures.etat_paiement_sage` (`EtatPaiementSage`,
`app/Support/EtatPaiementSage.php:10-16`) :
`preparation` → `paiement_disponible` → `virement_execute` → `paye`.
Création : `database/migrations/2026_06_10_140000_add_paiement_sage_statuts_to_factures_and_paiements_intacct.php:12-17`
(remap `en_attente` → `paiement_disponible` :
`database/migrations/2026_06_12_260000_remap_etat_paiement_en_attente_to_paiement_disponible.php`).

### 1.2 Dépôt — `depot_factures.statut`

Définition PHP : `DepotFacture::statutsAutorisesColonne()`
(`app/Models/DepotFacture.php:78-94`). ENUM MySQL :
`database/migrations/2026_05_29_100000_add_comptabilisee_to_depot_factures_statut_enum.php:21-35`.

| Valeur DB | Constante |
|-----------|-----------|
| `depose` | `STATUT_DEPOSE` |
| `attribue` | `STATUT_ATTRIBUE` |
| `en_analyse` | `STATUT_EN_ANALYSE` |
| `analyse` | `STATUT_ANALYSE` |
| `en_cours_saisie` | `STATUT_EN_COURS_SAISIE` |
| `en_validation_nv1` | `STATUT_EN_VALIDATION_NV1` |
| `en_validation_nv2` | `STATUT_EN_VALIDATION_NV2` |
| `a_comptabiliser` | `STATUT_A_COMPTABILISER` |
| `comptabilisee` | `STATUT_COMPTABILISEE` |
| `valide` | `STATUT_VALIDE` |
| **`paye`** | **`STATUT_PAYE`** |
| `rejete` | `STATUT_REJETE` |
| `archive` | `STATUT_ARCHIVE` |

Les deux colonnes **ne portent pas le même mot** : facture `payee`
(deux e), dépôt `paye` (un e). Normalisation affichage :
`FactureStatutService` mappe `paye`/`payee`/`paiement_confirme` vers
le badge « Payée » (`app/Services/FactureStatutService.php:24`).

### 1.3 Transitions prévues (post-compta) et déclencheurs

Workflow `workflow_validations.niveau` ENUM
(`database/migrations/2026_06_04_150000_add_comptabilisation_niveau_to_workflow_validations.php:19-25`) :
`nv1`, `nv2`, `mise_paiement`, `comptabilisation`, `paiement_effectif`.

Séquence nominale Hectare (pas de NV2) après NV1 :

1. `apresNv1Valide` ouvre `mise_paiement` en_attente et passe la
   facture `a_comptabiliser`
   (`FactureWorkflowApresNv1Service.php:129-139`).
2. Validation humaine `mise_paiement` → `ouvrirPaiementEffectifEnAttente`
   + `factures.statut = en_paiement`
   (`FactureController.php:1283-1287`).
3. Comptabilisation Intacct →
   `synchroniserWorkflowFactureComptabiliseeApresCompta` : WV
   `comptabilisation` valide, `mise_paiement` clôturée si encore
   en_attente, `paiement_effectif` ouvert en_attente
   (`FactureWorkflowService.php:54-58`).
4. Passage à `payee` : **plusieurs chemins**, recensé §2.

### 1.4 Les deux statuts `payee` / `paye` — même endroit ou deux chemins ?

**Souvent le même geste, pas toujours.**

| Chemin | Facture `payee` | Dépôt `paye` | Preuve |
|--------|-----------------|--------------|--------|
| Sync Sage | oui | oui (si `depotFacture` chargé) | `FacturePaiementStatutSyncService.php:325-336` |
| Confirmation manuelle fiche | oui | oui | `FactureController.php:514-525` |
| `FactureController::valider` sur `paiement_effectif` | oui | oui via `syncDepotStatut` | `FactureController.php:1292-1294` + `:1624-1631` |
| `ValidationController` (Mes tâches) | oui | **non** | `ValidationController.php:359-361` — `update` facture seul |
| APPYMT ERP (hors ACH) | oui | oui | `PaiementAppymtExecutionService.php:155-167` |
| Saisie historique (création) | oui | oui | `DepotFactureController.php:2937` + `:2978` |
| Super-admin correction | oui | oui | `FactureSuperAdminCorrectionService.php:543-544` |
| Artisan `factures:sync-depot-statuts` | non (lit) | oui (copie) | `SyncFactureDepotStatuts.php:57-64` |

**Divergence possible** : `ValidationController` peut poser `payee`
sur la facture sans toucher le dépôt. Le cas 1404 a **les deux** à
payé, donc ce chemin n’explique pas à lui seul le constat (il
n’écrit pas le dépôt). Le chemin Sage sync **écrit les deux**.

---

## 2. LE PASSAGE À « PAYÉE » — RECENSEMENT COMPLET

Aucun observer, aucun webhook, aucun import CSV ne pose `payee`.
`FactureObserver` loggue seulement `marche_historique` si `marche_id`
est posé (`app/Observers/FactureObserver.php:20-32`) — il ne décide
pas le statut.

### 2.1 Inventaire des écritures `factures.statut = payee`

**A. Remontée Sage (automatique) — HYPOTHÈSE DOMINANTE POUR LE 1404**

- Fichier : `app/Services/FacturePaiementStatutSyncService.php:325-347`
- Signal : `nouvelEtat === EtatPaiementSage::PAYE` après lecture
  APBILL/APPYMT (`:151`, `:170-171`, `:325`).
- Donnée Sage lue : voir §4 (`IntacctPaiementStatusService`).
- Effets dans **la même transaction** :
  - `factures.statut = payee` + `etat_paiement_sage = paye` +
    `intacct_when_paid` si WHENPAID présent (`:321-327`)
  - `depot_factures.statut = paye` + `date_paiement` depuis WHENPAID
    (`:329-336`)
  - si WV `paiement_effectif` encore `en_attente` :
    `confirmerPaiementEffectifManuel($id, null, $dateEffective)`
    (`:338-346`) — **validateur volontairement NULL**
- **Pas** d’entrée `facture_historique` `sync_statut_paiement_sage`
  sur ce palier : le `return` `:351` précède le `FactureHistorique::create`
  `:368-378`. Preuve code, pas preuve d’exécution sur le 1404.

**B. Bouton / POST confirmation paiement effectif (humain)**

- `FactureController::confirmerPaiementEffectif`
  (`FactureController.php:488-533`)
- Route : `POST /factures/{facture}/confirmer-paiement-effectif`
  (`routes/web.php:484`)
- Permission : `GS_COMPTABILITE` / `GS_DIRECTION_GENERALE` /
  `GS_ADMIN_ERP` (`FactureValidationPermissions.php:17-20`)
- Signal : date saisie + facture `en_paiement` + WV en_attente
- Validateur : `$user->id` (`:530`) — **colonne NON NULL**
- Le 1404 a un validateur vide → **ce n’est pas ce chemin**, sauf
  si un bug a ensuite vidé `validateur_id` (aucun code trouvé qui
  nullifie un validateur déjà posé hors rattrapage super-admin
  `rouvrirPaiementEffectifEnAttente`, `FactureWorkflowService.php:136-140`).

**C. Validation d’étape `paiement_effectif` sur la fiche**

- `FactureController::valider` (`:1292-1294`)
- Pose `validateur_id` **avant** le statut (`:1275-1281`)
- Validateur NON NULL si un utilisateur a cliqué.

**D. Validation depuis Mes tâches**

- `ValidationController.php:359-361`
- Pose `validateur_id` (`:351-355`) puis `payee` si niveau
  `mise_paiement` **ou** `paiement_effectif` (le `mise_paiement` →
  `payee` est un raccourci distinct du flux fiche, qui lui passe
  `en_paiement`). Validateur NON NULL. Dépôt non mis à jour.

**E. Création APPYMT depuis l’ERP (hors décaissement ACH)**

- `PaiementAppymtExecutionService::mettreAJourStatutFactureApresPaiement`
  (`:124-167`)
- Signal : **existence locale** d’une ligne `paiements_intacct` pour
  **chaque** RECORDNO payable — **aucune relecture** de l’état Sage
  (`:137-142`, `:155`)
- ACH : laisse `en_paiement` (`:145-152`)
- Validateur : `$userId` passé à
  `synchroniserApresComptabilisationIntacct(..., PAYEE, $userId)`
  (`:156-160`) → WV clôturée **avec** validateur
- Si une ligne `paiements_intacct` existe pour le 1404 **et** que
  `paiement_effectif.validateur_id` est NULL, ce chemin est
  **exclu** (il aurait écrit un validateur).

**F. Saisie historique (reprise déjà payée hors ERP)**

- Création : `DepotFactureController.php:2937` + `:2978`
  (`saisie_historique = true`)
- NV2 historique : `FactureWorkflowApresNv1Service.php:109-111`
- Le 1404 a un Bill Sage `691359` et un workflow compta / mise en
  paiement validé par des humains → **incompatible** avec une
  création historique directe (à confirmer par `saisie_historique`
  en SQL Q6.a).

**G. Super-admin changement d’étape**

- `FactureSuperAdminCorrectionService.php:543-555`
- Audit `changement_etape_superadmin` dans `facture_historique`
  (`:565-568`)
- Validateur = admin. Absente du 1404 si Q6.b ne trouve pas cette
  action.

**H. Commande artisan `factures:sync-depot-statuts`**

- Ne pose **pas** `factures.statut`. Copie dépôt depuis facture
  (`SyncFactureDepotStatuts.php:57-64`). Pertinent seulement si la
  facture était déjà `payee`.

**Non-écrivains vérifiés**

- `RecupererPaiementsIntacct` (`app/Jobs/RecupererPaiementsIntacct.php:143-153`) :
  INSERT `paiements_intacct` uniquement. **Ne touche pas**
  `factures.statut`.
- `RelancerValidationsEnRetard` : relance notif, ne valide pas
  (`app/Jobs/RelancerValidationsEnRetard.php:16-24`).
- `DepotReglementStatutService` : toggle `deja_reglee`, refuse si
  déjà `payee` (`:121-125`). N’écrit pas `payee`.
- `FactureVirementExecuteService` : pose `virement_execute`, pas
  `payee`.
- `FactureStatutService` : mapping d’affichage, pas d’écriture.

### 2.2 Synchronisation périodique depuis Sage ?

**Oui, mais pas par crontab SSH.** Trois déclencheurs web, plus un
`Schedule` Laravel dont l’exécution OVH est **NON DÉTERMINÉE**.

| Déclencheur | Point d’entrée | Fréquence | Force Sage |
|-------------|----------------|-----------|------------|
| Lazy auto liste `/factures` | `Global.vue:251-254` → `POST /factures/sync-paiements-sage/start` puis `/chunk` | si `settings.sync_paiements_sage_last_run` ≥ 4 h (`FacturePaiementStatutBatchService.php:47-58`, défaut `paiement_sage_batch_lazy_hours=4`, `config/erp.php:96`) | `syncFacture(..., true)` (`BatchService.php:151`) — **ignore le TTL 20 min** |
| Ouverture fiche facture | `FactureController::renderShowPage` `:637-640` | TTL 20 min (`config/erp.php:95`, `SyncService.php:64-66`) | `force=false` |
| Bouton fiche « actualiser Sage » | `POST /factures/{facture}/actualiser-statut-sage` (`routes/web.php:490`, `FactureController.php:564-571`) | manuel | `force=true` |
| Admin | `POST /admin/intacct/sync-paiements-statuts` (`IntacctController.php:252-257`) | manuel | `syncBatchEligible` (TTL respecté) |
| `Schedule` | `routes/console.php:43` `RecupererPaiementsIntacct` 10h et 16h | **si** `schedule:run` tourne | **n’écrit pas** `payee` |

Groupes qui déclenchent le lazy auto :
`GS_COMPTABILITE`, `GS_ADMIN_ERP`, `GS_DSI`, `GS_DIRECTION_GENERALE`
(`FactureGlobaleController.php:43`, `:196-218`).

OVH : pas de crontab SSH (mesure Robin, #1479). Aucun endpoint
`schedule:run` trouvé dans `app/` / `routes/`. Le `Schedule` Laravel
est donc **déclaré** (`routes/console.php:22-44`) et **non prouvé
exécuté**. Le lazy auto web, lui, est du code qui **tourne dès
qu’un comptable ouvre `/factures`** après 4 h.

Le JSON admin `read_only: true` (`IntacctController.php:261`) est
**faux** : `syncBatchEligible` écrit les statuts. Constat, pas un
fix.

Éligibilité batch (`BatchService.php:197-208`) : facture
`comptabilisee` ou `en_paiement`, `etat_paiement_sage` NULL /
`preparation` / `paiement_disponible` / `virement_execute`. Une
facture **déjà** `payee` + `etat_paiement_sage = paye` n’est plus
éligible (`SyncService.php:45-48`).

### 2.3 Le code vérifie-t-il l’état de la fiche Sage avant de conclure au paiement ?

**Partiellement, et pas l’extourne.**

`mapperVersEtatErp` (`IntacctPaiementStatusService.php:164-197`) :

1. **D’abord** `estApBillPaye($apbill)` → si vrai, retour
   immédiat `PAYE` (`:170-171`). DISPLAYSTATE / STATE APPYMT **non
   lus**.
2. Sinon DISPLAYSTATE : `Confirmed` → `PAYE`, `Delivered` →
   `paiement_disponible` (ou `virement_execute` si virement manuel),
   `Approved` → null (`:219-228`).
3. Sinon repli APPYMT `STATE` : `P` → disponible, `C` → `PAYE`
   (`:186-194`). **`V` n’est pas mappé** (tombe dans `return null`
   `:196`).

`estApBillPaye` (`:202-217`) :

```
STATE (minuscule) === 'paid'
OU (WHENPAID non vide ET TOTALDUE <= 0.0001)
```

Aucun test sur `Reversed`, `Reversal`, `Extourné`, `Extourne`, `V`,
`Voided`. Ces libellés existent **uniquement** pour le pré-check
doublon à la **création** d’APBILL
(`IntacctService::APBILL_PRECHECK_IGNORE_STATES_DEFAULT`
`IntacctService.php:53-58`, `config/erp.php:81-83`,
`isApBillStateIgnoredForPrecheck` `:2708-2720`). Ils ne sont **pas**
branchés sur la remontée paiement.

Conséquence **prouvée par le code** : une fiche dont l’API renvoie
`STATE=Reversed` (ou `V`, ou `Extourné`) **avec** `WHENPAID` renseigné
**et** `TOTALDUE` ≤ 0,0001 est classée `PAYE`. C’est exactement
l’hypothèse à vérifier sur le 1404, pas encore un fait Sage (il
manque TOTALDUE et le STATE API du 691359 — Q4 / probe Robin).

Si `TOTALDUE` est **absent** de la réponse, `champApbillFloat`
défaut à `1.0` (`:329-336`) → la seconde branche **ne** déclenche
**pas**. Dans ce cas seul `STATE === 'paid'` suffirait. À trancher
par lecture Sage du 691359.

Test unitaire existant : `STATE=Paid` prime sur DISPLAYSTATE
Delivered (`tests/Unit/IntacctPaiementStatutMappingTest.php:78-86`).
**Aucun test** `STATE=Reversed` + `WHENPAID` + `TOTALDUE=0`.

---

## 3. L’ÉTAPE `paiement_effectif` SANS VALIDATEUR

### 3.1 Création

`FactureWorkflowService::ouvrirPaiementEffectifEnAttente`
(`:35-51`) : INSERT `niveau=paiement_effectif`,
`validateur_id=null`, `statut=en_attente`. Appelée après compta
(`:58`) et après validation humaine `mise_paiement`
(`FactureController.php:1284`).

Donc **toutes** les lignes `paiement_effectif` naissent sans
validateur. Ce n’est pas encore le marqueur.

### 3.2 Validation / clôture

`cloturerPaiementEffectif` (`FactureWorkflowService.php:232-260`) :

- si ligne existante `en_attente` : `statut=valide`,
  `date_validation` = argument (souvent WHENPAID, **pas** `now()`),
  `validateur_id = $validateurId ?? $existant->validateur_id`
  (`:243-247`)
- si aucune ligne : CREATE déjà `valide` avec `$validateurId`
  (`:253-260`)

Appels avec `$validateurId = null` :

| Appelant | Preuve |
|----------|--------|
| Sync Sage `confirmerPaiementEffectifManuel($id, null, $date)` | `FacturePaiementStatutSyncService.php:342-346` |
| `synchroniserWorkflowFacturePayeeApresCompta` si `$validateurId` null | `FactureWorkflowService.php:78-82` |

Appels **avec** user : confirmation manuelle (`FactureController.php:528-532`),
APPYMT (`PaiementAppymtExecutionService.php:156-160`),
`FactureController::valider`, `ValidationController`.

### 3.3 NULL = passage automatique ?

**Oui, c’est le marqueur prévu par le code de la sync Sage.** Pas
le seul en théorie (CREATE `valide` avec null, super-admin qui
passerait null — le super-admin passe l’admin). En pratique, pour
une facture au circuit compta Hectare avec NV1 / mise_paiement /
comptabilisation **tous** pourvus d’un validateur, un
`paiement_effectif` `valide` + `validateur_id IS NULL` **est** la
signature de `FacturePaiementStatutSyncService`.

Piège de datation : `date_validation` est copiée de WHENPAID
(`SyncService.php:339-346` + `cloturerPaiementEffectif.php:234`).
Pour le 1404, si WHENPAID Sage = 11/09/2026, `date_validation`
vaudra 11/09/2026 00:00:00 **même si** l’ERP a écrit le
16/09. L’horloge réelle est `workflow_validations.updated_at`,
`factures.intacct_paiement_synced_at`, `factures.updated_at`.

Q3 (bloc SQL) mesure l’ampleur.

---

## 4. LE CHAMP SAGE LU

### 4.1 Champs réellement lus pour statuer

Lecture : `IntacctPaiementStatusService::lireStatutBill`
(`:37-157`). APBILL via `lireApBillAvecSession` ; champs demandés
`PROBE212_APBILL_FIELDS`
(`IntacctService.php:2795`) :
`RECORDNO,RECORDID,VENDORID,STATE,DOCNUMBER,TOTALENTERED,TOTALPAID,TOTALDUE,WHENPAID,WHENCREATED,WHENMODIFIED,WHENPOSTED,CURRENCY,LOCATIONID`.

APPYMT header : `DISPLAYSTATE`, `RAWSTATE`, `STATE`, `STATUS`,
`WHENCREATED` (liste probe `PROBE212_APPYMT_FIELDS` `:2797`).

Stockage ERP **après** lecture, **seulement** si une ligne
`paiements_intacct` existe déjà pour ce bill
(`FacturePaiementStatutSyncService.php:134-140`) :
`intacct_appymt_state`, `intacct_apbill_state`,
`intacct_paiement_synced_at`. La sync **ne crée pas** la ligne
paiement. Absence de ligne ≠ absence de sync.

Colonnes facture : `etat_paiement_sage`, `intacct_when_paid`,
`intacct_appymt_when_created`, `intacct_paiement_synced_at`.
**Aucun** champ `etat_sage` / `extourne` / `apbill_state` sur
`factures`.

### 4.2 Hypothèse WHENPAID sans contrôle d’état

**Le code fait exactement cela pour la branche 2 de
`estApBillPaye`.** Il ne faut pas la confirmer comme cause du 1404
sans `STATE` + `TOTALDUE` API du 691359.

La fiche UI Sage montre « Date du paiement intégral 11/09/2026 »
alors que l’état affiché est Extourné : compatible avec un
`WHENPAID` **conservé** après extourne. Si Sage met aussi
`TOTALDUE=0` (facture soldée par l’extourne), l’ERP conclut
`PAYE` **sans jamais tester Extourné**.

Si Sage laisse `STATE=Paid` (mot anglais) malgré l’UI Extourné,
la branche 1 (`state === 'paid'`) suffit **même sans WHENPAID**.
À lire dans Sage, pas à déduire.

### 4.3 Statut brut `V`

**NON DÉTERMINÉ** dans le code ERP. Aucune occurrence métier de
`STATE === 'V'` / Voided dans `IntacctPaiementStatusService` ni
`EtatPaiementSage`.

Nomenclature Sage **documentée dans ce dépôt** pour l’APBILL :
mots `Reversed` / `Reversal` (hint
`IntacctController.php:245`). Pour l’APPYMT, le code connaît
`A` (Approved), `P`, `C` (Confirmed)
(`IntacctPaiementStatusService.php:186-194`, tests `:59-75`).
`V` n’est ni priorisé (`prioriteAppymtHeaderState` `:265-269` :
P=3, A=2, **défaut=1** si non vide) ni mappé vers un état ERP.

Interprétations possibles, **non départagées** :

- APPYMT `STATE=V` (void) — le mapper ignore V, mais
  `estApBillPaye` a déjà pu renvoyer PAYE.
- APBILL `STATE=V` — `strtolower('V') !== 'paid'` → seule la
  branche WHENPAID+TOTALDUE compte.
- Champ UI « statut » distinct de `STATE` API (STATUS / RAWSTATE).

Tranchant : probe web `GET /admin/intacct/probe-apbill-states?entity=E-04&recordno=691359`
(`IntacctController.php:209-246`) — **HTTP**, pas CLI.

---

## 5. L’EXTOURNE

### 5.1 L’ERP connaît-il l’extourne ?

**Oui, ailleurs.** Pré-check création APBILL : ignore
`Reversed` / `Reversal` / `Extourné` / `Extourne`
(`config/erp.php:81-83`). Message utilisateur
`ApBillAlreadyExistsException.php:18` : « Extournez-le dans Sage ».
Annulation compta ERP : « Extournez manuellement dans Sage »
(`FactureAnnulerComptabilisationController.php:51`).
Dé-comptabilisation : même consigne dans l’historique
(`FactureDecomptabilisationService.php:127`).

**Non, pour le statut payé.** Zéro test d’extourne dans
`estApBillPaye` / `mapperVersEtatErp` / `appliquerEtat`.

Pas de statut ERP `extourne`. Pas de colonne dédiée.

### 5.2 Si une facture extournée est détectable, l’ERP le fait-il ?

Détectable **si** l’API renvoie `STATE` ∈ {Reversed, Reversal,
Extourné, Extourne} — le pré-check le montre. La sync paiement
**ne le fait pas** et **n’en fait rien**.

### 5.3 Chemin de retour arrière `payee` → non payée

| Geste | Effet | Sage |
|-------|-------|------|
| Super-admin `payee` → `en_paiement` | rouverture `paiement_effectif` en_attente, `date_paiement` dépôt vidée | non modifié (`FactureSuperAdminCorrectionService.php:539-548`, `:1606-1609`) |
| `FactureAnnulerPaiementService` / `factures:annuler-paiement --apply` | supprime `paiements_intacct` local, `rouvrirFilePaiementSansAppymt` → facture `comptabilisee`, dépôt `comptabilisee` si était `paye` | « Sage non modifié » (`FactureAnnulerPaiementService.php:111-117`) |
| `FactureDecomptabilisationService` | facture `a_comptabiliser`, bills ERP effacés ; **refusé** s’il reste des `paiements_intacct` (`:177-181`) | extourne manuelle Sage |

La sync Sage **refuse de reculer** :

- rang inférieur ignoré sauf si nouvel état est PAYE
  (`SyncService.php:262-273`)
- déjà `paye` : conserve, ne recule jamais (`:276-284`)
- `estEligible` faux si déjà `payee` **et** `etat_paiement_sage=paye`
  (`:45-48`) → **plus aucune lecture Sage**

Une extourne **après** le passage ERP à payée est **invisible**
à l’ERP tant que personne ne force un rattrapage manuel. Une
extourne **avant** / **pendant** une sync encore éligible est
lue comme PAYE si WHENPAID+TOTALDUE (ou STATE=Paid).

---

## 6. CHRONOLOGIE DU CAS RÉEL — CE QUE LE CODE PERMET DE DATER

Pièce : facture `FAC-2026-1404`, dépôt UUID
`cf67355e-f1b8-4794-99ba-9faa05ab34ac`, bill `691359`.
**Ne pas modifier.**

Horloges **utiles** vs **piège** :

| Champ | Sens |
|-------|------|
| `workflow_validations.date_validation` de `paiement_effectif` | **WHENPAID Sage**, pas l’heure ERP (`SyncService.php:339-346`) |
| `workflow_validations.updated_at` de cette ligne | heure réelle de clôture auto (si update depuis en_attente) |
| `factures.intacct_paiement_synced_at` | dernière lecture Sage réussie |
| `factures.intacct_when_paid` | min(WHENPAID) au moment du passage PAYE |
| `factures.updated_at` / `depot_factures.updated_at` | dernière écriture ligne (peut être autre chose que le statut) |
| `facture_historique` action `sync_statut_paiement_sage` | **absent** sur le palier PAYE (trou code `:351`) |
| `facture_historique` action `changement_etape_superadmin` | exclurait la sync auto si présent |
| `marche_historique` type `facture` | log observer, `user_id = auth()->id()` du navigateur qui a déclenché la sync (`FactureObserver.php:61`) — si `marche_id` non null |
| `paiements_intacct.intacct_apbill_state` | dernier STATE APBILL lu, **si** une ligne paiement existait |
| `settings.value` clé `sync_paiements_sage_last_run` | fin de dernier batch lazy/manuel |
| `api_logs` `service='intacct'` | appels HTTP Sage (`LogOutboundHttpCall.php:163-164`) ; `entity_id` n’est **pas** garanti = facture_id ; filtrer `context` / dates |
| `storage/logs/intacct.log` | debug `#213b` (`SyncService.php:109`, `PaiementStatusService.php:120`) — **fichier**, pas SQL |

Comparer `intacct_paiement_synced_at` / `wv.updated_at` à la date
d’extourne Sage (11/09/2026) :

- ERP **après** le 11/09 → sync a lu une fiche **déjà** extournée
  et l’a prise pour payée (hypothèse WHENPAID).
- ERP **avant** le 11/09 → l’ERP a marqué payée une fiche alors
  payée (ou jugée telle), puis Sage a extourné **sans** retour ERP
  (anti-régression). Deux causes différentes.

Requêtes : bloc final Q6.*.

---

## 7. AMPLEUR

L’ERP **ne stocke pas** l’état Sage courant des factures `payee`
(hors snapshot optionnel `paiements_intacct.intacct_apbill_state`,
seulement si une ligne paiement existe). On ne peut **pas** lister
en SQL « les payées dont Sage est extourné ». On liste les RECORDNO
à croiser côté Sage (Q7.a).

Q7.b : `payee` sans ligne `paiements_intacct` — compatible avec une
sync Sage **sans** APPYMT local (le 1404 si Robin n’a pas créé
d’APPYMT ERP).

Q7.c : dépôt vs facture incohérents (`payee` sans `paye` ou
l’inverse) — chemins divergents §1.4.

Q7.d : appartenance au « lot 50 » JSON `saisie.montant_ttc`. Ce
chiffre **n’est pas** dans `docs/suivi/` (recherché). Requête de
comptage + test d’appartenance du 1404 et d’un `FAC-2026-902` /
référence `%902%`. **Ne pas conclure** à 50 sans coller le COUNT.

Le JSON TTC n’est **pas** un signal de paiement. C’est un contrôle
de voisinage demandé par le prompt.

---

## 8. GARDE-FOUS ABSENTS (constat, pas d’implémentation)

Ce qui **empêche** aujourd’hui de marquer payée une facture non
payée :

- Confirmation manuelle : permission + facture `en_paiement` + WV
  en_attente (`FactureController.php:500-505`). N’empêche pas la
  sync Sage.
- `estApBillPaye` exige `STATE=paid` **ou** WHENPAID+TOTALDUE≈0.
  N’exige **pas** un APPYMT Confirmed, **pas** l’absence d’extourne.
- Anti-régression : empêche de **défaire** un faux payé, pas de le
  poser.
- `estEligible` : une fois `payee`+`paye`, plus de relecture.

Contrôles **absents** (constat) :

1. Refus de `PAYE` si APBILL `STATE` ∈ Reversed / Reversal /
   Extourné / Extourne / (éventuellement V).
2. Exiger DISPLAYSTATE=Confirmed **ou** APPYMT vivant, plutôt que
   WHENPAID seul.
3. Recul automatique si Sage n’est plus Paid.
4. Historique `facture_historique` sur le palier PAYE (trou `:351`).
5. Validateur système explicite (au lieu de NULL) pour tracer
   l’auto — aujourd’hui NULL **est** le traceur, mais illisible
   métier (« colonne vide »).
6. Le JSON admin `read_only: true` ne protège de rien.

---

## 9. VERDICT

### Cause établie vs hypothèses

**Établie par le code (mécanisme)** : la seule voie qui pose à la
fois `payee` + `paye` **et** clôture `paiement_effectif` **sans**
validateur est `FacturePaiementStatutSyncService::appliquerEtat`
quand Sage est mappé `PAYE`, via `estApBillPaye` (Paid **ou**
WHENPAID+TOTALDUE≈0) **sans** test d’extourne.

**Non établie sur le 1404 (fait d’exécution)** tant que Q3/Q6/Q7
et la lecture Sage du 691359 (STATE, WHENPAID, TOTALDUE) ne sont
pas collées. Classement :

1. **La plus vraisemblable** — sync Sage (lazy `/factures` ou
   ouverture de fiche) après (ou le jour de) l’extourne ; WHENPAID
   11/09 conservé + TOTALDUE 0 → `PAYE` → validateur NULL.
   *Tranche :* Q6 horloges ERP vs 11/09 ; probe STATE/TOTALDUE ;
   Q3 le 1404 dans la liste NULL.
2. Sync Sage **avant** l’extourne (facture alors réellement
   « Paid » côté API) puis extourne Sage le 11/09 **sans** retour
   ERP (anti-régression). *Tranche :* `intacct_paiement_synced_at`
   **strictement avant** l’heure d’extourne Sage ; STATE actuel
   Reversed alors que l’ERP est resté `paye`.
3. APPYMT ERP puis void Sage — **peu vraisemblable** si
   `validateur_id` NULL (l’APPYMT ERP pose un user) ; Q7.b /
   `paiements_intacct` tranchent.
4. Super-admin / confirmation manuelle / Mes tâches — **exclus**
   si validateur NULL et pas d’audit super-admin (Q6.b).

### Isolé ou reproductible ?

Le **mécanisme** est reproductible pour toute facture encore
éligible (`comptabilisee` / `en_paiement`) dont l’APBILL extourné
conserve WHENPAID et TOTALDUE≈0 (ou STATE=Paid). Hectare extourne
peu → **faible volumétrie attendue**, pas une garantie d’unicité.
Q3 compte les `paiement_effectif` NULL : c’est la mesure d’ampleur
**ERP** (faux payés auto), pas le croisement Sage.

### Une extourne **aujourd’hui** produirait-elle le même effet ?

- Facture encore `en_paiement` / `comptabilisee` : **oui**, à la
  prochaine sync (lazy 4 h ou ouverture fiche 20 min).
- Facture déjà `payee` + `etat_paiement_sage=paye` : **non** —
  plus lue ; l’extourne resterait invisible. C’est l’autre face
  du même trou.

### Données vs code (arbitrage Robin, pas d’exécution ici)

- **Données 1404** : pièce à conviction — ne pas y toucher avant
  arbitrage. Un rattrapage (super-admin `en_paiement` ou
  `factures:annuler-paiement`) est un geste métier, pas un fix
  code.
- **Code** : `estApBillPaye` + absence de recul + trou historique
  PAYE. Toute implémentation attend l’arbitrage.

---

## LEÇON

`WHENPAID` renseigné n’est pas un paiement. Sage le laisse sur une
fiche Extourné. L’ERP le traite comme `PAYE` dès que `TOTALDUE≈0`,
**avant** tout DISPLAYSTATE, et clôture `paiement_effectif` avec
`validateur_id` NULL — c’est le marqueur auto. Une fois `payee`+`paye`,
Sage n’est plus relu : l’extourne ultérieure est muette. Datation :
ne pas croire `date_validation` (copie de WHENPAID) ;
`updated_at` / `intacct_paiement_synced_at` seulement.

---

## REQUÊTES SQL (bloc unique — coller tel quel)

```sql
-- ============================================================
-- SUIVI #1570 — requêtes diagnostic (lecture seule)
-- Ne pas UPDATE / DELETE. Ne pas toucher FAC-2026-1404.
-- ============================================================

-- Q1.a ENUM réel factures.statut / depot_factures.statut
SHOW COLUMNS FROM factures LIKE 'statut';
SHOW COLUMNS FROM depot_factures LIKE 'statut';
SHOW COLUMNS FROM factures LIKE 'etat_paiement_sage';

-- Q3.a VOLUME — paiement_effectif valide SANS validateur
-- (requête la plus importante : ampleur des passages auto)
SELECT
  COUNT(*) AS nb_pe_valide_sans_validateur,
  MIN(wv.date_validation) AS date_validation_min,
  MAX(wv.date_validation) AS date_validation_max,
  MIN(wv.updated_at) AS updated_at_min,
  MAX(wv.updated_at) AS updated_at_max
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id AND f.deleted_at IS NULL
WHERE wv.niveau = 'paiement_effectif'
  AND wv.statut = 'valide'
  AND wv.validateur_id IS NULL;

-- Q3.b VOLUME par mois (horloge réelle = updated_at)
SELECT
  DATE_FORMAT(wv.updated_at, '%Y-%m') AS mois_erp,
  COUNT(*) AS nb
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id AND f.deleted_at IS NULL
WHERE wv.niveau = 'paiement_effectif'
  AND wv.statut = 'valide'
  AND wv.validateur_id IS NULL
GROUP BY DATE_FORMAT(wv.updated_at, '%Y-%m')
ORDER BY mois_erp;

-- Q3.c LISTE des factures concernées
SELECT
  f.id AS facture_id,
  f.reference,
  f.statut AS facture_statut,
  f.etat_paiement_sage,
  f.intacct_when_paid,
  f.intacct_paiement_synced_at,
  f.intacct_bill_id,
  f.intacct_record_no,
  df.id AS depot_id,
  df.uuid AS depot_uuid,
  df.statut AS depot_statut,
  df.intacct_bill_id AS depot_bill_id,
  df.date_paiement,
  df.saisie_historique,
  df.deja_reglee,
  fo.nom AS fournisseur,
  fo.intacct_id AS fournisseur_intacct,
  e.nom AS entite,
  e.intacct_id AS entite_intacct,
  p.libelle AS programme,
  wv.id AS wv_id,
  wv.date_validation AS pe_date_validation_whenpaid,
  wv.updated_at AS pe_updated_at_erp,
  wv.created_at AS pe_created_at,
  wv.validateur_id
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id AND f.deleted_at IS NULL
LEFT JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
LEFT JOIN fournisseurs fo ON fo.id = f.fournisseur_id
LEFT JOIN entites e ON e.id = f.entite_id
LEFT JOIN programmes p ON p.id = f.programme_id
WHERE wv.niveau = 'paiement_effectif'
  AND wv.statut = 'valide'
  AND wv.validateur_id IS NULL
ORDER BY wv.updated_at DESC;

-- Q3.d Contraste : paiement_effectif valide AVEC validateur (volume)
SELECT COUNT(*) AS nb_pe_valide_avec_validateur
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id AND f.deleted_at IS NULL
WHERE wv.niveau = 'paiement_effectif'
  AND wv.statut = 'valide'
  AND wv.validateur_id IS NOT NULL;

-- Q6.a CAS 1404 — facture + dépôt + Sage local
SELECT
  f.id, f.reference, f.statut, f.etat_paiement_sage,
  f.intacct_bill_id, f.intacct_record_no,
  f.intacct_when_paid, f.intacct_appymt_when_created,
  f.intacct_paiement_synced_at,
  f.virement_execute_at, f.virement_execute_par_id,
  f.montant_ttc, f.net_a_payer_ttc,
  f.created_at, f.updated_at, f.deleted_at,
  df.id AS depot_id, df.uuid, df.statut AS depot_statut,
  df.intacct_bill_id AS depot_bill_id, df.date_paiement,
  df.saisie_historique, df.deja_reglee, df.type_source,
  df.montant_ttc AS depot_montant_ttc,
  df.date_reglement, df.mode_reglement_direct,
  df.created_at AS depot_created_at, df.updated_at AS depot_updated_at,
  fo.nom AS fournisseur, fo.intacct_id AS fournisseur_intacct,
  e.nom AS entite, e.intacct_id AS entite_intacct,
  p.libelle AS programme,
  tf.libelle AS type_facture
FROM factures f
LEFT JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
LEFT JOIN fournisseurs fo ON fo.id = f.fournisseur_id
LEFT JOIN entites e ON e.id = f.entite_id
LEFT JOIN programmes p ON p.id = f.programme_id
LEFT JOIN types_factures tf ON tf.id = f.type_facture_id
WHERE f.deleted_at IS NULL
  AND (
    f.reference = 'FAC-2026-1404'
    OR df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac'
    OR df.intacct_bill_id = '691359'
    OR f.intacct_bill_id = '691359'
    OR f.intacct_record_no = '691359'
  );

-- Q6.b CAS 1404 — workflow complet (dates + auteurs)
SELECT
  wv.id, wv.niveau, wv.statut, wv.validateur_id, u.name AS validateur_nom,
  wv.date_assignation, wv.date_validation, wv.updated_at, wv.created_at,
  wv.commentaire, wv.bloque
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id
LEFT JOIN users u ON u.id = wv.validateur_id
LEFT JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
WHERE f.reference = 'FAC-2026-1404'
   OR df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac'
ORDER BY wv.id;

-- Q6.c CAS 1404 — facture_historique
SELECT
  fh.id, fh.action, fh.user_id, u.name AS acteur, fh.impersonator_id,
  fh.commentaire, fh.champs_modifies, fh.created_at
FROM facture_historique fh
LEFT JOIN users u ON u.id = fh.user_id
LEFT JOIN factures f ON f.id = fh.facture_id
LEFT JOIN depot_factures df ON df.id = fh.depot_facture_id
WHERE (f.reference = 'FAC-2026-1404' OR df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac')
ORDER BY fh.created_at, fh.id;

-- Q6.d CAS 1404 — paiements_intacct (trace APPYMT locale + dernier STATE lu)
SELECT
  pi.id, pi.intacct_payment_id, pi.intacct_bill_id, pi.vendor_id,
  pi.montant, pi.date_paiement, pi.mode_paiement, pi.payment_method_sage,
  pi.intacct_appymt_state, pi.intacct_apbill_state, pi.intacct_paiement_synced_at,
  pi.created_at, pi.updated_at
FROM paiements_intacct pi
LEFT JOIN depot_factures df ON df.id = pi.depot_facture_id
LEFT JOIN factures f ON f.id = df.facture_id
WHERE pi.intacct_bill_id = '691359'
   OR df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac'
   OR f.reference = 'FAC-2026-1404'
ORDER BY pi.id;

-- Q6.e CAS 1404 — marche_historique (observer, user du navigateur de la sync)
SELECT mh.id, mh.created_at, mh.user_id, u.name AS acteur, mh.type, mh.description
FROM marche_historique mh
LEFT JOIN users u ON u.id = mh.user_id
INNER JOIN factures f ON f.marche_id = mh.marche_id AND f.reference = 'FAC-2026-1404'
WHERE mh.type = 'facture'
  AND mh.description LIKE '%FAC-2026-1404%'
ORDER BY mh.created_at, mh.id;

-- Q6.f Dernier batch sync paiements Sage (lazy / bouton)
SELECT `key`, `value`, updated_at
FROM settings
WHERE `key` = 'sync_paiements_sage_last_run';

-- Q6.g api_logs Intacct autour du cas (HTTP, pas le fichier intacct.log)
-- entity_id n'est pas forcément facture_id : on filtre date + JSON context
SELECT
  al.id, al.created_at, al.user_id, u.name AS acteur,
  al.service, al.operation, al.http_status, al.success, al.duration_ms,
  al.error_summary, al.entity_type, al.entity_id
FROM api_logs al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.service = 'intacct'
  AND al.created_at >= '2026-09-10'
  AND al.created_at < '2026-09-17'
  AND (
    CAST(al.context AS CHAR) LIKE '%691359%'
    OR CAST(al.context AS CHAR) LIKE '%1404%'
    OR CAST(al.context AS CHAR) LIKE '%cf67355e-f1b8-4794-99ba-9faa05ab34ac%'
  )
ORDER BY al.created_at;

-- Q6.h Ventilations multi-fournisseur du dépôt (RECORDNO éventuels)
SELECT
  v.id, v.fournisseur_id, v.intacct_bill_id, v.statut_intacct, v.montant_ttc
FROM facture_ventilations v
INNER JOIN depot_factures df ON df.id = v.depot_facture_id
WHERE df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac'
   OR df.intacct_bill_id = '691359';

-- Q7.a LISTE Bill Sage des factures ERP payee (à croiser côté Sage : Extourné / Reversed)
SELECT
  f.id, f.reference, f.statut, f.etat_paiement_sage, f.intacct_when_paid,
  f.intacct_paiement_synced_at,
  df.uuid, df.statut AS depot_statut, df.intacct_bill_id AS depot_bill,
  f.intacct_bill_id AS facture_bill, f.intacct_record_no,
  e.intacct_id AS entite_intacct, e.nom AS entite,
  fo.nom AS fournisseur, fo.intacct_id AS fournisseur_intacct,
  p.libelle AS programme
FROM factures f
LEFT JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
LEFT JOIN entites e ON e.id = f.entite_id
LEFT JOIN fournisseurs fo ON fo.id = f.fournisseur_id
LEFT JOIN programmes p ON p.id = f.programme_id
WHERE f.deleted_at IS NULL
  AND f.statut = 'payee'
  AND (
    (df.intacct_bill_id IS NOT NULL AND df.intacct_bill_id <> '')
    OR (f.intacct_bill_id IS NOT NULL AND f.intacct_bill_id <> '')
    OR (f.intacct_record_no IS NOT NULL AND f.intacct_record_no <> '')
  )
ORDER BY f.id;

-- Q7.a2 Snapshot STATE Sage déjà stocké (seulement si paiements_intacct existe)
SELECT
  f.reference, pi.intacct_bill_id, pi.intacct_apbill_state, pi.intacct_appymt_state,
  pi.intacct_paiement_synced_at, pi.intacct_payment_id, pi.montant, pi.date_paiement
FROM factures f
INNER JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
INNER JOIN paiements_intacct pi
  ON (pi.depot_facture_id = df.id OR pi.intacct_bill_id = df.intacct_bill_id)
WHERE f.deleted_at IS NULL
  AND f.statut = 'payee'
  AND pi.intacct_apbill_state IS NOT NULL
  AND LOWER(pi.intacct_apbill_state) IN (
    'reversed', 'reversal', 'extourné', 'extourne', 'extourne', 'v', 'voided', 'void'
  )
ORDER BY f.id;

-- Q7.b payee SANS trace paiements_intacct (pas d'APPYMT local)
SELECT
  f.id, f.reference, f.statut, f.etat_paiement_sage, f.intacct_when_paid,
  df.uuid, df.intacct_bill_id, df.deja_reglee, df.saisie_historique,
  e.intacct_id AS entite_intacct
FROM factures f
LEFT JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
LEFT JOIN entites e ON e.id = f.entite_id
WHERE f.deleted_at IS NULL
  AND f.statut = 'payee'
  AND NOT EXISTS (
    SELECT 1 FROM paiements_intacct pi
    WHERE pi.depot_facture_id = df.id
       OR (df.intacct_bill_id IS NOT NULL AND df.intacct_bill_id <> '' AND pi.intacct_bill_id = df.intacct_bill_id)
       OR (f.intacct_bill_id IS NOT NULL AND f.intacct_bill_id <> '' AND pi.intacct_bill_id = f.intacct_bill_id)
       OR (f.intacct_record_no IS NOT NULL AND f.intacct_record_no <> '' AND pi.intacct_bill_id = f.intacct_record_no)
  )
ORDER BY f.id;

-- Q7.c Incohérence dépôt vs facture
SELECT
  f.id, f.reference, f.statut AS facture_statut, df.statut AS depot_statut,
  df.uuid, df.intacct_bill_id
FROM factures f
INNER JOIN depot_factures df ON df.facture_id = f.id AND df.deleted_at IS NULL
WHERE f.deleted_at IS NULL
  AND (
    (f.statut = 'payee' AND df.statut <> 'paye')
    OR (df.statut = 'paye' AND f.statut <> 'payee')
  )
ORDER BY f.id;

-- Q7.d Lot JSON saisie.montant_ttc incohérent vs colonne — COUNT + 1404 + 902
SELECT COUNT(*) AS nb_json_ttc_incoherent
FROM depot_factures df
WHERE df.deleted_at IS NULL
  AND JSON_EXTRACT(df.ocr_raw, '$.saisie.montant_ttc') IS NOT NULL
  AND CAST(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.montant_ttc')) AS DECIMAL(15,4))
      <> CAST(df.montant_ttc AS DECIMAL(15,4));

SELECT
  df.id, df.uuid, f.reference, df.statut, f.statut AS facture_statut,
  df.montant_ttc AS col_ttc,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.montant_ttc')) AS json_ttc,
  JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.montant_ttc')) AS json_ttc_type,
  df.intacct_bill_id
FROM depot_factures df
LEFT JOIN factures f ON f.id = df.facture_id
WHERE df.deleted_at IS NULL
  AND (
    df.uuid = 'cf67355e-f1b8-4794-99ba-9faa05ab34ac'
    OR f.reference = 'FAC-2026-1404'
    OR f.reference = 'FAC-2026-0902'
    OR f.reference = 'FAC-2026-902'
    OR f.reference LIKE '%-902'
    OR df.id = 902
  );

-- Q7.e payee + etat_paiement_sage = paye + paiement_effectif NULL (signature sync Sage)
SELECT COUNT(*) AS nb_signature_sync_sage
FROM factures f
INNER JOIN workflow_validations wv
  ON wv.facture_id = f.id
 AND wv.niveau = 'paiement_effectif'
 AND wv.statut = 'valide'
 AND wv.validateur_id IS NULL
WHERE f.deleted_at IS NULL
  AND f.statut = 'payee'
  AND f.etat_paiement_sage = 'paye';
```

---

## DÉPLOIEMENT-TEST

Aucun code applicatif. Après push : rien à migrer, rien à builder.
Robin : coller le bloc SQL (surtout Q3 puis Q6) ; probe Sage HTTP
`/admin/intacct/probe-apbill-states?entity=E-04&recordno=691359`
(STATE, WHENPAID, TOTALDUE). Ne pas modifier le 1404.
Ne pas exécuter `php artisan` réseau Sage.
