diff --git a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php new file mode 100644 index 0000000..e45e363 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php @@ -0,0 +1,234 @@ + 'Réception', + Storage::STATE_PRODUCTION => 'Production', + Storage::STATE_TRIAGE => 'Triage', + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] + private readonly StorageRepositoryInterface $repository, + private readonly SpreadsheetExporterInterface $exporter, + ) {} + + #[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)] + #[IsGranted('catalog.storages.view')] + public function __invoke(Request $request): Response + { + // Memes filtres que la vue liste (StorageProvider) pour que l'export reflete + // exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur + // numero), sites (?siteId[]), type (?storageTypeId), etat (?state). + // includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8). + $search = $request->query->getString('search') ?: null; + $siteIds = $this->readIntList($request->query->all()['siteId'] ?? []); + $storageTypeId = $this->readIntOrNull($request->query->get('storageTypeId')); + $state = $this->readState($request->query->get('state')); + + /** @var list $storages */ + $storages = $this->repository + ->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state) + ->getQuery() + ->getResult() + ; + + $binary = $this->exporter->export( + 'Stockages', + $this->buildHeaders(), + $this->buildRows($storages), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.5). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Nom', + 'Site', + 'Type de stockage', + 'Numéro', + 'États', + 'Créé le', + 'Modifié le', + ]; + } + + /** + * @param list $storages + * + * @return iterable> + */ + private function buildRows(array $storages): iterable + { + foreach ($storages as $storage) { + yield [ + $storage->getDisplayName(), + $this->formatSite($storage->getSite()), + $storage->getStorageType()?->getLabel(), + $storage->getNumero(), + $this->formatStates($storage), + $this->formatDate($storage->getCreatedAt()), + $this->formatDate($storage->getUpdatedAt()), + ]; + } + } + + /** + * Libelle du site « Nom (Code) » (ex. « Chatellerault (86) »). Le code peut + * etre absent : on retombe alors sur le seul nom. + */ + private function formatSite(?Site $site): string + { + if (null === $site) { + return ''; + } + + $name = (string) $site->getName(); + $code = $site->getCode(); + + return null !== $code && '' !== $code ? sprintf('%s (%s)', $name, $code) : $name; + } + + /** + * Libelles FR des etats du stockage, dans l'ordre canonique (Réception, + * Production, Triage), joints par virgule. Une valeur inattendue est ignoree. + */ + private function formatStates(Storage $storage): string + { + $states = $storage->getStates(); + + $labels = []; + foreach (self::STATE_LABELS as $code => $label) { + if (in_array($code, $states, true)) { + $labels[] = $label; + } + } + + return implode(', ', $labels); + } + + /** + * Formate un horodatage en « jj/mm/aaaa hh:mm » (vide si null). + */ + private function formatDate(?DateTimeImmutable $date): string + { + return $date?->format('d/m/Y H:i') ?? ''; + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('stockages-%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 StorageProvider : normalise en majuscules et + * n'accepte qu'une valeur de l'enum borne {RECEPTION, PRODUCTION, TRIAGE} ; + * 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 (`?storageTypeId=`). Aligne sur + * StorageProvider (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 StorageProvider. + * + * @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/StorageExportControllerTest.php b/tests/Module/Catalog/Api/StorageExportControllerTest.php new file mode 100644 index 0000000..a161a55 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageExportControllerTest.php @@ -0,0 +1,204 @@ +createAdminClient(); + $this->seedStorageEntity('NUM-A'); + + $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="stockages-', $disposition); + self::assertMatchesRegularExpression('/filename="stockages-\d{8}\.xlsx"/', $disposition); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes (§ 4.5). + $headerCells = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Nom', $headerCells[0]); + self::assertSame('Site', $headerCells[1]); + self::assertSame('Type de stockage', $headerCells[2]); + self::assertSame('Numéro', $headerCells[3]); + self::assertSame('États', $headerCells[4]); + self::assertSame('Créé le', $headerCells[5]); + self::assertSame('Modifié le', $headerCells[6]); + + // Au moins une ligne de donnees (le stockage seede) reperee par son numero. + self::assertContains('NUM-A', $this->numeros($response->getContent())); + } + + public function testExportExcludesSoftDeletedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('NUM-ACTIVE'); + $this->seedStorageEntity('NUM-DELETED', deletedAt: new DateTimeImmutable()); + + $numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('NUM-ACTIVE', $numeros); + self::assertNotContains('NUM-DELETED', $numeros); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('ALPHA-1'); + $this->seedStorageEntity('BETA-2'); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('ALPHA-1', $numeros); + self::assertNotContains('BETA-2', $numeros); + } + + public function testExportRespectsStorageTypeFilter(): void + { + $client = $this->createAdminClient(); + + $typeA = $this->seedStorageType('Cellule A'); + $typeB = $this->seedStorageType('Cellule B'); + $this->seedStorageEntity('TYPE-A', storageType: $typeA); + $this->seedStorageEntity('TYPE-B', storageType: $typeB); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?storageTypeId='.$typeA->getId())->getContent(), + ); + + self::assertContains('TYPE-A', $numeros); + self::assertNotContains('TYPE-B', $numeros); + } + + public function testExportRespectsStateFilter(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('STATE-PROD', [Storage::STATE_PRODUCTION]); + $this->seedStorageEntity('STATE-RECEP', [Storage::STATE_RECEPTION]); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?state=PRODUCTION')->getContent(), + ); + + self::assertContains('STATE-PROD', $numeros); + self::assertNotContains('STATE-RECEP', $numeros); + } + + public function testExportPopulatesAllBusinessColumns(): void + { + $client = $this->createAdminClient(); + + $site = $this->firstSite(); + $type = $this->seedStorageType('Cellule'); + $this->seedStorageEntity( + 'C3', + [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], + site: $site, + storageType: $type, + ); + + $row = $this->rowForNumero($client->request('GET', self::EXPORT_URL)->getContent(), 'C3'); + self::assertNotNull($row, 'Le stockage seede est absent de l\'export.'); + + // 0 Nom | 1 Site | 2 Type | 3 Numéro | 4 États | 5 Créé le | 6 Modifié le + self::assertSame('Cellule C3', $row[0]); + self::assertSame(sprintf('%s (%s)', $site->getName(), $site->getCode()), $row[1]); + self::assertSame('Cellule', $row[2]); + self::assertSame('C3', $row[3]); + // Ordre canonique (Réception avant Triage) independamment de l'ordre en base. + self::assertSame('Réception, Triage', $row[4]); + // Dates renseignees (Timestampable) au format jj/mm/aaaa hh:mm. + self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[5]); + self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]); + } + + public function testForbiddenWithoutStoragesViewPermission(): 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> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_storage_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 » (4e colonne, index 3) des lignes de donnees. + * + * @return list + */ + private function numeros(string $binary): array + { + $rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[3] ?? ''), $rows)); + } + + /** + * Renvoie la ligne de donnees dont la colonne « Numéro » vaut $numero, ou null. + * + * @return null|array + */ + private function rowForNumero(string $binary, string $numero): ?array + { + foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { + if ((string) ($row[3] ?? '') === $numero) { + return $row; + } + } + + return null; + } +}