---
suivi: 1228
date: 2026-08-06
sujet: Diag déplacement documents marché/avenant public → privé
chantier: documents / marchés / sécurité stockage
type: diagnostic
statut: poussé
hash: cb42ee81
fichiers:
  - tools/diag/diag_documents_marche_public_1228.php
  - docs/suivi/SUIVI_1228_diag_documents_marche_public.md
---

# SUIVI #1228 — PHASE 1 DIAGNOSTIC — documents marché hors disque public

## PROMPT ENVOYÉ

Déplacer les documents de marché et d'avenant du disque public vers le disque
privé. PHASE 1 lecture seule : tables/chemins, générateurs de liens, pattern
foncier réutilisable, stratégie migration 73 Mo, impacts si erreur. Script OVH
+ fiche. Aucun code métier / migration / Vue / rebuild / déplacement fichier.
`logos/` et `bug-reports/` hors périmètre. HEAD annoncé = `99c5bf39` ; base
réelle au commit = `origin/main` post-#1226.

## FICHES LUES

- `#1226` — inventaire dépôts : marchés/avenants confirmés sur **disque public**
  + SP best-effort ; `entity_documents` / dépôt factures déjà privés. LEÇON :
  extraire la capture, pas le pipeline ; volumes = environnement courant.
- `#1194` — pattern téléchargement foncier (`downloadDocument`, disque `local`)
  réutilisé comme référence contrôle de droits.
- `#1191` — LEÇON : reprise de données = décision Robin (`--apply`), pas un
  data-fix Cursor ; mesure OVH avant écriture.
- `#1188` — gardes `Schema::hasTable` / `hasColumn` avant chaque accès.
- `#1227` — fiche absente du repo au moment du diag ; lot htaccess+MIME annoncé
  en parallèle (ceinture, pas la solution). WIP local non touché.

---

## OBJET 1 — OÙ VIVENT LES CHEMINS

### Tables / colonnes (schéma migration + modèles)

| Table | Colonne chemin | Type | Autres colonnes utiles |
|-------|----------------|------|------------------------|
| `marche_documents` | `fichier_path` | `string` (varchar) | `type_document`, `nom_fichier`, `sharepoint_url`, `sharepoint_item_id`, `statut` |
| `avenant_documents` | `fichier_path` | `string` (varchar) | `nom_fichier`, `sharepoint_url`, `sharepoint_item_id`, `uploaded_by` |

Aucune colonne `disk` sur ces deux tables (contrairement à
`tache_suivi_documents.disk`).

### Format écrit par le code (mesure code, pas stub local)

`MarcheController::persistDocumentUpload` :

- répertoire relatif `marches/{marche_id}` ;
- fichier sous `storage_path('app/public/'.$relativeDirectory)` ;
- DB : `fichier_path = 'marches/{id}/{filename}'`.

`persistAvenantDocumentUpload` :

- `avenants/{avenant_id}/{uuid}_{nom}.ext` ;
- même racine `storage/app/public/`.

### 🔴 Verdict chemin relatif vs URL absolue

| Source | Résultat |
|--------|----------|
| Écriture code | **chemin relatif** sous `marches/` ou `avenants/` |
| Colonne `sharepoint_url` | URL absolue SharePoint (hors déplacement disque local) |
| Stub local Cursor | MySQL `erp_immo` injoignable — **pas de volume local présenté comme prod** |

**Conséquence** : si l’échantillon OVH (§2 du script) confirme 100 %
`chemin_relatif` et 0 `url_absolue` dans `fichier_path`, le déplacement
**n’exige pas de réécriture de lignes** — il suffit de changer la racine de
résolution dans le code (et éventuellement garder le même relatif sous
`storage/app/private/…`). Une URL absolue dans `fichier_path` forcerait une
réécriture ; le script classe chaque valeur pour trancher en prod.

Disque : **déduit en dur** (`storage_path('app/public/')`), jamais stocké.

Comptes / volumes : à coller depuis la sortie OVH (prod mesurée hors script :
~73 Mo / 25 dossiers `marches/`, ~290 Ko `avenants/`).

---

## OBJET 2 — QUI GÉNÈRE LES LIENS

