---
suivi: 1482
date: 2026-09-08
sujet: Diagnostic écarts tableau de bord compta (triple non_rattache, 147/146, pipeline)
chantier: comptabilite / synthese
type: diagnostic
statut: poussé
hash: 7f9ada06
fichiers:
  - docs/suivi/SUIVI_1482_diag_ecarts_tableau_bord_compta.md
---

# SUIVI #1482 — PHASE 1 DIAGNOSTIC — écarts tableau de bord compta

## PROMPT ENVOYÉ

Diagnostic lecture seule des écarts prod du tableau de bord #1478 :
triple carte « non_rattache », total 147 vs 146, pipeline 9 vs 10 et
12 vs 11. Aucun code applicatif, aucune donnée.

## BASE GIT

```
git log --oneline -1 origin/main
ab2c2908 chore(suivi): hash fiche #1481
```

(Attendu prompt : `ab2c2908` — conforme.)

MySQL agent : refusé. Aucun chiffre de production inventé. Seules
valeurs utilisées : celles de Robin + arithmétique sur ces valeurs
(102+11+32=145 ; 674 451,61 + 722,00 = 675 173,61).

## FICHES LUES

- **#1478** — service SQL dédié, totaux globaux (socle), clic volontairement
  hors `/factures/comptabilisation` (DG 403). LEÇON : « À payer » visait
  `PaiementLignesPayablesService::eligibleFacturesQuery` ; le service a
  **dupliqué** ce périmètre dans `aPayerJoinQuery()` au lieu de le
  réutiliser. Écarts « attendus » frise / deja_reglee / saisie_historique
  annoncés sans les mesurer facture par facture — c’est l’objet de ce lot.
- **#1467** — `ComptabilisationListeFilter` = société + nature + recherche ;
  pas le filtre de file. La file = `scopeEligibleComptabilisation`.
- **#1481 / #1480** — hors sujet fonctionnel ; acquis à ne pas toucher
  au lot de correction.

L’anomalie historique `valide_nv2` vs `envoye_nv2` citée comme « #1479 »
n’est **pas** dans la fiche #1479 (disparition staging SIWI). Instruite
ici comme piste séparée (C2).

---

## VERDICT (en tête) — triple « non_rattache »

**ÉTABLI.** Trois cartes distinctes parce que le bloc bancaire groupe
sur `compte_key` (identifiant de compte résolu) et affiche
`compte_label` (autre expression). Le libellé « non_rattache » /
« Non rattaché » n’est **pas** la clé.

Preuve :

1. SQL `GROUP BY` sur **cinq** expressions, dont `compteBancaireSql()`
   **et** `compteLabelSql()` (`ComptaTableauBordService` L373–377).
2. Repli PHP `pivoterBanques` L402–418 : une carte par `compte_key`.
   Le libellé « Non rattaché » n’est posé **que si**
   `$key === 'non_rattache'` (constante `COMPTE_NON_RATTACHE`).
   Sinon le Vue affiche `ligne.compte_label` brut (`TableauBord.vue` L239).
3. `compteLabelSql()` L662–670 : `COALESCE(fe_programme.name, override JSON, 'non_rattache')`.
   Si `intacct_financial_entities.name` est NULL/vide (colonne **nullable**,
   migration `2026_06_02_153000`) et qu’il n’y a pas d’override JSON,
   le label retombe sur le **littéral technique** `non_rattache` alors
   que `compte_key` peut être un vrai `intacct_id` (ex. défaut 512900 /
   512901, L649–657) ou un id programme.

Donc : **deux cartes « non_rattache » (snake_case)** = deux `compte_key`
différents, label = fallback technique. **Une carte « Non rattaché »**
= le vrai fallback `compte_key = 'non_rattache'`, seul chemin formaté.

Les trois effectifs Robin (102 + 11 + 32 = 145) sont un **sous-ensemble**
des 147 du pied : il reste 2 factures sur d’autres cartes (libellés
réels). Ne pas assigner 102/11/32 à 512900 vs 512901 sans la requête A2.

Correction nécessaire (non implémentée) : **une seule expression** pour
clé et libellé ; afficher le `name` ou, à défaut, l’`intacct_id` ; n’afficher
« Non rattaché » que lorsque **toutes** les sources sont vides ; un seul
groupe pour ce cas.

---

## A — Regroupement bancaire

### A1. Clé exacte

Pas le libellé. SQL (L361–377) :

- `compte_key` = `compteBancaireSql()` : COALESCE(
    override JSON `ocr_raw.mise_en_paiement.financial_entity_intacct_id`,
    `fe_programme.intacct_id`,
    défaut entité E-04→512900 / E-13→512901,
    `'non_rattache'`
  )
