---
suivi: 1629
date: 2026-09-22
sujet: Documents sur un scénario de faisabilité + viewer modal
chantier: module-foncier
type: feature
statut: poussé
hash: 310795b3
fichiers:
  - app/Http/Controllers/Foncier/ScenarioFaisabiliteController.php
  - app/Models/ScenarioFaisabilite.php
  - app/Services/Foncier/FoncierEntityDocumentAccess.php
  - app/Services/Foncier/FoncierEntityDocumentService.php
  - app/Support/Foncier/FoncierDocumentProfile.php
  - resources/js/Components/DocumentPreviewDialog.vue
  - resources/js/Components/Foncier/EntityDocumentsPanel.vue
  - resources/js/Pages/Foncier/Scenarios/Show.vue
  - routes/web.php
  - tests/Feature/Foncier/FoncierScenarioDocuments1629SourceTest.php
  - tests/Feature/Foncier/FoncierEntityDocumentsSourceTest.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1629_documents_scenario_faisabilite.md
---

# SUIVI #1629 — documents d’un scénario de faisabilité + viewer en modale

## PROMPT ENVOYÉ

Attacher des documents à un scénario de faisabilité (`/foncier/scenarios/{id}`) :
plusieurs fichiers par scénario, rattachés à CE scénario, 50 Mo par fichier, et
un viewer en fenêtre modale (PDF + images intégrés, autres formats en
téléchargement explicite). Réutiliser le pipeline documents polymorphe du #1627,
sans second pipeline ni nouveau service de stockage. Ne pas modifier les limites
des autres entités, ni le contenu métier du scénario, ni SharePoint.

## FICHES LUES

- **#1627** — cap de taille porté par `FoncierDocumentProfile` (dossier 50 Mo,
  reste 10 Mo). Impact : constante dédiée `MAX_TAILLE_OCTETS_SCENARIO`, pas de
  bump du défaut global.
- **#1117 LEÇON** — un seul pipeline, caps dans le profil, pas N services
  copiés. Impact : le contrôleur scénario utilise le trait existant.
- **#1131** — patron de branchement d’une entité secondaire (valorisation) :
  trait + 3 endpoints + segment de stockage.
- **#1203** — onglet Documents agrégé du dossier (`serializeAggregatedForDossier`).
  Impact : le scénario est volontairement **hors** de cet agrégat.
- **#1142 / #1144 / #1145** — socle scénario : table `scenarios`, gel, versions.
- **#892 / #903** — `DocumentPreviewDialog.vue` existe déjà (Marchés, Appels
  d’offres). Impact : réutilisé, aucune modale écrite de zéro.
- **#1628 / zones communes** — série Vite : rebase avant build.

## AVANT MODIFICATION (constat)