| Lieu | `fichier:ligne` | Mécanisme | Rupture ? |
|------|-----------------|-----------|-----------|
| Vue fiche marché DOCUMENTS | `Show.vue:2244-2248` `documentViewHref` | URL **construite à la main** vers la route `/programmes/…/documents/{id}/download` ; sinon `sharepoint_url` | Route relative OK après move **si** le download change de racine. **Pas** `Storage::url`. |
| Vue AVENANTS PJ | `Show.vue:2051-2060` `avenantDocumentViewHref` | Idem route download avenant | Idem |
| Présenteur Inertia show | `MarcheController.php:709-714` | Expose `fichier_path` + `sharepoint_url` au front | `fichier_path` relatif fuité → permet de reconstruire `/storage/{fichier_path}` |
| Avenants payload | `MarcheController.php:1943-1949` | `id`, `nom_fichier`, `sharepoint_url` (pas `fichier_path`) | — |
| Download marché | `MarcheController.php:1176-1209` | Route auth ; **racine hardcodée** `storage_path('app/public/'.$doc->fichier_path)` ; `file_get_contents` + `response($contenu)` | **Point de rupture** (racine public) |
| Download avenant | `MarcheController.php:1286-1324` | Idem | **Point de rupture** |
| Persist / destroy / purge / migrate SP | `MarcheController.php:1024,1158,2225,2297,2495` | Même préfixe `app/public/` | **Points de rupture** (écriture/suppression) |
| Routes | `web.php:2041,2065` | Sous middleware `auth` | Contrôle session OK ; pas de Policy dédiée |
| `Storage::url()` / `asset()` | — | **Aucun** usage pour marchés/avenants | — |
| E-mails / notifications Teams | — | **Aucun** générateur trouvé vers `/storage/marches` | Liens manuels possibles hors code |
| Aktor / assistant | — | **N’expose pas** ces documents | — |
| Exports / PDF | — | Pas de lien fichier marché local trouvé | — |

**URL construite à la main (à signaler)** :

1. Les href Vue vers `/…/download` (chemins d’app, pas disque) — stables.
2. Les 8 résolutions `storage_path('app/public/'.$fichier_path)` — **vraie
   rupture** au déplacement.
3. Fuite potentielle : quiconque voit `fichier_path` dans le JSON Inertia peut
   ouvrir `https://…/storage/{fichier_path}` tant que le symlink public existe.

---

## OBJET 3 — LE MODÈLE À RÉUTILISER

### Pattern foncier (`FoncierEntityDocumentService` + trait)

