feat(catalog) : ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation)
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.
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Application\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisation serveur des champs texte d'un Product, appliquee par le
|
||||||
|
* ProductProcessor AVANT l'unicite du code et la persistance (RG-6.07, spec-back
|
||||||
|
* M6 § 6). Jumeau du CarrierFieldNormalizer (M4), recentre sur les deux champs
|
||||||
|
* texte du produit.
|
||||||
|
*
|
||||||
|
* - code : trim + UPPER (cohérent avec la stratégie de codes stables du Catalog —
|
||||||
|
* le code produit fait office de cle metier saisie, unique global parmi les
|
||||||
|
* actifs RG-6.01).
|
||||||
|
* - name : trim simple (pas de changement de casse — libelle affiche).
|
||||||
|
*
|
||||||
|
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||||
|
* trim devient null. En pratique le ProductProcessor n'appelle ces methodes
|
||||||
|
* qu'apres validation (NotBlank deja joue par API Platform), donc le code et le
|
||||||
|
* name sont non vides a ce stade — le retour null reste un garde-fou.
|
||||||
|
*/
|
||||||
|
final class ProductFieldNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Code produit en majuscules (RG-6.07) : " ble-01 " -> "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ use Doctrine\Common\Collections\Collection;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
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
|
* 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.
|
// TimestampableBlamableSubscriber au prePersist / preUpdate.
|
||||||
use TimestampableBlamableTrait;
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -143,7 +154,7 @@ class Product implements TimestampableInterface, BlamableInterface
|
|||||||
#[ORM\Column(type: 'json')]
|
#[ORM\Column(type: 'json')]
|
||||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||||
#[Assert\Choice(
|
#[Assert\Choice(
|
||||||
choices: ['PURCHASE', 'SALE', 'OTHER'],
|
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
|
||||||
multiple: true,
|
multiple: true,
|
||||||
message: 'État de produit invalide.',
|
message: 'État de produit invalide.',
|
||||||
multipleMessage: 'État de produit invalide.',
|
multipleMessage: 'État de produit invalide.',
|
||||||
@@ -358,4 +369,65 @@ class Product implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
return $this;
|
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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Module\Catalog\Domain\Repository;
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\Product;
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
interface ProductRepositoryInterface
|
interface ProductRepositoryInterface
|
||||||
{
|
{
|
||||||
@@ -20,4 +21,25 @@ interface ProductRepositoryInterface
|
|||||||
* ignore les supprimes).
|
* ignore les supprimes).
|
||||||
*/
|
*/
|
||||||
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
|
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<int> $siteIds
|
||||||
|
*/
|
||||||
|
public function createListQueryBuilder(
|
||||||
|
bool $includeDeleted = false,
|
||||||
|
?string $search = null,
|
||||||
|
?int $categoryId = null,
|
||||||
|
?string $categoryCode = null,
|
||||||
|
?string $state = null,
|
||||||
|
array $siteIds = [],
|
||||||
|
): QueryBuilder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Catalog\Application\Service\ProductFieldNormalizer;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor d'ecriture du produit (M6, POST / PATCH). Cf. spec-back M6 § 4.3 /
|
||||||
|
* § 4.4 + RG-6.01 / RG-6.03 / RG-6.07. Jumeau du CategoryProcessor (409 doublon)
|
||||||
|
* et du CarrierProcessor (normalisation serveur).
|
||||||
|
*
|
||||||
|
* Sequence (POST / PATCH) :
|
||||||
|
* 1. Normalisation serveur (RG-6.07) via ProductFieldNormalizer : code trim+UPPER,
|
||||||
|
* name trim. Jouee AVANT l'unicite et la persistance ; la validation
|
||||||
|
* (NotBlank/Length + Callback RG-6.05/6.06) a deja joue cote API Platform sur
|
||||||
|
* la saisie brute.
|
||||||
|
* 2. RG-6.03 : champs conditionnels SALE. Si `states` ne contient pas SALE,
|
||||||
|
* `manufactured` et `containsMolasses` sont forces false serveur (ils ne sont
|
||||||
|
* saisissables que si l'etat contient SALE).
|
||||||
|
* 3. RG-6.01 : unicite GLOBALE du `code` parmi les actifs. Pre-check deterministe
|
||||||
|
* (excluant le produit courant en PATCH) -> 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<Product, Product>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||||
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider Product (lecture, ERP-200) :
|
||||||
|
* - LISTE : exclut par defaut les produits soft-deleted (RG-6.09), trie par
|
||||||
|
* name ASC (defaut spec § 4.1), applique les filtres du drawer « Filtrer »
|
||||||
|
* (?search, ?categoryId / ?categoryCode, ?state, ?siteId[]) 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 produit puis renvoie null (404) s'il est soft-deleted —
|
||||||
|
* le soft-delete n'est jamais expose au M6 (§ 2.7), aucun flag includeDeleted.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<Product>
|
||||||
|
*/
|
||||||
|
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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine;
|
|||||||
use App\Module\Catalog\Domain\Entity\Product;
|
use App\Module\Catalog\Domain\Entity\Product;
|
||||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,4 +47,104 @@ class DoctrineProductRepository extends ServiceEntityRepository implements Produ
|
|||||||
|
|
||||||
return [] !== $qb->getQuery()->getResult();
|
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<int>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user