- `compte_label` = `compteLabelSql()` : COALESCE(
    `fe_programme.name`,
    le même override JSON,
    `'non_rattache'`
  )
- plus nature (programme vs FG), origine (fonds propres vs financement),
  bucket d’échéance.

Puis PHP re-agrège **uniquement** par `compte_key` (L402–418).

### A2. Pourquoi trois groupes au même libellé

**ÉTABLI** : `GROUP BY` / pivot sur `compte_key`, affichage sur `compte_label`.
`NULL ≠ ''` est géré (`NULLIF(TRIM(...))`) — ce n’est pas trois NULL
SQL distincts. Ce n’est pas non plus trois natures : la nature est une
**cellule** dans la carte, pas une carte.

**Hypothèses sur l’identité des trois keys** (à trancher par la requête
ci-dessous, ne pas choisir) :

- H1 — deux défauts 512900 / 512901 (FG sans `fe_programme.name`) + vrai non rattaché.
- H2 — deux `programmes.financial_entity_id` dont `name` est vide + vrai non rattaché.
- H3 — mélange (override JSON, id programme, défaut).

### A3. Deux rendus de libellé

**ÉTABLI.** Deux chemins :

| Chemin | Condition | Texte |
|--------|-----------|--------|
| PHP L410–411 | `compte_key === 'non_rattache'` | « Non rattaché » |
| PHP L412–414 + Vue L239 | tout autre key | `compte_label` brut, donc le littéral SQL `non_rattache` |

Aucun formatage Vue de `non_rattache` → « Non rattaché ».

### A4. Intention #1478 vs réalisation

L’intention (aucune facture disparue) est bonne. La réalisation crée
N cartes dès que N `compte_key` distincts ont un label vide. Un seul
groupe « Non rattaché », label unique formaté.

---

## B — Écart 147 vs 146 (722,00 €)

### B1. Même requête ?

**ÉTABLI : non.** Même **socle** `aPayerJoinQuery()` (L589–621), deux
consommations :

- Pipeline « À payer » : `pipelineAPayer()` L199–216 — `COUNT(factures.id)`
  / `SUM(factures.montant_ht)` **sans** les jointures bancaires.
- Pied bancaire : `banques()` L367–386 — le même socle **plus**
  leftJoin programmes, fe_programme, trois `entites`, puis GROUP BY
  5 expressions ; le pied somme les `nombre_ligne` / `total_ligne` PHP
  (L483–484).

### B2. 1 facture / 722,00 €

Arithmétique sur les chiffres Robin : 674 451,61 + 722,00 = 675 173,61.
Un left join **ne peut pas ajouter** une facture absente du socle ; il
peut **dupliquer** une ligne. **Hypothèse principale à vérifier**
(requête B) : `COUNT(*)` = 147 et `COUNT(DISTINCT factures.id)` = 146
après les jointures de `banques()`, la facture dupliquée a
`montant_ht = 722.00`.

Les jointures sont toutes sur des PK (`programmes.id`, `fe.id`,
`entites.id`) — un doublon PK est **peu probable**. La requête B tranchera
(doublon vs autre). Candidats secondaires à lister par la même requête :
facture sans échéance (elle **reste** dans le socle, bucket
`sans_echeance`, L698–699 — n’explique pas un écart d’effectif entre
les deux blocs) ; `deleted_at` (filtré des deux côtés).

### B3. Avoirs

**ÉTABLI.** `aPayerJoinQuery()` n’applique **pas** `FactureEstAvoir`.
Un avoir comptabilisé avec `paiement_effectif` en_attente est **inclus**
dans le pipeline « À payer » **et** le bloc bancaire. Son `montant_ht`
négatif **minore** le total, il n’est pas exclu. L’onglet liste
« Avoirs (4) » est un autre périmètre (`FactureEstAvoir::applyDepotScope`,
tab `avoirs`) — sans rapport mécanique avec 146/147.

L’écran mise en paiement (`eligibleFacturesQuery` L253–268) n’exclut
pas non plus les avoirs au niveau SQL ; `collectLineStubs` peut
ensuite **omettre** une ligne si `intacct_bill_id` vide (L541–542) —
écart possible **écran MEP vs carte**, non mesuré par Robin ici.

### B4. Défaut de conception

