---
suivi: 1512
date: 2026-09-10
sujet: Diagnostic — cloisonnement sectoriel du sélecteur dossiers Projet
chantier: factures / depot / destination-projet
type: diagnostic
statut: poussé
hash: 6af390fa
fichiers:
  - docs/suivi/SUIVI_1512_diag_cloisonnement_secteur_selecteur_projet.md
---

# SUIVI #1512 — PHASE 1 diagnostic : cloisonnement secteur × sélecteur Projet

## PROMPT ENVOYÉ

Diagnostic seul. Le sélecteur de dossiers fonciers du dépôt (destination
Projet, #1505/#1506) respecte-t-il le cloisonnement par secteur du
module Foncier ? Fait observé : dossiers de Mudaison (secteur Eric)
proposés à un saisisseur. Identité du saisisseur non confirmée — traiter
les deux cas. Ne pas coder.

## BASE GIT

HEAD local = `origin/main` = `91294570` (chore hash fiche #1511).
Le prompt indiquait HEAD prod = `70446c64` (fiche #1510) et **#1511 en
cours** : **périmé**. #1511 est **déjà sur main** (`90652f87` +
`91294570`). Aucun rebase à faire. Aucun fichier applicatif modifié.

Coordination #1511 : même composant (`DepotProjetDossierFields.vue`,
`DepotProjetDossierService`, routes `depot-factures/dossiers-fonciers/*`).
#1511 = les ids **sélectionnés ne sont pas écrits** par `valider()`.
#1512 = **ce que le sélecteur liste**. Les deux constats ne se
contredisent pas. Un fix #1511 n’applique pas le secteur ; un fix
secteur n’écrit pas les colonnes.

## FICHES LUES

- **#1505 LEÇON** — identifier ≠ consulter ; journal à la sélection,
  jamais à la frappe ; dépôt ouvert à tous, sans `foncier.lecture`.
  Impact : le sélecteur est un endpoint **auth**, pas un écran foncier.
- **#1508 LEÇON** — « partout » n’est pas un seul scope ; un endpoint
  de recherche créé hors du scope de son module est une fuite. A15
  classait déjà `searchDossiersProjet` **N/A facture**, ouvert aux
  déposants, **sans** analyser le secteur. Impact : même famille de
  défaut, autre règle (secteur / ACL dossier, pas FactureVisibility).
- **#1504 LEÇON** — un nom dans un sélecteur de dépôt élargit
  l’audience (GS_COMPTABILITE, GS_ENVOL `!foncier.*`). Arbitrage A
  accepté au #1505. Impact : si le filtre sectoriel n’est pas là,
  l’exposition des noms est **plus large** que « les dossiers de mon
  secteur ».
- **#1463 LEÇON** — ne pas toucher `Parcelles/Show.vue`. Impact : hors
  cause ; fiche lue, non modifiée.
- **#1511** — persist `valider()` sans les FK Projet. Hors cause du
  cloisonnement ; ne pas mélanger les lots.

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

---

## A. LE MÉCANISME DE CLOISONNEMENT

### A1. Service / scope — parcelles

Le filtre sectoriel des **parcelles** n’est pas `mon_secteur` (confort
d’affichage). C’est le scope global R1 :

- `app/Models/Scopes/FoncierParcelleVisibiliteScope.php` L21-110
- alimenté par `FoncierParcelleVisibiliteService` (`codesInsee` L38-41
  → `SecteurResolutionService::codesInseePourUtilisateur`)
- branché sur `Parcelle` via `HasFoncierParcelleVisibilite::boot` L17-20

Extrait R1 (L40-47) :

    $q->where($table.'.createur_id', $userId);          // R1b
    if ($codes !== []) {
        $q->orWhereIn($table.'.code_insee_commune', $codes);  // R1a secteur
    }

Plus R1c (habilitation **dossier** portant la parcelle) et R1e (paquet
propriétaire). R1d / habilitation parcelle = usage, **pas** visibilité
(commentaire L16-17 du scope).

`secteur_context` (front `Prospection/Index.vue` L572-575) vient de
`ProspectionController::secteurContext` L608-618 :

    $codes = $this->secteurs->codesInseePourUtilisateur($user);
    $groupe = ParcelleProspectionQuery::groupeAccesTotalProspection($user);
    return [
        'nb_communes' => count($codes),
        'a_secteur' => $codes !== [],
        'vue_complete' => $groupe !== null,
        'acces_total_groupe' => $groupe,
    ];

`vue_complete` / `acces_total_groupe` = bandeau « Vue complète — vous
voyez toutes les parcelles (groupe …) ». C’est un **confort UI** par
dessus R1, pas le SQL du cloisonnement. Si `vue_complete`, le filtre
`mon_secteur` est sauté (prospection) ; le bypass R3 saute tout le
WHERE du scope.

### A2. Règle exacte (certitude : oui, lue)

**Titulaire OU suppléant**, pas titulaire seul.
`SecteurResolutionService::codesInseePourUtilisateur` L75-79 :

    ->where('actif', true)
    ->where(function ($q) use ($user): void {
        $q->where('titulaire_id', $user->id)
            ->orWhereHas('suppleants', fn ($sq) => $sq->where('users.id', $user->id));
    })

`utilisateurCouvreCommune` L132-147 : même union (titulaire puis
suppléants). `secteur_suppleants` vide aujourd’hui (mesure Robin) ⇒
en pratique titulaire seul, mais le code inclut les suppléants.

**`secteurs.actif` est pris en compte** : `where('actif', true)` sur
les secteurs de l’utilisateur (L76) **et** sur le `with(['secteur' =>
fn ($q) => $q->where('actif', true)…])` de `resolveParCodeInsee`
(L40, L57). Un secteur inactif ne suggère rien et n’alimente pas
les codes INSEE.

**Ordre de résolution commune → secteur** (`resolveParCodeInsee` L13-16,
L37-64) : 1) périmètre `type=commune` ; 2) sinon EPCI de
`communes.code_epci` ; 3) sinon `null`.

