---
suivi: 1255
date: 2026-08-07
sujet: Export ZIP devis PDF appel d'offres (un dossier par lot, conformes uniquement)
chantier: appels-offres / exports
type: feature
statut: poussé
hash: 0e9d24d1
fichiers:
  - app/Services/AoDevisZipExportService.php
  - app/Http/Controllers/AppelOffreController.php
  - routes/web.php
  - resources/js/Pages/AppelsOffres/Show.vue
  - tests/Unit/AoDevisZipExportServiceTest.php
  - tools/diag/mesure_export_zip_devis_ao_1255.php
  - database/data/journal_mises_a_jour_post_2026_06_12.php
  - public/build/
  - docs/suivi/SUIVI_1255_export_zip_devis_ao.md
---

# SUIVI #1255 — Export ZIP devis AO

## PROMPT ENVOYÉ

Export ZIP des devis PDF d'un appel d'offres : un dossier par lot, dernier
devis de chaque entreprise conforme uniquement, fichiers locaux (pas Graph),
rapport d'exclusions visible, bouton à côté Excel/PDF, permission `ao.lecture`,
pas de migration, pas de toucher aux exports existants ni à `AoDocumentService`.

## FICHES LUES

- `#1231` — `normalizeDynamicFolderName` unifié ; leçon collisions / écrasement
  silencieux `{TYPE}.pdf` ; réutiliser ce normaliseur (pas un 3ᵉ).
- `#1228` — audit chemins locaux marchés (0 manquant / 0 orphelin) ; à
  reproduire pour `ao_devis` via script mesure.
- `#1108` — échec muet pire qu'un refus → `_rapport.txt` obligatoire dans le ZIP.
- `#1250` — pas de fiche `#1248` concurrente sur `AoDocumentService` au moment
  du lot ; WIP local `#1251`/`#1254` sur `Marches/Show.vue` (hors périmètre).
- `#1192` — grammaire visuelle boutons export (outlined, small).

## SYNTHÈSE

### (a) Sélection

| Critère | Valeur vérifiée |
|---|---|
| Conformité | Colonne `ao_consultations.conforme` (boolean, défaut `true`, migration `2026_07_01_120000_add_conforme_to_ao_consultations_table`) |
| Dernier devis | `date_devis` ASC puis `id` ASC → `last()` = max date puis max id — **identique** à `AoConsultation::dernierDevis()` / `AoDevis::isLatest` (pas `created_at`) |

### (b) Cas limites

| Cas | Traitement |
|---|---|
| Conforme sans devis | Absent du ZIP ; motif `sans_devis` dans `_rapport.txt` |
| Dernier devis sans `devis_pdf_local_path` | Absent ; motif `sans_pdf` |
| Path en base, fichier absent disque | Absent ; motif `pdf_absent_disque` (pas de repli SharePoint) |
| Lot sans consultation / sans conforme / sans PDF exportable | **Pas de dossier vide** ; listé dans « Lots sans dossier » |

### (c) Nommage

- Dossier : `SharePointService::normalizeDynamicFolderName(libelle)` → `LOT_1_GROS_OEUVRE`
- Fichier : `devis_` + lower(normaliseur(entreprise)) + `.pdf`
- Collision : suffixe `_2`, `_3`… + `Log::warning` + section collisions du rapport

### (d) Génération

- `ZipArchive` sur fichier temporaire (pas en mémoire) ; unlink dans `finally`
- Nom : `{reference}_devis_{Ymd}.zip`
- Rapport : `_rapport.txt` toujours présent dans le ZIP

### (e) Droits

- `assertFeaturePermission(..., 'ao.lecture')` + `assertAoScope`
- Signalement : `ao.devis.lecture` existe au catalogue et n'est branché nulle part ;
  le ZIP contient des PDF concurrents / montants. Excel/PDF existants n'ont
  toujours que `assertAoScope` (dette préexistante). Pas de `GS_*` en dur.

### (f) Anti-collision

- `#1248` : aucune fiche `docs/suivi/` ; `AoDocumentService` non touché
- Page `AppelsOffres/Show.vue` distincte de `Marches/Show.vue` (#1251/#1254 WIP local non inclus)

## LEÇON

« Dernier devis » métier = `date_devis` puis `id`, déjà codifié sur le modèle —
ne pas réinventer avec `created_at`. Pour un export ZIP multi-fichiers, un
`_rapport.txt` interne bat un flash Inertia (téléchargement stream).

## DÉPLOIEMENT

```
git pull origin main
php artisan optimize:clear
php artisan view:clear
php artisan journal:sync
```

Puis `php tools/diag/mesure_export_zip_devis_ao_1255.php AO-2026-003` pour coller
poids / durée / décomptes réels.