| Élément | Détail |
|---------|--------|
| Disque | `FoncierEntityDocumentService::DISK = 'local'` → `storage/app/private` |
| Chemin DB | relatif `foncier/{segment}/{id}/{uuid}.ext` (`entity_documents.chemin`) |
| Route | `…/documents/{entityDocument}` → `downloadDocument` (ex. dossier #1194) |
| Droits | `FoncierEntityDocumentAccess::assertView` (ACL dossier / permission) |
| Réponse | `response()->file()` (inline) ou `response()->download()` — **BinaryFileResponse**, pas chargement mémoire entier |
| Absent | `abort(404)` si `!$disk->exists($chemin)` |

### Dépôt factures

Disque nommé `factures` → `storage/app/private/factures` ; téléchargement via
contrôleur authentifié (pas d’URL `/storage/…`).

### Marchés aujourd’hui

- Routes download **déjà** sous `auth` + `abort_unless` appartenance
  programme/marché/avenant.
- **Pas** de Policy / pas de `authorize()` / pas de droit distinct document.
- Si `sharepoint_url` non vide → **redirect** SharePoint (bypass local).
- Sinon : **charge tout le fichier en mémoire** (`file_get_contents`) — moins
  adapté que le foncier pour gros PDF.
- Fichiers **aussi** servis anonymement via symlink `public/storage` → faille
  de contrôle (objet du lot).

### Réutilisable tel quel pour 73 Mo ?

**Oui pour le contrôle d’accès** (route + assert appartenance, éventuellement
aligné « qui voit le marché »). **Améliorer le streaming** : passer à
`response()->file()` / `Storage::response()` comme le foncier — le plafond
upload marché est déjà `max:20480` (20 Mo) ; le plus gros fichier réel = sortie
§3 OVH. X-Sendfile non requis sur OVH mutualisé si BinaryFileResponse.

### Policy documents marché ?

**Absente.** Aujourd’hui : tout utilisateur authentifié qui atteint la route
avec les bons IDs (et donc qui peut voir la fiche) peut télécharger. Pas de
droit granulaire `marches.documents.download`.

**Question Robin (ne pas trancher)** : le téléchargement reste-t-il aligné sur
la visibilité fiche marché, ou un droit / groupe M365 distinct est-il requis
(RC Pro, Kbis, RIB, marché signé) ?

---

## OBJET 4 — STRATÉGIE MIGRATION 73 Mo (pas d’exécution)

### Script futur (lot suivant)

- Défaut **SIMULATION** ; écriture uniquement avec `--apply` (convention
  `#1225` / leçon `#1191`).
- Ordre strict : **copier** → vérifier taille + checksum → (DB si besoin) →
  **puis** supprimer la source. Jamais l’inverse.
- Si `fichier_path` reste relatif inchangé : **pas d’UPDATE DB** ; changer
  uniquement la racine code (+ dual-read).
- Idempotence : skip si cible existe et hash identique.

### Réversibilité

- Pendant la transition : ne pas supprimer le public tant que les downloads
  privés sont validés.
- Script inverse `--apply` (privé → public) ou restauration depuis copie
  encore présente.
- Journaliser chaque fichier traité (id, path, hash, statut).

### Cohabitation recommandée (lecture dual)

```
1) tenter storage/app/private/{fichier_path}
2) sinon retomber sur storage/app/public/{fichier_path}
3) sinon 404
```

Écritures nouvelles → **privé uniquement**. Après période de confiance :
supprimer le public + retirer le fallback. C’est le chemin le plus sûr.

### OVH / CLI

- Pas de worker (`QUEUE_CONNECTION=sync`) : commande CLI synchrone OK (pas
  d’HTTPS externe).
- Mesurer `max_execution_time` CLI (§6) : souvent `0` = illimité ; sinon
  fractionner par lots de N fichiers.
- Quota : besoin d’≈ 80 Mo libres pour cohabitation temporaire (§6
  `disk_free_space`).

---

## OBJET 5 — CE QUI CASSE SI ON SE TROMPE

| Symptôme | Cause |
|----------|--------|
| Download 404 sur fiche | Fichier déplacé / racine code non mise à jour / path orphelin |
| Compteur « X/4 pièces » faux ? | Compteur = `documents_requis − documents_manquants` (**présence de lignes DB** par `type_document`), **pas** existence fichier — un move incomplet **ne** change **pas** ce compteur |
| Bandeau « Documents manquants : RC… » à tort | Idem : basé sur types absents en base, pas sur disque |
| PJ avenant introuvable | Download local 404 ; si `sharepoint_url` présent → SP toujours OK |
| Liens morts `/storage/marches/…` | Symlink public après suppression des fichiers |

### Liens déjà partagés (mail / Teams)

Le code applicatif **ne génère pas** d’e-mail avec `/storage/marches/…`, mais
un utilisateur a pu copier l’URL publique du navigateur ou du JSON
`fichier_path`. Après déplacement → **404 anonyme**.

**Question Robin (instruire, ne pas trancher)** :

- Faut-il une **redirection de compatibilité** `/storage/marches|avenants/…`
  → route download authentifiée (404 sans session / login) ?
- Ou accepter la rupture des anciens liens publics (objectif sécurité) ?
- Les `sharepoint_url` restent valides indépendamment.

---

## SCRIPT DE DIAG

```text
php tools/diag/diag_documents_marche_public_1228.php
```

Sections : schéma → échantillon/classification chemins → volumes disque →
cohérence base↔disque → disk stocké/déduit → PHP CLI + espace libre.

---

## DÉPLOIEMENT-TEST

```text
git pull origin main
php tools/diag/diag_documents_marche_public_1228.php
```

Coller la sortie (surtout §2 verdict + §4 manquants/orphelins + §3 plus gros
fichier + §6 free space) dans le ticket. Pas de migrate / journal / build.

## LEÇON

`fichier_path` relatif + racine hardcodée = déplacement **code-first** sans
réécriture DB **si** OVH confirme zéro URL dans `fichier_path`. Le vrai trou
n’est pas l’absence de route download (elle existe sous `auth`) mais le
**symlink public** + fuite du relatif dans Inertia. Dual-read privé→public
avant purge. Ne pas confondre « documents manquants » (métier types DB) avec
fichiers absents du disque.