**Commune hors de tout secteur** (`resolveParCodeInsee` → `null`,
`utilisateurCouvreCommune` → `false`) :

- Personne n’obtient R1a (secteur) pour cette commune.
- La parcelle reste visible seulement par : groupes bypass R3, **R1b
  `parcelles.createur_id`**, R1c (dossier), R1e (paquet), pas « visible
  par tous ».
- Ce n’est **pas** lié uniquement à la géographie : la déduction du
  prompt (« PAS lié à `parcelles.createur_id` ») est **incomplète**.
  R1b existe (L41-42 du scope, L49-51 du service). Le secteur est le
  cran géographique ; le créateur est un cran parallèle.

### A3. Accès total — groupes et biais Robin

`ParcelleProspectionQuery::groupeAccesTotalProspection` L43-56 lit
**`m365GroupNames()` brut** (User L119-122), **pas**
`hasAnyM365Group()`. Ordre : `GS_ADMIN_ERP`, puis
`GS_DIRECTION_GENERALE`, puis `GS_GEMME_PDG`. **Pas GS_DSI.**

Aligné `RecordAcl::BYPASS_PARCELLE_VIEW_GROUPS` L76-80 :
`GS_DIRECTION_GENERALE`, `GS_GEMME_PDG`, `GS_ADMIN_ERP`. **Pas GS_DSI.**

`FoncierParcelleVisibiliteService::userBypassesParcelleView` L23-32 :
si un de ces groupes est dans `m365GroupNames()`, le scope **ne pose
aucun WHERE** (scope L30-32 : `return`).

Biais Robin (confirmé) :

- `User::m365GroupNames()` L119-122 : tableau `m365_groups` tel quel.
- `hasAnyM365Group()` L146-149 : **TRUE d’emblée si `isAdminERP()`**.
- `Gate::before` `AppServiceProvider` L69-75 : toutes les Gates true
  pour GS_ADMIN_ERP. Robin = **seul** membre. Tout test de permission
  avec son compte est faussement positif. Le bandeau « Vue complète
  (groupe GS_ADMIN_ERP) » est exactement ce bypass.

