---
suivi: 1555
date: 2026-09-15
sujet: Diagnostic — chaînage dossier foncier ↔ programme
chantier: foncier / programmes
type: diagnostic
statut: poussé
hash: a269183c
fichiers:
  - docs/suivi/SUIVI_1555_diag_chainage_dossier_programme.md
---

# SUIVI #1555 — PHASE 1 diagnostic : chaînage dossier foncier ↔ programme

## PROMPT ENVOYÉ

Diagnostic seul. Un dossier foncier donne UN programme, et un seul.
Association depuis le dossier (Associer / Créer un programme). Onglet
FONCIER sur le programme, patron CHANTIER. Onglet FACTURATION du
dossier à retirer. Destination PROJETS hors lot. Pas de fusion de
tables, pas de rattachement automatique, pas de tranches.

## BASE GIT

HEAD diagnostic = `a269183c`. Aucun
fichier applicatif modifié. Aucun MySQL (requêtes du point 2 à coller).

## FICHES LUES

- **#1518** — cité par le prompt (`programmes.dossier_foncier_id`
  n’existe pas). **Aucune fiche locale `SUIVI_1518_*.md`.** Confirmé
  ici par schéma / fillable (point 1).
- **#1512 LEÇON** — dossiers = `RecordHabilitationScope` (créateur ∪
  `record_habilitations` ∪ bypass `m365GroupNames()` brut, dont
  GS_DSI). Sélecteur dépôt Projet = ACL dossier, pas le secteur
  prospection. Impact : cloisonnement dossier ≠ programme.
- **#1519 LEÇON** — `Programme::prochainCode` (L488-503) ignore les
  soft-deleted ; UNIQUE `code_envol` → 500. Impact : « Créer un
  programme » depuis le dossier emprunte le même générateur.
- **#1448 LEÇON** — postes dossier sur la même table
  `postes_budgetaires`, XOR `programme_id` / `dossier_foncier_id`.
  Bascule prévue par UPDATE (pas de copie). Pas de droit par onglet :
  `authorize('view')` puis tous les onglets. Impact : retirer
  FACTURATION n’est pas anodin pour les lignes déjà créées ; le
  sélecteur dépôt PROJET lit la nomenclature d’enseigne, pas ces
  lignes.
- **#1351 LEÇON** — sous-nav CHANTIER = `Teleport` +
  `ObjectSubNavBar` `tone="surface"` vers `#chantier-subnav-host`.
  Identité programme **pas** dans cette barre. Impact : patron
  réutilisable ; le contenu CHANTIER est un hub de pages déjà
  séparées (`embedded`), ce que le dossier n’est pas.
- **#1104 LEÇON** — onglets programme définis **deux fois**
  (`AppShell.vue` `programmeNavItems` + rail `Programmes/Index.vue`).
  `AppShell.vue` = zone commune (~10 lots). Impact : FONCIER en 2ᵉ
  position touche AppShell ; rebase avant build.
- **#1196** — ancres dossier figées (`DossierFoncierTabs` +
  `DOSSIER_FONCIER_TAB_KEYS`). Query `onglet` + hash.
- **#1507 / #1492** — onglet liste factures Projet =
  `depot_factures.dossier_foncier_id IS NOT NULL`. Hors lot
  destination PROJET.

