---
suivi: 1757
date: 2026-09-30
sujet: Diagnostic onglet FONCIER absent de la barre programme
chantier: foncier
type: diagnostic
statut: poussé
hash:
fichiers:
  - docs/suivi/SUIVI_1757_diag_onglet_foncier_programme.md
---

# SUIVI #1757 — Diagnostic onglet FONCIER absent sur le programme

## PROMPT ENVOYÉ

PHASE 1 diagnostic. Ne pas coder. Sur `/programmes/122` (HORIZON), en usurpation d'Eric PORTALES et en Robin PORTALES, la barre est RÉSUMÉ · COMMERCIALISATION · ACQUÉREURS · CHANTIER · FINANCIER · DOCUMENTS. Aucune entrée FONCIER. Sur le dossier 92 (DF-2026-0004 ZEN), le bouton « Programme HORIZON · HEC34-033 » s'affiche. Règle attendue : même visibilité que l'ouverture de la fiche dossier (créateur, habilitation lecture ou gestion, direction générale, admin ERP).

## FICHES LUES

- #1753 LEÇON : deux habilitations distinctes. On ne déduit pas la visibilité du dossier depuis celle du programme, ni l'inverse. L'entrée se calcule côté serveur. Le hub (avant Commercialisation) et le raccourci fiche (après Documents) sont deux entrées ; le hub ne rend pas `Foncier/Dossiers/Show`.
- #1555, rappelée par #1753 : ne pas fusionner les ACL programme et dossier.

## CONSTAT 1 — l'entrée après Documents n'apparaît pas

La prop partagée est bien calculée sur cette route.

- `HandleInertiaRequests.php:121` : `'programme' => fn () => $this->resolveProgrammeFromRoute($request)`. Closure évaluée à chaque visite, pas une prop Inertia paresseuse exclue du premier chargement.
- `HandleInertiaRequests.php:190-194` : si `route('programme')` n'est pas un `Programme`, la prop vaut `null`. Sur `GET /programmes/{programme}` (`routes/web.php:2096`, `ProgrammeDashboardController::show`), le paramètre est un modèle. La prop est donc calculée.
- `HandleInertiaRequests.php:198-204` : le tableau partagé contient `foncier_tab_visible` et `foncier_dossiers`.
- `HandleInertiaRequests.php:211-218` : `foncier_dossiers` = `DossierProgrammeLink::dossiersVisiblesPourNavigation($request->user(), $programme)`.
- `DossierProgrammeLink.php:137-140` : retour `[]` si `Auth::user()` est absent ou si son id diffère de l'utilisateur passé. L'usurpation fait `Auth::login($target)` (`ImpersonationService.php:69`) : `Auth` et `$request->user()` sont Eric. Ce garde-fou ne vide pas la liste en usurpation, ni pour Robin connecté lui-même.
- `DossierProgrammeLink.php:142-143` : `DossierFoncier::query()->where('dossiers_fonciers.programme_id', ...)`. Le scope global `RecordHabilitationScope` s'applique (`HasRecordHabilitations.php:19`). SoftDeletes est sur le modèle (`DossierFoncier.php:23`) : un dossier en corbeille est écarté. Pas de `DB::table()`.
- `RecordHabilitationScope.php:25-42` : pas de filtre si `userBypassesView` ; sinon créateur ou ligne `record_habilitations` active (lecture ou gestion). `RecordHabilitationScope.php:46-55` et `RecordAcl.php:53-58` : bypass `GS_DIRECTION_GENERALE`, `GS_GEMME_PDG`, `GS_DSI`, `GS_ADMIN_ERP` via `m365GroupNames()` (colonne locale, pas un appel Graph).
- `AppShell.vue:77-88` : `headerProgrammeId` lit `page.props.programme.id`, sinon l'id dans l'URL. La barre s'affiche.
- `AppShell.vue:306-326` : l'entrée n'est ajoutée que si `page.props.programme.foncier_dossiers` est un tableau non vide. Un élément : lien. Plusieurs : menu. Tableau vide ou clé absente : rien.
- `ObjectSubNavBar.vue:90` : rend `items` tels quels, sans second filtre.
- Écrasement : Inertia remplace une prop partagée par la prop de page du même nom. `ProgrammeDashboardController.php:132-134` construit `$programmePayload` = `inertiaBannerProps()` + `statut` seulement. `Programme.php:547-560` : id, nom, code, logo, commerciaux. Aucune clé foncier. `ProgrammeDashboardController.php:189-190` envoie `'programme' => $programmePayload`. La page reçue par `AppShell` n'a donc ni `foncier_dossiers` ni `foncier_tab_visible`, même si le middleware les a calculés.

Le bouton du dossier ne passe pas par cette prop. Il lit `page.props.dossier.programme_lien`, produit par `lienVersProgrammeSiVisible` (`DossierProgrammeLink.php:176` et suivantes) via `ProgrammeAccessService`, pas par le scope dossier. D'où le bouton visible et l'entrée programme absente.