Les scopes Eloquent **n’utilisent pas** `hasAnyM365Group` ni Gate :
ils lisent `m365GroupNames()`. Robin voit quand même tout, parce que
`GS_ADMIN_ERP` est dans `BYPASS_PARCELLE_VIEW_GROUPS` et dans
`groupeAccesTotalProspection`.

### A4. Dossiers fonciers — PAS le même cloisonnement

`DossierFoncier` use `HasRecordHabilitations` (modèle L20) → scope
`RecordHabilitationScope` L16-44. **Aucun** appel à
`SecteurResolutionService` / `codesInsee` / `code_insee_commune` pour
l’ACL.

Règle dossier (défaut = invisible) :

1. `dossiers_fonciers.createur_id` = user
2. OU `record_habilitations` active (`lecture`|`gestion`, `retire_le` NULL)
3. OU bypass `RecordAcl::BYPASS_VIEW_GROUPS` L53-58 :
   `GS_DIRECTION_GENERALE`, `GS_GEMME_PDG`, **`GS_DSI`**, `GS_ADMIN_ERP`

**GS_DSI est dans le bypass dossiers, PAS dans le bypass parcelles.**
C’est l’asymétrie structurante de ce diagnostic.

`code_insee_commune` / `enseigne_id` existent sur le dossier (fiche,
nomenclature) : **non utilisés** pour filtrer qui voit le dossier.
`DossierFoncierPolicy::view` = `userCanViewRecord` (ACL), pas le
secteur.

---

## B. LE SÉLECTEUR #1505 — CŒUR

### B5. `searchDossiersProjet` applique-t-il le filtre sectoriel ?

**NON.**

Route : `GET /depot-factures/dossiers-fonciers/search`
(`routes/web.php` L327-328), groupe `auth` seulement (L187). **Pas**
`feature.permission:foncier.lecture`.

Contrôleur `DepotFactureController::searchDossiersProjet` L580-588 :
`abort_unless` user authentifié, puis
`DepotProjetDossierService::search`. **Aucun** secteur, **aucun**
`foncier.lecture`.

Service L29-60 :

    $q = DossierFoncier::query()
        ->with(['commune:code_insee,nom', 'proprietaires', …]);
    // LIKE reference / libelle / commune.nom / proprietaires.nom
    return $q->orderBy('dossiers_fonciers.reference')->limit($limit)->get()…

`DossierFoncier::query()` conserve `RecordHabilitationScope` (ACL
dossier). **Pas** `FoncierParcelleVisibiliteScope`. **Pas**
`codesInseePourUtilisateur`. `q` vide ⇒ jusqu’à 50 dossiers ACL-visibles
triés par référence (`DepotProjetDossierFields.vue` L74-82 envoie `q`
tel quel, y compris vide).

### B6. Show par id et postes

| Endpoint | Fichier | Secteur ? | Hors ACL dossier |
|----------|---------|-----------|------------------|
| `GET …/dossiers-fonciers/{dossierFoncier}` | `showDossierProjet` L594-603 | **NON** | Binding + `findSerialized` = même scope ACL → **404** si hors ACL. Bypass (dont GS_DSI) : **oui, n’importe quel id**. |
| `GET …/postes-budgetaires-types` | L609-615 | **NON** | Dossier déjà bindé ; postes = nomenclature de l’enseigne, pas un second filtre géographique. |
| `POST …/journaliser-selection` | L621-628 | **NON** | Même binding. |

Un utilisateur peut-il atteindre un dossier hors **secteur** par URL ?
Oui dès qu’il passe l’ACL dossier (créateur, habilité, ou bypass).
Le secteur n’est jamais consulté.

### B7. Dépôt ouvert à tous, sans `foncier.lecture`

Conséquence : tout utilisateur authentifié frappe le sélecteur.
Ce qu’il **voit** n’est pas « tous les dossiers du groupe » :

