[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:
@@ -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