feat(catalog) : ERP-202 — export XLSX du catalogue produits (filtres liste)

This commit is contained in:
Matthieu
2026-06-25 11:54:50 +02:00
parent 8644ad79ce
commit cbc445a539
2 changed files with 549 additions and 0 deletions
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
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;
use function in_array;
use function is_int;
use function is_string;
/**
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
* en prose volontairement (pas de {@see} inter-module : violerait la regle
* ABSOLUE n°1).
*
* 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/products/export.xlsx`
* comme l'item `GET /api/products/{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 produits (MEMES filtres que
* `GET /api/products` via {@see ProductProvider}, deleguee a
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
*/
#[AsController]
final class ProductExportController
{
/**
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
* stockage en base.
*/
private const array STATE_LABELS = [
Product::STATE_PURCHASE => 'Achat',
Product::STATE_SALE => 'Vendu',
Product::STATE_OTHER => 'Autre',
];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
private readonly ProductRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('catalog.products.view')]
public function __invoke(Request $request): Response
{
// Memes filtres que la vue liste (ProductProvider) pour que l'export
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
// jamais expose au M6 (§ 2.7).
$search = $request->query->getString('search') ?: null;
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
$categoryCode = $request->query->getString('categoryCode') ?: null;
$state = $this->readState($request->query->get('state'));
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
/** @var list<Product> $products */
$products = $this->repository
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
->getQuery()
->getResult()
;
$binary = $this->exporter->export(
'Catalogue produits',
$this->buildHeaders(),
$this->buildRows($products),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.5).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Numéro',
'Nom',
'États',
'Catégorie',
'Sites',
'Types de stockage',
'Fabriqué',
'Contient mélasse',
];
}
/**
* @param list<Product> $products
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $products): iterable
{
foreach ($products as $product) {
yield [
$product->getCode(),
$product->getName(),
$this->formatStates($product),
$product->getCategory()?->getName(),
$this->formatSites($product),
$this->formatStorageTypes($product),
$product->isManufactured() ? 'Oui' : 'Non',
$product->containsMolasses() ? 'Oui' : 'Non',
];
}
}
/**
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
* Autre), joints par virgule. Une valeur inattendue est ignoree.
*/
private function formatStates(Product $product): string
{
$states = $product->getStates();
$labels = [];
foreach (self::STATE_LABELS as $code => $label) {
if (in_array($code, $states, true)) {
$labels[] = $label;
}
}
return implode(', ', $labels);
}
/**
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
* par virgule.
*/
private function formatSites(Product $product): string
{
$names = [];
foreach ($product->getSites() as $site) {
// @var Site $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Libelles des types de stockage du produit, dedupliques, tries, joints par
* virgule.
*/
private function formatStorageTypes(Product $product): string
{
$labels = [];
foreach ($product->getStorageTypes() as $storageType) {
// @var StorageType $storageType
$label = $storageType->getLabel();
if (null !== $label && '' !== $label) {
$labels[$label] = true;
}
}
return $this->joinSorted($labels);
}
/**
* @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('catalogue-produits-%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 le filtre `?state=` comme le ProductProvider : normalise en majuscules
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
* autre valeur est ignoree (null).
*/
private function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
}
/**
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
* ProductProvider (tolere int ou chaine numerique).
*/
private function readIntOrNull(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
* catalog.products.view, 401 anonyme.
*
* @internal
*/
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/products/export.xlsx';
/**
* Purge des produits + types de stockage de test AVANT le cleanup parent :
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
* donc les categories ne peuvent etre supprimees tant que des produits les
* referencent. La suppression des produits cascade les jonctions
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
*/
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Product::class)->execute();
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_CATEGORY_TYPE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithHeaderRow(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_A', '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="catalogue-produits-', $disposition);
self::assertMatchesRegularExpression(
'/filename="catalogue-produits-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headerCells = $grid[0];
self::assertSame('Numéro', $headerCells[0]);
self::assertSame('Nom', $headerCells[1]);
self::assertContains('États', $headerCells);
self::assertContains('Catégorie', $headerCells);
self::assertContains('Sites', $headerCells);
self::assertContains('Types de stockage', $headerCells);
self::assertContains('Fabriqué', $headerCells);
self::assertContains('Contient mélasse', $headerCells);
// Au moins une ligne de donnees (le produit seede).
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
}
public function testExportExcludesSoftDeletedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('TEST_PRD_ACTIVE', $codes);
self::assertNotContains('TEST_PRD_DELETED', $codes);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('TEST_PRD_SRCH', $codes);
self::assertNotContains('TEST_PRD_OTHER', $codes);
}
public function testExportRespectsStateFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
);
self::assertContains('TEST_PRD_SALE', $codes);
self::assertNotContains('TEST_PRD_BUY', $codes);
}
public function testExportPopulatesAllBusinessColumns(): void
{
$client = $this->createAdminClient();
$site = $this->firstSite();
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
$category = $this->createCategory('test_cat_export_produit');
$this->seedProduct(
'TEST_PRD_FULL',
'Complet',
[Product::STATE_PURCHASE, Product::STATE_SALE],
true,
true,
null,
$site,
$storageType,
$category,
);
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
self::assertSame('TEST_PRD_FULL', $row[0]);
self::assertSame('Complet', $row[1]);
self::assertSame('Achat, Vendu', $row[2]);
self::assertSame((string) $category->getName(), $row[3]);
self::assertSame((string) $site->getName(), $row[4]);
self::assertSame('Tas de test', $row[5]);
self::assertSame('Oui', $row[6]);
self::assertSame('Oui', $row[7]);
}
public function testForbiddenWithoutProductsViewPermission(): 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);
}
/**
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
* defaut). Les relations omises sont creees a la volee. Persistance directe
* via l'EM : on bypasse le Processor/Validator (non teste ici).
*
* @param list<string> $states
*/
private function seedProduct(
string $code,
string $name,
array $states = [Product::STATE_PURCHASE],
bool $manufactured = false,
bool $containsMolasses = false,
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
?Category $category = null,
): Product {
$em = $this->getEm();
$product = new Product();
$product->setCode($code);
$product->setName($name);
$product->setStates($states);
$product->setManufactured($manufactured);
$product->setContainsMolasses($containsMolasses);
$product->setCategory($category ?? $this->createCategory());
$product->addSite($site ?? $this->firstSite());
$product->addStorageType($storageType ?? $this->seedStorageType('TEST_'.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
$product->setDeletedAt($deletedAt);
$em->persist($product);
$em->flush();
return $product;
}
/**
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
*/
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
$em->persist($storageType);
$em->flush();
return $storageType;
}
/**
* Premier site seede (les sites existent en base de test, comme dans les
* autres tests d'export).
*/
private function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
return $site;
}
/**
* 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 « Numéro » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function codes(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));
}
/**
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null.
*
* @return null|array<int, mixed>
*/
private function rowForCode(string $binary, string $code): ?array
{
$grid = $this->gridFromResponse($binary);
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $code) {
return $row;
}
}
return null;
}
}