- GS_ENVOL (`!foncier.*`, #1504/#1505) / GS_COMPTABILITE (pas
  `foncier.*`) : seulement dossiers dont ils sont créateur ou
  habilités — en pratique souvent **vide**.
- GS_HECTARE (`foncier.lecture` + `gestion`) : même ACL dossier,
  **sans** bypass, **sans** secteur. Un HECTARE titulaire d’un
  secteur voit les **parcelles** de son périmètre (R1a) mais **pas**
  automatiquement les dossiers de ces communes (sauf créateur /
  habilitation).
- GS_DSI (`*` + bypass dossiers, **pas** bypass parcelles) : **tous**
  les dossiers, y compris Mudaison, alors que Prospection lui refuse
  les parcelles d’Eric (sauf R1b/c/e).
- GS_ADMIN_ERP / GS_DIRECTION_GENERALE / GS_GEMME_PDG : tous les
  dossiers (bypass). DG a `!foncier.*` : n’entre pas dans `/foncier/*`
  mais **entre** dans le sélecteur dépôt.

`foncier.lecture` mesuré (config) : GS_DSI (`*`), GS_GEMME_PDG, GS_HECTARE.
`foncier.gestion` : GS_DSI, GS_HECTARE. **GS_DSI ≠ GS_ADMIN_ERP.**

### B8. RGPD — SIGNALÉ (point le plus sensible)

Arbitrage #1504 option A, livré #1505 : format
`MUDAISON — MARTIN, BLANC (DF-2026-0003)` dans un sélecteur de **dépôt**,
journal `depot_select` à la sélection. Robin a accepté que les
**déposants** voient les noms des dossiers **qui leur sont proposés**.

Sans filtre sectoriel, « qui leur sont proposés » =

- pour un bypass (DSI, DG, GEMME_PDG, ADMIN) : **tous** les dossiers,
  donc **tous** les noms de propriétaires de **tous** les secteurs ;
- pour un HECTARE non habilité hors de ses dossiers : pas ces noms
  (ACL) — mais ce n’est **pas** le secteur qui les cache.

L’arbitrage A n’a **pas** dit « tous les secteurs ». L’exposition
actuelle pour les groupes bypass est **plus large** que le cloisonnement
parcelle que l’écran Foncier affiche à ces mêmes personnes (sauf ADMIN /
DG / GEMME qui voient déjà toutes les parcelles). **GS_DSI** est le cas
le plus net : pas de vue complète parcelles, vue complète dossiers +
noms dans le dépôt.