## CONSTAT 2 — l'onglet hub avant Commercialisation

`AppShell.vue:242-249` : l'onglet n'est poussé que si `page.props.programme.foncier_tab_visible` est vrai, et il est inséré avant Commercialisation (`AppShell.vue:250`).

La valeur vient de `HandleInertiaRequests.php:221-228` → `DossierProgrammeLink::tabVisiblePour` (`DossierProgrammeLink.php:64-71`) : dossier vivant `programme_id` sans le scope d'habilitation (`queryDossiersVivantsSansAcl`, `whereNull deleted_at`), puis `$dossier->userCanViewRecord($user)` (`HasRecordHabilitations.php:56-69` : bypass, sinon créateur, sinon habilitation lecture ou gestion). `DossierFoncierPolicy.php:10-12` : `view` appelle la même méthode.

Sur `/programmes/122`, cette clé est calculée puis effacée par le même écrasement `ProgrammeDashboardController.php:190`. `foncier_tab_visible` absent est faux : l'onglet hub n'est pas ajouté. Même mécanisme pour Eric usurpé et pour Robin.

## COMMANDE (lecture seule, bash OVH, aucun caractère point d'exclamation)

À lancer depuis le répertoire de l'application. `Auth::setUser` ne écrit pas la session web. Aucun `save`, aucun `update`.

php artisan tinker --execute='$eric = App\Models\User::query()->where("name", "Eric PORTALES")->first(); $robin = App\Models\User::query()->where("name", "Robin PORTALES")->first(); $programme = App\Models\Programme::query()->find(122); $dossier = App\Models\DossierFoncier::withoutGlobalScopes()->where("id", 92)->first(); foreach ([$eric, $robin] as $user) { if ($user === null || $programme === null || $dossier === null) { echo "manquant user=".($user === null ? "oui" : "non")." programme=".($programme === null ? "oui" : "non")." dossier=".($dossier === null ? "oui" : "non")."\n"; continue; } Auth::setUser($user); $nav = App\Support\Foncier\DossierProgrammeLink::dossiersVisiblesPourNavigation($user, $programme); $tab = App\Support\Foncier\DossierProgrammeLink::tabVisiblePour($user, $programme); $view = Illuminate\Support\Facades\Gate::forUser($user)->allows("view", $dossier); $deleted = $dossier->deleted_at === null ? "null" : (string) $dossier->deleted_at; echo $user->id." ".$user->name." auth=".(int) Auth::id()." programme_id=".(int) $dossier->programme_id." deleted_at=".$deleted." tab=".($tab ? "1" : "0")." policy_view=".($view ? "1" : "0")." nav=".json_encode($nav)."\n"; }'

Lecture attendue : `programme_id` du dossier 92, `deleted_at`, puis pour chaque personne `tab` (hub), `policy_view` (ouverture fiche), `nav` (entrée barre). Si `nav` contient le dossier 92 et que la barre reste vide, le constat 1 est confirmé en données : le calcul réussit, la page l'écrase.

## CORRECTIF PROPOSÉ (non implémenté)

1. Ne plus faire porter `foncier_tab_visible` et `foncier_dossiers` par la clé `programme`, que les contrôleurs remplacent (`ProgrammeDashboardController`, et les autres pages programme qui envoient `inertiaBannerProps()` : commercialisation, marchés, bilan, documents, etc.). Les calculer dans le middleware sous une clé partagée que ces pages n'envoient pas, et lire cette clé dans `AppShell` `programmeNavItems`. La règle reste `userCanViewRecord` / le scope dossier : créateur, habilitation lecture ou gestion, groupes de contournement (direction générale, PDG, DSI, admin ERP). Pas une nouvelle règle. L'usurpation est déjà l'utilisateur `Auth` effectif.
2. Un seul onglet FONCIER. Aujourd'hui les deux libellés sont identiques (`AppShell.vue:245` hub, `AppShell.vue:312` lien). Une fois l'écrasement retiré, la barre en afficherait deux. Garder le lien (ou le menu s'il y a plusieurs dossiers visibles) après Documents : c'est l'entrée alignée sur l'ouverture de la fiche. Ne plus pousser l'onglet hub dans la barre. La route `/programmes/{id}/foncier` peut rester ; le hub n'ouvre qu'un dossier (`dossierPourProgrammeSansAcl`), le menu couvre tous ceux que la personne peut ouvrir.

## DÉPLOIEMENT-TEST

Fiche seule. Aucun changement d'écran. Pas de `journal:sync`. Après pull : rien d'autre.

## LEÇON

Une prop de page Inertia du même nom remplace toute la prop partagée, sans fusion. Le bandeau programme et les drapeaux de navigation ne peuvent pas partager la clé `programme` tant que les contrôleurs renvoient leur propre payload.
