---
suivi: 1498
date: 2026-09-10
sujet: Diagnostic filtre partiel mise en paiement
chantier: comptabilite / mise-en-paiement
type: diagnostic
statut: poussé
hash: 0d15d615
fichiers:
  - docs/suivi/SUIVI_1498_diag_filtre_partiel_mise_en_paiement.md
---

# SUIVI #1498 — PHASE 1 diagnostic — filtre partiel MEP

## PROMPT ENVOYÉ

Après #1496, `GET /factures/mise-en-paiement?f_societe=13,4` retire
seulement deux lignes E-11 (FAC-2026-1246, FAC-2026-1245) et laisse
passer E-11, E-03, E-10, E-17, E-19, E-23, E-24. Diagnostic seul.
Pas de code applicatif.

## BASE GIT

Attendu : `8361526f` (fiche #1496). Réel : `063ae1c0` chore hash fiche
#1497 (largeur centre d’aide). origin/main à jour. Aucun fichier
applicatif touché. Aide / #1497 non lu pour coder.

## FICHES LUES

- **#1496 LEÇON** — trois conventions de nommage ; factoriser le parseur
  avant de propager un filtre. Impact : le PHP MEP lit `f_societe`, mais
  l’UI de l’écran n’a pas été branchée sur ce paramètre.
- **#1495** (arrêt, pas de fiche disque) — MEP lisait `entite_id`, pas
  `f_societe`. C’est exactement le trou que le #1496 a voulu boucher côté
  serveur, sans toucher le Vue.
- **#1483 LEÇON** — le clic ouvre le périmètre du compteur. Impact : un
  `f_societe` dans l’URL qui ne survit pas au chargement de la liste
  recasse le même acquis.

## SYNTHÈSE

Deux défauts superposés. Le premier est **établi par le code**. Le
second (pourquoi ces deux factures E-11 et pas la troisième) **n’est
pas certain** sans les `updated_at` / le JSON — clause d’arrêt C.

### A. Chaîne de filtrage

1. Lecture — `PaiementController::filtresFromRequest` L221-231 :
   `entite_id` ET `f_societe` copiés depuis la query. `f_societe` EST lu
   sur `index` (L36) et `lignes` (L92). Pas perdu sur le contrôleur.

2. Parsing — `PaiementLignesFiltre::entiteIdsFromFiltres` L139-147 :
   `QueryIdListParser::parse(f_societe)` d’abord ; si vide, `entite_id`.
   `f_societe=13,4` → `[13, 4]`. Priorité canonique respectée.

3. Préfiltre SQL — `applyToFacturesQuery` L38-51, appelé depuis
   `PaiementLignesPayablesService::buildLignesBatch` L97 (index ET
   lignes, même méthode). 3 OR sur le Builder Facture :
   `factures.entite_id IN (13,4)`
   OR `depotFacture.entite_id IN (13,4)` (relation `latestOfMany`)
   OR `programme.entite_id IN (13,4)`.
   Ce n’est PAS `SocieteEntiteResolver` (dépôt OR programme seulement).
   Ce n’est PAS le COALESCE bancaire.

4. Stubs — `collectLineStubs` L502-552. Une ligne MEP = un bill Sage
   (mono) ou une ventilation envoyée. Pas une ligne 401.
   `filterFieldsFromFacture` L705-718 pose `entite_id` =
   `PaiementLigneReferentielResolver::resolveEntite` **sans** override
   JSON.

5. `matchesLine` L80-90 — appelé dans `buildLignesBatch` L114 **sur
   le stub**, pour index autant que pour `lignes`. `in_array` sur
   `(int) $line['entite_id']`. Si le stub a l’id 11, la ligne sort.

6. Rendu — `serializeFactureContext` L754-763 **réapplique** un
   override `ocr_raw.mise_en_paiement.entite_intacct_id` (colonne
   affichée `entite_intacct_id`, libellé E-11 / E-23). Le Vue filtre
   visuellement sur ce code (`Index.vue` L720-724,
   `entiteDisplayLabel`).

7. 🔴 Trou Vue — `Index.vue` `buildFilterQueryParams` L165-168 n’émet
   que `entite_id`. Jamais `f_societe`. `filtres.entite_id` est hydraté
   depuis `filtres_actifs.entite_id` (L117-120), pas depuis
   `f_societe`. URL `?f_societe=13,4` → le select affiche « Toutes les
   sociétés », `entite_id` reste vide.

8. 🔴 `loadMoreLignes` L247-257 : `URLSearchParams(buildFilterQueryParams())`
   + `cursor` + `limit=25`. IntersectionObserver L285-288,
   `rootMargin: 200px` — le second batch part dès que le sentinelle
   approche, souvent sans geste de scroll. Ce fetch **n’a pas**
   `f_societe` → `lignes()` applique un filtre vide → le reste de la
   liste est le file complet à partir du curseur.

Conséquence établie : le premier lot Inertia (25) est filtré ; les
lots suivants ne le sont pas. Les lignes écartées du lot 1 et situées
avant le curseur ne sont jamais rechargées. Le reste de l’écran = file
non filtrée. Ça produit une liste quasi identique à l’écran sans
filtre, moins quelques lignes du premier lot.

`index` et `lignes` PHP appliquent le même filtre. L’écart n’est pas
« matchesLine oublié sur un des deux ». C’est le Vue qui n’envoie plus
le paramètre.

### B. Entité d’une « ligne »

Pas de colonne d’entité par ligne comptable 401. Une ligne MEP est un
stub de paiement.

Source du stub (filtre) — `PaiementLigneReferentielResolver::resolveEntite`
L51-80, priorité :
1. `factures.entite` (`factures.entite_id`)
2. `depot_factures.entite` (`depot_factures.entite_id`)
3. `programmes.entite` (`programmes.entite_id`)

Source affichée — la même, puis si
`ocr_raw.mise_en_paiement.entite_intacct_id` est non vide,
`Entite` dont `intacct_id` = cette valeur (`serializeFactureContext`
L757-762). Persist MEP (`PaiementLignePersistService` L58-72) écrit
le JSON **et** `depot.entite_id` / `facture.entite_id`. Après un
enregistrement via l’écran, colonnes et JSON sont alignés. Un JSON
orphelin (saisie ancienne, autre writer) peut encore diverger.

Deux sémantiques, trois endroits :
- Filtre liste globale / resolver #1496 : dépôt OR programme.
- Préfiltre MEP : facture OR dépôt OR programme (plus large).
- COALESCE bancaire tableau de bord : clé de compte, pas un filtre.
  N’intervient pas ici.

Une ligne affichée E-11 peut donc avoir un stub `entite_id` = 13 ou 4
(si `facture.entite_id` est ENVOL/HECTARE et que l’écran montre un
override JSON, ou l’inverse). Le filtre `matchesLine` ne voit pas
E-11 ; l’utilisateur si.

### C. Pourquoi 1246 / 1245 et pas 1093 — PAS CERTAIN

Le code **ne permet pas** d’affirmer pourquoi ces deux références
précises. Deux mécanismes compatibles avec le constat ; les départager
exige le SQL (section E).

Candidat 1 (suit A, plausible) : ordre `updated_at DESC, id DESC`
(`eligibleFacturesQuery` L267-268). 1245/1246 (n° proches, récents)
tombent dans la fenêtre du premier lot filtré, `matchesLine` ou le
3 OR les écarte, le curseur passe devant. 1093 (n° plus ancien) est
chargé ensuite **sans** filtre. Même entité affichée E-11, sort
différent selon la position. Le montant 0,00 € de 1246 n’entre dans
aucun prédicat (pas de filtre montant). Particularité écartée.

Candidat 2 : 1245/1246 n’ont aucun des trois OR à 13/4 (stub E-11
pur). 1093 a `programme.entite_id` ou `facture.entite_id` ∈ {13,4}
tout en s’affichant E-11 (override). Alors même un filtre appliqué
sur toute la liste garderait 1093 et retirerait 1245/1246.

Sans `updated_at` ni les trois `entite_id` ni le JSON, choisir 1 ou 2
serait une fausse cause. Arrêt C.

### D. Régression ou préexistant

Diff `98f545ff` : `PaiementController` ajoute `f_societe` dans
`filtresFromRequest`. `PaiementLignesFiltre` passe int → liste, même
3 OR, même `matchesLine` sur `line.entite_id`. **Aucun `.vue`.**

- `?entite_id=13` : le Vue hydratait déjà le select et renvoyait
  `entite_id` au `loadMore`. Comportement **préexistant**, lots
  suivants encore filtrés.
- `?f_societe=13,4` : paramètre **nouveau** #1496. Le Vue ne le
  connaît pas. Les lots suivants partent sans filtre. **Régression
  d’intégration** du #1496 (trou Vue), pas un changement de
  sémantique PHP. L’annonce « sémantique MEP inchangée » est vraie
  pour `applyToFacturesQuery` / `matchesLine`. Elle est fausse pour
  l’URL `f_societe` une fois l’écran scrolé / auto-complété.

Expérience à faire (même compte Sabrina/Rudy, pas Robin) :

1. `/factures/mise-en-paiement` (témoin).
2. `/factures/mise-en-paiement?entite_id=13` — ancien paramètre.
3. `/factures/mise-en-paiement?f_societe=13` — même id, nouveau nom.
4. `/factures/mise-en-paiement?f_societe=13,4` — le cas mesuré.

Comparer 2 et 3 après avoir laissé le sentinelle charger (ou scroller
en bas). Si 2 est nettement plus court et 3 quasi identique au témoin
: le trou Vue est la cause de **cette** mesure. Si 2 et 3 sont tous
deux quasi identiques au témoin : le 3 OR PHP est trop large, A est
secondaire.

### E. SQL (aucun chiffre produit ici)

Dernier dépôt = `MAX(depot_factures.id)` (même sémantique que
`latestOfMany('id')`). Colonnes qualifiées.

Cinq factures :

SELECT
  factures.id AS factures_id,
  factures.reference,
  factures.updated_at,
  factures.entite_id AS facture_entite_id,
  entite_facture.intacct_id AS facture_entite_intacct,
  entite_facture.nom AS facture_entite_nom,
  depot_factures.id AS depot_id,
  depot_factures.entite_id AS depot_entite_id,
  entite_depot.intacct_id AS depot_entite_intacct,
  programmes.id AS programme_id,
  programmes.libelle AS programme_libelle,
  programmes.enseigne_id AS programme_enseigne_id,
  programmes.entite_id AS programme_entite_id,
  entite_programme.intacct_id AS programme_entite_intacct,
  JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.mise_en_paiement.entite_intacct_id')) AS mep_entite_intacct,
  depot_factures.montant_ttc AS depot_montant_ttc,
  factures.net_a_payer_ttc
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 entites AS entite_facture ON entite_facture.id = factures.entite_id
LEFT JOIN entites AS entite_depot ON entite_depot.id = depot_factures.entite_id
LEFT JOIN entites AS entite_programme ON entite_programme.id = programmes.entite_id
WHERE factures.deleted_at IS NULL
  AND factures.reference IN (
    'FAC-2026-1246', 'FAC-2026-1245', 'FAC-2026-1093',
    'FAC-2026-952', 'FAC-2026-1197'
  );

Rang dans la file à payer (pour C) :

SELECT factures.reference, factures.updated_at, factures.id
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
  AND EXISTS (
    SELECT 1 FROM workflow_validations wv
    WHERE wv.facture_id = factures.id
      AND wv.niveau = 'paiement_effectif' AND wv.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv2
    WHERE wv2.facture_id = factures.id
      AND wv2.niveau = 'paiement_effectif' AND wv2.statut = 'valide'
  )
ORDER BY factures.updated_at DESC, factures.id DESC;

Ampleur JSON override ≠ intacct de resolveEntite (facture puis dépôt
puis programme) :

SELECT COUNT(*) AS nb_file_a_payer,
  SUM(CASE
    WHEN NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.mise_en_paiement.entite_intacct_id'))), '') IS NOT NULL
     AND NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(depot_factures.ocr_raw, '$.mise_en_paiement.entite_intacct_id'))), '')
         <> TRIM(COALESCE(entite_facture.intacct_id, entite_depot.intacct_id, entite_programme.intacct_id, ''))
    THEN 1 ELSE 0 END) AS nb_override_json_diverge
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 entites AS entite_facture ON entite_facture.id = factures.entite_id
LEFT JOIN entites AS entite_depot ON entite_depot.id = depot_factures.entite_id
LEFT JOIN entites AS 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
  AND EXISTS (
    SELECT 1 FROM workflow_validations wv
    WHERE wv.facture_id = factures.id
      AND wv.niveau = 'paiement_effectif' AND wv.statut = 'en_attente'
  )
  AND NOT EXISTS (
    SELECT 1 FROM workflow_validations wv2
    WHERE wv2.facture_id = factures.id
      AND wv2.niveau = 'paiement_effectif' AND wv2.statut = 'valide'
  );

