[ERP-58] Implémenter l'export XLSX du répertoire clients (#37)
## Contexte
Ticket ERP-58 (M1 Commercial, spec-back § 4.6) — export XLSX du répertoire clients.
Branche stackée sur ERP-57. **Cible la MR sur \`feature/ERP-57-...\`** (squash merge).
## Objectif d'archi : un service d'export RÉUTILISABLE
Le générique vit dans \`Shared\`, le module Client ne déclare que QUOI exporter.
### Shared (le COMMENT — sans métier)
- \`Shared/Domain/Contract/SpreadsheetExporterInterface\` : \`export(string $sheetTitle, array $headers, iterable $rows): string\`. Zéro connaissance métier.
- \`Shared/Infrastructure/Export/PhpSpreadsheetExporter\` : implémentation PhpSpreadsheet (en-tête ligne 1 + lignes, retour binaire via fichier temporaire). Titre d'onglet assaini (≤ 31 car., caractères Excel interdits retirés). Supporte un \`iterable\` paresseux (generator).
- Auto-aliasé (un seul implémenteur) → \`SpreadsheetExporterInterface\` résout vers \`PhpSpreadsheetExporter\`.
> Tout futur module réutilise \`SpreadsheetExporterInterface\` sans toucher au Client.
### Commercial (le QUOI)
- \`ClientExportController\` (controller custom, \`#[Route('/api/clients/export.xlsx', priority: 1)]\` — **priority:1 obligatoire** pour éviter le conflit API Platform \`{id}\`). Security \`commercial.clients.view\`.
- Mêmes filtres que \`GET /api/clients\` (non archivés par défaut, \`?search\`, \`?categoryType\`, \`?includeArchived\`). **Filtrage factorisé dans \`ClientRepository::createListQueryBuilder()\`** (search + categoryType déplacés depuis \`ClientProvider\`) → liste paginée et export partagent strictement la même logique, zéro duplication.
- Colonnes (§ 4.6) : Nom entreprise, Nom contact principal, Prénom, Tél. principal, Tél. secondaire, Email, Catégories (CSV), Sites (CSV = union distincte des sites des adresses), **SIREN (omis si pas \`commercial.clients.accounting.view\`)**, Date de création.
- Réponse : \`Content-Type: …spreadsheetml.sheet\`, \`Content-Disposition: attachment; filename="repertoire-clients-{YYYYMMDD}.xlsx"\`.
## Dépendance
\`composer require phpoffice/phpspreadsheet\` (^5.7). Nettoyage recipes vérifié : seuls \`composer.json\`/\`composer.lock\` modifiés (pas de scaffolding parasite, \`symfony.lock\` désormais versionné).
## Tests (404 OK)
- **Unitaire Shared** : XLSX relisible (en-têtes + 2 lignes), generator, titre assaini.
- **Fonctionnel** : 200 (Content-Type + filename), exclusion archives par défaut, \`?search\`/\`?categoryType\`, SIREN présent (accounting.view) / absent (view seul), 403 sans \`clients.view\`, 401 anonyme.
## Note
Au démarrage, \`symfony/intl\` (requis par ERP-57, contrainte \`Bic\`) manquait du vendor → \`composer install\` joué pour rétablir une base saine.
## ⚠️ Heads-up review (@Tristan) — fichiers « propriété » d'ERP-55 touchés
Cette MR refactore deux fichiers introduits par ERP-55 :
- **`ClientRepository::createListQueryBuilder()`** accueille désormais le filtrage `search` + `categoryType` (signature `(bool $includeArchived, ?string $search, ?string $categoryType)`).
- **`ClientProvider`** délègue ce filtrage au repository → il **perd sa dépendance `EntityManager`** et ses méthodes privées `applySearch` / `applyCategoryType`.
**Pourquoi** : DRY entre la liste paginée (`GET /api/clients`) et l'export — une seule source de vérité pour la sélection des clients. Effet de bord positif : ça résout **plus proprement la fuite d'abstraction** que tu avais pointée en revue ERP-55 (P2) — la sous-requête `categoryType` n'est plus construite via l'`EntityManager` injecté dans le provider, mais à l'intérieur du repository (là où l'accès Doctrine est légitime).
Pas de changement de comportement de l'API liste : régression couverte par `ClientApiTest` (tri, exclusion archives, includeArchived, pagination) — tout vert.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #37
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #37.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
|
||||
Generated
+425
-80
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2410dcfdb94553f520e1186a73fa98c5",
|
||||
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -1160,6 +1160,85 @@
|
||||
},
|
||||
"time": "2026-03-17T15:23:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
"version": "3.4.4",
|
||||
@@ -2630,6 +2709,191 @@
|
||||
],
|
||||
"time": "2025-12-20T17:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -3052,6 +3316,115 @@
|
||||
},
|
||||
"time": "2026-01-06T21:53:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.1",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"ext-intl": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.5",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
},
|
||||
{
|
||||
"name": "Owen Leibman"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
|
||||
},
|
||||
"time": "2026-04-20T02:42:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.2",
|
||||
@@ -3513,6 +3886,57 @@
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/simple-cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/simple-cache.git",
|
||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\SimpleCache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interfaces for simple caching",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"caching",
|
||||
"psr",
|
||||
"psr-16",
|
||||
"simple-cache"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-10-29T13:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.8",
|
||||
@@ -8352,85 +8776,6 @@
|
||||
],
|
||||
"time": "2022-12-23T10:58:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/xdebug-handler",
|
||||
"version": "3.0.5",
|
||||
|
||||
@@ -18,6 +18,18 @@ interface ClientRepositoryInterface
|
||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
||||
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
||||
* - $categoryType : restreint aux clients possedant au moins une categorie
|
||||
* du type donne (code). Ignore si null/vide.
|
||||
*
|
||||
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||
* partagent strictement la meme logique de selection.
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryType = null,
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
@@ -46,7 +44,6 @@ final class ClientProvider implements ProviderInterface
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||
@@ -67,10 +64,15 @@ final class ClientProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$categoryType = $filters['categoryType'] ?? null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
||||
$this->applySearch($qb, $filters['search'] ?? null);
|
||||
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
is_string($categoryType) ? $categoryType : null,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
@@ -114,55 +116,6 @@ final class ClientProvider implements ProviderInterface
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, mixed $search): void
|
||||
{
|
||||
if (!is_string($search) || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
||||
{
|
||||
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sous-requete construite via l'EntityManager (et non
|
||||
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
|
||||
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
|
||||
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
|
||||
$sub = $this->em->createQueryBuilder()
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
|
||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des clients (memes filtres que
|
||||
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
|
||||
* et mapping metier des colonnes.
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `commercial.clients.accounting.view` (gating identique a la lecture).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ClientExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('commercial.clients.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$categoryType = $request->query->getString('categoryType') ?: null;
|
||||
|
||||
/** @var list<Client> $clients */
|
||||
$clients = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryType)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire clients',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($clients, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
|
||||
* date de creation, uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom entreprise',
|
||||
'Nom contact principal',
|
||||
'Prénom',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Client> $clients
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $clients, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($clients as $client) {
|
||||
$row = [
|
||||
$client->getCompanyName(),
|
||||
$client->getLastName(),
|
||||
$client->getFirstName(),
|
||||
$client->getPhonePrimary(),
|
||||
$client->getPhoneSecondary(),
|
||||
$client->getEmail(),
|
||||
$this->formatCategories($client),
|
||||
$this->formatSites($client),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $client->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $client->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du client, dedupliques, tries, joints par virgule.
|
||||
*/
|
||||
private function formatCategories(Client $client): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($client->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
|
||||
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
|
||||
* sites de toutes les adresses du client (decision validee 01/06).
|
||||
*/
|
||||
private function formatSites(Client $client): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($client->getAddresses() as $address) {
|
||||
foreach ($address->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur ClientProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
||||
{
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryType = null,
|
||||
): QueryBuilder {
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
@@ -42,6 +45,54 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCategoryType($qb, $categoryType);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void
|
||||
{
|
||||
if (null === $categoryType || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat d'export d'une feuille de calcul tabulaire vers un binaire XLSX.
|
||||
*
|
||||
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
|
||||
* module appelant decide QUOI exporter (en-tetes + lignes deja mappees) ; cette
|
||||
* interface decrit seulement COMMENT produire le fichier. Aucun module n'est
|
||||
* couple a une implementation concrete : on depend de ce contrat (dans Shared),
|
||||
* jamais l'inverse (regle ABSOLUE n°1).
|
||||
*
|
||||
* Implementee par App\Shared\Infrastructure\Export\PhpSpreadsheetExporter (on
|
||||
* ne la reference pas via @see pour ne pas creer un import Domain -> Infra).
|
||||
*/
|
||||
interface SpreadsheetExporterInterface
|
||||
{
|
||||
/**
|
||||
* Genere un classeur XLSX a une feuille et retourne son contenu binaire.
|
||||
*
|
||||
* @param string $sheetTitle titre de l'onglet (assaini / tronque par l'implementation si besoin)
|
||||
* @param list<string> $headers libelles de la ligne d'en-tete (ligne 1)
|
||||
* @param iterable<list<null|scalar>> $rows lignes de donnees ; chaque ligne est une liste de cellules alignee sur $headers
|
||||
*
|
||||
* @return string contenu binaire du fichier XLSX
|
||||
*/
|
||||
public function export(string $sheetTitle, array $headers, iterable $rows): string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Export;
|
||||
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
|
||||
*
|
||||
* Strictement technique : ecrit la ligne d'en-tete puis les lignes de donnees
|
||||
* dans l'unique feuille du classeur, et retourne le binaire. Aucune logique
|
||||
* metier, aucune reference a une entite d'un module — le mapping des colonnes
|
||||
* est de la responsabilite de l'appelant.
|
||||
*/
|
||||
final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
|
||||
{
|
||||
// Excel limite le titre d'un onglet a 31 caracteres et interdit certains
|
||||
// caracteres ; on assainit pour ne jamais faire echouer setTitle().
|
||||
private const int MAX_SHEET_TITLE_LENGTH = 31;
|
||||
private const string INVALID_TITLE_CHARS = '*:/\?[]';
|
||||
|
||||
public function export(string $sheetTitle, array $headers, iterable $rows): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
|
||||
|
||||
// Ligne 1 : en-tete.
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
|
||||
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
|
||||
// paresseux (generator) sans tout materialiser en memoire.
|
||||
$rowNumber = 2;
|
||||
foreach ($rows as $row) {
|
||||
$sheet->fromArray($row, null, 'A'.$rowNumber);
|
||||
++$rowNumber;
|
||||
}
|
||||
|
||||
return $this->toBinary($spreadsheet);
|
||||
}
|
||||
|
||||
private function toBinary(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
|
||||
// Le writer ecrit vers un chemin de fichier : on passe par un fichier
|
||||
// temporaire puis on lit son contenu binaire.
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'xlsx_export_');
|
||||
if (false === $tmpFile) {
|
||||
throw new RuntimeException('Impossible de creer un fichier temporaire pour l\'export XLSX.');
|
||||
}
|
||||
|
||||
try {
|
||||
$writer->save($tmpFile);
|
||||
$binary = file_get_contents($tmpFile);
|
||||
if (false === $binary) {
|
||||
throw new RuntimeException('Lecture du fichier XLSX temporaire impossible.');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
} finally {
|
||||
// Libere les references internes de PhpSpreadsheet puis supprime le
|
||||
// fichier temporaire, meme en cas d'exception.
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les caracteres interdits et tronque a 31 caracteres ; renvoie un
|
||||
* titre par defaut si la chaine resultante est vide.
|
||||
*/
|
||||
private function sanitizeSheetTitle(string $title): string
|
||||
{
|
||||
$clean = str_replace(str_split(self::INVALID_TITLE_CHARS), '', $title);
|
||||
$clean = mb_substr($clean, 0, self::MAX_SHEET_TITLE_LENGTH);
|
||||
|
||||
return '' === $clean ? 'Export' : $clean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire clients (M1, § 4.6).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||
* archives par defaut, respect des filtres ?search / ?categoryType, gating de
|
||||
* la colonne SIREN selon commercial.clients.accounting.view, 403 sans
|
||||
* commercial.clients.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/clients/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-clients-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-clients-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headers = $grid[0];
|
||||
self::assertSame('Nom entreprise', $headers[0]);
|
||||
self::assertContains('Catégories', $headers);
|
||||
self::assertContains('Sites', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Active One');
|
||||
$this->seedClient('Archived One', true);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Searchable Alpha');
|
||||
$this->seedClient('Other Beta');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsCategoryTypeFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Distrib Co', false, 'DISTRIBUTEUR');
|
||||
$this->seedClient('Secteur Co', false, 'SECTEUR');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?categoryType=DISTRIBUTEUR')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('DISTRIB CO', $names);
|
||||
self::assertNotContains('SECTEUR CO', $names);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Siren Co');
|
||||
$em = $this->getEm();
|
||||
$seed->setSiren('123456789');
|
||||
$em->flush();
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user qui n'a QUE clients.view.
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedClient('No Siren Co');
|
||||
$em = $this->getEm();
|
||||
$seed->setSiren('987654321');
|
||||
$em->flush();
|
||||
|
||||
$creds = $this->createUserWithPermission('commercial.clients.view');
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertNotContains('SIREN', $grid[0]);
|
||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutClientsViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom entreprise » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Shared\Infrastructure\Export;
|
||||
|
||||
use App\Shared\Infrastructure\Export\PhpSpreadsheetExporter;
|
||||
use Generator;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Test unitaire du service Shared d'export XLSX. Verifie que le binaire produit
|
||||
* est un vrai fichier XLSX relisible, que l'en-tete et les lignes sont ecrits
|
||||
* dans le bon ordre, qu'un iterable paresseux (generator) est accepte et que le
|
||||
* titre d'onglet est assaini.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class PhpSpreadsheetExporterTest extends TestCase
|
||||
{
|
||||
public function testExportProducesReadableXlsxWithHeadersAndRows(): void
|
||||
{
|
||||
$binary = new PhpSpreadsheetExporter()->export(
|
||||
'Feuille test',
|
||||
['Nom', 'Email'],
|
||||
[
|
||||
['Alpha', 'alpha@test.fr'],
|
||||
['Beta', null],
|
||||
],
|
||||
);
|
||||
|
||||
self::assertNotSame('', $binary);
|
||||
// Un fichier XLSX (OOXML) est une archive ZIP : signature "PK\x03\x04".
|
||||
self::assertStringStartsWith("PK\x03\x04", $binary);
|
||||
|
||||
$grid = $this->grid($binary);
|
||||
self::assertSame(['Nom', 'Email'], $grid[0]);
|
||||
self::assertSame('Alpha', $grid[1][0]);
|
||||
self::assertSame('alpha@test.fr', $grid[1][1]);
|
||||
self::assertSame('Beta', $grid[2][0]);
|
||||
// Cellule null a l'ecriture -> vide a la relecture.
|
||||
self::assertNull($grid[2][1]);
|
||||
}
|
||||
|
||||
public function testExportAcceptsGeneratorRows(): void
|
||||
{
|
||||
$rows = (static function (): Generator {
|
||||
yield ['L1'];
|
||||
|
||||
yield ['L2'];
|
||||
})();
|
||||
|
||||
$grid = $this->grid(new PhpSpreadsheetExporter()->export('Gen', ['H'], $rows));
|
||||
|
||||
self::assertSame('H', $grid[0][0]);
|
||||
self::assertSame('L1', $grid[1][0]);
|
||||
self::assertSame('L2', $grid[2][0]);
|
||||
}
|
||||
|
||||
public function testLongOrInvalidSheetTitleIsSanitized(): void
|
||||
{
|
||||
// Titre > 31 caracteres + caracteres interdits par Excel ([ ] : * etc.).
|
||||
$binary = new PhpSpreadsheetExporter()->export(
|
||||
str_repeat('A', 50).'[]:*?/\\',
|
||||
['H'],
|
||||
[['x']],
|
||||
);
|
||||
|
||||
$title = $this->load($binary)->getActiveSheet()->getTitle();
|
||||
self::assertLessThanOrEqual(31, mb_strlen($title));
|
||||
self::assertStringNotContainsString('[', $title);
|
||||
self::assertStringNotContainsString(':', $title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX et renvoie la grille de cellules (ligne 0 = entete).
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function grid(string $binary): array
|
||||
{
|
||||
return $this->load($binary)->getActiveSheet()->toArray();
|
||||
}
|
||||
|
||||
private function load(string $binary): Spreadsheet
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user