Merge pull request 'feat(catalog) : M7 — export XLSX des stockages (ERP-214)' (#166) from feat/erp-214-storage-export-xlsx into develop
Auto Tag Develop / tag (push) Failing after 9s
Auto Tag Develop / tag (push) Failing after 9s
This commit was merged in pull request #166.
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Controller;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Storage;
|
||||||
|
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
|
||||||
|
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 de la liste des stockages (M7, spec-back § 4.5). Jumeau du
|
||||||
|
* ProductExportController (M6) — reference en prose volontairement (pas de {@see}
|
||||||
|
* inter-module).
|
||||||
|
*
|
||||||
|
* 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/storages/export.xlsx`
|
||||||
|
* comme l'item `GET /api/storages/{id}.{_format}` (id="export", _format="xlsx")
|
||||||
|
* — cf. CLAUDE.md « controller custom sous /api ». Etant un controller (et non un
|
||||||
|
* #[ApiResource]), il n'est PAS scanne par CollectionsArePaginatedTest : aucune
|
||||||
|
* entree EXCLUDED necessaire (comme ProductExportController).
|
||||||
|
*
|
||||||
|
* 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 stockages (MEMES filtres que
|
||||||
|
* `GET /api/storages` via le StorageProvider, deleguee a
|
||||||
|
* {@see StorageRepositoryInterface::createListQueryBuilder()} — l'export reflete
|
||||||
|
* exactement ce que l'utilisateur voit a l'ecran) et mapping metier des colonnes.
|
||||||
|
* Les stockages soft-deleted (RG-7.07) sont toujours exclus, comme en liste (le
|
||||||
|
* soft-delete n'est jamais expose, § 2.8).
|
||||||
|
*/
|
||||||
|
#[AsController]
|
||||||
|
final class StorageExportController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Libelles FR des etats (RG-7.04) pour la colonne « États ». L'ordre des cles
|
||||||
|
* fixe l'ordre d'affichage (Réception, Production, Triage) independamment de
|
||||||
|
* l'ordre de stockage en base.
|
||||||
|
*/
|
||||||
|
private const array STATE_LABELS = [
|
||||||
|
Storage::STATE_RECEPTION => '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<Storage> $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<string>
|
||||||
|
*/
|
||||||
|
private function buildHeaders(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nom',
|
||||||
|
'Site',
|
||||||
|
'Type de stockage',
|
||||||
|
'Numéro',
|
||||||
|
'États',
|
||||||
|
'Créé le',
|
||||||
|
'Modifié le',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Storage> $storages
|
||||||
|
*
|
||||||
|
* @return iterable<list<null|scalar>>
|
||||||
|
*/
|
||||||
|
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<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,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Storage;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'export XLSX des stockages (M7, § 4.5) — ERP-214.
|
||||||
|
*
|
||||||
|
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de colonnes),
|
||||||
|
* exclusion des stockages soft-deleted par defaut (RG-7.07), respect des filtres
|
||||||
|
* ?search (numero) / ?storageTypeId / ?state, peuplement des colonnes metier
|
||||||
|
* (displayName, site « Nom (Code) », type, numero, etats joints, dates), 403 sans
|
||||||
|
* catalog.storages.view, 401 anonyme.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class StorageExportControllerTest extends AbstractStorageApiTestCase
|
||||||
|
{
|
||||||
|
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
private const string EXPORT_URL = '/api/storages/export.xlsx';
|
||||||
|
|
||||||
|
public function testExportReturnsXlsxResponseWithHeaderRow(): void
|
||||||
|
{
|
||||||
|
$client = $this->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<int, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<int, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user