fix(catalog) : M7 — durcissement stockages (états JSONB séquentiels + Assert\Unique, neutralisation injection formules XLSX partagée, parité listing/export via StorageListFilters, streaming export)

- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique.
- PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier).
- StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif).
- Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée.
- Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
This commit is contained in:
2026-06-29 18:01:54 +02:00
parent caa558f582
commit 7075f0f95d
10 changed files with 392 additions and 157 deletions
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Filter;
use App\Module\Catalog\Domain\Entity\Storage;
use function in_array;
use function is_array;
use function is_int;
use function is_string;
/**
* Filtres de liste des stockages : SOURCE UNIQUE de verite du parsing des parametres
* de requete (?search sur numero, ?siteId[], ?storageTypeId, ?state). Partagee par le
* StorageProvider (liste paginee) et le StorageExportController (export XLSX) pour
* garantir que l'export reflete EXACTEMENT ce que l'utilisateur voit a l'ecran.
*
* Sans cette factorisation, les deux endpoints parsaient les memes filtres avec des
* regles subtilement differentes (numero litteral « 0 » coerce a null cote export,
* id non positif accepte cote liste mais ignore cote export, parametre tableau
* jetant un 400 cote export) : autant de divergences liste/export. Une seule
* implementation -> zero drift, chaque nouveau filtre se branche en un seul endroit.
*/
final readonly class StorageListFilters
{
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
private const array VALID_STATES = [
Storage::STATE_RECEPTION,
Storage::STATE_PRODUCTION,
Storage::STATE_TRIAGE,
];
/**
* @param list<int> $siteIds
*/
private function __construct(
public ?string $search,
public array $siteIds,
public ?int $storageTypeId,
public ?string $state,
) {}
/**
* Construit les filtres depuis une source brute : le `$context['filters']`
* d'API Platform cote provider, ou `$request->query->all()` cote controller
* d'export. Tolere scalaire ou tableau, ignore les entrees invalides — jamais
* d'exception sur une saisie malformee (ex: `?search[]=x`).
*
* @param array<string, mixed> $query
*/
public static function fromQuery(array $query): self
{
return new self(
self::readSearch($query['search'] ?? null),
self::readSiteIds($query['siteId'] ?? null),
self::readPositiveInt($query['storageTypeId'] ?? null),
self::readState($query['state'] ?? null),
);
}
/**
* Recherche partielle sur numero : valeur trimmee, ou null si absente / vide.
* La chaine « 0 » est un numero valide (VARCHAR) et N'EST PAS coercee a null.
*/
private static function readSearch(mixed $raw): ?string
{
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Liste d'identifiants de sites (OR). Tolere une valeur scalaire unique
* (`?siteId=1`) ou un tableau (`?siteId[]=1&siteId[]=2`), dedup, ordre stable.
*
* @return list<int>
*/
private static function readSiteIds(mixed $raw): array
{
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
$id = self::readPositiveInt($value);
if (null !== $id) {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
/**
* Identifiant entier STRICTEMENT POSITIF (un id metier l'est toujours) ou null.
* Un 0 ou un negatif est traite comme « pas de filtre », jamais comme un id
* impossible (qui renverrait une liste vide cote provider mais tout cote export).
*/
private static function readPositiveInt(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;
}
/**
* Filtre ?state= : normalise en majuscules, n'accepte qu'une valeur de l'enum
* borne {RECEPTION, PRODUCTION, TRIAGE} ; toute autre valeur est ignoree (null).
*/
private static function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
}
+7 -1
View File
@@ -158,6 +158,7 @@ class Storage implements TimestampableInterface, BlamableInterface
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
#[Assert\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')]
#[Assert\Choice(
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
multiple: true,
@@ -230,7 +231,12 @@ class Storage implements TimestampableInterface, BlamableInterface
*/
public function setStates(array $states): static
{
$this->states = $states;
// `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente
// malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif)
// ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le
// CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en
// 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique.
$this->states = array_values($states);
return $this;
}
@@ -9,12 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Application\Filter\StorageListFilters;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function in_array;
use function is_int;
use function is_string;
@@ -22,8 +22,9 @@ use function is_string;
* Provider Storage (lecture, ERP-213) :
* - LISTE : exclut par defaut les stockages soft-deleted (RG-7.07), trie par
* site.code ASC, storageType.label ASC, numero ASC (defaut spec § 4.1), applique
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state) et renvoie
* une collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state — parses par
* {@see StorageListFilters}, source partagee avec l'export) et renvoie une
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
* Echappatoire ?pagination=false respectee (alimentation d'un select).
* - ITEM : recharge le stockage puis renvoie null (404) s'il est soft-deleted — le
@@ -33,13 +34,6 @@ use function is_string;
*/
final class StorageProvider implements ProviderInterface
{
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
private const array VALID_STATES = [
Storage::STATE_RECEPTION,
Storage::STATE_PRODUCTION,
Storage::STATE_TRIAGE,
];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
@@ -49,13 +43,16 @@ final class StorageProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null
{
if ($operation instanceof CollectionOperationInterface) {
// Filtres parses par la source partagee avec l'export (parite garantie).
$filters = StorageListFilters::fromQuery($context['filters'] ?? []);
// includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8).
$qb = $this->repository->createListQueryBuilder(
false,
$this->readSearch($context),
$this->readSiteIds($context),
$this->readStorageTypeId($context),
$this->readState($context),
$filters->search,
$filters->siteIds,
$filters->storageTypeId,
$filters->state,
);
// Echappatoire ?pagination=false : collection complete sans Paginator.
@@ -93,80 +90,4 @@ final class StorageProvider implements ProviderInterface
return $storage;
}
/**
* Lit le filtre `?search=` (recherche partielle sur numero). Renvoie la valeur
* trimmee ou null si absente / vide.
*/
private function readSearch(array $context): ?string
{
$raw = $context['filters']['search'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
*
* @return list<int>
*/
private function readSiteIds(array $context): array
{
$raw = $context['filters']['siteId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
/**
* Lit le filtre `?storageTypeId=` (drawer « Filtrer »). Renvoie l'id entier ou
* null si absent / non numerique.
*/
private function readStorageTypeId(array $context): ?int
{
$raw = $context['filters']['storageTypeId'] ?? null;
if (is_int($raw)) {
return $raw;
}
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
}
/**
* Lit le filtre `?state=` (RECEPTION / PRODUCTION / TRIAGE). Normalise en
* majuscules et n'accepte qu'une valeur de l'enum borne ; toute autre valeur est
* ignoree (null).
*/
private function readState(array $context): ?string
{
$raw = $context['filters']['state'] ?? null;
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
}
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Application\Filter\StorageListFilters;
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 Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -17,8 +19,6 @@ 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
@@ -57,10 +57,17 @@ final class StorageExportController
Storage::STATE_TRIAGE => 'Triage',
];
/**
* Taille du lot avant `EntityManager::clear()` pendant le streaming des lignes :
* borne la memoire (identity map) sur un gros export sans tout materialiser.
*/
private const int EXPORT_BATCH_SIZE = 200;
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)]
@@ -69,18 +76,18 @@ final class StorageExportController
{
// 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).
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state). Parses par
// la MEME source que le provider ({@see StorageListFilters}) -> aucune
// divergence possible (numero « 0 », parametre tableau, id non positif).
// 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'));
$filters = StorageListFilters::fromQuery($request->query->all());
/** @var list<Storage> $storages */
// Streaming via toIterable() : on ne materialise pas toute la table en memoire
// (cf. buildRows + EXPORT_BATCH_SIZE) avant de construire le classeur.
$storages = $this->repository
->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state)
->createListQueryBuilder(false, $filters->search, $filters->siteIds, $filters->storageTypeId, $filters->state)
->getQuery()
->getResult()
->toIterable()
;
$binary = $this->exporter->export(
@@ -111,12 +118,18 @@ final class StorageExportController
}
/**
* @param list<Storage> $storages
* Mappe chaque stockage en ligne d'export, en consommant un iterable paresseux
* (Doctrine `toIterable()`). Toutes les N lignes (EXPORT_BATCH_SIZE), on vide
* l'identity map (`clear()`) pour borner la memoire sur un gros export — sans
* danger ici, le controller ne fait que lire.
*
* @param iterable<Storage> $storages
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $storages): iterable
private function buildRows(iterable $storages): iterable
{
$count = 0;
foreach ($storages as $storage) {
yield [
$storage->getDisplayName(),
@@ -127,6 +140,10 @@ final class StorageExportController
$this->formatDate($storage->getCreatedAt()),
$this->formatDate($storage->getUpdatedAt()),
];
if (0 === ++$count % self::EXPORT_BATCH_SIZE) {
$this->em->clear();
}
}
}
@@ -182,53 +199,4 @@ final class StorageExportController
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;
}
}
@@ -5,10 +5,15 @@ declare(strict_types=1);
namespace App\Shared\Infrastructure\Export;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use function is_string;
/**
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
*
@@ -31,19 +36,45 @@ final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
// Ligne 1 : en-tete.
$sheet->fromArray($headers, null, 'A1');
$this->writeRow($sheet, $headers, 1);
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
// paresseux (generator) sans tout materialiser en memoire.
$rowNumber = 2;
foreach ($rows as $row) {
$sheet->fromArray($row, null, 'A'.$rowNumber);
$this->writeRow($sheet, $row, $rowNumber);
++$rowNumber;
}
return $this->toBinary($spreadsheet);
}
/**
* Ecrit une ligne cellule par cellule. Toute valeur CHAINE est ecrite en type
* STRING explicite (jamais interpretee comme formule), ce qui neutralise
* l'injection de formules / DDE (« CSV / Formula injection ») : une cellule dont
* la valeur commence par `=` `+` `-` `@` (saisie utilisateur, ex. un numero) n'est
* pas evaluee a l'ouverture du fichier, et ce SANS apostrophe visible. Les valeurs
* non-chaines (int / float / null) gardent leur type naturel.
*
* @param list<null|scalar> $row
*/
private function writeRow(Worksheet $sheet, array $row, int $rowNumber): void
{
$column = 1;
foreach ($row as $value) {
$coordinate = Coordinate::stringFromColumnIndex($column).$rowNumber;
if (is_string($value)) {
$sheet->setCellValueExplicit($coordinate, $value, DataType::TYPE_STRING);
} else {
$sheet->setCellValue($coordinate, $value);
}
++$column;
}
}
private function toBinary(Spreadsheet $spreadsheet): string
{
$writer = new Xlsx($spreadsheet);