Journal : toujours à la **sélection**, pas à la recherche (#1505).
Taper « Mudaison » charge les noms dans le JSON **sans** ligne
`proprietaire_access_logs`. Conforme au lot #1505, aggravant si la
liste n’est pas sectorisée.

---

## C. RAPPROCHEMENT #1508

Même défaut de conception : un endpoint de **recherche** créé pour un
écran A **sans reprendre le scope du module** dont il lit les données.

| Endpoint | Scope attendu du module source | Scope réel |
|----------|--------------------------------|------------|
| `ChargePortageController::searchDepotFactures` L71-100 (`/foncier/charges-portage/depot-factures/search`) | `FactureVisibility` | **aucun** — `DepotFacture::query()` brut, LIKE + montants, `foncier.lecture` seulement. Fuite **active** #1447/#1508. |
| `DepotFactureController::searchDossiersProjet` L580 + service L32 | si on attend le cloisonnement **Foncier géographique** : R1 / codes INSEE | **NON** — ACL dossier seulement, pas de secteur, pas de `foncier.lecture` |
| `ActeFoncierController::search` (#1510) | ACL acte (`userPeutVoirActe`) | `foncier.lecture` + filtre ACL acte. **Pas** de secteur (les actes ne sont pas R1). Hors sujet géographique. |
| `*:parcelles.search` (dossiers, promesses, scénarios, actes) | R1 parcelle | **conservé** (`Parcelle::query()`, commentaire #1288 Promesse L197). |
| `dossiers.proprietaires.search` | scope #1116 propriétaires | `Proprietaire::query()` — scope référentiel **conservé**. |
| Prefills `queryWithoutReferentielAcl` / `withoutGlobalScope(RecordHabilitationScope)` (Acte L528, Offre, Promesse, …) | — | **saut volontaire** sur des **ids déjà connus**, pas une recherche ouverte. |

Le sélecteur #1505 est le jumeau « dépôt → dossiers » de la fuite
#1508 « foncier → dépôts » : dans les deux cas on a ouvert une
recherche transversale. Différence : #1508 n’a **aucun** scope métier
sur les factures ; #1505 a l’ACL **dossier** (donc pas un dump total
pour GS_HECTARE / GS_ENVOL). Ce n’est pas « pas de filtre du tout ».
C’est **le mauvais filtre** si l’attente est le secteur.

---

## D. AMPLEUR ET CONSÉQUENCES

### D10. Qui verrait des dossiers hors de son secteur aujourd’hui ?

Aucun chiffre ici. Requête E2. Qualitativement :

- **Tous** les membres des groupes bypass dossiers (ADMIN, DG,
  GEMME_PDG, **DSI**) voient **tous** les dossiers, y compris Mudaison
  (secteur Eric), y compris avec les noms.
- Plus : créateur ou habilité d’un dossier dont la commune n’est pas
  dans *ses* secteurs (titulaire/suppléant).
- Un GS_HECTARE **sans** ces crans, **sans** être Eric, **ne devrait
  pas** voir les dossiers Mudaison. Si Robin confirme que le saisisseur
  est dans ce cas, l’ACL dossier est cassée ou le compte a une
  habilitation / un second groupe — **à mesurer**, ne pas inventer.

Deux cas demandés :

1. Saisisseur = bypass (DSI / DG / GEMME / ADMIN, ou Robin qui
   « montre ») : le sélecteur **a fait ce que le code ACL dicte**. Ce
   n’est pas un trou SQL ; c’est l’absence de secteur sur un écran
   dépôt. Le bandeau Prospection de Robin ne prouve rien.
2. Saisisseur = HECTARE hors secteur Eric, hors habilitation : alors
   **oui**, fuite inattendue — le code lu **ne le prévoit pas**. Relire
   `m365_groups` et `record_habilitations` de ce user (E2) avant tout
   fix.

### D11. Redondance secteur 1 (Clapiers commune + EPCI 243400017)

**Inoffensive pour le cloisonnement.** Unique DB
(`secteur_perim_commune_unique`, `secteur_perim_epci_unique`).
`codesInseePourUtilisateur` déduplique par `$codes[$c] = true` puis
`array_keys` (L92-126). Le scope fait `whereIn`, **pas** un JOIN des
deux types de périmètre. Pas de doublon de lignes parcelle. L’UI
secteurs peut signaler une exception redondante (`contreditEpciLocal`)
: affichage seulement.

### D12. Commune hors de tout secteur

Aucune des 5 communes mesurées n’est dans ce cas (mesure Robin).
Règle code : `resolveParCodeInsee` → `null` → pas de R1a. Visible
uniquement via bypass / créateur / dossier / paquet. **Pas** visible
par tous. Un dossier dont `code_insee_commune` est NULL ou hors
périmètre : le sélecteur le liste quand même s’il passe l’ACL
(libellé `SANS COMMUNE` si nom vide, service L181-183).

---

## RÉPONSE BINAIRE

**Le sélecteur n’applique pas le filtre sectoriel. NON.**

Il applique `RecordHabilitationScope` (créateur ∪ habilitations ∪
bypass dossiers). Le cloisonnement géographique (titulaire ∪
suppléants, `actif`, codes INSEE) est **réservé aux parcelles**.

---

## POINT DE RUPTURE (une phrase)

`DepotProjetDossierService::search` (L32) interroge `DossierFoncier`
sous ACL d’habilitation, alors que le cloisonnement Foncier que
l’utilisateur connaît à l’écran Parcelles est
`FoncierParcelleVisibiliteScope` (R1a codes INSEE) — deux règles,
deux tables, le dépôt n’a branché que la première.

---

## PHASE 2 — découpage proposé (ne pas faire ici)

Ne pas coller au fix d’écriture #1511. Lots séparés.

### Décision préalable (Robin / DPO) — obligatoire avant code

Le sélecteur dépôt doit-il lister :

- (S1) comme aujourd’hui (ACL dossier seulement) — documenter que
  GS_DSI / DG / GEMME / ADMIN voient tous les noms, tous secteurs ;
- (S2) ACL dossier **ET** commune du dossier ∈ codes INSEE de
  l’utilisateur (sauf bypass parcelle ? ou bypass dossier ?) ;
- (S3) restreindre le bouton Projet à `foncier.lecture` (#1504 option C,
  contredit « ouvert à tous ») ;
- (S4) libellé sans nom hors secteur / hors `foncier.lecture`.

Un cloisonnement mal calqué (appliquer R1a aux dossiers **sans**
trancher le bypass DSI et le dépôt ouvert à ENVOL/COMPTA) donnerait
l’illusion d’une protection : ENVOL verrait 0 dossier (déjà le cas
ACL) ; DSI continuerait à tout voir si on réutilise
`BYPASS_VIEW_GROUPS`.

### Lots possibles après arbitrage

1. **Identité du saisisseur** (mesure, pas de code) — SQL E2. Trancher
   cas 1 vs cas 2 ci-dessus.
2. Si S2 : filtre `code_insee_commune` ∈ `codesInseePourUtilisateur`
   dans `DepotProjetDossierService::search` / `findSerialized` /
   binding show+postes. **Uniquement** ces trois endpoints dépôt.
   Ne pas poser le secteur en global scope sur `DossierFoncier`
   (casserait la liste dossiers pour un titulaire non créateur).
3. RGPD : si S2 n’est pas choisi, au minimum journaliser ou masquer
   les noms hors secteur — nouvel arbitrage DPO, pas un silent fix.
4. Ne pas toucher `valider()` ici (#1511). Ne pas toucher
   `ChargePortageController::searchDepotFactures` ici (#1508 lot 2b).
5. Ne pas toucher `Parcelles/Show.vue`, `FactureVisibility`,
   `DepotProjetDossierFields.vue` hors nécessité du filtre.

Clause d’arrêt du prompt : la règle parcelle **et** la règle dossier
sont établies avec certitude. Pas d’arrêt. Le trou est
l’**absence d’application** du secteur au sélecteur, pas une règle
illisible.

---

## SQL POUR ROBIN (aucun chiffre)

Noms canoniques respectés. Colonnes qualifiées. Pas de
`Facture::depotFacture()`. `only_full_group_by` : pas de GROUP BY
d’expression.

### E1. Chaque dossier : commune, EPCI, secteur couvrant, titulaire

Résolution alignée sur le code : périmètre commune d’abord, sinon EPCI,
secteurs `actif = 1`.

    SELECT
      d.id AS dossier_id,
      d.reference,
      d.enseigne_id,
      d.code_insee_commune,
      c.nom AS commune_nom,
      c.code_epci,
      c.nom_epci,
      COALESCE(s_c.id, s_e.id) AS secteur_id,
      COALESCE(s_c.libelle, s_e.libelle) AS secteur_libelle,
      COALESCE(s_c.titulaire_id, s_e.titulaire_id) AS titulaire_id,
      u.name AS titulaire_nom,
      CASE
        WHEN s_c.id IS NOT NULL THEN 'commune'
        WHEN s_e.id IS NOT NULL THEN 'epci'
        ELSE NULL
      END AS secteur_source
    FROM dossiers_fonciers AS d
    LEFT JOIN communes AS c
      ON c.code_insee = d.code_insee_commune
    LEFT JOIN secteur_perimetres AS sp_c
      ON sp_c.type = 'commune'
     AND sp_c.code_insee_commune = d.code_insee_commune
    LEFT JOIN secteurs AS s_c
      ON s_c.id = sp_c.secteur_id
     AND s_c.actif = 1
    LEFT JOIN secteur_perimetres AS sp_e
      ON sp_e.type = 'epci'
     AND sp_e.code_epci = c.code_epci
    LEFT JOIN secteurs AS s_e
      ON s_e.id = sp_e.secteur_id
     AND s_e.actif = 1
    LEFT JOIN users AS u
      ON u.id = COALESCE(s_c.titulaire_id, s_e.titulaire_id)
    ORDER BY d.id;

### E2. Qui voit chaque dossier (règle ACL du sélecteur) vs secteur

Bypass `m365GroupNames` ∈ GS_ADMIN_ERP, GS_DIRECTION_GENERALE,
GS_GEMME_PDG, GS_DSI — **brut**, pas `hasAnyM365Group`. Plus créateur
et habilitations actives. Colonne `couvre_secteur` = titulaire ou
suppléant du secteur résolu en E1.

    SELECT
      d.id AS dossier_id,
      d.reference,
      d.code_insee_commune,
      vu.user_id,
      vu.user_name,
      vu.user_email,
      vu.via,
      CASE
        WHEN EXISTS (
          SELECT 1
          FROM secteurs AS s
          LEFT JOIN secteur_suppleants AS ss
            ON ss.secteur_id = s.id
           AND ss.user_id = vu.user_id
          WHERE s.actif = 1
            AND (s.titulaire_id = vu.user_id OR ss.user_id IS NOT NULL)
            AND (
              EXISTS (
                SELECT 1 FROM secteur_perimetres AS sp
                WHERE sp.secteur_id = s.id
                  AND sp.type = 'commune'
                  AND sp.code_insee_commune = d.code_insee_commune
              )
              OR EXISTS (
                SELECT 1
                FROM communes AS c2
                INNER JOIN secteur_perimetres AS sp2
                  ON sp2.type = 'epci'
                 AND sp2.code_epci = c2.code_epci
                 AND sp2.secteur_id = s.id
                WHERE c2.code_insee = d.code_insee_commune
                  AND NOT EXISTS (
                    SELECT 1 FROM secteur_perimetres AS spx
                    INNER JOIN secteurs AS sx ON sx.id = spx.secteur_id AND sx.actif = 1
                    WHERE spx.type = 'commune'
                      AND spx.code_insee_commune = d.code_insee_commune
                      AND sx.id <> s.id
                  )
              )
            )
        ) THEN 1 ELSE 0
      END AS couvre_secteur
    FROM dossiers_fonciers AS d
    INNER JOIN (
      SELECT
        d2.id AS dossier_id,
        u.id AS user_id,
        u.name AS user_name,
        u.email AS user_email,
        'bypass_m365' AS via
      FROM dossiers_fonciers AS d2
      INNER JOIN users AS u ON 1 = 1
      WHERE JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_ADMIN_ERP'))
         OR JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_DIRECTION_GENERALE'))
         OR JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_GEMME_PDG'))
         OR JSON_CONTAINS(u.m365_groups, JSON_QUOTE('GS_DSI'))
      UNION ALL
      SELECT
        d2.id, u.id, u.name, u.email, 'createur'
      FROM dossiers_fonciers AS d2
      INNER JOIN users AS u ON u.id = d2.createur_id
      UNION ALL
      SELECT
        rh.habilitable_id, u.id, u.name, u.email, CONCAT('habilitation:', rh.droit)
      FROM record_habilitations AS rh
      INNER JOIN users AS u ON u.id = rh.user_id
      INNER JOIN dossiers_fonciers AS d2 ON d2.id = rh.habilitable_id
      WHERE rh.retire_le IS NULL
        AND rh.droit IN ('lecture', 'gestion')
        AND rh.habilitable_type IN (
          'App\\Models\\DossierFoncier',
          'dossiers_fonciers'
        )
    ) AS vu ON vu.dossier_id = d.id
    ORDER BY d.id, vu.user_id;

Ajuster `habilitable_type` sur une ligne réelle (`SELECT DISTINCT
habilitable_type FROM record_habilitations LIMIT 20`) : le morph
Laravel peut être le FQCN. Lignes `couvre_secteur = 0` = dossiers
hors secteur de cet utilisateur **quand même** listés par le
sélecteur.

Pour le saisisseur observé (quand l’id sera connu) : filtrer
`vu.user_id = <id>` et `d.code_insee_commune = '34176'`.

### E3. Communes de dossiers / parcelles hors de tout périmètre actif

    SELECT
      'dossier' AS source,
      d.id AS objet_id,
      d.code_insee_commune,
      c.nom AS commune_nom,
      c.code_epci
    FROM dossiers_fonciers AS d
    LEFT JOIN communes AS c ON c.code_insee = d.code_insee_commune
    WHERE d.code_insee_commune IS NULL
       OR (
         NOT EXISTS (
           SELECT 1
           FROM secteur_perimetres AS sp
           INNER JOIN secteurs AS s ON s.id = sp.secteur_id AND s.actif = 1
           WHERE sp.type = 'commune'
             AND sp.code_insee_commune = d.code_insee_commune
         )
         AND NOT EXISTS (
           SELECT 1
           FROM secteur_perimetres AS sp
           INNER JOIN secteurs AS s ON s.id = sp.secteur_id AND s.actif = 1
           WHERE sp.type = 'epci'
             AND sp.code_epci = c.code_epci
         )
       )
    UNION ALL
    SELECT
      'parcelle',
      p.id,
      p.code_insee_commune,
      c.nom,
      c.code_epci
    FROM parcelles AS p
    LEFT JOIN communes AS c ON c.code_insee = p.code_insee_commune
    WHERE p.deleted_at IS NULL
      AND (
        p.code_insee_commune IS NULL
        OR (
          NOT EXISTS (
            SELECT 1
            FROM secteur_perimetres AS sp
            INNER JOIN secteurs AS s ON s.id = sp.secteur_id AND s.actif = 1
            WHERE sp.type = 'commune'
              AND sp.code_insee_commune = p.code_insee_commune
          )
          AND NOT EXISTS (
            SELECT 1
            FROM secteur_perimetres AS sp
            INNER JOIN secteurs AS s ON s.id = sp.secteur_id AND s.actif = 1
            WHERE sp.type = 'epci'
              AND sp.code_epci = c.code_epci
          )
        )
      )
    ORDER BY source, objet_id;

Attendu si la mesure Robin tient : 0 ligne parmi les 5 communes
citées ; d’éventuelles lignes = communes hors référentiel secteurs
(règle D12).

---

## CE QUI N’A PAS PU ÊTRE ÉTABLI

- Identité du saisisseur (Robin ne l’a pas confirmée).
- `m365_groups` / habilitations de ce compte.
- Morph exact `record_habilitations.habilitable_type` en base.
- Nombre d’utilisateurs bypass et d’habilitations transverses.
- Overrides `feature_permissions` par utilisateur (hors
  `default_mappings`).
- sqlite skipped : **aucune preuve de droits**.

## DÉPLOIEMENT-TEST

Fiche seule. Après push :

    git pull origin main

Pas de migrate, pas de `journal:sync`, pas de build, pas de
`route:clear`. Aucun PHP applicatif modifié.

Vérification : exécuter E1–E3 en prod ; **ne pas** valider des droits
avec le compte Robin.

## LEÇON

Le cloisonnement Foncier que l’écran Parcelles affiche n’est pas un
scope unique du module : parcelles = géographie (R1a) ∪ créateur ∪
dossier ; dossiers = créateur ∪ habilitation ∪ bypass **avec GS_DSI**.
Un sélecteur « dossiers » branché depuis le dépôt hérite de la
seconde règle, pas de la première. #1504 a tranché l’audience des
**noms**, pas le **périmètre géographique**. #1508 a déjà nommé le
patron : recherche transversale sans le scope du module source.
GS_DSI voit les dossiers Mudaison dans le dépôt et pas les parcelles
Mudaison dans Prospection — c’est cohérent avec le code, pas avec
l’attente métier « secteur ».
