feat(catalog) : M7 — export XLSX des stockages (GET /api/storages/export.xlsx, filtres actifs) (ERP-214)

This commit is contained in:
2026-06-29 16:50:40 +02:00
parent 0aa97b5975
commit 0800ed99cf
2 changed files with 438 additions and 0 deletions
@@ -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;
}
}