**ÉTABLI.** Deux chiffres « à payer » doivent sortir d’**une** fonction
(idéalement `PaiementLignesPayablesService::eligibleFacturesQuery`,
visée #1478 L95–97 de la fiche, non appelée par le dashboard).
`aPayerJoinQuery` est une copie SQL (MAX(id) dépôt vs `latestOfMany`,
`saisie_historique` false **OU NULL** vs `= false`).

---

## C — Écarts de pipeline

### C1. « À comptabiliser » : 9 (carte) vs 10 (écran comptabilisation)

**ÉTABLI : ce ne sont pas les mêmes requêtes, et le clic n’ouvre pas cet écran.**

Carte (`pipelineParStatutDepot` L161–171 + `etapeDepotSql` L630–631) :

- `depot_factures.statut = 'a_comptabiliser'`
- `deleted_at` NULL
- `saisie_historique` false **ou** NULL
- COUNT des **dépôts**
- pas de `deja_reglee`, pas de statut facture

Écran (`ComptabilisationController::index` L159 +
`DepotFacture::scopeEligibleComptabilisation` L372–387) :

- `saisie_historique = false` (**pas** de OR NULL)
- statut dépôt **pas** rejeté/archivé
- `statut = a_comptabiliser` **OU**
  (`statut = valide` ET facture.statut ∈
  `a_comptabiliser`, `en_paiement`, `mise_en_paiement`)
- exclut facture `rejetee`

`ComptabilisationListeFilter` (#1467) n’ajoute **rien** sans query
société/nature/recherche : il n’explique pas 9 vs 10.

Direction 10 > 9 : l’écran peut compter un dépôt `statut=valide`
encore « éligible compta ». C’est la différence de code qui va dans
le bon sens. À nommer avec la requête C1.

URL de la carte (L98) : `/factures?tab=tous&f_statut=a_comptabiliser`
— **pas** `/factures/comptabilisation` (choix #1478 : DG 403).
Le filtre liste est encore un **troisième** périmètre :
`FactureGlobaleEtapeStatutFilter` L111
`normalizedStatut IN ('a_comptabiliser', 'mise_en_paiement')`
(statut **facture prioritaire**, `FactureStatutService` L189–200).

Clic **trompeur** vis-à-vis de l’écran « 10 » **et** du compteur « 9 ».

### C2. « En validation NV2 » : 12 (carte) vs 11 (liste)

**ÉTABLI : périmètres différents ; le clic ouvre la liste à 11.**

Carte : `depot_factures.statut = 'en_validation_nv2'` brut, historique
false/NULL, pas de filtre `deja_reglee`.

Liste `/factures?tab=tous&f_statut=nv2` (`FactureGlobaleEtapeStatutFilter` L107–110) :

- `deja_reglee = false`
- `saisie_historique = false`
- statut **normalisé** = `nv2_en_cours`
  (raw ∈ `en_validation_nv2`, `en_attente_nv2`, `nv2_en_cours`)
- raw = COALESCE(facture.statut, depot.statut)

Direction 12 > 11 : compatible avec **1 dépôt `deja_reglee=1` encore
en `en_validation_nv2`**, ou `saisie_historique` NULL côté carte.
Les alias `en_attente_nv2` élargiraient la **liste**, pas la carte —
ne expliquent pas un liste plus petite.

Historique `valide_nv2` vs `envoye_nv2` : le compteur **ne lit pas**
`facture_historique`. Piste séparée (dépôts NV2 déjà `valide_nv2` dans
l’historique, ou chemin `valide_nv2_compta_deja_reglee`). Requête C2b.

### C3. Tableau légitime / fautif

Valeurs écran = celles que Robin a comparées. « — » = non constaté.

| Carte | Compteur | Valeur écran | Écart | Légitime ou fautif | Cause sourcée |
|-------|----------|--------------|-------|--------------------|---------------|
| En dépôt | 41 | non comparée | — | non mesuré | Statuts = `statutsStadeDepot` (mêmes 5 que l’onglet). Carte exclut `saisie_historique` ; onglet `en_depot` exclut les avoirs (`applyTabScope`). Écart possible non observé. |
| NV1 | 69 | non comparée | — | même schéma que NV2 | Filtre liste `f_statut=nv1` : `deja_reglee=false` + frise `nv1_en_cours` (L103–106). Carte = statut brut `en_validation_nv1`. |
| NV2 | 12 | 11 (liste `f_statut=nv2`) | +1 carte | **Fautif vis-à-vis du clic** (URL = cette liste) | C2 : statut brut vs frise + `deja_reglee`. Mécanismes #1478 **peuvent** expliquer 1 ligne — à **nommer** (pas « attendu » tant que la facture n’est pas listée). |
| À comptabiliser | 9 | 10 (écran comptabilisation) | −1 carte | **Fautif vis-à-vis de l’écran métier** ; le clic n’y va pas | C1 : `statut=a_comptabiliser` vs `eligibleComptabilisation` (inclut `valide` + facture éligible). `deja_reglee` / frise **n’expliquent pas** cet écart (absents de la carte). |
| À payer | 146 / 674 451,61 € | pied banque 147 / 675 173,61 € | −1 / −722 € | **Fautif interne dashboard** | B : même socle, jointures extra côté banque. `saisie_historique` identique. Frise non utilisée. |

### C4. Quelle définition doit faire foi ? (ne pas trancher)

- **A** — Aligner le tableau de bord sur les écrans de travail
  (comptabilisation, liste frise, MEP). Le comptable retrouve ses repères.
- **B** — Aligner les écrans sur le statut brut dépôt. Cohérence
  technique, rupture d’habitude (frise / deja_reglee).
- **C** — Afficher les deux (ex. « 9 au statut / 10 en file comptable »)
  avec mention explicite.

Robin arbitrera. Le clic doit dans tous les cas ouvrir **le même**
périmètre que le chiffre.

### C5. URLs vs compteurs — tests concrets pour Robin

| Carte | URL (`urls()` L92–106) | Même périmètre que le compteur ? |
|-------|------------------------|----------------------------------|
| En dépôt | `/factures?tab=en_depot` | Non : onglet exclut avoirs ; carte non. |
| NV1 | `/factures?tab=tous&f_statut=nv1` | Non : frise + `deja_reglee=false`. |
| NV2 | `/factures?tab=tous&f_statut=nv2` | **Non — écart 12 vs 11 déjà vu.** |
| À comptabiliser | `/factures?tab=tous&f_statut=a_comptabiliser` | **Non** (ni le 9, ni l’écran /comptabilisation des 10). |
| À payer | `/factures/mise-en-paiement` | Proche du socle, mais MEP compte des **lignes** (`collectLineStubs`, multi-fournisseur / bill id) et `saisie_historique=false` strict + `latestOfMany`. |
| Fournisseur inactif | `/factures?tab=tous&inclure_en_depot=1` | **Non** : pas de filtre inactif. |
| RIB manquant | `/factures/mise-en-paiement` | **Non** : pas de filtre RIB. |
| Doublon | `/factures?tab=tous&inclure_en_depot=1` | **Non**. |
| Poste sans compte | `/admin/nomenclature` | **Non** (nomenclature, pas la file). |
| Échec Sage | `/factures?tab=tous&f_statut=a_comptabiliser` | **Non** (pas le sous-ensemble historique `intacct_echec_comptabilisation`). |
| Échues | `/factures?tab=tous&f_echeance_fin={hier}` | **Non** : la carte exclut `payee`/`rejetee` (L300) ; le filtre liste ne le fait pas. |

---

## D — Audit de cohérence

### D1. Toutes les requêtes

| Bloc | Requête (méthode) | Statuts | Jointures | Exclusions | Montant | Date / âge |
|------|-------------------|---------|-----------|------------|---------|------------|
| Pipeline dépôt | `pipelineParStatutDepot` | dépôt : 5 stade + nv1 + nv2 + a_comptabiliser | leftJoin factures (deleted_at NULL) | dépôt deleted_at ; saisie_historique false/NULL | SUM COALESCE(factures.montant_ht, depot.montant_ht) | MIN(depot.created_at) → jours |
| Pipeline à payer | `pipelineAPayer` / `aPayerJoinQuery` | facture `comptabilisee` + dépôt `comptabilisee` + WV `paiement_effectif` en_attente, pas valide | join dernier dépôt (MAX id) | deleted_at ; saisie_historique false/NULL | SUM factures.montant_ht | MIN(depot.created_at) |
| Banque | `banques` + même socle | idem à payer | + programmes, fe, 3 entites | idem | SUM factures.montant_ht par groupe | buckets sur échéance COALESCE (facture, ocr) |
| Fournisseur inactif | `queryFournisseurInactif` | dépôt hors paye/rejete/archive | left factures + join fournisseurs COALESCE | saisie_historique false/NULL | (compte seuls) | — |
| RIB manquant | `queryRibManquant` | socle à payer | + fournisseurs | IBAN null/vide | — | — |
| Doublon | `queryDoublonNonControle` | hors circuit | — | doublon_de_id NOT NULL, doublon_controle false/NULL | — | — |
| Poste sans compte | `queryPosteSansCompte` | hors circuit | join postes_budgetaires_types | compte_comptable_id NULL | — | — |
| Échec Sage | `queryEchecSage` | dépôt `a_comptabiliser` | exists facture_historique action `intacct_echec_comptabilisation` | saisie_historique false/NULL | — | — |
| Échues agrégat + top 5 | `echues` | facture NOT IN (payee, rejetee) | left fournisseurs (top 5) | deleted_at ; date_echeance NULL exclue | SUM factures.montant_ht | date_echeance < aujourd’hui |

`FactureVisibility` : **aucun** bloc du service. Contrôleur abort
`ComptaTableauBordAcces` puis `agregats()` sans user.

### D2. Autres incohérences internes (code, pas chiffres)

- Clics de blocages / échues (C5) : compteurs ≠ listes cibles.
- Somme des cellules d’une carte bancaire : PHP additionne des
  `round(..., 2)` par bucket (L429–431, L467–472) — risque d’1 centime,
  pas 722 €. À contrôler visuellement (requête D2 optionnelle).
- Graphique Apex : mêmes `montant_ht` que les cartes (Vue L22–26) —
  cohérent **entre eux**, tous HT.

### D3. HT ou TTC — **ÉTABLI : toute la page est en HT**

- Pipeline : champ `montant_ht`, graphique nommé « Montant HT » (Vue L24).
- Échues : colonne « Montant HT » (Vue L195).
- Banque : `SUM(factures.montant_ht)`.
- Écran comptabilisation : `montant_ttc` (Controller L173).
- Une facture se paie TTC (`factures.net_a_payer_ttc` / `montant_ttc`).

Afficher « À payer » en HT est **faux métier**. Pas un mélange HT/TTC
**à l’intérieur** du dashboard ; un mélange **dashboard HT vs écrans TTC**.

### D4. FactureVisibility / socle

**ÉTABLI.** `SOCLE_TOTAL_GROUPS` L25–30 = GS_ADMIN_ERP, GS_DSI,
GS_DIRECTION_GENERALE, GS_COMPTABILITE. `applyScope` L188–189 : no-op
si socle. `ComptaTableauBordAcces::GROUPES_AUTORISES` = les trois du
dashboard (pas GS_DSI). Robin (GS_ADMIN_ERP) : totaux globaux, listes
globales. Aucun bloc du service n’appelle `applyScope` ni
`hasAnyM365Group`.

---

## Requêtes de mesure (à coller — MySQL 8.4, only_full_group_by)

Ne pas relancer `siwi:import`. Lecture seule.

### A2 — Voir les trois keys derrière « non_rattache »

```
-- Qualifier toutes les colonnes. Adapter uniquement si SHOW COLUMNS le impose.

SELECT
  COALESCE(
    NULLIF(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.mise_en_paiement.financial_entity_intacct_id')), '')), ''),
    NULLIF(TRIM(COALESCE(fe_programme.intacct_id, '')), ''),
    CASE
      WHEN TRIM(COALESCE(entite_facture.intacct_id, entite_depot.intacct_id, entite_programme.intacct_id, '')) = 'E-04' THEN '512900'
      WHEN TRIM(COALESCE(entite_facture.intacct_id, entite_depot.intacct_id, entite_programme.intacct_id, '')) = 'E-13' THEN '512901'
      ELSE NULL
    END,
    'non_rattache'
  ) AS compte_key,
  COALESCE(
    NULLIF(TRIM(COALESCE(fe_programme.name, '')), ''),
    NULLIF(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.mise_en_paiement.financial_entity_intacct_id')), '')), ''),
    'non_rattache'
  ) AS compte_label,
  fe_programme.id AS fe_id,
  fe_programme.intacct_id AS fe_intacct_id,
  fe_programme.name AS fe_name,
  COUNT(factures.id) AS nombre,
  ROUND(SUM(factures.montant_ht), 2) AS montant_ht
FROM factures
INNER JOIN depot_factures
  ON depot_factures.facture_id = factures.id
 AND depot_factures.deleted_at IS NULL
 AND depot_factures.id = (
   SELECT MAX(df2.id) FROM depot_factures df2
   WHERE df2.facture_id = factures.id AND df2.deleted_at IS NULL
 )
LEFT JOIN programmes ON programmes.id = factures.programme_id
LEFT JOIN intacct_financial_entities fe_programme ON fe_programme.id = programmes.financial_entity_id
LEFT JOIN entites entite_facture ON entite_facture.id = factures.entite_id
LEFT JOIN entites entite_depot ON entite_depot.id = depot_factures.entite_id
LEFT JOIN entites entite_programme ON entite_programme.id = programmes.entite_id
WHERE factures.deleted_at IS NULL
  AND factures.statut = 'comptabilisee'
  AND depot_factures.statut = 'comptabilisee'
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND EXISTS (
    SELECT 1 FROM workflow_validations
    WHERE workflow_validations.facture_id = factures.id
      AND workflow_validations.niveau = 'paiement_effectif'
      AND workflow_validations.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv_paye
    WHERE wv_paye.facture_id = factures.id
      AND wv_paye.niveau = 'paiement_effectif'
      AND wv_paye.statut = 'valide'
  )
GROUP BY compte_key, compte_label, fe_id, fe_intacct_id, fe_name
ORDER BY nombre DESC;
```

Complément colonnes (si doute sur `name`) :

```
SHOW COLUMNS FROM intacct_financial_entities LIKE 'name';
SHOW COLUMNS FROM intacct_financial_entities LIKE 'intacct_id';
```

### B — 146 vs 147 / facture à 722,00 €

```
-- Socle seul (carte À payer)
SELECT COUNT(factures.id) AS n, ROUND(SUM(factures.montant_ht), 2) AS ht
FROM factures
INNER JOIN depot_factures
  ON depot_factures.facture_id = factures.id
 AND depot_factures.deleted_at IS NULL
 AND depot_factures.id = (
   SELECT MAX(df2.id) FROM depot_factures df2
   WHERE df2.facture_id = factures.id AND df2.deleted_at IS NULL
 )
WHERE factures.deleted_at IS NULL
  AND factures.statut = 'comptabilisee'
  AND depot_factures.statut = 'comptabilisee'
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND EXISTS (
    SELECT 1 FROM workflow_validations
    WHERE workflow_validations.facture_id = factures.id
      AND workflow_validations.niveau = 'paiement_effectif'
      AND workflow_validations.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv_paye
    WHERE wv_paye.facture_id = factures.id
      AND wv_paye.niveau = 'paiement_effectif'
      AND wv_paye.statut = 'valide'
  );

-- Après jointures banque : COUNT vs DISTINCT
SELECT
  COUNT(factures.id) AS n_lignes,
  COUNT(DISTINCT factures.id) AS n_distinct,
  ROUND(SUM(factures.montant_ht), 2) AS ht
FROM factures
INNER JOIN depot_factures
  ON depot_factures.facture_id = factures.id
 AND depot_factures.deleted_at IS NULL
 AND depot_factures.id = (
   SELECT MAX(df2.id) FROM depot_factures df2
   WHERE df2.facture_id = factures.id AND df2.deleted_at IS NULL
 )
LEFT JOIN programmes ON programmes.id = factures.programme_id
LEFT JOIN intacct_financial_entities fe_programme ON fe_programme.id = programmes.financial_entity_id
LEFT JOIN entites entite_facture ON entite_facture.id = factures.entite_id
LEFT JOIN entites entite_depot ON entite_depot.id = depot_factures.entite_id
LEFT JOIN entites entite_programme ON entite_programme.id = programmes.entite_id
WHERE factures.deleted_at IS NULL
  AND factures.statut = 'comptabilisee'
  AND depot_factures.statut = 'comptabilisee'
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND EXISTS (
    SELECT 1 FROM workflow_validations
    WHERE workflow_validations.facture_id = factures.id
      AND workflow_validations.niveau = 'paiement_effectif'
      AND workflow_validations.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv_paye
    WHERE wv_paye.facture_id = factures.id
      AND wv_paye.niveau = 'paiement_effectif'
      AND wv_paye.statut = 'valide'
  );

-- Factures dont montant_ht = 722.00 dans le socle (si DISTINCT < COUNT, lister les doublons)
SELECT factures.id, factures.reference, factures.montant_ht, factures.montant_ttc,
       COUNT(*) AS nb_lignes_apres_join
FROM factures
INNER JOIN depot_factures
  ON depot_factures.facture_id = factures.id
 AND depot_factures.deleted_at IS NULL
 AND depot_factures.id = (
   SELECT MAX(df2.id) FROM depot_factures df2
   WHERE df2.facture_id = factures.id AND df2.deleted_at IS NULL
 )
LEFT JOIN programmes ON programmes.id = factures.programme_id
LEFT JOIN intacct_financial_entities fe_programme ON fe_programme.id = programmes.financial_entity_id
LEFT JOIN entites entite_facture ON entite_facture.id = factures.entite_id
LEFT JOIN entites entite_depot ON entite_depot.id = depot_factures.entite_id
LEFT JOIN entites entite_programme ON entite_programme.id = programmes.entite_id
WHERE factures.deleted_at IS NULL
  AND factures.statut = 'comptabilisee'
  AND depot_factures.statut = 'comptabilisee'
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND EXISTS (
    SELECT 1 FROM workflow_validations
    WHERE workflow_validations.facture_id = factures.id
      AND workflow_validations.niveau = 'paiement_effectif'
      AND workflow_validations.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv_paye
    WHERE wv_paye.facture_id = factures.id
      AND wv_paye.niveau = 'paiement_effectif'
      AND wv_paye.statut = 'valide'
  )
GROUP BY factures.id, factures.reference, factures.montant_ht, factures.montant_ttc
HAVING COUNT(*) > 1 OR factures.montant_ht = 722.00;
```

Avoirs dans le socle à payer :

```
SELECT factures.id, factures.reference, factures.montant_ht, factures.montant_ttc,
       JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.saisie.type_facture')) AS type_saisie
FROM factures
INNER JOIN depot_factures
  ON depot_factures.facture_id = factures.id
 AND depot_factures.deleted_at IS NULL
 AND depot_factures.id = (
   SELECT MAX(df2.id) FROM depot_factures df2
   WHERE df2.facture_id = factures.id AND df2.deleted_at IS NULL
 )
WHERE factures.deleted_at IS NULL
  AND factures.statut = 'comptabilisee'
  AND depot_factures.statut = 'comptabilisee'
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND EXISTS (
    SELECT 1 FROM workflow_validations
    WHERE workflow_validations.facture_id = factures.id
      AND workflow_validations.niveau = 'paiement_effectif'
      AND workflow_validations.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv_paye
    WHERE wv_paye.facture_id = factures.id
      AND wv_paye.niveau = 'paiement_effectif'
      AND wv_paye.statut = 'valide'
  )
  AND (
    UPPER(TRIM(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.saisie.type_facture')), ''))) = 'AVOIR'
    OR EXISTS (
      SELECT 1 FROM types_factures tf
      WHERE tf.id = factures.type_facture_id AND UPPER(TRIM(tf.libelle)) = 'AVOIR'
    )
  );
```

### C1 — 9 vs 10 à comptabiliser

```
-- Carte (9 attendu si prod inchangé)
SELECT depot_factures.id, depot_factures.uuid, depot_factures.statut,
       depot_factures.saisie_historique, depot_factures.deja_reglee,
       depot_factures.fournisseur_nom, depot_factures.montant_ht, depot_factures.montant_ttc,
       factures.id AS facture_id, factures.statut AS facture_statut, factures.reference
FROM depot_factures
LEFT JOIN factures ON factures.id = depot_factures.facture_id AND factures.deleted_at IS NULL
WHERE depot_factures.deleted_at IS NULL
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND depot_factures.statut = 'a_comptabiliser'
ORDER BY depot_factures.id;

-- Écran comptabilisation (10 attendu)
SELECT depot_factures.id, depot_factures.uuid, depot_factures.statut,
       depot_factures.saisie_historique, depot_factures.deja_reglee,
       depot_factures.fournisseur_nom, depot_factures.montant_ttc,
       factures.id AS facture_id, factures.statut AS facture_statut, factures.reference
FROM depot_factures
LEFT JOIN factures ON factures.id = depot_factures.facture_id AND factures.deleted_at IS NULL
WHERE depot_factures.saisie_historique = 0
  AND depot_factures.statut NOT IN ('rejete', 'archive')
  AND (
    depot_factures.statut = 'a_comptabiliser'
    OR (
      depot_factures.statut = 'valide'
      AND factures.statut IN ('a_comptabiliser', 'en_paiement', 'mise_en_paiement')
    )
  )
  AND (factures.id IS NULL OR factures.statut <> 'rejetee')
ORDER BY depot_factures.id;
```

La/les lignes présentes dans la 2e et absentes de la 1re **sont** l’écart.

Liste ouverte par le clic (3e périmètre, frise) :

```
-- f_statut=a_comptabiliser : COALESCE(facture.statut, depot.statut) normalisé
-- IN a_comptabiliser, mise_en_paiement (voir FactureStatutService::RAW_GROUPS)
```

### C2 — 12 vs 11 NV2

```
-- Carte
SELECT depot_factures.id, depot_factures.uuid, depot_factures.statut,
       depot_factures.deja_reglee, depot_factures.saisie_historique,
       depot_factures.fournisseur_nom, factures.statut AS facture_statut, factures.reference
FROM depot_factures
LEFT JOIN factures ON factures.id = depot_factures.facture_id AND factures.deleted_at IS NULL
WHERE depot_factures.deleted_at IS NULL
  AND (depot_factures.saisie_historique = 0 OR depot_factures.saisie_historique IS NULL)
  AND depot_factures.statut = 'en_validation_nv2'
ORDER BY depot_factures.id;

-- Liste f_statut=nv2 (deja_reglee=0, historique=0, raw normalisé nv2_en_cours)
SELECT depot_factures.id, depot_factures.uuid, depot_factures.statut,
       depot_factures.deja_reglee, depot_factures.saisie_historique,
       factures.statut AS facture_statut, factures.reference
FROM depot_factures
LEFT JOIN factures ON factures.id = depot_factures.facture_id AND factures.deleted_at IS NULL
WHERE depot_factures.deleted_at IS NULL
  AND depot_factures.deja_reglee = 0
  AND depot_factures.saisie_historique = 0
  AND COALESCE(NULLIF(TRIM(factures.statut), ''), depot_factures.statut)
      IN ('en_validation_nv2', 'en_attente_nv2', 'nv2_en_cours')
ORDER BY depot_factures.id;
```

C2b — historique (piste, pas le compteur) :

```
SELECT action, COUNT(*) AS n
FROM facture_historique
WHERE action IN ('valide_nv2', 'envoye_nv2', 'valide_nv2_compta_deja_reglee')
GROUP BY action;
```

---

## E — Remédiation proposée (pas exécutée)

### E1. Par défaut

| Défaut | Fichiers | Correction |
|--------|----------|------------|
| Triple non_rattache | `ComptaTableauBordService` (`compteBancaireSql` / `compteLabelSql` / `pivoterBanques`) ; éventuellement Vue si leftover brut | Une clé = un libellé. Lookup `name` ; fallback `intacct_id` ; « Non rattaché » seulement si sources vides. Un groupe. |
| 147 vs 146 | `banques()` jointures ; `aPayerJoinQuery` vs `eligibleFacturesQuery` | `COUNT(DISTINCT factures.id)` après diagnostic B ; **mieux** : une seule query builder partagée. |
| 9 vs 10 | `pipelineParStatutDepot` vs réutiliser `scopeEligibleComptabilisation` | Après arbitrage C4. URL : soit `/factures/comptabilisation` (casse DG), soit documenter, soit chiffre frise = URL frise. |
| 12 vs 11 | `etapeDepotSql` vs `FactureGlobaleEtapeStatutFilter` | Après C4 : même prédicat que l’URL. |
| HT « à payer » | service + Vue | Après arbitrage : `montant_ttc` / `net_a_payer_ttc` pour les blocs paiement. |
| Clics blocages | `urls()` | Query string alignée, ou retirer le lien. |

Ne pas modifier `ComptabilisationController` / `DepotFactureController`
dans le premier lot de fix (zones sensibles, test manuel réel).

### E2. Source unique

- « À payer » : `PaiementLignesPayablesService::eligibleFacturesQuery()`
  (déjà visée #1478, non branchée). Dashboard + MEP.
- « À comptabiliser » : soit `scopeEligibleComptabilisation`, soit
  `FactureGlobaleEtapeStatutFilter::applyEtape('a_comptabiliser')` —
  **un** des deux, le même que l’URL.
- « NV2 » : le prédicat de `FactureGlobaleEtapeStatutFilter` `nv2`
  **si** l’URL reste `f_statut=nv2`.

### E3. Découpage / ordre

1. **Bloc bancaire** (structurel, trois cartes mensongères, indépendant
   de l’arbitrage C4). Y coller le DISTINCT / source unique « à payer »
   (147/146) — même socle.
2. **HT vs TTC** sur les blocs paiement (arbitrage métier court).
3. **Pipeline + URLs** après C4 (définition). Sans arbitrage, tout
   alignement est un choix déguisé.

Inversement (pipeline d’abord) : on peaufinerait des compteurs dont
le bloc banque reste illisible. D’où banque **avant**.

### E4. Arbitrages Robin (A/B/C, sans recommandation imposée)

1. Définition « à comptabiliser » : file `eligibleComptabilisation` (A) /
   statut brut dépôt (B) / les deux affichés (C).
2. Définition NV1/NV2 : frise liste (A) / statut brut (B) / les deux (C).
3. Cible de clic « À comptabiliser » : rester sur liste globale (DG ok)
   / écran comptabilisation (DG 403) / deux liens.
4. Montant « à payer » / banque / échues : rester HT / passer TTC
   (`montant_ttc`) / `net_a_payer_ttc`.
5. Avoirs dans « à payer » : inclus (aujourd’hui) / exclus comme l’onglet
   Avoirs / ligne séparée.

---

## LEÇON

Un agrégat qui « s’inspire » d’un écran sans appeler la même fonction
diverge dès la première condition (`saisie_historique` NULL, frise,
`valide` vs `a_comptabiliser`). Grouper sur un id et afficher un autre
COALESCE produit des jumeaux de libellé. Annoncer des écarts « attendus »
sans les lister facture par facture ne tient pas en recette compta.

## DÉPLOIEMENT-TEST

Aucun PHP applicatif. Après `git pull` : lire cette fiche. Exécuter
les requêtes A2, B, C1, C2. Coller les résultats avant tout lot de fix.
Pas de `journal:sync`. Pas de npm/composer/optimize.