Aucun PHP applicatif modifié. Aucune migration. Pas d’entrée journal
(#412 : diagnostic invisible).

---

## 1. LE SENS DE LA LIAISON

### État actuel (certitude : oui)

Aucune FK entre `programmes` et `dossiers_fonciers`.

- `Programme::$fillable` (`app/Models/Programme.php` L26-67) : pas de
  `dossier_foncier_id`. Aucune relation `dossierFoncier()`. SoftDeletes
  L15.
- `DossierFoncier::$fillable` (`app/Models/DossierFoncier.php` L74-90)
  : pas de `programme_id`. Aucune relation `programme()`. Pas de
  SoftDeletes.
- Migration programmes initiale : `database/migrations/2026_04_18_100000_create_programmes_table.php`
  L15-18 (`id`, `libelle`, timestamps) puis ajouts (`commune`,
  `departement`, `enseigne_id`, `code_envol` UNIQUE,
  `2026_04_23_100000_add_fields_to_programmes_table.php` L12-14).
- Migration dossiers : `database/migrations/2026_07_30_150200_create_dossiers_fonciers_table.php`
  L14-40 (`enseigne_id`, `reference` UNIQUE, `libelle`,
  `code_insee_commune` FK `communes`, `createur_id`, …).

Le #1518 est donc toujours vrai : **`programmes.dossier_foncier_id`
n’existe pas.**

### Option A — `programmes.dossier_foncier_id` nullable + UNIQUE

- Dossier sans programme : aucun programme ne pointe vers lui. OK.
- Programme sans dossier : colonne NULL. OK. Tout l’existant.
- Un-à-un : UNIQUE sur `dossier_foncier_id` (MySQL autorise plusieurs
  NULL).
- Lecture depuis le dashboard programme : `belongsTo` naturel.

**Piège SoftDeletes (#1519 déjà sur cette table).** UNIQUE inclut les
lignes `deleted_at IS NOT NULL`. Un programme soft-deleted qui
conserve `dossier_foncier_id = X` **bloque** toute nouvelle association
du dossier X. MySQL n’a pas d’index unique partiel. Il faudrait NULL
la colonne au `delete()`, ou un unique applicatif.

### Option B — `dossiers_fonciers.programme_id` nullable + UNIQUE
**(recommandée)**

- Dossier sans programme : `programme_id` NULL. OK.
- Programme sans dossier : aucune ligne dossier ne le référence. OK.
  Tout l’existant.
- Un-à-un : UNIQUE `dossiers_fonciers.programme_id` (plusieurs NULL
  OK). Un programme ne peut pas être lié à deux dossiers.
- Association **depuis le dossier** (décision métier) = écrire la
  colonne de l’objet qu’on a sous les yeux.
- `DossierFoncier` n’a **pas** SoftDeletes → l’UNIQUE ne se heurte
  pas au piège #1519.
- Inverse : `Programme::hasOne(DossierFoncier::class, 'programme_id')`.
- Au soft-delete d’un programme : NULL `dossiers_fonciers.programme_id`
  dans la même transaction (`ProgrammeController::destroy` L953-974
  aujourd’hui ne fait que `$programme->delete()`).

### Recommandation

**Option B : `dossiers_fonciers.programme_id` UNSIGNED NULL, FK vers
`programmes.id` (restrict ou nullOnDelete), UNIQUE court nommé
(`df_programme_uq`).**

Justification : le un-à-un est porté par l’objet qui initie le lien ;
la table dossiers n’est pas soft-deleted ; les programmes existants
restent inchangés (pas de nouvelle colonne sur une table déjà
piégée par UNIQUE + SoftDeletes). Pas de fusion des deux tables.

Ne pas poser la FK dans les deux sens.

---

## 2. ÉTAT DES DONNÉES — requêtes SQL (à coller en prod)

Pas d’accès MySQL ici. Mesure, **pas** de rattachement automatique.

### Volumes

```sql
SELECT COUNT(*) AS nb_dossiers
FROM dossiers_fonciers;

SELECT COUNT(*) AS nb_programmes_actifs
FROM programmes
WHERE deleted_at IS NULL;

SELECT COUNT(*) AS nb_programmes_soft_deleted
FROM programmes
WHERE deleted_at IS NOT NULL;

SELECT statut, COUNT(*) AS n
FROM programmes
WHERE deleted_at IS NULL
GROUP BY statut;
```

### Postes budgétaires dossier (point 3)

```sql
SELECT COUNT(*) AS nb_postes_dossier
FROM postes_budgetaires
WHERE dossier_foncier_id IS NOT NULL;

SELECT COUNT(DISTINCT dossier_foncier_id) AS nb_dossiers_avec_poste
FROM postes_budgetaires
WHERE dossier_foncier_id IS NOT NULL;

SELECT df.id, df.reference, df.libelle, COUNT(pb.id) AS nb_postes
FROM postes_budgetaires pb
JOIN dossiers_fonciers df ON df.id = pb.dossier_foncier_id
GROUP BY df.id, df.reference, df.libelle
ORDER BY nb_postes DESC;
```

### Factures destination PROJET (2 attendues)

```sql
SELECT COUNT(*) AS nb_depots_avec_dossier
FROM depot_factures
WHERE dossier_foncier_id IS NOT NULL;

SELECT destination, COUNT(*) AS n
FROM depot_factures
WHERE dossier_foncier_id IS NOT NULL
GROUP BY destination;

SELECT df.id, df.uuid, df.destination, df.dossier_foncier_id,
       d.reference, d.libelle, df.poste_budgetaire_type_id
FROM depot_factures df
LEFT JOIN dossiers_fonciers d ON d.id = df.dossier_foncier_id
WHERE df.dossier_foncier_id IS NOT NULL;
```

(`depot_factures` : colonne `uuid` selon le modèle ; ajuster si le
DESCRIBE diffère. Ancrage #1492 = `dossier_foncier_id` +
`poste_budgetaire_type_id`, `app/Support/DepotFactureProjetAncrage.php`.)

### Rapprochements évidents (mesure, pas de reprise)

Commune programme = **libellé libre** (`programmes.commune`,
migration `2026_04_23_100000_add_fields_to_programmes_table.php` L13).
Dossier = **code INSEE** (`dossiers_fonciers.code_insee_commune`,
`communes.nom` / `communes.departement`).

```sql
-- Même enseigne + même commune (nom, insensible à la casse)
SELECT d.id AS dossier_id, d.reference, d.libelle AS dossier_libelle,
       p.id AS programme_id, p.code_envol, p.libelle AS programme_libelle,
       e.code AS enseigne, c.nom AS commune
FROM dossiers_fonciers d
JOIN enseignes e ON e.id = d.enseigne_id
JOIN communes c ON c.code_insee = d.code_insee_commune
JOIN programmes p ON p.enseigne_id = d.enseigne_id
                 AND p.deleted_at IS NULL
                 AND LOWER(TRIM(p.commune)) = LOWER(TRIM(c.nom))
ORDER BY e.code, c.nom, d.reference;

-- Même enseigne + libellés proches (sous-chaîne)
SELECT d.id AS dossier_id, d.reference, d.libelle AS dossier_libelle,
       p.id AS programme_id, p.code_envol, p.libelle AS programme_libelle,
       e.code AS enseigne
FROM dossiers_fonciers d
JOIN enseignes e ON e.id = d.enseigne_id
JOIN programmes p ON p.enseigne_id = d.enseigne_id
                 AND p.deleted_at IS NULL
                 AND (
                   LOWER(d.libelle) LIKE CONCAT('%', LOWER(p.libelle), '%')
                   OR LOWER(p.libelle) LIKE CONCAT('%', LOWER(d.libelle), '%')
                 )
ORDER BY e.code, d.reference;

-- Comptage des paires enseigne+commune
SELECT COUNT(*) AS nb_paires_enseigne_commune
FROM dossiers_fonciers d
JOIN communes c ON c.code_insee = d.code_insee_commune
JOIN programmes p ON p.enseigne_id = d.enseigne_id
                 AND p.deleted_at IS NULL
                 AND LOWER(TRIM(p.commune)) = LOWER(TRIM(c.nom));
```

Ne pas en déduire un UPDATE.

---

## 3. ONGLET FACTURATION DU DOSSIER — LE RETIRER PROPREMENT

### Ce qu’il contient

- UI : `resources/js/Pages/Foncier/Dossiers/Show.vue` L1469-1474
  (`TabPanel header="Facturation"`) →
  `DossierFacturationPanel.vue`.
- Bloc postes : L100-247 du panel (CRUD montants).
- Bloc « Factures du dossier » **inactif** L250-256 : *« La
  destination PROJETS du dépôt n’est pas encore ouverte »*.
- Nav : `DossierFoncierTabs::KEYS` L20 `facturation` ;
  `DOSSIER_FONCIER_TAB_KEYS` `useTabAnchor.js` L194 ;
  `AppShell.vue` `DOSSIER_TAB_LABELS.facturation` L154.

### Routes / contrôleurs / composants **spécifiques** à cet onglet

| Pièce | Fichier | Rôle |
|---|---|---|
| GET payload | `DossierFoncierController::show` L514-515 | `payloadPourDossier` |
| POST/PATCH/DELETE | `routes/web.php` L992-1002 `foncier.dossiers.postes-budgetaires.*` | CRUD |
| Contrôleur | `DossierFoncierPosteBudgetaireController.php` | `authorize('update')` + `foncier.gestion` |
| Service | `DossierFoncierPosteBudgetaireService.php` | XOR `programme_id` NULL |
| Panel Vue | `DossierFacturationPanel.vue` | UI unique |

Le reste de `Show.vue` / `DossierFoncierController::show` sert les
autres onglets. Ne pas supprimer le contrôleur show.

### Ce que les postes deviennent

Table partagée `postes_budgetaires` (`#1448`). Scope global
`PosteBudgetaireProgrammeScope` L22 : `whereNotNull(programme_id)` →
les lignes dossier sont **invisibles** au bilan / marchés tant que
`programme_id` est NULL.

Bascule documentée #1448 :

```
UPDATE postes_budgetaires
SET programme_id = :id_programme, dossier_foncier_id = NULL
WHERE dossier_foncier_id = :id_dossier;
```

**Ne pas l’exécuter en PHASE 2 sans décision** (interdit de
rattachement automatique). Les lignes restent sur le dossier, hors
bilan, jusqu’à un lot dédié « bascule à l’association ».

### Ce que le retrait ne casse pas

- **Nomenclature d’enseigne** (`poste_budgetaire_types`) : intacte.
  Le panel ne fait que la *lire* (`typesActifsPourEnseigne` L134-140).
- **Sélecteur poste saisie facture destination PROJET** :
  `DepotProjetDossierService::postesActifsPourDossier` L87-117 liste
  les **types** `PosteBudgetaireType::pourEnseigne`, **pas** les
  lignes `postes_budgetaires` du dossier. Hors lot destination
  PROJET : ce chemin reste.
- **XOR** `pb_parent_xor` : inchangé si on ne touche pas aux lignes.
- **Export** : aucun export dédié « postes dossier » trouvé dans
  `app/` (grep contrôleur/service Foncier postes).

### Les 2 factures PROJET

Ancrage = `depot_factures.dossier_foncier_id` +
`poste_budgetaire_type_id` (`DepotFactureProjetAncrage`,
`DepotFacture::dossierFoncier()` L861). **Pas** `postes_budgetaires.id`.

Accessibles aujourd’hui :

- Liste globale onglet Projet :
  `app/Services/FactureGlobaleListeQuery.php` L241
  `whereNotNull(depot_factures.dossier_foncier_id)`.
- Fiche facture / dépôt via `uuid`.

Retirer l’onglet FACTURATION du dossier **ne les cache pas**. Le bloc
« Factures du dossier » n’affiche de toute façon rien.

`posteUtiliseParFacture` (service L254-265) empêche d’effacer un
poste dossier si un dépôt a le même couple dossier+type. Donc : ne
pas DELETE les postes en même temps que l’onglet.

### Coût du retrait UI

Retirer `facturation` de `DossierFoncierTabs::KEYS`,
`DOSSIER_FONCIER_TAB_KEYS`, labels AppShell, `TabPanel` Show.vue.
Garder les routes CRUD (ou 410) tant que des lignes existent.
Alias `facturation` → repli `synthese` (`resolve()` L52-67 déjà
repli silencieux vers synthèse si clé inconnue — si on retire
l’alias, un lien `?onglet=facturation` tombe sur synthèse).

---

## 4. ONGLET FONCIER — RÉUTILISER LE PATRON CHANTIER

### Navigation programme actuelle

`AppShell.vue` `programmeNavItems` L218-254 : RÉSUMÉ,
COMMERCIALISATION, (ACQUÉREURS), **CHANTIER**, BILAN, BUDGET,
DOCUMENTS. Ordre #1104. FONCIER n’existe pas.

Identité dans la barre gold : nom + `code_envol`
(`headerProgrammeName` / `headerProgrammeCode` L354-357), pas le
dossier.

`showHeaderProgrammeBar` L92-100 : vrai dès que
`headerProgrammeId` (props.programme.id ou URL `/programmes/{id}`).

`showHeaderDossierBar` L103-105 : **seulement**
`component === 'Foncier/Dossiers/Show'`. Les deux barres sont
`v-if` / `v-else-if` L1898 / L2027 → **mutuellement exclusives**.

### Patron CHANTIER (niveau 2)

1. Route `GET /programmes/{programme}/marches`
   (`routes/web.php` L2305) → `MarcheController::index` L85-87 →
   `SuiviChantierController` → Inertia `SuiviChantier/Index`.
2. AppShell : barre programme gold + hôte
   `#chantier-subnav-host` `class="contents"` L2067-2070
   (`v-if="showChantierSubnavHost"`).
3. `SuiviChantier/Index.vue` L314-332 : `Teleport to="#chantier-subnav-host" defer`
   + `ObjectSubNavBar` **`tone="surface"`** `nowrap`. **Pas**
   d’identité dossier/programme dans cette barre (slot identity
   vide). Trailing = boutons métier.
4. Onglets hash (`ORDERED_TAB_IDS` L101) : APPELS D'OFFRES · MARCHÉS
   · FACTURES · … · SYNTHÈSE. Clic = `replaceState` hash, contenu
   `v-show` de pages **déjà autonomes** avec prop `embedded`.

Header fil d’Ariane masqué si `SuiviChantier/Index` (L2075).

### `ObjectSubNavBar` est-il réutilisable ?

**Oui, générique.** `resources/js/Components/ObjectSubNavBar.vue`
L1-24 : items `{ key, label, href?, active, placeholder }`, `tone`
gold|surface, `nowrap`. Consommateurs : barre programme, barre
dossier, identité foncier, CHANTIER, fiche marché, dépôt.

`SuiviChantier/Index.vue` **n’est pas** générique : liste d’onglets,
permissions par onglet (`factures_tab_visible`, etc.), Teleport vers
un hôte **nommé**. Il faudrait un jumeau `Programmes/Foncier.vue` (ou
`SuiviFoncier/Index`) + hôte `#foncier-subnav-host` dans AppShell.

### Les écrans dossier peuvent-ils s’afficher sous un programme ?

Aujourd’hui **non, pas tel quel**.

`Foncier/Dossiers/Show.vue` ≈ 1605 lignes, **un seul** `TabView`
L645-1579. La nav PrimeVue est masquée (CSS L1597-1603) : les
onglets visibles sont ceux d’AppShell dossier. Contenu :

| Onglet | Où | Autonome ? |
|---|---|---|
| SYNTHÈSE | inline Show.vue L646-1006 | non |
| FONCIER | `DossierFoncierTab.vue` | composant, mais props du show |
| NÉGOCIATIONS | inline L1019-1152 | non |
| FAISABILITÉ | inline L1154-1311 | non |
| PROMESSES | inline L1313-1467 | non |
| FACTURATION | `DossierFacturationPanel.vue` | à retirer |
| DOCUMENTS | `EntityDocumentsPanel.vue` | oui-ish |
| SUIVI | inline L1498-1510 | non |
| ACCÈS | inline L1512+ | **interdit** depuis le programme |

Le `show()` (`DossierFoncierController.php` L354-516) charge un
graphe énorme (parcelles, offres, promesses, scénarios,
habilitations, documents, tâches…). Route
`foncier.dossiers.show` + `authorize('view')` L358. Binding
`{dossierFoncier}` + scope global ACL.

Deep links (offre, parcelle, promesse) →
`Foncier/Offres/Show` etc. → `showHeaderFoncierIdentityBar`, **plus**
de barre programme.

Les POST (inline edit, offres) redirigent vers
`foncier.dossiers.show` + `onglet`. Sous un shell programme, il
faudrait `return_to`.

**Opposé de CHANTIER** : CHANTIER agrège des Index déjà séparés
(`Marches/Index`, `AppelsOffres/Index`, …) avec `embedded`. Le
dossier est une page unique.

### Routes à ajouter (si imbrication vraie)

```
GET /programmes/{programme}/foncier
GET /programmes/{programme}/foncier/{onglet?}
```

Onglets hash alignés dossier : `#synthese` `#foncier` `#negociations`
`#faisabilite` `#promesses` `#documents` `#suivi`. Pas `#facturation`,
pas `#acces`.

Props Inertia **obligatoires** : `programme` (pour la barre gold).
Sinon `showHeaderProgrammeBar` tombe.

Rendu : **nouveau composant** `Programmes/Foncier.vue`, pas
`Foncier/Dossiers/Show` — sinon AppShell affiche la barre dossier et
cache la barre programme (L2027 `v-else-if`).

### Coût réel vs repli

| Voie | Travail | Risque | Maquette |
|---|---|---|---|
| **A. Imbrication vraie** | Hôte AppShell (zone commune) + page hub + extraire 6 TabPanels de Show.vue + `return_to` sur les POST + deep links (offre/parcelle) cassent le shell | Élevé. 1–2 lots front lourds, collisions AppShell | Conforme |
| **B. Repli lien** | Onglet FONCIER → `Link` `/foncier/dossiers/{id}` (403/404 si pas le droit). Boutons Associer/Créer sur le dossier. | Faible. 1 lot | Non : on quitte le programme |
| **C. Hub mince (recommandé PHASE 2)** | Même nav que A (barre programme + sous-nav surface). Corps = référence + libellé dossier + **contenu Synthèse** (ou iframe Inertia du graphe show **sans** TabView nav). Autres sous-onglets = même page `?onglet=` en chargeant le payload `DossierFoncierController` factorisé. Deep links restent hors shell (acceptable PHASE 2) | Moyen | Conforme sur la nav ; contenu partiel |

Chiffrage indicatif (pas d’implémentation ici) :

- B : ~0,5–1 lot.
- C : ~1,5–2 lots (dont AppShell).
- A complet (deep links dans le shell) : 3+ lots.

**Repli si C dérape :** onglet FONCIER du programme affiche
référence + libellé + bouton « Ouvrir le dossier » (B), sous-nav
masquée. La maquette complète reprend un lot suivant.

---

## 5. LES DROITS — POINT CRITIQUE

### Dossier (rappel #1512 / #1288)

Scope global `RecordHabilitationScope` L32-42 :
`createur_id` ∪ `record_habilitations` actives (lecture|gestion).

Bypass vue `RecordAcl::BYPASS_VIEW_GROUPS` L53-58, lu **en brut**
dans le scope L48-52 :

```
GS_DIRECTION_GENERALE, GS_GEMME_PDG, GS_DSI, GS_ADMIN_ERP
```

Policy `DossierFoncierPolicy::view` L10-13 =
`$dossier->userCanViewRecord($user)` (même règle, `HasRecordHabilitations`
L56-69).

`show()` L358 : `$this->authorize('view', $dossierFoncier)`.

**Pas de droit par onglet** (#1448). `authorize('view')` ouvre
SYNTHÈSE … ACCÈS. ACCÈS **écriture** =
`manageHabilitations` (`RecordAcl::MANAGE_HABILITATIONS_GROUPS` L65-68
: DG, GEMME_PDG, ADMIN_ERP — **pas GS_DSI**).

### Programme (distinct)

`ProgrammeAccessService` L12-18 : *« quels programmes cet utilisateur
peut-il voir »*.

- Tous les programmes actifs si `userCanAccessAllProgrammes` L102-106 :

```
return $user->isAdminERP()
    || $user->hasAnyM365Group(['GS_DSI', 'GS_DIRECTION_GENERALE', 'GS_COMPTABILITE']);
```

  **Dette :** `hasAnyM365Group` / `isAdminERP` short-circuitent
  GS_ADMIN_ERP (`User.php` L103-114, L146-148). `Gate::before`
  `AppServiceProvider.php` L69-74 renvoie true pour `isAdminERP()`.
  Toute proposition PHASE 2 pour l’onglet FONCIER doit lire
  **`m365GroupNames()` brut**, jamais `hasAnyM365Group` ni Gate.

- Sinon : UNION `programme_responsables.user_id` ∪ programmes des
  enseignes GS_ENVOL→ENVOL / GS_HECTARE→HECTARE (`GROUPE_ENSEIGNE_MAP`
  L29-32). `enseigneCodesForUser` L134-136 utilise encore
  `hasM365Group` (même piège).

**GS_GEMME_PDG** : bypass **dossier** (#1434), **absent** du bypass
programme. Voit tous les dossiers, pas tous les programmes.

Liste `/programmes` (`ProgrammeController::index` L62-111) : filtre
statut + enseigne **confort** (#1059), **sans**
`ProgrammeAccessService`. Dashboard `ProgrammeDashboardController::show`
L36 : **pas** d’appel `accessibleProgrammeIds`. Un utilisateur qui
connaît l’URL ouvre la fiche. Le switcher, lui, est filtré
(`feature.permission:programmes.lecture` + AccessService).

### Quelqu’un qui voit un programme voit-il forcément son dossier ?

**NON.**

Contre-exemples lus dans le code :

1. Collaborateur GS_ENVOL : voit les programmes enseigne ENVOL
   (AccessService). Dossier lié : invisible s’il n’est ni créateur,
   ni habilité, ni bypass (scope L32-42).
2. Comptable GS_COMPTABILITE : voit **tous** les programmes (L102-106).
   Pas dans `BYPASS_VIEW_GROUPS` dossier → dossier masqué.
3. GS_DSI : exception — bypass **les deux**. Ce n’est pas la règle
   métier générale.

La réponse attendue est donc **confirmée : NON**.

### Masquer l’onglet FONCIER côté serveur

Ne pas se fier à un `v-if` seul.

1. **Prop serveur** `foncier_tab_visible` (dashboard + AppShell),
   calculée : dossier lié existe **et**
   `$dossier->userCanViewRecord($user)` avec groupes via
   `m365GroupNames()` (déjà le cas dans le trait). Si le global
   scope masque la relation `hasOne`, elle revient `null` → onglet
   absent (équivalent « pas de dossier pour moi »).
2. **Route** `GET /programmes/{programme}/foncier` :
   charger le dossier **`withoutGlobalScope(RecordHabilitationScope)`**
   pour distinguer « pas de lien » (404) et « lien mais pas le droit »
   (403). Puis `$this->authorize('view', $dossier)` (policy, pas Gate
   manuel). Ne pas utiliser `hasAnyM365Group`.
3. Ne pas envoyer le graphe dossier (prix, propriétaires) dans les
   props du dashboard programme si `!userCanViewRecord`.

### Chaque écran du sous-menu : droits propres ?

Pour les 6 onglets restants **dans le hub** : **l’accès `view` au
dossier suffit**, comme aujourd’hui (#1448). Pas de droit par onglet.

Exceptions :

- **ACCÈS** : absent du hub programme (décision métier). Reste sur
  `Foncier/Dossiers/Show` + `manageHabilitations`.
- **Écriture** (inline, offres, documents) : `update` /
  `foncier.gestion` inchangés. Un lecteur programme+dossier voit, un
  non-`peut_update` ne mute pas.
- **Deep links** parcelle / offre : scopes et policies **déjà**
  distincts (`FoncierParcelleVisibiliteScope` ≠ dossier). Ne pas les
  réécrire dans ce chaînage.

### Inversement : quelqu’un qui voit un dossier voit-il son programme ?

**Pas forcément.**

- Habilité dossier sans GS_HECTARE/GS_ENVOL et sans rôle
  `programme_responsables` : dossier oui, programme non (sauf s’il
  tape l’URL dashboard, aujourd’hui non gardée).
- GS_HECTARE + habilitation dossier HECTARE : oui (enseigne).
- GS_GEMME_PDG : dossier oui (bypass), programme seulement s’il est
  responsable ou a GS_HECTARE/ENVOL.

PHASE 2 n’a pas à « ouvrir » le programme au habilité dossier. Le
chaînage est une nav, pas une fusion d’ACL.

---

## 6. LES DEUX BOUTONS

### Où les placer

Onglet **SYNTHÈSE** du dossier, carte identité
(`Show.vue` L646-672 : référence, enseigne, classe Sage, …).
Visible si `peut_update` (`DossierFoncierController` L469 =
`userCanManageRecord`). Pas sur ACCÈS. Pas dans la barre AppShell
dossier (identité = référence · libellé L2032-2037).

États :

- Pas de programme : « Associer à un programme » + « Créer un
  programme ».
- Programme lié : libellé + `code_envol` + lien
  `/programmes/{id}` + (option) Dissocier.

### « Créer un programme »

Aujourd’hui `ProgrammeController::create` L388-422 /
`store` L425-466. Champs requis : `enseigne_id`, `nom`,
`departement`, `commune`. Optionnels : banque, logo, responsables,
`code_analytique_compta`, `responsable_suivi_commercialisation_id`.
`entite_id` = `enseigne.entite_par_defaut_id` L457. `code_envol` =
`Programme::prochainCode` L447.

Préremplissage **fiable** depuis le dossier :

| Champ programme | Source dossier | Fiable ? |
|---|---|---|
| `enseigne_id` | `dossiers_fonciers.enseigne_id` | oui |
| `libelle` / `nom` | `dossiers_fonciers.libelle` | oui (modifiable) |
| `commune` | `communes.nom` via `code_insee_commune` | oui (libellé, pas l’INSEE — le programme n’a pas de code INSEE) |
| `departement` | `communes.departement` | oui |
| `intacct_class_analytics_id` | dossier L75-76 / L129-131 | oui si saisie |
| `entite_id` / société | dossier : **aucune** société | non — seulement défaut enseigne, déjà le `store` |
| `code_envol` | `dossiers_fonciers.reference` | **non** — autre format, générateur obligatoire |
| responsables | `dossier_foncier_user` (responsables métier) | discutable, pas le même pivot que `programme_responsables` |

### Défaut #1519

`Programme::prochainCode` L488-503 : `static::query()` **sans**
`withTrashed()`. UNIQUE `code_envol`
(`2026_04_23_100000_add_fields_to_programmes_table.php` L14).
Soft-delete → le code est reproposé → 500.

**Oui, le bouton « Créer un programme » exposerait le même 500.**
PHASE 2 : soit corriger `prochainCode` (`withTrashed()` + message
422 au lieu d’une 500) **dans le même lot** que le bouton, soit ne
pas ouvrir Créer tant que #1519 n’est pas livré.

`storeHorsErp` ne passe pas par le générateur (#1519 fiche) : hors
sujet ici (première opération = programme ERP).

### Associer / dissocier / droits

- **Associer** : choisir un programme **sans** dossier déjà lié
  (UNIQUE). Filtrer par enseigne du dossier pour limiter les
  erreurs, sans l’imposer si le métier le veut.
- **Dissocier** : NULL `programme_id`. Ne pas supprimer le
  programme. Qui : `userCanManageRecord` (même que `peut_update`).
  Pas GS_DSI seul sur un dossier dont il n’a pas la gestion
  (`userCanManageRecord` L72-80 : DSI-only ≠ direction).
- **Créer** : `update` dossier **et** capacité `store` programme
  (route `POST /programmes` L1858 : `auth` seulement, pas
  `feature.permission:programmes.ecriture`). PHASE 2 : ne pas
  élargir ce trou ; garder `peut_update` dossier comme cran.

---

## 7. CE QUI DEVIENDRA POSSIBLE ENSUITE

(Liste, **rien à implémenter**.)

- Bascule postes dossier → programme (#1448 UPDATE XOR) puis
  remontée dans `BilanFinancierService` (déjà joint sur
  `programme_id`).
- Parcelles / propriétaires du dossier sur le RÉSUMÉ programme.
- Chronologie unifiée (étapes programme ∪ promesses / actes dossier).
- GED : pont SharePoint dossier ↔ arborescence programme (deux
  arbres aujourd’hui).
- Tâches morph `attachable` dossier visibles dans le panneau
  programme (`taches_suivi_dossier` déjà sur la synthèse dossier
  L512).
- Fermeture destination PROJET des factures : imputer au programme
  une fois le lien posé (autre chantier).
- Tranches sous le programme (« deux opérations sur un terrain ») :
  le un-à-un dossier↔programme **ne bloque pas** des tranches
  enfants du programme.

Le chaînage Option B ne ferme aucune de ces portes.

---

## GARDE-FOUS RESPECTÉS

- Pas de fusion des tables.
- Pas de rattachement automatique des existants.
- Pas de tranches / phases.
- Destination PROJET non touchée.
- Pas de renommage de « programme ».

---

## DÉPLOIEMENT

Diagnostic : `git pull origin main` suffit. Pas de migrate, pas de
`journal:sync`, pas de build.

## LEÇON

Deux ACL distinctes (dossier = liste blanche par enregistrement ;
programme = enseigne ∪ responsables ∪ bypass compta) interdisent de
déduire la visibilité de l’un à partir de l’autre. Un onglet FONCIER
sur le programme doit être **calculé et autorisé côté serveur** avec
`m365GroupNames()` brut. `ObjectSubNavBar` est générique ; le hub
CHANTIER l’est moins, et le dossier encore moins (page unique).
Imbriquer le dossier dans le programme sans extraire `Show.vue`,
c’est se battre avec deux barres AppShell mutuellement exclusives.
UNIQUE + SoftDeletes sur `programmes` (#1519) interdit de poser le
un-à-un sur cette table.
