diff --git a/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php b/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php new file mode 100644 index 0000000..f4ca480 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php @@ -0,0 +1,261 @@ + '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 $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 + */ + private function buildHeaders(): array + { + return [ + 'Numéro', + 'Nom', + 'États', + 'Catégorie', + 'Sites', + 'Types de stockage', + 'Fabriqué', + 'Contient mélasse', + ]; + } + + /** + * @param list $products + * + * @return iterable> + */ + 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 $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 + */ + 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; + } +} diff --git a/tests/Module/Catalog/Api/ProductExportControllerTest.php b/tests/Module/Catalog/Api/ProductExportControllerTest.php new file mode 100644 index 0000000..74c8c8d --- /dev/null +++ b/tests/Module/Catalog/Api/ProductExportControllerTest.php @@ -0,0 +1,288 @@ +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 $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> + */ + 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 + */ + 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 + */ + 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; + } +}