From ac8650026607906cd4fb0b4e812c0ee1d948878a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 25 Jun 2026 11:16:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20ERP-200=20=E2=80=94=20Produ?= =?UTF-8?q?ctProvider=20+=20ProductProcessor=20(unicit=C3=A9=20code,=20RG-?= =?UTF-8?q?6.03/05/06,=20normalisation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider de lecture (liste paginée Hydra filtrée + item) : - exclut les produits soft-deleted (RG-6.09), tri name ASC ; - filtres ?search (code+name), ?categoryId/?categoryCode, ?state (JSONB @>), ?siteId[] (EXISTS) ; - Get item : 404 sur soft-deleted (non exposé au M6, § 2.7) ; - pagination obligatoire via Paginator ORM (règle n°13), échappatoire ?pagination=false. Processor d'écriture (POST/PATCH) : - normalisation serveur code trim+UPPER, name trim (RG-6.07, ProductFieldNormalizer) ; - RG-6.03 : manufactured/containsMolasses forcés false si states sans SALE ; - RG-6.01 : unicité globale du code parmi les actifs -> 409 (pré-check + filet anti-race index partiel), propertyPath code côté front. Entité Product : Assert\Callback RG-6.05 (catégorie de type PRODUIT) et RG-6.06 (types de stockage disponibles sur au moins un site choisi), atPath pour mapping inline 422 ; constantes d'états. Repository : createListQueryBuilder (filtres + eager-load category/sites/storageTypes) + existsActiveByCode déjà en place. make test vert (873 tests), php-cs-fixer OK. --- .../Service/ProductFieldNormalizer.php | 55 ++++++ src/Module/Catalog/Domain/Entity/Product.php | 74 ++++++- .../Repository/ProductRepositoryInterface.php | 22 +++ .../State/Processor/ProductProcessor.php | 124 ++++++++++++ .../State/Provider/ProductProvider.php | 185 ++++++++++++++++++ .../Doctrine/DoctrineProductRepository.php | 101 ++++++++++ 6 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 src/Module/Catalog/Application/Service/ProductFieldNormalizer.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php diff --git a/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php b/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php new file mode 100644 index 0000000..31afe21 --- /dev/null +++ b/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php @@ -0,0 +1,55 @@ + "BLE-01". Conserve + * null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank + * de l'entite qui rejette le vide, pas le normalizer). + */ + public function normalizeCode(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtoupper($value, 'UTF-8'); + } + + /** + * Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide + * apres trim devient null. + */ + public function normalizeName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/src/Module/Catalog/Domain/Entity/Product.php b/src/Module/Catalog/Domain/Entity/Product.php index 41bfca7..ac9219f 100644 --- a/src/Module/Catalog/Domain/Entity/Product.php +++ b/src/Module/Catalog/Domain/Entity/Product.php @@ -23,6 +23,9 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; + +use function in_array; /** * Produit du catalogue (M6 Catalog) — entite racine du module produit, jumelle de @@ -109,6 +112,14 @@ class Product implements TimestampableInterface, BlamableInterface // TimestampableBlamableSubscriber au prePersist / preUpdate. use TimestampableBlamableTrait; + /** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */ + public const string STATE_PURCHASE = 'PURCHASE'; + public const string STATE_SALE = 'SALE'; + public const string STATE_OTHER = 'OTHER'; + + /** Code de type de categorie autorise pour un produit (RG-6.05). */ + private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -143,7 +154,7 @@ class Product implements TimestampableInterface, BlamableInterface #[ORM\Column(type: 'json')] #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')] #[Assert\Choice( - choices: ['PURCHASE', 'SALE', 'OTHER'], + choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER], multiple: true, message: 'État de produit invalide.', multipleMessage: 'État de produit invalide.', @@ -358,4 +369,65 @@ class Product implements TimestampableInterface, BlamableInterface return $this; } + + /** + * RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee + * applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback + * + ->atPath('category') pour que la 422 porte un propertyPath consommable par + * useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne + * leve que si une categorie est presente ET non-PRODUIT. + */ + #[Assert\Callback] + public function validateCategoryIsProductType(ExecutionContextInterface $context): void + { + if (null === $this->category) { + return; + } + + if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) { + $context->buildViolation('La catégorie sélectionnée doit être de type Produit.') + ->atPath('category') + ->addViolation() + ; + } + } + + /** + * RG-6.06 : chaque type de stockage choisi doit etre disponible sur AU MOINS UN + * des sites choisis (intersection non vide). Validee via Callback + + * ->atPath('storageTypes'). On ne croise que si les deux collections sont non + * vides : leur absence est deja couverte par les Assert\Count(min: 1) dedies. + */ + #[Assert\Callback] + public function validateStorageTypesAvailableOnSelectedSites(ExecutionContextInterface $context): void + { + if ($this->sites->isEmpty() || $this->storageTypes->isEmpty()) { + return; + } + + // Ensemble des ids de sites selectionnes (lookup O(1)). + $selectedSiteIds = []; + foreach ($this->sites as $site) { + $selectedSiteIds[$site->getId()] = true; + } + + foreach ($this->storageTypes as $storageType) { + $available = false; + foreach ($storageType->getSites() as $storageTypeSite) { + if (isset($selectedSiteIds[$storageTypeSite->getId()])) { + $available = true; + + break; + } + } + + if (!$available) { + $context->buildViolation('Le type de stockage « {{ label }} » n\'est disponible sur aucun des sites sélectionnés.') + ->setParameter('{{ label }}', (string) $storageType->getLabel()) + ->atPath('storageTypes') + ->addViolation() + ; + } + } + } } diff --git a/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php index 424e176..c217260 100644 --- a/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Catalog\Domain\Repository; use App\Module\Catalog\Domain\Entity\Product; +use Doctrine\ORM\QueryBuilder; interface ProductRepositoryInterface { @@ -20,4 +21,25 @@ interface ProductRepositoryInterface * ignore les supprimes). */ public function existsActiveByCode(string $code, ?int $excludeId = null): bool; + + /** + * QueryBuilder de la liste produits (consomme par le ProductProvider) : exclut + * par defaut les soft-deleted (RG-6.09), trie par name ASC (defaut spec § 4.1) + * et applique les filtres optionnels du drawer « Filtrer » : + * - `$search` : recherche partielle case-insensitive sur `code` + `name`. + * - `$categoryId` : restreint a une categorie precise (par id). + * - `$categoryCode` : restreint a une categorie precise (par code stable). + * - `$state` : appartenance a la colonne JSONB `states` (PURCHASE|SALE|OTHER). + * - `$siteIds` : produit disponible sur AU MOINS UN des sites passes. + * + * @param list $siteIds + */ + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + ?int $categoryId = null, + ?string $categoryCode = null, + ?string $state = null, + array $siteIds = [], + ): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php new file mode 100644 index 0000000..1d86c58 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php @@ -0,0 +1,124 @@ + 409 ; l'index partiel + * uq_product_code_active reste le filet anti-race au flush. + * 4. Persistance via le persist_processor Doctrine ORM. + * + * Mode strict PATCH (RETEX M1) : la security d'operation exige deja + * `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de + * permission au M6 — § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission » + * a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split + * comptable Client RG-1.28) : le 403 global est porte par la security d'operation, + * pas par un guard de champ ici. + * + * Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de + * stockage disponibles sur les sites choisis) sont portees par des Assert\Callback + * + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor), + * pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping + * inline, pas un toast — convention ERP-101). + * + * @implements ProcessorInterface + */ +final class ProductProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly ProductFieldNormalizer $normalizer, + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')] + private readonly ProductRepositoryInterface $repository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Product) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim). + $this->normalize($data); + + // 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels + // « Fabrique » / « Contient de la melasse » sont forces false serveur. + if (!in_array(Product::STATE_SALE, $data->getStates(), true)) { + $data->setManufactured(false); + $data->setContainsMolasses(false); + } + + // 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit + // courant en PATCH). Pre-check explicite -> 409 deterministe. + $code = (string) $data->getCode(); + if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) { + throw $this->duplicateCodeConflict($code); + } + + // 4. Persistance, avec filet anti-race sur l'index partiel. + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Insertion concurrente du meme code entre le pre-check et le flush + // (collision sur uq_product_code_active — unicite parmi les actifs). + throw $this->duplicateCodeConflict($code, $e); + } + } + + /** + * Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si + * une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH + * partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont. + */ + private function normalize(Product $data): void + { + if (null !== $data->getCode()) { + $data->setCode((string) $this->normalizer->normalizeCode($data->getCode())); + } + + if (null !== $data->getName()) { + $data->setName((string) $this->normalizer->normalizeName($data->getName())); + } + } + + /** + * RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le + * champ `code` (setError('code', ...) + toast — convention useFormErrors ERP-101 + * / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`. + */ + private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException + { + return new ConflictHttpException( + sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code), + $previous, + ); + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php new file mode 100644 index 0000000..68a7551 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php @@ -0,0 +1,185 @@ + + */ +final class ProductProvider implements ProviderInterface +{ + /** Etats valides du filtre ?state= (enum borne, RG-6.02). */ + private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')] + private readonly ProductRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null + { + if ($operation instanceof CollectionOperationInterface) { + // includeDeleted toujours false : le soft-delete n'est pas expose au M6. + $qb = $this->repository->createListQueryBuilder( + false, + $this->readSearch($context), + $this->readCategoryId($context), + $this->readCategoryCode($context), + $this->readState($context), + $this->readSiteIds($context), + ); + + // Echappatoire ?pagination=false : collection complete sans Paginator. + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + // Branche paginee standard : offset/limit via Pagination, enveloppe dans + // le Paginator ORM (fetchJoinCollection: true pour compter correctement + // malgre les fetch-joins to-many sites/storageTypes du QueryBuilder). + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); + } + + // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $product = $this->repository->findById((int) $id); + if (null === $product) { + return null; + } + + // § 2.7 : un produit soft-deleted n'est jamais expose (404). + if (null !== $product->getDeletedAt()) { + return null; + } + + return $product; + } + + /** + * Lit le filtre `?search=` (recherche partielle code + name). 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 `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null + * si absent / non numerique. + */ + private function readCategoryId(array $context): ?int + { + $raw = $context['filters']['categoryId'] ?? null; + + if (is_int($raw)) { + return $raw; + } + + return is_string($raw) && ctype_digit($raw) ? (int) $raw : null; + } + + /** + * Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou + * null si absent / vide. + */ + private function readCategoryCode(array $context): ?string + { + $raw = $context['filters']['categoryCode'] ?? null; + + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + + return '' === $raw ? null : $raw; + } + + /** + * Lit le filtre `?state=` (PURCHASE / SALE / OTHER). 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; + } + + /** + * 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 + */ + 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)); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php index 8851aa4..c24e1a1 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine; use App\Module\Catalog\Domain\Entity\Product; use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -46,4 +47,104 @@ class DoctrineProductRepository extends ServiceEntityRepository implements Produ return [] !== $qb->getQuery()->getResult(); } + + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + ?int $categoryId = null, + ?string $categoryCode = null, + ?string $state = null, + array $siteIds = [], + ): QueryBuilder { + // Eager-load des relations embarquees en liste (product:read) pour eviter + // un N+1 par produit : category (ManyToOne, sur), sites et storageTypes + // (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le + // provider enveloppe la requete dans un Paginator(fetchJoinCollection: true), + // compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids). + $qb = $this->createQueryBuilder('p') + ->leftJoin('p.category', 'cat')->addSelect('cat') + ->leftJoin('p.sites', 's')->addSelect('s') + ->leftJoin('p.storageTypes', 'stp')->addSelect('stp') + ->orderBy('p.name', 'ASC') + ; + + // RG-6.09 : la liste exclut par defaut les produits soft-deleted. + if (!$includeDeleted) { + $qb->andWhere('p.deletedAt IS NULL'); + } + + // ?search= : recherche partielle case-insensitive sur code + name. Les + // metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les + // deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec + // les autres filtres (AND lie plus fort que OR en DQL). + if (null !== $search && '' !== trim($search)) { + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + $qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)') + ->setParameter('search', $pattern) + ; + } + + // ?categoryId= : filtre par categorie precise (id). + if (null !== $categoryId) { + $qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId); + } + + // ?categoryCode= : filtre par categorie precise (code stable). + if (null !== $categoryCode && '' !== trim($categoryCode)) { + $qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode)); + } + + // ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas + // exprimer la containment jsonb -> on resout les ids matchant en SQL natif + // (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition + // toujours fausse (aucun produit), sans casser le reste de la requete. + if (null !== $state) { + $stateIds = $this->matchingStateIds($state); + if ([] === $stateIds) { + $qb->andWhere('1 = 0'); + } else { + $qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds); + } + } + + // ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR). + // Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites + // eager-loadee `s` (sinon les autres sites du produit disparaitraient du + // JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository). + if ([] !== $siteIds) { + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from(Product::class, 'p_si') + ->join('p_si.sites', 's_si') + ->where('p_si = p') + ->andWhere('s_si.id IN (:siteIds)') + ; + $qb->andWhere($qb->expr()->exists($sub->getDQL())) + ->setParameter('siteIds', $siteIds) + ; + } + + return $qb; + } + + /** + * Ids des produits dont la colonne JSONB `states` contient l'etat donne, via + * l'operateur de containment Postgres `@>`. L'etat est borne a l'enum + * {PURCHASE, SALE, OTHER} en amont (ProductProvider) — pas de saisie libre ici. + * + * @return list + */ + private function matchingStateIds(string $state): array + { + $rows = $this->getEntityManager()->getConnection() + ->executeQuery( + 'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)', + ['state' => (string) json_encode([$state])], + ) + ->fetchFirstColumn() + ; + + return array_map(static fn (mixed $id): int => (int) $id, $rows); + } }