feat(catalog) : categories multi-types (M:N) + bouton Filtres liste (#75)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Une `Category` ne pouvait appartenir qu'à **un seul** `CategoryType` (ManyToOne). Le besoin métier : plusieurs types par catégorie. Cette MR fait passer la relation en **ManyToMany** et ajoute le bouton **« Filtres »** à droite de la liste des catégories (modèle Répertoire Clients). Slice vertical complet (le passage M:N casse mécaniquement le contrat inter-module `CategoryInterface`, consommé par la RG-2.10 fournisseurs). ## Volet A — Relation M:N - `Category.categoryType` (ManyToOne) → `categoryTypes` (ManyToMany, jonction `category_category_type`). Au moins un type obligatoire (`Assert\\Count(min:1)`). - **Unicité du nom GLOBALE** parmi les actifs (`uq_category_name_active`, remplace `uq_category_name_type_active`). Message 409 reformulé. - Migration : table de jonction + backfill + drop colonne `category_type_id` + nouvel index. Validée **rejouable sur base fraîche**. - Contrat Shared : `getCategoryTypeCode()` → `getCategoryTypeCodes(): array`. `Supplier`/`SupplierAddress`/`SupplierFixtures` revalident « contient FOURNISSEUR » (RG-2.10). - Provider/Repository : filtre type via sous-requête `EXISTS` (ne tronque pas la collection embarquée), eager-load anti-N+1. ## Volet B — Bouton « Filtres » - Drawer recherche par nom + types multi (OR). Compteur de filtres actifs. État local, jamais persisté en URL. - Back : filtres `?name=` et `?typeId[]=` sur la collection. ## Front - Multi-select `MalioSelectCheckbox`, `useCategoryForm` en `categoryTypeIds[]`, colonne « Types », clés i18n. ## Tests / vérifs - `make test` : **582 tests, 2474 assertions, 0 échec** ✅ - `make nuxt-test` : **236 tests** ✅ - `make php-cs-fixer-allow-risky` ✅ - Migration rejouée sur base fraîche (`make db-reset`) ✅ - Nouveau `CategoryFilterTest` (name partiel + typeId[] OR + multi-type non dupliqué) --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #75 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #75.
This commit is contained in:
@@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
||||
* (FK vers le referentiel statique CategoryType).
|
||||
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs
|
||||
* `categoryTypes` (ManyToMany vers le referentiel statique CategoryType,
|
||||
* table de jonction `category_category_type`). Une categorie peut appartenir
|
||||
* a plusieurs types simultanement (>= 1 obligatoire, RG-1.05).
|
||||
*
|
||||
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
||||
@@ -81,12 +85,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||
#[ORM\Table(name: 'category')]
|
||||
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
||||
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
|
||||
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
|
||||
// index partiel via attribut.
|
||||
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS
|
||||
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code
|
||||
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine
|
||||
// ORM ne sait pas exprimer un index partiel via attribut.
|
||||
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
||||
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
@@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
#[Groups(['category:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||
/**
|
||||
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le
|
||||
* referentiel statique CategoryType via la jonction `category_category_type`.
|
||||
* Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
|
||||
* supprime tant qu'il reste reference par une categorie.
|
||||
*
|
||||
* @var Collection<int, CategoryType>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: CategoryType::class)]
|
||||
#[ORM\JoinTable(name: 'category_category_type')]
|
||||
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
private ?CategoryType $categoryType = null;
|
||||
private Collection $categoryTypes;
|
||||
|
||||
/**
|
||||
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||
@@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
#[Groups(['category:read'])]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categoryTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategoryType(): ?CategoryType
|
||||
/**
|
||||
* @return Collection<int, CategoryType>
|
||||
*/
|
||||
public function getCategoryTypes(): Collection
|
||||
{
|
||||
return $this->categoryType;
|
||||
return $this->categoryTypes;
|
||||
}
|
||||
|
||||
public function setCategoryType(?CategoryType $categoryType): static
|
||||
public function addCategoryType(CategoryType $categoryType): static
|
||||
{
|
||||
$this->categoryType = $categoryType;
|
||||
if (!$this->categoryTypes->contains($categoryType)) {
|
||||
$this->categoryTypes->add($categoryType);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategoryType(CategoryType $categoryType): static
|
||||
{
|
||||
$this->categoryTypes->removeElement($categoryType);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemente CategoryInterface : code du type rattache (ou null). Permet
|
||||
* aux modules tiers de filtrer/valider par type metier sans dependre de
|
||||
* Catalog.
|
||||
* Implemente CategoryInterface : liste des codes de types rattaches a la
|
||||
* categorie. Permet aux modules tiers de filtrer/valider par type metier
|
||||
* (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getCategoryTypeCode(): ?string
|
||||
public function getCategoryTypeCodes(): array
|
||||
{
|
||||
return $this->categoryType?->getCode();
|
||||
return array_values(array_filter(
|
||||
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
|
||||
@@ -23,10 +23,26 @@ interface CategoryRepositoryInterface
|
||||
/**
|
||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||
* - $typeCode non null : ne garde que les categories dont le CategoryType
|
||||
* porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
|
||||
* multi-select Categorie du fournisseur (M2, RG-2.10).
|
||||
* - $typeCode non null : ne garde que les categories PORTANT ce code de type
|
||||
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select
|
||||
* Categorie du fournisseur (M2, RG-2.10).
|
||||
* - $nameSearch non null : recherche partielle case-insensitive sur le nom
|
||||
* (filtre `?name=` de la liste admin).
|
||||
* - $typeIds non vide : ne garde que les categories portant AU MOINS UN des
|
||||
* types (OR, filtre `?typeId[]=` de la liste admin).
|
||||
* - Tri : name ASC (RG-1.10).
|
||||
*
|
||||
* Les categories etant en ManyToMany avec leurs types, la collection
|
||||
* `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la
|
||||
* serialisation, et `distinct` est applique des qu'un filtre type joint la
|
||||
* table de jonction (evite les lignes dupliquees).
|
||||
*
|
||||
* @param list<int> $typeIds
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder;
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $typeCode = null,
|
||||
?string $nameSearch = null,
|
||||
array $typeIds = [],
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
|
||||
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
|
||||
* UniqueConstraintViolationException remontee par Postgres (collision sur
|
||||
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
|
||||
* le message attendu par la spec (RG-1.07).
|
||||
* l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les
|
||||
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07).
|
||||
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
|
||||
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||
@@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
|
||||
// RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted
|
||||
// (uq_category_name_active). L'unicite n'est plus liee au type depuis le
|
||||
// passage en ManyToMany.
|
||||
throw new HttpException(
|
||||
409,
|
||||
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
|
||||
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
$qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeDeleted,
|
||||
$this->readTypeCode($context),
|
||||
$this->readNameSearch($context),
|
||||
$this->readTypeIds($context),
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
||||
@@ -115,4 +120,48 @@ final class CategoryProvider implements ProviderInterface
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
|
||||
* Renvoie la valeur trimmee ou null si absente / vide.
|
||||
*/
|
||||
private function readNameSearch(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['name'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
|
||||
* Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
|
||||
* les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readTypeIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['typeId'] ?? 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
$category = new Category();
|
||||
$category->setName($name);
|
||||
$category->setCode($code);
|
||||
$category->setCategoryType($type);
|
||||
$category->addCategoryType($type);
|
||||
$manager->persist($category);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,19 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
||||
return [] !== $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
|
||||
{
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $typeCode = null,
|
||||
?string $nameSearch = null,
|
||||
array $typeIds = [],
|
||||
): QueryBuilder {
|
||||
// Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
|
||||
// serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
|
||||
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
|
||||
// compatible avec ce fetch-join to-many.
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->leftJoin('c.categoryTypes', 'cte')
|
||||
->addSelect('cte')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
@@ -58,16 +68,45 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
||||
$qb->andWhere('c.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
// Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
|
||||
// les categories du type demande (ex. FOURNISSEUR). La jointure reste
|
||||
// compatible avec le Paginator ORM (fetchJoinCollection) du provider.
|
||||
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10,
|
||||
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS
|
||||
// restreindre la collection eager-loadee `cte` (sinon les autres types de
|
||||
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
|
||||
if (null !== $typeCode) {
|
||||
$qb->join('c.categoryType', 'ct')
|
||||
->andWhere('ct.code = :typeCode')
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Category::class, 'c_tc')
|
||||
->join('c_tc.categoryTypes', 'ct_tc')
|
||||
->where('c_tc = c')
|
||||
->andWhere('ct_tc.code = :typeCode')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('typeCode', $typeCode)
|
||||
;
|
||||
}
|
||||
|
||||
// Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
|
||||
// types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
|
||||
if ([] !== $typeIds) {
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Category::class, 'c_ti')
|
||||
->join('c_ti.categoryTypes', 'ct_ti')
|
||||
->where('c_ti = c')
|
||||
->andWhere('ct_ti.id IN (:typeIds)')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('typeIds', $typeIds)
|
||||
;
|
||||
}
|
||||
|
||||
// Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
|
||||
if (null !== $nameSearch && '' !== $nameSearch) {
|
||||
$qb->andWhere('LOWER(c.name) LIKE :nameSearch')
|
||||
->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur le
|
||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le
|
||||
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du
|
||||
* module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
@@ -300,16 +300,17 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
|
||||
* POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
* Platform, sur POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
|
||||
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
|
||||
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
|
||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
|
||||
@@ -219,15 +219,16 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
|
||||
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
|
||||
@@ -421,11 +421,12 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
||||
return $this->categoryCache[$name];
|
||||
}
|
||||
|
||||
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par
|
||||
// le seul `name` rattacherait une categorie homonyme d'un autre type (ex.
|
||||
// futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une
|
||||
// categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP
|
||||
// (findBy ne sait pas filtrer une propriete imbriquee categoryType.code).
|
||||
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
|
||||
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
|
||||
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
|
||||
// plus une categorie, mais on conserve la verification du type pour
|
||||
// ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
|
||||
// est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
|
||||
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
|
||||
'name' => $name,
|
||||
'deletedAt' => null,
|
||||
@@ -433,7 +434,7 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate instanceof CategoryInterface
|
||||
&& self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) {
|
||||
&& in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
|
||||
return $this->categoryCache[$name] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user