Vérifier aussi `SELECT id, intacct_id, nom FROM entites WHERE id IN (13,4);`

### Question métier — NE PAS TRANCHER

Une facture dont `facture.entite_id` / programme est ENVOL (13) et dont
la colonne Société à l’écran montre E-11 : le filtre « société = E-13 »
doit-il la garder ou l’exclure ?

- Garder (périmètre « société propriétaire / 3 OR / stub») : on paie
  depuis l’écran ENVOL des lignes affichées E-11. Risque : le comptable
  croit n’avoir que ENVOL, il vire aussi d’autres locations affichées.
- Exclure (périmètre « ce que la colonne montre », `entite_intacct_id`
  y compris override) : la liste colle à l’œil. Risque : une facture
  ENVOL dont on a changé la location Intacct disparaît du filtre
  ENVOL, oubliée ou payée sous une autre société.
- Restreindre au `SocieteEntiteResolver` (dépôt OR programme, comme la
  liste globale) : cohérent dashboard / `/factures`. Change le 3 OR
  actuel (perte du `factures.entite_id` seul).

Robin tranche, éventuellement Sabrina/Rudy. Aucun défaut proposé.

### PHASE 2 — seulement si l’expérience D confirme le trou Vue

Point de code : `resources/js/Pages/Factures/Paiement/Index.vue`
(`buildFilterQueryParams`, hydratation `filtres` depuis
`filtres_actifs.f_societe`). `PaiementController` expose déjà
`f_societe`. Ne pas toucher `PaiementLignesFiltre` tant que la
question métier n’est pas tranchée — coller A et la sémantique 3 OR
dans le même lot reproduirait #1380–#1384.

Si l’expérience D montre que `entite_id=13` est aussi quasi identique
au témoin : A n’explique pas la mesure ; revenir sur C + SQL avant
tout correctif.

### Ce qui n’a pas pu être établi

- Positions exactes de 1245, 1246, 1093 dans l’ordre `updated_at`.
- Leurs trois `entite_id` et le JSON MEP.
- L’ampleur réelle override JSON vs colonnes.
- Si 13/4 sont bien ENVOL/HECTARE en base (Robin : `entites.id`).
- Aucun PHP modifié. Aucun runtime installé. sqlite skipped, 0 preuve
  only_full_group_by. Pas de navigateur. Pas de chiffre.

## DÉPLOIEMENT-TEST

Fiche seule. `git pull origin main`. Pas de migrate, pas de
journal:sync, pas de npm.

## LEÇON

Un paramètre serveur nouveau que le Vue de scroll infini n’émet pas
filtre le premier lot et laisse le reste intact. Sur un écran de
virements, ça ressemble à un filtre. Tester le **second** lot XHR, pas
seulement l’URL du GET initial. `entite_id` (l’UI) et `f_societe`
(l’URL tableau de bord) doivent emprunter le même tuyau jusqu’au
`fetch` du curseur.
