---
suivi: 1508
date: 2026-09-10
sujet: Diagnostic visibilité factures destination PROJET
chantier: factures / depot / destination-projet
type: diagnostic
statut: poussé
hash: 25613cd3
fichiers:
  - docs/suivi/SUIVI_1508_diag_visibilite_factures_projet.md
---

# SUIVI #1508 — PHASE 1 diagnostic : visibilité des factures PROJET

## PROMPT ENVOYÉ

Étape 4/5 du chantier PROJET : diagnostic seul de la visibilité. Une
facture destination PROJET ne doit être visible que par le valideur NV1,
le déposant, GS_DIRECTION_GENERALE, GS_COMPTABILITE, GS_HECTARE,
GS_ADMIN_ERP — partout (listes, recherches, exports, widgets,
notifications). Le dépôt reste ouvert à tous ; la consultation est
restreinte. Aujourd’hui la règle n’est pas implémentée. Recensement
exhaustif des points d’entrée, lecture de FactureVisibility, sources
déposant / NV1, risques de blocage workflow, SQL pour Robin, découpage
phase 2. Aucun code applicatif.

## BASE GIT

HEAD local = `origin/main` = `a7dd467f` (chore hash fiche #1507).
Le prompt indiquait HEAD prod = `d36d3bb8` (fiche #1506) : **périmé**.
#1507 (onglet Projet + bloc dossier fiche) est **déjà sur main**, pas
« en cours ». Ce lot ne touche ni `Global.vue` ni `FactureGlobaleListeQuery`
ni `FactureGlobaleController`.

Aucun PHP applicatif modifié. Aucune migration. Aucun script. Aucun
runtime PHP installé. Aucun navigateur. Aucun chiffre mesuré (MySQL
127.0.0.1:3306 inaccessible depuis Cursor).

## FICHES LUES

- **#1428** — **ABSENTE du disque** (`docs/suivi/SUIVI_1428*` introuvable,
  comme #1493). Les quatre fuites sont reconstituées depuis **#1410
  LIMITES CONNUES** (panel programme, marché, CIE, recherche foncier)
  et **#1447** (`ChargePortageController::searchDepotFactures` L71-122
  toujours ouvert). Impact : le recensement des quatre fuites s’appuie
  sur le code actuel + #1410, pas sur le texte original de l’audit.
- **#1410 LEÇON** — un verrou dans FactureVisibility ne couvre pas
  programme / marché / CIE / foncier. Ne jamais brancher un filtre de
  visibilité dans les agrégats sans les lister. Impact : la règle PROJET
  dans FV seul laisserait ces quatre points inchangés.
- **#1492 LEÇON** — ancrer sur le type de nomenclature, pas la ligne
  `postes_budgetaires`. Identifiant Projet retenu pour ce diagnostic :
  `depot_factures.dossier_foncier_id IS NOT NULL` (aligné #1507), pas le
  JSON `ocr_raw.saisie.destination`.
- **#1505 LEÇON** — identifier n’est pas consulter ; le dépôt est ouvert
  à tous. Impact : la restriction vise la **consultation**, pas le POST
  de dépôt. Un déposant hors groupes voit encore **sa** facture.
- **#1506** — fix AutoComplete (saisie). Hors visibilité.
- **#1504** — arrêt RGPD sélecteur (noms propriétaires). Hors visibilité
  consultation facture ; le sélecteur de **dépôt** reste ouvert.
- **#1483 LEÇON** — une clé de groupement doit produire le libellé ;
  clic tableau de bord vers un écran 403 n’est pas un tableau de bord.
  Impact : le TDB compta n’utilise **pas** FactureVisibility (accès
  page = `ComptaTableauBordAcces`).
- **#1485 LEÇON** — badge ≠ filtre. Impact : ne pas « cacher » Projet
  en retirant un onglet ; filtrer en couche données.
- **#1486 LEÇON** — NV2 = `programmes.enseigne_id` → `enseignes.code =
  ENVOL`. Pas `entites.enseigne_id`. Un `programme_id` NULL (cas Projet
  / FG) **ne passe pas en NV2**.
- **#1461** — `JSON_SET` + `JSON_QUOTE` a double-encodé
  `ocr_raw.saisie.validateur_id` ; `json_unquote` dans applyScope est le
  contre-feu. Historique `valide_nv1` incomplet (454 validations NV1
  sans entrée, rappel prompt — non remesuré ici).
- **#1447** — fuite `searchDepotFactures` documentée, non colmatée.
- **#1478** — TDB compta : `m365GroupNames()` brut ; GS_DSI volontairement
  absent de cet écran. Test source affirme l’absence d’`applyScope`.

## SYNTHÈSE

La règle PROJET **n’existe nulle part** dans `FactureVisibility`. Une
facture Projet est aujourd’hui traitée comme une facture **sans
programme** : invisible via le cran enseigne (ENVOL/HECTARE), visible
par le socle total (y compris **GS_DSI**, absent de la liste Robin),
par le déposant (`depot_factures.user_id`), par le valideur désigné
(NV1 **et NV2**), et — sur la **fiche** seulement — par quiconque a
`factures.lecture` (dont GS_ENVOL via `*.lecture`). Quatre listes
hors FV (#1410) restent des passoires ; une seule d’entre elles
restitue aujourd’hui des factures Projet (`searchDepotFactures`).

Insérer la règle dans FV est possible, mais **pas** comme un OR de plus :
c’est un **AND** qui doit aussi s’appliquer au socle (pour exclure
GS_DSI) et à `userCanViewFacturePage` (pour fermer `factures.lecture`).
Un lot unique qui toucherait tous les écrans reproduirait les sept
précédents « filtre écrit, jamais exécuté ».

---

## A. RECENSEMENT DES POINTS D’ENTRÉE

Verdicts : **FV** = applique `FactureVisibility` (`applyScope` / `canView`
/ `canViewFacture` / `userCanViewFacturePage`) ; **pas FV** ; **N/A** ;
**à vérifier**.

« Applique FV » ≠ « applique la règle PROJET ». Aucun point n’applique
la règle PROJET aujourd’hui. Le verdict FV décrit seulement le mécanisme
existant.

Identifiant Projet utilisé ci-dessous : `depot_factures.dossier_foncier_id
IS NOT NULL` (#1507). `ocr_raw.saisie.destination = PROJET` n’est pas
la clé de liste.

### A1. Liste globale `/factures` et onglets

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Liste (Tous, Projet, Programmes, FG, Avoirs, En dépôt, Autres) | `FactureGlobaleListeQuery::baseQuery` L49-51 | `GET /factures` (`factures.globale`) | **FV** — `applyScope` L51. Onglets = filtres métier **après** le scope. L’onglet Projet (#1507) élargit l’**affichage** des lignes déjà visibles, il ne restreint pas. |
| Compteurs d’onglets | même fichier L583-584 | idem | **FV** — `applyScope` sur `$base` |
| Options de filtres | même fichier L431-432 | idem | **FV** |
| Porte d’entrée page | `FactureGlobaleController::index` L179-183 | idem | **FV** — `userCanAccessFacturesPage` (permission `factures.lecture` **OU** au moins un dépôt visible). Un GS_ENVOL entre par `*.lecture` même s’il ne devrait voir aucune Projet. |
| Actions de masse (valider, supprimer, ZIP, attributer, virement) | `FactureGlobaleController` L240-244 et jumeaux | `POST /factures/*` | **FV** — `applyScope` sur le `whereHas('depotFacture')` |

### A2. Recherche de la liste globale

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| `FactureGlobaleRechercheFilter::apply` | L19-36 | query `global` sur `GET /factures` | **FV (hérité)** — le filtre LIKE s’applique **sur** la query déjà scopée (`FactureGlobaleListeQuery` L353-365 après L51). Pas de requête parallèle. Une recherche ne contourne pas applyScope. |

### A3. Fiches facture / dépôt

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Fiche `/factures/fiche/{id}` | `FactureController::showFiche` L473-476 + `abortUnlessCanViewFacture` L621-626 | `GET /factures/fiche/{facture}` | **FV** — `userCanViewFacturePage`. ⚠️ si `factures.lecture` : **true** sauf verrou (#1410). GS_ENVOL (`*.lecture`) peut ouvrir une Projet **par URL**. |
| Fiche `/programmes/{id}/factures/{id}` | `FactureController::show` L465-468 | même abort | **FV** — même porte. |
| Fiche dépôt `/depot-factures/{uuid}` | `DepotFactureController::findDepotForView` L4396-4411 | `GET /depot-factures/{uuid}` | **FV** — `canView` (pas `userCanViewFacturePage`). Cran `factures.lecture` **absent** ici. |
| JSON data / PDF dépôt | `data` L646, `pdf` L2932-2938 | `GET .../data`, `GET .../pdf` | **FV** — même `canView`. |
| Commentaires | `FactureCommentaireController` L92 | `POST/PATCH/DELETE /factures/fiche/{id}/commentaires` | **FV** — `userCanViewFacturePage`. |
| Recherche Graph (réattribution) | `GraphUserSearchController` L27 | lié à une facture | **FV** — `userCanViewFacturePage`. |
| Documents joints | `DepotFactureDocumentAccess::canManage` L11 | `/depot-factures/{uuid}/documents/*` | **FV** — `canView`. |
| Avoirs proposables | `DepotFactureController::avoirsProposables` L1259 + `ApplicationAvoirDepotService` | `GET .../avoirs-proposables` | **FV sur le dépôt courant** ; la **liste des avoirs** renvoyée **n’applique pas FV** (`ApplicationAvoirDepotService` sans import). **À vérifier** si un avoir Projet peut y figurer. |

### A4. Comptabilisation `/factures/comptabilisation`

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Liste éligibles | `ComptabilisationController::index` L159 | `GET /factures/comptabilisation` | **pas FV**. Porte : `ensureComptabilisationUser` L91-104 = `hasAnyM365Group(['GS_COMPTABILITE','GS_ADMIN_ERP','GS_DSI'])`. ⚠️ `hasAnyM365Group` = TRUE d’emblée si `isAdminERP()` (User L146-149). **GS_DSI voit toutes les Projet** à comptabiliser. |
| Fiche data / update / envoyer Intacct | `findDepotComptabilisation` L127-131 | `GET/PATCH/POST .../{uuid}` | **pas FV** — lookup UUID brut après la porte de groupe. |

### A5. Mise en paiement `/factures/mise-en-paiement`

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Page + `/lignes` | `PaiementController::index` L32 / `lignes` L88 ; `PaiementLignesPayablesService::eligibleFacturesQuery` L255 | `GET /factures/mise-en-paiement`, `GET .../lignes` | **pas FV**. Porte L198-214 : GS_DSI, GS_COMPTABILITE, GS_ADMIN_ERP, GS_DIRECTION_GENERALE via `hasAnyM365Group`. Toutes les factures `comptabilisee` éligibles, y compris Projet. **GS_DSI** encore dans la porte. |

### A6. Tableau de bord compta `/factures/tableau-de-bord`

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Accès page | `ComptaTableauBordController` L29 ; `ComptaTableauBordAcces` L19-23 | `GET /factures/tableau-de-bord` | **pas FV**. Groupes : GS_COMPTABILITE, GS_DIRECTION_GENERALE, GS_ADMIN_ERP via `m365GroupNames()` brut. **GS_DSI volontairement absent** (#1478). Audience ⊂ liste Robin (HECTARE n’a pas cet écran). |
| Cartes, blocages, pipeline, échues | `ComptaTableauBordService` L235-260 et suivants | idem | **pas FV** — agrégats sur **toutes** les factures (filtre entité seulement). Test `ComptaTableauBord1478SourceTest` **affirme** l’absence d’`applyScope`. Inclut les montants Projet dans les totaux. Pour les groupes autorisés de cet écran, c’est **conforme à la liste Robin**. Ce n’est pas un filtre par facture. |

### A7–A10. Les quatre fuites #1428 / #1410

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Panel factures d’un programme | `FactureController::indexProps` L100-113 | `GET /programmes/{programme}/factures` | **pas FV**. `where('programme_id', $programme->id)` sans scope. **Pour Projet typique** (`programme_id` NULL) : **N/A** — la ligne n’apparaît pas. **Fuite active** si un dépôt Projet a un `programme_id` (pollution FG, #1467). |
| Liste factures d’un marché | `MarcheController::facturesDepotPourMarche` L2302-2306 | fiche marché (plusieurs `Inertia::render` L867, L1756, L1828) | **pas FV**. `pourMarche($marche->id)`. **N/A Projet typique** (pas de marché). Fuite si `marche_id` renseigné. |
| Sélecteur CIE | `CieController::depotFacturesOptions` L477-480 | payload CIE `index` L62 / `show` L133 | **pas FV**. Filtre `programme_id`. Porte CIE : GS_ENVOL + socle (`AuthorizesCieAccess` L12-17, `hasAnyM365Group`). **N/A Projet typique**. Fuite si `programme_id` posé. Montants aussi via `CieService::resolveFactureCoutPourOperation` L802 — **pas FV**. |
| Recherche foncier dépôts | `ChargePortageController::searchDepotFactures` L71-100 | `GET /foncier/charges-portage/depot-factures/search` | **pas FV**. **FUITE ACTIVE PROJET** : LIKE sur tous les dépôts (n°, fournisseur, montants), limite 30, permission `foncier.lecture` seulement. Audience : GS_HECTARE (dans la liste Robin), **GS_GEMME_PDG** (hors liste), GS_DSI / GS_ADMIN_ERP (`*`). GS_ENVOL a `!foncier.*` → n’atteint pas cette route. Commentaire UI `Parcelles/Show.vue` L417 : fuite toujours ouverte. |

### A11. Écran de dépôt et liste des dépôts

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| POST dépôt (tout utilisateur auth) | `DepotFactureController::store` L185 | `POST /depot-factures` | **N/A visibilité** — décision Robin : dépôt ouvert à tous (#1505). |
| Liste boîte `/depot-factures` | `buildBoiteDepotListeQuery` L3297-3300 | `GET /depot-factures`, `GET /depot-factures/liste` | **FV** — `visiblePar($user)` = `applyScope` (`DepotFacture` L303-305). `peutVoirTousLesDepotsBoite` L3178-3180 = `hasSocleTotalAccess` (donc **GS_DSI** voit toutes les Projet dans la boîte). |
| Widget dépôt dashboard | `WidgetDataService::depotFacture` L126 | payload dashboard | **N/A** — formulaire de dépôt (`can_deposit: true`), pas une liste de factures. |

### A12. Widgets, tâches, cloche

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| Widget « à valider » NV1/NV2 | `WidgetDataService::facturesAValiderNvRows` L238-275 | dashboard | **FV post-filtre** — requête `workflowValidations.validateur_id = user` puis `canViewFacture`. Tampon 50. |
| Widget « à compléter » | `depotsACompleterRows` L327-359 | dashboard | **pas FV**. Visibilité = `tasks.assignee_id`. Cohérent si l’assigné est le déposant ; **à vérifier** si un assigné hors liste Robin existe sur un dépôt Projet. |
| Widget factures bloquées | `facturesBloquees` L418-453 | dashboard | **FV post-filtre** `canViewFacture`. |
| Mes tâches (contexte facture) | `MesTachesController` L91-108 | `/mes-taches` | **pas FV**. Charge `Facture` par `erp_context.facture_id` (référence, fournisseur, montant TTC, programme). Fuite si la tâche est assignée à quelqu’un hors allowlist Projet. |
| Badge nav `factures_en_attente_count` | `HandleInertiaRequests::computeFacturesEnAttenteCount` L344-372 | partagé Inertia | **pas FV**. Compte NV1 assignées + tout NV2 si `GS_PDG_ENVOL` + `mise_en_paiement` si COMPTA. Pas de détail facture (existence seulement). Projet skip NV2 aujourd’hui → le cran PDG_ENVOL est **N/A Projet**. |
| Liste `/validations` | `ValidationController::buildFacturesForUser` L159-237 | `/validations` | **pas FV**. Éligibilité métier (NV1 `validateur_id`, rôles programme, PDG toutes NV2, comptable `mise_paiement`). Admin : `buildToutesFacturesAttente`. Pour Projet (`programme_id` NULL) les chemins conducteur/commercial ne matchent pas. |

### A13. Exports

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| ZIP PDF liste globale | `FacturePdfZipService` L104 | `POST /factures/telecharger-pdf-zip` | **FV** — `applyScope`. |
| Grille de prix (#1494/#1499) | `GrillePrixExportService` | exports commercial | **N/A** — aucun `facture` / `depot_facture` dans le service. |
| Autre export CSV/XLSX factures | grep contrôleurs `*Facture*` | — | **Aucun export tableur factures trouvé** hors ZIP. **À vérifier** côté outils métier non grepés (SharePoint, Intacct). |

### A14. Aktor / assistant IA

| Point | Fichier + ligne | Verdict |
|-------|-----------------|---------|
| `AideVisibiliteService` | FAQ articles par groupe M365 | **N/A factures** — anti-fuite FAQ, pas de query `DepotFacture`. |
| `AssistantConsolidationTools` | `lister_taches_ouvertes`, `lire_tache`, `chercher_historique` | **N/A** — liste fermée, pas d’outil facture. Journal filtré M365. |
| Résidu | description d’une `taches_suivi` / PJ collée dans le chat | **À vérifier** — le modèle peut restituer un n° collé par l’utilisateur, pas un dump ERP. |

### A15. API JSON / recherches

| Point | Fichier + ligne | Route | Verdict |
|-------|-----------------|-------|---------|
| `routes/api.php` | — | — | **N/A** — aucune route facture. |
| Recherche dossiers Projet (saisie) | `DepotFactureController::searchDossiersProjet` | `GET /depot-factures/dossiers-fonciers/search` | **N/A facture** — dossiers, pas dépôts. Ouvert aux déposants (#1505). |
| Recherche foncier dépôts | voir A10 | — | **pas FV** (fuite). |

### A16. Notifications Teams / e-mail / cloche

| Point | Fichier + ligne | Verdict |
|-------|-----------------|---------|
| `NotificationFactureService` | L20-90 NV1, L93 NV2, L138 compta | E-mail **désactivé** (`Log::info('Email désactivé…')`). Le service **assigne** encore `workflow_validations.validateur_id` et upsert les tâches NV1. **Pas d’envoi Teams** dans ce fichier. Corps HTML (fournisseur + montant) **jamais envoyé tant que l’e-mail reste off**. Si réactivation : destinataire = valideur / rôle programme / GS_COMPTABILITE — pour Projet le fallback rôle programme est vide (`programme_id` NULL). |
| Cloche `AlerteService::notifierValidateurNv1SauteeDejaReglee` L814 | destinataire = le valideur passé en argument | **N/A restriction groupes** — ciblé personne. Mentionne référence + fournisseur + montant. |
| Cloche facture réglée à comptabiliser | `groupe_cible` GS_COMPTABILITE L800 | GS_COMPTABILITE ∈ liste Robin. |
| Teams factures | pas d’appel `TeamsNotificationService` depuis `NotificationFactureService` | **N/A aujourd’hui**. Mentions `@` : `MentionNotificationDispatcher` = no-op Teams (#446). |

### A17. Autres surfaces (hors liste minimale du prompt)

| Point | Fichier + ligne | Verdict |
|-------|-----------------|---------|
| Super-admin factures | `FactureSuperAdminController::ensureSuperAdminUser` L269-274 | **pas FV**. Porte GS_DSI **ou** GS_ADMIN_ERP (`hasAnyM365Group`). **GS_DSI voit les Projet**. Recherche `FactureSuperAdminCorrectionService::rechercherFactures` sans scope. |
| Trésorerie programme | `TresorerieService::chargerDepotsProgramme` L172 | **pas FV**. Agrégat `pourProgramme`. **N/A Projet typique**. |
| Bilan financier | `BilanFinancierService` SUM `depot_factures.montant_ht` par marché | **pas FV**. Agrégat. **N/A Projet typique**. |
| Synthèse marché | `MarcheSyntheseFinanciereService` L360, L552, L681 | **pas FV**. Agrégat. |
| KPI marché / programme | `MarcheController::sommeMontantHtDepotsProgramme` L2382 | **pas FV**. |
| Avancement sous-traitant | `MontantFactureSousTraitantService` L609-611 | **FV** sur les **lignes détaillées** (`canView` / `canViewFacture`) ; les **totaux** amont (L156, L789) **sans FV**. Compteur `non_visibles` (#1409). |
| Poste budgétaire dossier « utilisé par une facture » | `DossierFoncierPosteBudgetaireService::posteUtiliseParFacture` L262 | **N/A restitution** — `exists()` booléen, pas de liste. |
| Sync Sage / rapprochement | `FactureSyncPaiementsSageController` | **à vérifier** — écran COMPTA/DSI/ADMIN, probablement toutes les factures. |
| SharePoint / copies PDF hors ERP | — | **à vérifier** (hors couche Laravel). |
| `tools/diag/*` | CLI | Hors consultation utilisateur. |

### Clause d’arrêt — exhaustivité

Le recensement couvre les routes web factures/dépôt, les quatre fuites
#1410, les écrans compta, widgets, tâches, Aktor outillé, exports
identifiés, cloche/e-mail. **Il ne peut pas être certifié complet à 100 %** :

1. Fiche #1428 absente : d’autres fuites de cet audit ont pu exister.
2. Overrides `feature_permissions` par utilisateur (hors `default_mappings`) non lus en base.
3. SharePoint, Intacct, `api_logs`, add-in Outlook : hors grep applicatif.
4. Un `programme_id` / `marche_id` posé à tort sur un dépôt Projet bascule A7–A9 de N/A à fuite — **non mesuré**.
5. Toute nouvelle route ajoutée après `a7dd467f` n’est pas dans ce tableau.

Un point oublié = une fuite. Les lignes **à vérifier** ne sont pas « conformes ».

---

## B. MÉCANISME EXISTANT — `FactureVisibility`

Fichier lu en entier : `app/Support/FactureVisibility.php`.

### Structure

Commentaire L15-21 (matrice) :

1. Socle total → voit TOUT.
2. Auteur du dépôt.
3. Validateur désigné NV1/**NV2** (workflow **OU** ocr_raw).
4. Membre d’enseigne (GS_ENVOL → ENVOL, GS_HECTARE → HECTARE) via **programme**, pas dossier.
5. Permission `factures.lecture` — **uniquement** sur `userCanViewFacturePage` / `userCanAccessFacturesPage`, **pas** dans `canView` ni `applyScope`. Le commentaire L21 est trompeur.

### `hasSocleTotalAccess` (L239-242)

```
return count(array_intersect(self::SOCLE_TOTAL_GROUPS, $user->m365GroupNames())) > 0;
```

Groupes L25-30 : `GS_ADMIN_ERP`, **`GS_DSI`**, `GS_DIRECTION_GENERALE`, `GS_COMPTABILITE`.

**`GS_HECTARE` n’est pas dans le socle.** **`GS_DSI` l’est** — absent de la liste Robin.

API correcte : `m365GroupNames()` brut (User L119-122). **Pas** `hasAnyM365Group()` / `hasM365Group()` (court-circuit `isAdminERP()` L136-150). `Gate::before` AppServiceProvider L69-75 passe **toutes** les Gates à true pour GS_ADMIN_ERP.

### `applyScope` (L185-211)

- Si socle : **aucun WHERE** (voit aussi les verrouillées, #1410).
- Sinon : `depot_factures.user_id = user` **OU** valideur désigné (workflow nv1/nv2 **OU** JSON ocr_raw) **OU** (non verrouillé ET enseigne ENVOL/HECTARE via programme).

`resolveEnseigneCode` L279-299 : `programme.enseigne` du dépôt ou de la facture. **Jamais** `dossiers_fonciers.enseigne_id`. Un dépôt Projet sans `programme_id` → `null` → cran enseigne inactif.

Conséquence actuelle pour une Projet typique (`programme_id` NULL) :

| Acteur | Voit-il la Projet via FV liste ? |
|--------|----------------------------------|
| Socle (ADMIN, **DSI**, DG, COMPTA) | oui (early-return) |
| Déposant (`user_id`) | oui |
| Valideur NV1/NV2 désigné | oui |
| GS_HECTARE (enseigne seule) | **non** |
| GS_ENVOL (enseigne seule) | **non** |
| GS_ENVOL via fiche `factures.lecture` | **oui** (`userCanViewFacturePage`) |

La règle Robin **élargit** GS_HECTARE (consultation Projet) et **réduit** GS_DSI + GS_ENVOL (fiche).

### Le déposant existe déjà

`canView` L107-109 et `applyScope` L193 : `depot_factures.user_id`. Colonne réelle, fillable L153, posée à la création L440 (`'user_id' => $user->id`).

### La règle PROJET peut-elle s’insérer ?

**Oui, dans le même fichier**, mais **pas comme un 6e OR**. Forme nécessaire :

`WHERE (NOT projet) OR (projet AND (déposant OR NV1 OR groupes Robin))`

Ce prédicat doit s’appliquer **aussi après le early-return socle** (sinon GS_DSI continue de tout voir) et dans `canView` / `canViewFacture` / `userCanViewFacturePage` (sinon `factures.lecture` contourne).

`applyScope` et `canView` doivent rester la source unique (#207). Brancher la règle seulement dans `Global.vue` = passoire.

Groupes Robin à lire via `m365GroupNames()` brut, jamais `hasAnyM365Group`.

---

## C. LES DEUX RÔLES INDIVIDUELS

### C20. Déposant — source retenue

| Candidat | Existe ? | Rôle |
|----------|----------|------|
| `depot_factures.user_id` | **OUI** — colonne, fillable, posée à `create` (`DepotFactureController` L440) | **Source de visibilité déjà utilisée par FV** |
| `depot_factures.validateur_id` | **N’EXISTE PAS** | — |
| `ocr_raw.saisie.validateur_id` | oui | valideur, **pas** déposant |
| `facture_historique.action = 'depose'` | oui — `logHistorique('depose')` L452 ; `user_id` = `auth()->id()` sauf `system: true` (`Historisable` L30) | **trace**, pas la source opérationnelle. Impersonation : `impersonator_id` séparé. Un replay historique peut diverger de `user_id` courant. |

**Retenu pour la phase 2 : `depot_factures.user_id`.** Ne pas reconstruire le déposant depuis l’historique.

Cas limite (C22, sans trancher) : utilisateur désactivé / soft-deleted — l’id reste ; la visibilité « déposant » tient tant que le compte se reconnecte. Compte supprimé : plus d’accès individuel, les groupes restent.

### C21. Valideur NV1 — quelle source est fiable ?

Trois encodages coexistent. Robin : **NV1 seulement, pas NV2**.

| Source | Fiabilité | Usage actuel FV |
|--------|-----------|-----------------|
| `workflow_validations` `niveau='nv1'` `validateur_id` | **Opérationnelle** une fois le workflow engagé. Statuts `en_attente` / `valide` lus par `isDesignatedValidator` L252-256. Peut être **NULL** (NV2 L30 le montre pour nv2 ; nv1 est en général peuplé par `FactureValidateurNv1AssignmentService`). Réattribution = cette table. | OUI (nv1 **et nv2**) |
| `ocr_raw.saisie.validateur_id` / `_email` | Désignation **à la saisie**, y compris **avant** création Facture. Double encodage #1461 : 5 valideurs aveugles ; `applyDesignatedValidatorScope` L321-322 utilise `json_unquote`. Peut rester un **azure_id** string, pas un `users.id`. `DepotFactureValidateurEffectif` fallback : réglages fournisseur puis **déposant**. | OUI, si `estDesigneDansSaisie` |
| `facture_historique` `valide_nv1` | **NON FIABLE** — rappel mesuré 454 NV1 sans entrée. Trace d’événement, pas d’assignation. | non utilisé par FV |

**Retenu pour la phase 2 (consultation) :**

1. Si `facture_id` : `workflow_validations` où `niveau = 'nv1'` et `statut IN ('en_attente','valide')` et `validateur_id = user` — **prioritaire** (réattribution, workflow réel).
2. Sinon (dépôt pas encore engagé) : `ocr_raw.saisie.validateur_id` / `_email` via `DepotFactureValidateurEffectif::estDesigneDansSaisie` (même chemin que FV aujourd’hui), **sans** le fallback « déposant si vide » pour le **rôle NV1** (le déposant a déjà son propre cran).
3. **Ne pas** utiliser `facture_historique`.
4. **Ne pas** ouvrir NV2 pour Projet (décision Robin + circuit actuel sans NV2).

FV actuel ouvre NV2 : un valideur NV2 hors groupes verrait la facture. Pour Projet, aujourd’hui il n’y a **pas** de ligne NV2 (voir D24). Si on insère la règle « NV1 only » dans FV **globalement**, on casserait la visibilité NV2 des factures **ENVOL**. Donc le cran « NV1 only » doit être **local à Projet**, pas un retrait NV2 du socle FV.

### C22. Cas limites — énoncés, non tranchés

- **Projet sans valideur NV1** : dépôt en saisie, `ocr_raw` vide, pas de `workflow_validations`. Visibles : déposant + groupes Robin. Personne pour valider tant qu’un NV1 n’est pas désigné — **blocage métier possible**, distinct de la visibilité.
- **NV1 changé en cours de route** : `workflow_validations.validateur_id` à jour ; l’ancien disparaît de FV (statut plus `en_attente`/`valide` sur son id, selon implémentation). L’ocr_raw peut encore porter l’ancien id → **double source** pourrait laisser l’ancien voir la facture. Phase 2 : décider si ocr_raw est ignoré dès qu’un WV nv1 existe.
- **Déposant désactivé** : `user_id` orphelin ; plus d’accès individuel.
- **Saisie historique / reprise** : `saisie_historique = 1` ; `user_id` = uploader ; circuit NV2 sauté (`FactureWorkflowApresNv1Service` L109). Visibilité = même règle Projet si `dossier_foncier_id` NOT NULL.
- **ocr_raw encore quoté** : applyScope `json_unquote` ; `canView` PHP peut échouer si l’id est `'"123"'`. #1461 a normalisé ; **relancer le SQL E** pour les Projet restantes.

---

## D. IMPACT ET RISQUES

### D23. Combien de personnes verraient encore les Projet ?

**Aucun chiffre nouveau.** Peuplement groupes **déjà mesuré** (prompt, non revérifié) :

- GS_ADMIN_ERP : 1 (Robin)
- GS_DIRECTION_GENERALE : 4 (Anne 37, Guilhem 36, Laury 18, Robin 1)
- GS_COMPTABILITE : 5
- GS_HECTARE : 13

Plus, **par facture**, le déposant et le NV1 (peuvent déjà être dans ces groupes). Union réelle = SQL E, pas une addition.

**Écart vs aujourd’hui (qualitatif) :**

- **Perdent** les Projet : GS_DSI (socle actuel), GS_ENVOL qui ouvre une fiche par URL (`factures.lecture`), tout utilisateur enseigne-only.
- **Gagnent** les Projet en liste : GS_HECTARE (aujourd’hui cran enseigne inactif sans programme).

### D24. Blocage workflow NV2 — SIGNALÉ

`FacturePasseParValidationNv2::check` L49-51 : si `facture.programme_id === null` → **pas de NV2**.

`FactureEstFraisGeneraux::check` : `programme_id === null` → traité comme **FG** (NV1 seul).

Une facture Projet (#1492 : pas de programme) **ne passe pas en NV2 aujourd’hui**, même si `dossiers_fonciers.enseigne_id` est ENVOL. Le helper NV2 **ignore le dossier**.

**Risque de blocage NV2 : nul sur le circuit actuel.** Il devient réel **seulement si** un lot futur (étape 5 CLASSID, ou « corriger » FG vs Projet) branche NV2 sur `dossiers_fonciers.enseigne_id`. Dans ce cas un NV2 hors groupes Robin **ne verrait pas** la facture — **le signaler avant tout changement de circuit**.

GS_DIRECTION_GENERALE est dans la liste Robin : un NV2 DG verrait encore la facture. Un NV2 **hors DG** (ex. `GS_PDG_ENVOL` seul, hors les six groupes) serait bloqué.

### D25. Comptabilisation / mise en paiement / Sage

GS_COMPTABILITE ∈ liste Robin → pas d’exclusion du métier comptable.

**GS_DSI** est sur les portes comptabilisation, mise en paiement, super-admin factures, **pas** sur le TDB (#1478). La règle Robin **l’exclut** des Projet. Décision à prendre en phase 2 : soit GS_DSI perd les Projet sur ces écrans (diagnostic Intacct plus difficile), soit exception DSI documentée (contredit « partout »).

GS_HECTARE n’est pas sur ces écrans : ils verront les Projet en **liste globale** après FV, pas en compta.

Rapprochement Sage : **à vérifier** (même porte COMPTA/DSI).

### D26. Tests — jamais le compte Robin

`hasAnyM365Group()` / `hasM365Group()` / `Gate::before` : tout contrôle validé avec Robin (`isAdminERP`, seul GS_ADMIN_ERP) est **faussement positif**.

`FactureVisibility::hasSocleTotalAccess` et `ComptaTableauBordAcces` lisent `m365GroupNames()` brut — **moins** biaisés, mais Robin est quand même dans GS_ADMIN_ERP donc dans le socle.

Comptes indiqués (prompt, non vérifiés ici) : Sabrina (24) ou Rudy (COMPTA) ; Anne (37) ou Laury (18) DG — **pas Guilhem (36)** ; un GS_HECTARE (SQL E) ; un compte **hors tous ces groupes** (SQL E, à identifier — **non établi**).

Tests sqlite skipped (`pdo_sqlite` absent) : **aucune valeur de preuve droits**. Validation **manuelle en production**.

---

## E. SQL POUR ROBIN

Cursor n’a pas MySQL. **Aucun chiffre ci-dessous.** Coller tel quel. `only_full_group_by` actif. Colonnes qualifiées. Pas de `depot_factures.validateur_id`. Pas de `programmes.nom` / `programmes.societe_id`.

### E1. Factures Projet : déposant, NV1, dossier, statut

```
SELECT
  df.id AS depot_id,
  df.uuid AS depot_uuid,
  df.statut AS depot_statut,
  df.user_id AS deposant_id,
  u.name AS deposant_nom,
  u.email AS deposant_email,
  df.dossier_foncier_id,
  doss.reference AS dossier_reference,
  doss.code_insee_commune,
  doss.enseigne_id AS dossier_enseigne_id,
  df.poste_budgetaire_type_id,
  df.facture_id,
  f.reference AS facture_reference,
  f.statut AS facture_statut,
  f.programme_id,
  fourn.nom AS fournisseur_nom,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS ocr_validateur_id,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_email')) AS ocr_validateur_email,
  wv.niveau AS wv_nv1_niveau,
  wv.statut AS wv_nv1_statut,
  wv.validateur_id AS wv_nv1_validateur_id,
  uv.name AS wv_nv1_nom,
  uv.email AS wv_nv1_email
FROM depot_factures df
LEFT JOIN users u ON u.id = df.user_id
LEFT JOIN dossiers_fonciers doss ON doss.id = df.dossier_foncier_id
LEFT JOIN factures f ON f.id = df.facture_id
LEFT JOIN fournisseurs fourn ON fourn.id = COALESCE(df.fournisseur_id, f.fournisseur_id)
LEFT JOIN workflow_validations wv
  ON wv.id = (
    SELECT w2.id
    FROM workflow_validations w2
    WHERE w2.facture_id = df.facture_id
      AND w2.niveau = 'nv1'
    ORDER BY w2.id DESC
    LIMIT 1
  )
LEFT JOIN users uv ON uv.id = wv.validateur_id
WHERE df.deleted_at IS NULL
  AND df.dossier_foncier_id IS NOT NULL
ORDER BY df.id DESC;
```

### E2. Nombre d’utilisateurs par groupe concerné

```
SELECT grp AS groupe, COUNT(*) AS n_users
FROM users
JOIN JSON_TABLE(
  CASE
    WHEN JSON_VALID(m365_groups) THEN m365_groups
    ELSE JSON_ARRAY()
  END,
  '$[*]' COLUMNS (grp VARCHAR(64) PATH '$')
) g
WHERE grp IN (
  'GS_ADMIN_ERP',
  'GS_DIRECTION_GENERALE',
  'GS_COMPTABILITE',
  'GS_HECTARE',
  'GS_DSI',
  'GS_ENVOL',
  'GS_GEMME_PDG',
  'GS_PDG_ENVOL'
)
GROUP BY grp
ORDER BY grp;
```

Si `JSON_TABLE` indisponible sur la version MySQL OVH, repli :

```
SELECT 'GS_ADMIN_ERP' AS groupe,
       COUNT(*) AS n_users
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_ADMIN_ERP'))
UNION ALL
SELECT 'GS_DIRECTION_GENERALE', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_DIRECTION_GENERALE'))
UNION ALL
SELECT 'GS_COMPTABILITE', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_COMPTABILITE'))
UNION ALL
SELECT 'GS_HECTARE', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_HECTARE'))
UNION ALL
SELECT 'GS_DSI', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_DSI'))
UNION ALL
SELECT 'GS_ENVOL', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_ENVOL'))
UNION ALL
SELECT 'GS_GEMME_PDG', COUNT(*)
FROM users
WHERE JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_GEMME_PDG'));
```

### E3. Projet dont le NV1 est NULL ou introuvable

```
SELECT
  df.id AS depot_id,
  df.uuid,
  df.statut AS depot_statut,
  df.user_id AS deposant_id,
  df.facture_id,
  f.statut AS facture_statut,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS ocr_validateur_id,
  wv.validateur_id AS wv_nv1_validateur_id
FROM depot_factures df
LEFT JOIN factures f ON f.id = df.facture_id
LEFT JOIN workflow_validations wv
  ON wv.id = (
    SELECT w2.id
    FROM workflow_validations w2
    WHERE w2.facture_id = df.facture_id
      AND w2.niveau = 'nv1'
    ORDER BY w2.id DESC
    LIMIT 1
  )
WHERE df.deleted_at IS NULL
  AND df.dossier_foncier_id IS NOT NULL
  AND (
    df.facture_id IS NULL
    OR wv.id IS NULL
    OR wv.validateur_id IS NULL
    OR NOT EXISTS (
      SELECT 1 FROM users ux WHERE ux.id = wv.validateur_id
    )
  )
ORDER BY df.id DESC;
```

### E4. Compte de test hors allowlist (à identifier)

```
SELECT u.id, u.name, u.email, u.m365_groups
FROM users u
WHERE u.id NOT IN (1, 36)
  AND NOT JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_ADMIN_ERP'))
  AND NOT JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_DIRECTION_GENERALE'))
  AND NOT JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_COMPTABILITE'))
  AND NOT JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_HECTARE'))
  AND NOT JSON_CONTAINS(m365_groups, JSON_QUOTE('GS_DSI'))
ORDER BY u.id
LIMIT 20;
```

(1 = Robin d’après le peuplement cité ; 36 = Guilhem, biaisé. Ajuster si les id ont changé.)

### E5. Pollution : Projet avec programme_id (bascule A7/A9 en fuite)

```
SELECT
  df.id,
  df.uuid,
  df.programme_id,
  p.libelle AS programme_libelle,
  df.dossier_foncier_id,
  df.marche_id
FROM depot_factures df
LEFT JOIN programmes p ON p.id = df.programme_id
WHERE df.deleted_at IS NULL
  AND df.dossier_foncier_id IS NOT NULL
  AND (df.programme_id IS NOT NULL OR df.marche_id IS NOT NULL);
```

---

## F. DÉCOUPAGE PHASE 2 (moins risqué → plus risqué)

Ne pas livrer un lot unique « partout ». Chaque lot doit avoir un test
source (assert présence du prédicat) **et** une verif manuelle hors compte Robin.

### Lot 2a — Prédicat central dans `FactureVisibility` (risque moyen, levier max)

**Périmètre :** `canView`, `canViewFacture`, `applyScope`, `userCanViewFacturePage`
(fermer `factures.lecture` sur Projet). Prédicat AND Projet. Groupes via
`m365GroupNames()`. NV1 = WV puis ocr_raw (C21). Déposant = `user_id`.
**Ne pas** retirer NV2 du socle général.

**Couvert automatiquement :** liste globale + recherche + onglet Projet,
fiches, PDF, ZIP, masse, boîte dépôt `visiblePar`, documents, commentaires,
Graph search, widget NV (post-filtre), `MontantFactureSousTraitant` lignes.

**Pas couvert :** A7–A10, compta, paiement, TDB, validations, badge,
mes-tâches, widget a_completer, super-admin, agrégats.

**Fichiers :** `app/Support/FactureVisibility.php` + tests source. Ne pas
toucher `Global.vue` / liste (#1507 déjà livré). Ne pas toucher le cast
`ocr_raw => array`.

**Point de décision avant code :** GS_DSI perd-il les Projet partout y
compris socle (oui si on suit Robin à la lettre) ?

### Lot 2b — Fuite active Projet : `searchDepotFactures` (risque bas, fuite réelle)

**Périmètre :** `ChargePortageController::searchDepotFactures` — brancher
`visiblePar` / `applyScope` **après** 2a (sinon le scope n’a pas encore
la règle Projet ; GS_HECTARE gagnerait quand même via 2a).

**Fichiers :** `ChargePortageController.php`, éventuellement test source
#1447. UI `Parcelles/Show.vue` commentaire fuite.

### Lot 2c — Les trois autres fuites #1410 (risque bas pour Projet typique, haut pour le verrou historique)

Panel programme, marché, CIE : brancher FV. Peu d’effet Projet si E5 est
vide ; effet fort sur le **verrou** #1410 (dette ouverte). Ne pas mélanger
avec 2a dans le même commit.

**Fichiers :** `FactureController::indexProps`, `MarcheController::facturesDepotPourMarche`,
`CieController::depotFacturesOptions` (+ `CieService` montants si on vise
« partout »).

### Lot 2d — GS_DSI sur écrans compta / paiement / super-admin (risque haut métier)

Si 2a exclut déjà GS_DSI via FV, ces écrans **n’utilisent pas FV** : GS_DSI
continue de tout voir. Soit on y branche applyScope (les Projet
disparaissent pour DSI), soit exception documentée.

**Fichiers :** `ComptabilisationController`, `PaiementLignesPayablesService`,
`FactureSuperAdminCorrectionService`. TDB : audience déjà ⊂ Robin — **pas
prioritaire** (agrégats, pas de liste nominative hors cartes).

### Lot 2e — Tâches, badge, validations, widget a_completer, cloche (risque moyen fuite résiduelle)

`ValidationController`, `HandleInertiaRequests::computeFacturesEnAttenteCount`,
`MesTachesController` (joindre FV avant de sérialiser fournisseur/montant),
`WidgetDataService::depotsACompleterRows`, alertes cloche qui citent une
facture.

### Lot 2f — Agrégats (risque bas Projet, haut collatéral)

Bilan, trésorerie, synthèse marché, KPI `sommeMontantHtDepotsProgramme`.
#1410 LEÇON : ne pas y brancher un verrou à la légère. Pour Projet typique
(pas de programme/marché) l’effet est nul. Reporter tant que E5 est vide.

### Hors phase 2 (étape 5)

CLASSID à la comptabilisation — **risque haut**, autre lot. Ne pas y
glisser la visibilité. Ne pas brancher NV2 sur `dossiers_fonciers.enseigne_id`
sans rouvir D24.

---

## 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 de ce diagnostic (pas de code) : relire le tableau A ;
exécuter E1–E5 en prod ; **ne pas** valider des droits avec le compte Robin.

## LEÇON

« Partout » n’est pas `applyScope` : `userCanViewFacturePage` (`factures.lecture`),
les quatre listes #1410, et les écrans compta (porte de **groupe**, pas FV)
sont trois familles distinctes. Un early-return socle qui inclut GS_DSI
contredit une allowlist qui ne l’inclut pas. NV2 Projet est un risque
**latent** (programme_id NULL aujourd’hui), pas actuel. Le déposant est
`user_id`, pas l’historique. Le NV1 fiable une fois engagé est
`workflow_validations`, pas `valide_nv1` dans l’historique. Fiche #1428
absente : ne jamais traiter une référence de suivi comme un fichier
garantit.