**Table et modèle du scénario.** `App\Models\ScenarioFaisabilite`,
`protected $table = 'scenarios'`. `Schema::getColumnListing` impossible en local
(MySQL 127.0.0.1 refusé, même constat #1626 / #1628) : colonnes relevées sur le
dump `hectareenvol.sql` (CREATE TABLE `scenarios`) + `$fillable` :
`id`, `dossier_foncier_id`, `version`, `libelle`, `mode`, `statut`,
`retenu_par_id`, `date_retenu`, `gele`, `programme_id`, `createur_id`,
`commentaire`, `created_at`, `updated_at`. **Pas de `deleted_at`.**

**SoftDeletes : non.** `ScenarioFaisabilite` n’utilise pas le trait ; `destroy()`
est une suppression physique. Sans traitement, les `entity_documents` du scénario
devenaient orphelins (lignes + fichiers sur disque). Corrigé dans ce lot.

**Patron dossier foncier (répliqué à l’identique).**
- Contrôleur : `use ManagesFoncierEntityDocuments` + 3 accesseurs
  (`foncierDocuments`, `foncierDocumentAccess`, `foncierDocumentRedirectRoute`).
- Payload show : `documents`, `document_limits`, `peut_upload_documents`,
  `peut_supprimer_documents`, `documents_store_url`, `documents_base_url`.
- Trait : `storeEntityDocuments` / `downloadEntityDocument` /
  `destroyEntityDocument`, validation `max:` issue du profil.
- Routes : `documents.store` (POST), `documents.download` (GET),
  `documents.destroy` (DELETE).
- UI : `EntityDocumentsPanel.vue` (lit `document_limits.max_taille_mo`).

**Aperçu aujourd’hui.** `EntityDocument::isInlineViewable()` = PDF + `image/*`,
servi par le trait avec `Content-Disposition: inline`. Une modale existait
**déjà** : `resources/js/Components/DocumentPreviewDialog.vue` (#892, réutilisée
#903 par `Marches/Show.vue` et `AppelsOffres/Show.vue`) — Dialog PrimeVue,
`iframe` pour PDF, `img` pour images, message + lien de téléchargement sinon.
Elle a été réutilisée, pas réécrite. Les panneaux documents foncier ouvraient
jusqu’ici le fichier dans un nouvel onglet (`target="_blank"`).

## SYNTHÈSE

**Profil.** `FoncierDocumentProfile::for()` accepte `ScenarioFaisabilite` :
segment `scenarios`, `MAX_PAR_SCENARIO = 20`, taille
`MAX_TAILLE_OCTETS_SCENARIO = 50 Mo` (constante dédiée). Le choix de taille est
passé d’un ternaire à un `match (true)` : dossier 50, scénario 50, **défaut 10**
— offre, promesse, avenant, acte, valorisation, échange inchangés (couvert par
test).

**Rattachement.** `ScenarioFaisabilite::documents()` en `morphMany` sur
`entity_documents` (`documentable_type` = `App\Models\ScenarioFaisabilite`, pas
de morph map dans l’app). Le payload du scénario n’utilise que
`serializeCollection($scenarioFaisabilite)` ; le scénario n’est pas ajouté à
`serializeAggregatedForDossier`. Un dépôt sur S1 n’apparaît donc ni sur S2 ni sur
le dossier. Stockage `foncier/scenarios/{id}/`, disque `local` privé.

**Suppression du scénario.** `destroy()` purge les documents via
`FoncierEntityDocumentService::delete()` (ligne + fichier disque + log +
snapshot d’audit) avant le `delete()` du scénario.

**Droits (exactement ceux de la page scénario).**
- Consulter : `$user->can('view', $dossier)` → `DossierFoncierPolicy::view` →
  `userCanViewRecord` (bypass groupe, créateur, ou habilitation dossier).
- Déposer / supprimer : middleware de route `feature.permission:foncier.gestion`
  + `$user->can('update', $dossier)` → `userCanManageRecord`, **et** scénario non
  gelé (aligné sur `assertNonGele`, qui verrouille le reste de la fiche).
- Aucun contrôle de groupe M365 ajouté : rien à durcir en `m365GroupNames()`
  brut ici, la protection est la Policy d’enregistrement. `Gate::before`
  (GS_ADMIN_ERP) laisse effectivement tout passer → **le compte de Robin ne
  prouve aucun refus**.
- **Test du 403 :** compte tiers ayant `foncier.lecture` mais **aucune
  habilitation sur ce dossier**, et **sans** GS_ADMIN_ERP / GS_DSI /
  GS_DIRECTION_GENERALE / GS_GEMME_PDG (groupes de `RecordAcl::BYPASS_VIEW_GROUPS`),
  et n’étant pas le `createur_id` du dossier.

**Viewer modal.** `EntityDocumentsPanel` monte `DocumentPreviewDialog` :
`previewKindOf()` mappe `application/pdf` → `pdf`, `image/*` → `image`, sinon
`unknown`. Pour `unknown`, l’URL d’affichage reste vide et la modale affiche
« Ce format ne peut pas s’afficher dans le navigateur (Word, Excel, ZIP,
plans…) » + bouton de téléchargement : aucune tentative d’affichage. Ctrl/Cmd/
Shift + clic et clic milieu ne sont pas interceptés (le `href` reste le lien de
téléchargement). `closePreview()` remet URL, titre, kind et download à vide, et
le contenu du Dialog est démonté à la fermeture → pas de fichier retenu.
Réouverture testée. Le panneau étant partagé, le gain d’UX vaut aussi pour
dossier / offres / promesses / actes / parcelles (limites inchangées).

**Flux (pas de chargement en mémoire).** Le téléchargement passe par
`response()->file($path, $headers)` pour l’inline et `response()->download(...)`
sinon — `BinaryFileResponse`, envoi en flux par Symfony. Aucun
`file_get_contents` / `base64_encode` dans le trait (assertion de test). Un PDF
de 50 Mo n’est donc pas bufferisé côté PHP ; le rendu dans l’`iframe` reste à la
charge du lecteur PDF du navigateur. Plafond de requête inchangé : 120 Mo (deux
fichiers de 50 Mo passent, trois non).

## VÉRIFICATIONS

`php -l` : OK sur les 8 fichiers PHP modifiés + le journal.

Reflection / DI (sortie brute en synthèse) : contrôleur instanciable,
`authorize` présent (trait `AuthorizesRequests` conservé), 3 méthodes documents
présentes, `MorphMany` retourné par `documents()`, profil scénario
`{segment: scenarios, max_documents: 20, max_taille_octets: 52428800}`,
limites dossier 50 / offre 10, et les 3 routes générées.

Tests : `FoncierScenarioDocuments1629SourceTest` (11 tests) +
`FoncierEntityDocumentsSourceTest` + `FoncierControllersIntegritySourceTest`
(#1103) verts. **3 échecs préexistants hors périmètre**, tous des assertions de
source sur `resources/js/Pages/Foncier/Dossiers/Show.vue` (`'faisabilite'`,
`openReponse`, `proprietaire_ids`) : ce fichier a été refactoré en composants par
un lot antérieur et n’est **pas** modifié ici (`git diff HEAD` vide sur ce
fichier).

## DÉPLOIEMENT-TEST

```bash
git pull origin main
php artisan optimize:clear
php artisan view:clear
php artisan route:clear   # 3 routes ajoutées
php artisan journal:sync
```

Pas de migration (aucune table ni colonne ajoutée : `entity_documents`
polymorphe existant). Pas de `npm`, pas de `composer` sur OVH.

Tests fonctionnels :
1. Déposer un document sur S1 → il apparaît sur S1.
2. Ouvrir S2 puis le dossier → le document n’y apparaît pas.
3. Fichier 45 Mo accepté ; 55 Mo refusé avec « max 50 Mo » (contrôle navigateur
   + `max:51200` serveur).
4. Clic sur un PDF → affichage dans la modale.
5. Clic sur une image → affichage dans la modale.
6. Clic sur un Word / Excel → message d’impossibilité + téléchargement.
7. Fermer la modale, la rouvrir sur un autre document.
8. Supprimer un document → disparaît et fichier retiré du disque.
9. Dossier toujours 50 Mo, offre toujours 10 Mo.
10. Compte tiers non habilité (voir ci-dessus) → page scénario 403, et accès
    direct à `/foncier/scenarios/{id}/documents/{doc}` → 403.

## LEÇON

Une entité **sans SoftDeletes** branchée sur un pipeline de documents
polymorphe crée des orphelins silencieux : la purge doit être écrite dans le
`destroy()`, aucune cascade ne la fait (`entity_documents` n’a pas de FK vers une
table polymorphe).

Avant d’écrire une modale d’aperçu, chercher l’existante : `DocumentPreviewDialog`
était déjà là depuis #892 et couvrait PDF / image / format non affichable. Le
brancher dans le panneau **partagé** propage le gain à six pages d’un coup — mais
oblige à vérifier que les caps et les droits de ces pages ne bougent pas.
