---
suivi: 1516
date: 2026-09-10
sujet: Diagnostic — fuite visibilité boîte de dépôt (Eric id 35)
chantier: factures / depot / visibilite
type: diagnostic
statut: poussé
hash: 6b98959e
fichiers:
  - docs/suivi/SUIVI_1516_diag_fuite_boite_depot.md
---

# SUIVI #1516 — PHASE 1 diagnostic : fuite boîte de dépôt

## PROMPT ENVOYÉ

Diagnostic seul. Eric PORTALES (id 35, groupes GS_GROUPE / GS_ENVOL /
GS_HECTARE, hors socle) voit 8 dépôts d’autrui dans `/depot-factures`.
Règle Robin : déposant ∪ valideur ∪ groupes du socle. Ne pas coder.
Ne pas modifier `FactureVisibility` (lecture), ni `DepotFactureController`
(#1515), ni le scope parcelles (#1514).

## BASE GIT

HEAD local = `origin/main` = `80401e08` (chore hash fiche #1515).
Le prompt citait `8117bde3` (#1514) avec **#1515 peut-être en cours** :
**périmé**. #1515 déjà sur main (`4ffcecc0` + `80401e08`) — PATCH
saisie Projet / `analytics_id`. Aucun rebase. Aucun fichier applicatif
modifié. `DepotFactureController` lu, non écrit.

## FICHES LUES

- **#1508 LEÇON** — « partout » n’est pas `applyScope` ; socle avec
  GS_DSI ; déposant = `user_id` ; NV1 fiable = `workflow_validations`.
  Impact : le point 11 « liste boîte = FV » est **vrai** (le scope
  s’applique) et **insuffisant** (FV ≠ règle Robin : cran enseigne).
- **#1461 LEÇON** — `JSON_SET` + `JSON_QUOTE` = guillemets littéraux ;
  normalisation `depot-factures:normalize-validateur-id`. Impact : le
  cran valideur SQL compare en **égalité de chaînes**, pas un CAST
  UUID→int. Piste UUID écartée comme faux positif pour l’id 35.
- **#1483 / #1485 / #1503 LEÇON** — tableau de bord et liste globale
  compta : porte de **groupe**, pas FV. Hors cause de cette fuite
  boîte ; recensés en C pour ne pas les « corriger » par accident.
- **#1515 LEÇON** — exception Projet sur `analytics_id` à Enregistrer.
  Hors visibilité. Ne pas rouvrir le PATCH saisie.

Aucun PHP applicatif modifié. Aucune migration. Aucun script. Aucun
runtime PHP invoqué. Aucun navigateur. Aucun chiffre (MySQL
inaccessible). sqlite skipped : **aucune valeur de preuve sur les
droits**.

---

## A. LA LISTE DE LA BOÎTE DE DÉPÔT

### A1. `applyScope` est bien appliqué

`DepotFactureController::buildBoiteDepotListeQuery` L3338-3343 :

    $query = DepotFacture::query()
        ->visiblePar($user)
        ->whereIn('statut', DepotFacture::statutsBoiteDepot())
        ->with([...]);

`DepotFacture::scopeVisiblePar` L303-307 :

    FactureVisibility::applyScope($query, $user);

Chemins d’entrée **identiques** (même builder) :

- `index` L123-133 — `GET /depot-factures` (Inertia, pagination 30)
- `liste` L161-169 — `GET /depot-factures/liste` (XHR, même
  `resolveBoiteDepotListeContext` + `buildBoiteDepotListeQuery`)

**Verdict #1508 point 11 : confirmé.** La liste n’oublie pas
`visiblePar`. La contradiction avec la mesure n’est pas « le scope
absent ». C’est **le contenu du scope** (cran enseigne, B5).

Statuts boîte (`DepotFacture::statutsBoiteDepot` L106-113) :
`depose`, `analyse`, `attribue`, `en_cours_saisie`. Hors boîte
(engagés) : pas dans cette liste.

### A2. Deuxième chemin — pas un oubli de scope (#1500)

Le front `DepotFactures/Index.vue` :

- Premier rendu : `listItems` = props Inertia `depots.data` (L159,
  `initListStateFromProps` L852-855, `onMounted` L857-863). **Pas**
  de `fetchListe` immédiat.
- XHR `GET /depot-factures/liste` : `fetchListe` L745-754 via
  `buildListeQueryParams` L709-742, déclenché par `watch` des filtres
  L848-850 (`scheduleFiltresReload` 250 ms) et le sentinel de
  pagination L796.

Les deux chemins appellent `visiblePar`. **Pas** le défaut #1500
« un chemin filtré, l’autre non ». La différence entre SSR et XHR
est uniquement les **query params** des cases (A3).

Autres `visiblePar` du même contrôleur, **sans** cases à compléter /
mes dépôts :

- `resolveEntitesFiltreBoiteDepot` L3626-3628 — options « Par entité »
- `resolveValidateursFiltreBoiteDepot` L3738-3740 — options valideur

Ces listes déroulantes voient **tout le périmètre FV** (enseigne
comprise), même si la liste principale est restreinte par « à
compléter ». Fuite de **noms** (entités, valideurs), pas des PDF.

### A3. Cases « Mes factures à compléter » / « Voir mes dépôts »

Défauts serveur `resolveBoiteDepotListeContext` L3300-3307 :

- `mes_depots` absent → **false**
- `a_completer` absent → **true** (#1450)

Front L166-167 : `filtreMesDepots` = props === true ; `filtreACompleter`
= props !== false. XHR envoie toujours `mes_depots=0|1` et
`a_completer=0|1` (L711-719).

Filtres **en plus** de `visiblePar` (`buildBoiteDepotListeQuery`
L3379-3393) :

| mes_depots | a_completer | AND supplémentaire |
|------------|-------------|-------------------|
| 1 | 1 | `user_id = moi` **OU** valideur JSON (id **ou azure_id** ou e-mail) |
| 1 | 0 | `user_id = moi` seulement |
| 0 | 1 | valideur JSON (id / azure_id / e-mail), + déposant si valideur vide |
| 0 | 0 | **aucun** — reste seul `visiblePar` |

« Voir mes dépôts » **décochée** ne signifie pas à elle seule « voir
ceux des autres ». **Les deux décochées** = tout ce que FV autorise.

Le libellé « (tous) » L1293 n’apparaît que si
`can_voir_tous_depots` (`hasSocleTotalAccess`, L3219-3221) **et**
les deux cases off. Eric n’est pas socle : **aucun bandeau** alors
qu’il voit tout ENVOL + tout HECTARE non verrouillés.

Aide UI L1304-1306 : « union : vos dépôts et/ou les factures dont
vous êtes le valideur ». Elle **ne décrit pas** l’état deux cases
off pour un non-socle.

Premier GET sans query : `a_completer` true → la SSR est restreinte
au cran valideur (azure_id inclus, `whereSaisieValidateurEstUser`
L3529-3547). Les 8 dépôts ont **trois UUID valideur distincts** : ils
ne peuvent pas tous matcher l’`azure_id` unique d’Eric. **Donc la
liste observée n’était pas sous « à compléter » coché** — case
décochée (ou XHR `a_completer=0`).

`whereSaisieValidateurEstUser` compare en **égalité de chaînes**
(`json_unquote(...) = ?`), avec `users.id` **et** `users.azure_id`.
Pas de CAST UUID→entier.

---

## B. `FactureVisibility` — RELU EN ENTIER

Fichier `app/Support/FactureVisibility.php`.

### B4. Matrice L15-21

1. Socle total → tout.
2. Auteur du dépôt (`user_id`).
3. Valideur NV1/NV2 (workflow **OU** `ocr_raw.saisie.validateur_id` /
   e-mail).
4. **Membre d’enseigne** (GS_ENVOL → ENVOL, GS_HECTARE → HECTARE).
5. `factures.lecture` — **uniquement** `userCanViewFacturePage` /
   `userCanAccessFacturesPage`, **pas** dans `canView` ni `applyScope`.
   Le commentaire L21 est trompeur (déjà #1508).

Socle L25-30 : `GS_ADMIN_ERP`, `GS_DSI`, `GS_DIRECTION_GENERALE`,
`GS_COMPTABILITE`. Eric n’y est pas. `hasSocleTotalAccess` L239-242
lit `m365GroupNames()` **brut** — pas `hasAnyM365Group()`.

`userHasGroup` L360-363 : `in_array($group, $user->m365GroupNames())`.
Même API brute.

### B5. `applyScope` L185-211 — cause

- Socle : **aucun WHERE**.
- Sinon : `depot_factures.user_id = user`
  **OU** `applyDesignatedValidatorScope`
  **OU** (non verrouillé **ET** enseigne ENVOL si `GS_ENVOL`)
  **OU** (non verrouillé **ET** enseigne HECTARE si `GS_HECTARE`).

Eric a **GS_ENVOL et GS_HECTARE**. Les deux branches enseigne
L197-209 s’ouvrent. Il voit **toutes** les factures programme ENVOL
**et** HECTARE non verrouillées (statuts boîte en plus). C’est
**exactement** le symptôme : dépôts de Benjamin, Isabelle, Magali,
Lahouari — aucun besoin d’être déposant ni valideur.

Ce cran est **intentionnel dans le code** (matrice L19, tests
`FactureVisibilityMatrixTest` L151-164 : un GS_ENVOL **doit** voir
1 dépôt programme ENVOL d’autrui). Ce n’est pas un WHERE oublié.
La règle Robin **révoque** ce cran.

`canView` L115-127 : **le même cran enseigne**. Liste et fiche
alignées.

### B6. Enseigne sans programme

`applyResolvedEnseigneCodeScope` L336-357 :

1. `programme.enseigne.code` du dépôt
2. sinon `programme_id` NULL **et** `facture.programme.enseigne`
3. sinon décalage facture.programme ≠ dépôt.programme

`resolveEnseigneCode` L279-299 : **uniquement** programme (dépôt ou
facture). Jamais `dossiers_fonciers.enseigne_id`. Dépôt Projet / FG
**sans** `programme_id` et sans `facture.programme` → cran enseigne
**inactif**. Les 8 observés, s’ils sont destination PROGRAMMES,
passent par (1). **À confirmer par SQL**, pas mesuré ici.

Codes comparés en dur `=== 'ENVOL'` / `'HECTARE'` (interdit
`aModule` côté métier enseigne — préexistant, hors ce diagnostic).

### B7. Cran valideur — UUID ≠ fuite

`applyDesignatedValidatorScope` L305-330 (liste SQL) :

- `workflow_validations.validateur_id` = **entier** `users.id`
  (nv1/nv2, statut en_attente|valide)
- **et** JSON
  `trim(json_unquote(json_extract(ocr_raw,'$.saisie.validateur_id'))) = ?`
  avec `?` = `(string) $user->id` (**"35"**, pas l’azure_id)
- **et** e-mail JSON en égalité lower/trim

`isDesignatedValidator` / `canView` L244-276 :

- d’abord `workflow_validations.validateur_id` entier
- sinon `DepotFactureValidateurEffectif::idEffectif` puis
  `(int) $idEffectif === $userId`

`(int) '17503848-2760-…'` → 17503848 ≠ 35.
`(int) 'd50f056a-…'` → 0 ≠ 35.
SQL : `'17503848-2760-…' = '35'` → faux (égalité de chaînes, **pas**
de CAST).

**Piste UUID écartée** comme correspondance inattendue vers Eric.

Le JSON **peut** être un UUID : le backfill #1461 écrit `azure_id`
si présent, sinon `CAST(u.id AS CHAR)`. C’est le format **voulu**
pour le Select Graph, pas un résidu. `applyScope` (liste FV) ne
compare **pas** l’azure_id — seul le filtre « à compléter » le fait.
Un valideur stocké en UUID **n’est pas** vu via le cran JSON de
`applyScope` (il l’est via e-mail ou `workflow_validations`). C’est
le défaut inverse #1461 (valideur aveugle), **pas** cette fuite.

Deux sources : `ocr_raw.saisie.validateur_id` **et**
`workflow_validations.validateur_id`. Le scope liste les **deux**
(OR). Cohérence non mesurée en base (SQL fourni).

---

## C. AMPLEUR

### C8. Requêtes — aucun chiffre Cursor

Voir section SQL. Robin compare : (règle Robin) vs (`applyScope`
actuel = + enseigne).

### C9. Autres écrans — même cause vs autre cause

**Même cause** (`FactureVisibility::applyScope` / `canView`, cran
enseigne) :

- `/factures` — `FactureGlobaleListeQuery::baseQuery` L51
- compteurs / ZIP PDF `FacturePdfZipService` L104
- `FactureGlobaleController` (plusieurs `applyScope`)
- fiche dépôt `show` / `data` / `pdf` via `canViewDepot` L4448-4452
- options filtre boîte L3627 et L3739

GS_ENVOL a `*.lecture` (`config/feature_permissions.php` L270-272)
→ `userCanViewFacturePage` **true** hors verrou (#1508) : **autre**
porte (permission), pas le cran enseigne, déjà nommée au #1508.

**Pas FV — causes distinctes, ne pas les « réparer » dans le lot
enseigne :**

- `ComptabilisationController`, `PaiementLignesPayablesService`,
  `ComptaTableauBordService` (#1483/#1503) : porte groupe COMPTA/DG/ADMIN
- panel programme, liste marché, sélecteur CIE (#1508 A7-A9)
- **`ChargePortageController::searchDepotFactures` L71-100** : LIKE
  sans FV, limite 30, `foncier.lecture`. Eric a GS_HECTARE → **atteint
  cette route**. Fuite **active distincte**, pas le cran enseigne.
  Toujours ouverte (#1508). Hors phase 2 visibilité FV.

---

## D. CONSÉQUENCE — OUVERTURE, PAS SEULEMENT LA LISTE

Liste (`formatBoiteDepotListeItem` L3784-3818) : nom de fichier,
déposant (nom **et e-mail**), `validateur_email`, `url_fichier`.
Pas de montant HT/TTC dans ce JSON. Les noms type `ORANGE_…` /
`STYL'FACADES_…` sont le `nom_affichage`.

Clic ligne L914-922 : `GET /depot-factures/{uuid}/data` →
`findDepotForView` L4437-4441 → `canView` (**cran enseigne**).
Payload de saisie complet (fournisseur, montants, OCR).

PDF :

- iframe L1529 `/depot-factures/{uuid}/pdf` → `pdf()` L2973-2979 :
  charge le dépôt **sans** `visiblePar`, puis `canViewDepot` =
  `canView` (enseigne). **Pièce ouverte.**
- Icône liste L1433-1440 : `row.url_fichier` =
  `getUrlFichierAttribute` L772-779 : `Storage::disk('factures')
  ->temporaryUrl(..., 20 min)`. URL signée **après** inclusion dans
  la liste FV. **Pièce téléchargeable depuis la liste.**

Ce n’est pas une liste indiscrète isolée : **fuite de pièces**.

---

## IMPERSONATION — ÉCARTÉE

`ImpersonationService::start` L65-76 : `Auth::login($target)`.
`Auth::user()` / `$request->user()` = **Eric**, `m365_groups`
d’Eric. Session `impersonator_id` = Robin (bandeau
`HandleInertiaRequests::resolveImpersonationShare` L133-150) :
affichage seulement.

`hasSocleTotalAccess` et `userHasGroup` lisent
`m365GroupNames()` de l’utilisateur **effectif**. Eric n’a pas
GS_ADMIN_ERP → `isAdminERP()` false → `Gate::before`
(AppServiceProvider L69-75) **ne s’applique pas**.
`hasAnyM365Group()` (court-circuit admin) n’est **pas** utilisé par
FV. `flushCache($target)` L78.

Robin n’est pas « sous » le scope. Hypothèse **écartée**.

---

## SQL POUR ROBIN (aucun chiffre ici)

### 1. Les 8 dépôts observés

    SELECT
      df.id,
      df.uuid,
      df.nom_affichage,
      df.nom_fichier_original,
      df.user_id,
      u.name AS deposant,
      df.programme_id,
      df.entite_id,
      df.statut,
      df.facture_id,
      df.verrouille,
      p.libelle AS programme_libelle,
      e.code AS enseigne_code,
      JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS saisie_validateur_id,
      JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS saisie_validateur_json_type,
      JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_email')) AS saisie_validateur_email,
      JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.destination')) AS destination
    FROM depot_factures df
    LEFT JOIN users u ON u.id = df.user_id
    LEFT JOIN programmes p ON p.id = df.programme_id
    LEFT JOIN enseignes e ON e.id = p.enseigne_id
    WHERE df.nom_affichage IN (
      'ORANGE_1698692D1',
      'BL_NOTAIRES_SELLEM_GRANDJEAN',
      'STYL''FACADES_N-109026034',
      'AQUA_GEO_SPHERE_213',
      'OCCIPLAC_F202608283',
      'NG_FACADES_0000298',
      'EMC_83_SIT_10',
      'EAU_DE_METROPOLE_MARSEILLE_40260700'
    )
    OR df.nom_fichier_original IN (
      'ORANGE_1698692D1',
      'BL_NOTAIRES_SELLEM_GRANDJEAN',
      'STYL''FACADES_N-109026034',
      'AQUA_GEO_SPHERE_213',
      'OCCIPLAC_F202608283',
      'NG_FACADES_0000298',
      'EMC_83_SIT_10',
      'EAU_DE_METROPOLE_MARSEILLE_40260700'
    )
    OR df.nom_affichage LIKE 'ORANGE_1698692D1%'
    OR df.nom_affichage LIKE 'BL_NOTAIRES_SELLEM_GRANDJEAN%'
    OR df.nom_affichage LIKE '%STYL%FACADES%N-109026034%'
    OR df.nom_affichage LIKE 'AQUA_GEO_SPHERE_213%'
    OR df.nom_affichage LIKE 'OCCIPLAC_F202608283%'
    OR df.nom_affichage LIKE 'NG_FACADES_0000298%'
    OR df.nom_affichage LIKE 'EMC_83_SIT_10%'
    OR df.nom_affichage LIKE 'EAU_DE_METROPOLE_MARSEILLE_40260700%';

Attendu si la cause enseigne tient : `enseigne_code` IN ('ENVOL','HECTARE'),
`programme_id` NOT NULL, `statut` ∈ boîte, `user_id` ≠ 35.

### 2. Formats `ocr_raw.saisie.validateur_id` (tous dépôts)

    SELECT
      CASE
        WHEN JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id') IS NULL THEN 'NULL'
        WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) IN ('', 'null') THEN 'vide'
        WHEN LEFT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')), 1) = CHAR(34)
          THEN 'double_encode_guillemet'
        WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) REGEXP '^[0-9]+$'
          THEN 'entier_ou_chiffres'
        WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) REGEXP
          '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
          THEN 'uuid'
        ELSE 'autre'
      END AS format_validateur,
      JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS json_type,
      COUNT(*) AS n
    FROM depot_factures df
    GROUP BY format_validateur, json_type
    ORDER BY n DESC
    LIMIT 18446744073709551615;

(`LIMIT` maximal : `only_full_group_by` / `derived_merge` — alias
GROUP BY, pas d’expression nue hors SELECT list MySQL 8 si besoin ;
ici GROUP BY d’alias dérivé via CASE nommé — si 1055, envelopper en
sous-requête aliasée + GROUP BY format_validateur, json_type +
LIMIT 18446744073709551615.)

Variante sûre 1055 :

    SELECT x.format_validateur, x.json_type, COUNT(*) AS n
    FROM (
      SELECT
        CASE
          WHEN JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id') IS NULL THEN 'NULL'
          WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) IN ('', 'null') THEN 'vide'
          WHEN LEFT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')), 1) = CHAR(34)
            THEN 'double_encode_guillemet'
          WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) REGEXP '^[0-9]+$'
            THEN 'entier_ou_chiffres'
          WHEN JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) REGEXP
            '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
            THEN 'uuid'
          ELSE 'autre'
        END AS format_validateur,
        JSON_TYPE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS json_type
      FROM depot_factures df
    ) x
    GROUP BY x.format_validateur, x.json_type
    ORDER BY n DESC
    LIMIT 18446744073709551615;

### 3. Eric (35) — règle Robin vs FV actuel (boîte)

    SELECT u.id, u.name, u.azure_id, u.m365_groups, u.email
    FROM users u
    WHERE u.id = 35;

    -- Règle Robin (déposant ∪ valideur JSON id/azure/e-mail ∪ workflow nv1/nv2)
    SELECT COUNT(*) AS n_regle_robin
    FROM depot_factures df
    WHERE df.statut IN ('depose', 'analyse', 'attribue', 'en_cours_saisie')
      AND (
        df.user_id = 35
        OR TRIM(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id'))) IN (
          '35',
          (SELECT TRIM(azure_id) FROM users WHERE id = 35)
        )
        OR LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_email')))) = (
          SELECT LOWER(TRIM(email)) FROM users WHERE id = 35
        )
        OR EXISTS (
          SELECT 1
          FROM workflow_validations wv
          WHERE wv.facture_id = df.facture_id
            AND wv.niveau IN ('nv1', 'nv2')
            AND wv.statut IN ('en_attente', 'valide')
            AND wv.validateur_id = 35
        )
      );

    -- Périmètre FV actuel hors socle : Robin + enseigne ENVOL/HECTARE non verrouillée
    SELECT COUNT(*) AS n_fv_enseigne
    FROM depot_factures df
    LEFT JOIN programmes p ON p.id = df.programme_id
    LEFT JOIN enseignes e ON e.id = p.enseigne_id
    LEFT JOIN factures f ON f.id = df.facture_id
    LEFT JOIN programmes pf ON pf.id = f.programme_id
    LEFT JOIN enseignes ef ON ef.id = pf.enseigne_id
    WHERE df.statut IN ('depose', 'analyse', 'attribue', 'en_cours_saisie')
      AND (
        df.user_id = 35
        OR TRIM(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id'))) IN (
          '35',
          (SELECT TRIM(azure_id) FROM users WHERE id = 35)
        )
        OR LOWER(TRIM(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_email')))) = (
          SELECT LOWER(TRIM(email)) FROM users WHERE id = 35
        )
        OR EXISTS (
          SELECT 1 FROM workflow_validations wv
          WHERE wv.facture_id = df.facture_id
            AND wv.niveau IN ('nv1', 'nv2')
            AND wv.statut IN ('en_attente', 'valide')
            AND wv.validateur_id = 35
        )
        OR (
          (
            (df.facture_id IS NOT NULL AND (f.verrouille = 0 OR f.verrouille IS NULL))
            OR (df.facture_id IS NULL AND (df.verrouille = 0 OR df.verrouille IS NULL))
          )
          AND (
            e.code IN ('ENVOL', 'HECTARE')
            OR (df.programme_id IS NULL AND ef.code IN ('ENVOL', 'HECTARE'))
          )
        )
      );

Hors socle, utilisateurs à recenser (même couple de COUNT, `u.id`
à substituer) :

    SELECT u.id, u.name, u.m365_groups
    FROM users u
    WHERE JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_ENVOL'))
       OR JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_HECTARE'))
    ORDER BY u.id;

Ne pas utiliser le compte Robin (GS_ADMIN_ERP).

---

## CLAUSE D’ARRÊT

**Mécanisme : certain.** `applyScope` s’applique ; il contient le
cran enseigne ; Eric porte GS_ENVOL + GS_HECTARE ; `canView` ouvre
fiche et PDF.

**Les 8 lignes : certaines SI** le SQL (1) montre une enseigne
ENVOL ou HECTARE et un `user_id` ≠ 35. Cursor ne l’a pas mesuré.
Si le SQL montre `programme_id` NULL (Projet / FG sans programme) :
le cran enseigne **n’explique pas** ces 8 → **ne pas coder** un
retrait d’enseigne comme correctif de *ces* lignes.

UUID / impersonation : **écartés** (source).

Ne pas toucher `ChargePortageController` dans le même lot (cause
distincte).

---

## DÉCOUPAGE PHASE 2 (après collage SQL, pas avant)

Un lot : retirer le cran 4 enseigne de `applyScope`, `canView` et
`canViewFacture`. Aligner le commentaire L15-21. Inverser
`FactureVisibilityMatrixTest` (aujourd’hui **exige** la vue
enseigne). Cases boîte : confort inchangé (union déposant/valideur).
Ne pas modifier `DepotFactureController` (zone #1515). Ne pas
« corriger » le UUID. `ChargePortageController` : lot séparé (#1508).

Audience qui **perd** la vue programme d’autrui : GS_ENVOL,
GS_HECTARE (hors socle, hors déposant, hors valideur). C’est la
décision Robin.

## JOURNAL

Aucune entrée (diagnostic, pas de changement utilisateur tant que
phase 2 n’est pas poussée).

## DÉPLOIEMENT-TEST

    git pull origin main

Pas de migrate, route:clear, journal:sync, npm, optimize.

Robin : coller SQL 1–3. Confirmer l’état des deux cases au moment
des 8. Compte Eric seulement, pas Robin.

## LEÇON

« Verdict FV » ne veut pas dire « déposant ∪ valideur ∪ socle ».
`FactureVisibility` a un 4e OR enseigne, testé comme un droit.
Un user GS_ENVOL+GS_HECTARE voit tout programme des deux enseignes
dès que les cases confort sont off — et `canView` ouvre le PDF.
L’égalité SQL `json_unquote = '35'` ne convertit pas un UUID.
`Auth::login($cible)` substitue les groupes ; le bandeau
d’impersonation n’élargit pas le scope.
