feat(catalog) : M6 — StorageType référentiel plat + seed migration (drop storage_type_site)

La disponibilité « type de stockage par site » relèvera de la future entité
Stockage (site + type), pas du référentiel. On retire donc la jointure M2M
storage_type_site et le filtrage du multi-select par site (RG-6.06 revue) :

- migration : DROP storage_type_site + seed idempotent des 10 types (prod-safe,
  ON CONFLICT) ;
- StorageType : référentiel plat (plus de relation sites) ;
- Product : suppression du Assert\Callback de disponibilité par site ;
- provider/repository : /storage_types renvoie tous les types (plus de ?siteId[]) ;
- front : useStorageTypeOptions charge tout dans loadReferentials, setSites sans
  cascade/purge ;
- fixture, ColumnCommentsCatalog, tests et spec-back M6 alignés.
This commit is contained in:
2026-06-26 15:39:11 +02:00
parent a6b8e7145e
commit fced2c2cfd
16 changed files with 235 additions and 422 deletions
+5 -44
View File
@@ -49,12 +49,12 @@ use function in_array;
* contient SALE, sinon forces false serveur.
* - RG-6.04 : `sites` >= 1.
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
* - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes.
* - RG-6.06 : `storageTypes` >= 1 (referentiel plat — plus de filtrage par site).
*
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
*
* Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le
* Les RG inter-champs (RG-6.03/6.05) et l'unicite du code passent par le
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
*
@@ -204,9 +204,9 @@ class Product implements TimestampableInterface, BlamableInterface
private Collection $sites;
/**
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
* Types de stockage du produit (>= 1, RG-6.06). Referentiel plat : tous les
* types sont selectionnables (plus de filtrage par site). Cote inverse en
* ON DELETE RESTRICT : un type reference par un produit ne peut etre supprime.
*
* @var Collection<int, StorageType>
*/
@@ -396,43 +396,4 @@ class Product implements TimestampableInterface, BlamableInterface
;
}
}
/**
* 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()
;
}
}
}
}
@@ -9,22 +9,19 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
* (node 1503-34285) au ticket ERP-201.
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste
* definitive d'Aurore (HP-M6-02 / ERP-201).
*
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
* (le filtrage est applique cote provider en ERP-201).
* Referentiel PLAT : un type de stockage n'est PAS rattache a des sites. La
* disponibilite « tel type sur tel site » releve de la future entite Stockage
* (module Stockage : un stockage = 1 site + 1 type) et sera derivee des stockages
* reels, pas portee par ce referentiel. Le multi-select « Type de stockage » du
* formulaire produit liste donc TOUS les types, sans filtrage par site (RG-6.06).
*
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
@@ -39,10 +36,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
#[ApiResource(
operations: [
// Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider
// (ERP-201) : alimente le multi-select « Type de stockage » du formulaire
// produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra +
// echappatoire ?pagination=false (referentiel borne).
// Tri label ASC porte par le StorageTypeProvider : alimente le multi-select
// « Type de stockage » du formulaire produit (TOUS les types — referentiel
// plat). Pagination Hydra + echappatoire ?pagination=false (referentiel borne).
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']],
@@ -75,24 +71,6 @@ class StorageType
#[Groups(['storage_type:read'])]
private ?string $label = null;
/**
* Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non
* exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=`
* du referentiel (branche en ERP-201).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'storage_type_site')]
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
private Collection $sites;
public function __construct()
{
$this->sites = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -121,28 +99,4 @@ class StorageType
return $this;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(Site $site): static
{
$this->sites->removeElement($site);
return $this;
}
}
@@ -21,11 +21,8 @@ interface StorageTypeRepositoryInterface
/**
* QueryBuilder de la liste des types de stockage (consomme par le
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
* AU MOINS UN des sites passes.
*
* @param list<int> $siteIds
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2). Referentiel plat :
* plus de filtrage par site (la dispo par site releve du futur module Stockage).
*/
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
public function createListQueryBuilder(): QueryBuilder;
}
@@ -18,12 +18,12 @@ use function is_int;
use function is_string;
/**
* Provider StorageType (referentiel lecture seule, ERP-201) :
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
* types disponibles sur au moins un des sites passes) et collection PAGINEE
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
* alimenter le multi-select « Type de stockage » du formulaire produit
* (referentiel borne — pagination_client_enabled).
* Provider StorageType (referentiel plat lecture seule) :
* - LISTE : tri `label ASC` (defaut spec § 4.2) et collection PAGINEE Hydra
* (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
* alimenter le multi-select « Type de stockage » du formulaire produit avec
* TOUS les types (referentiel borne — pagination_client_enabled). Plus de
* filtrage par site : la dispo par site releve du futur module Stockage.
* - ITEM : lookup simple par id.
*
* @implements ProviderInterface<StorageType>
@@ -39,7 +39,7 @@ final class StorageTypeProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
{
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
$qb = $this->repository->createListQueryBuilder();
// Echappatoire ?pagination=false : collection complete sans Paginator
// (alimentation du multi-select, referentiel borne).
@@ -65,30 +65,4 @@ final class StorageTypeProvider implements ProviderInterface
return $this->repository->findById((int) $id);
}
/**
* 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));
}
}
@@ -6,41 +6,39 @@ namespace App\Module\Catalog\Infrastructure\DataFixtures;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed du referentiel `storage_type` (M6).
* Fixtures du module Catalog : seed du referentiel PLAT `storage_type` (M6).
*
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes et libelles ci-dessous
* sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste definitive (ERP-201).
* La liste actuelle reprend les 10 valeurs de la maquette Figma (node 1503-34285).
*
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
* doit exister pour alimenter le formulaire produit et les tests du filtre
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
* pas une donnee de demo — miroir CategoryTypeFixtures).
* Referentiel PLAT : un type de stockage n'est plus rattache a des sites (la dispo
* par site releve du futur module Stockage). Cette fixture ne seede donc que les
* lignes `storage_type` ; la voie prod-safe est l'INSERT idempotent de la migration
* Version20260626100000 (les fixtures ne tournent pas en prod). Source unique : les
* memes 10 valeurs ici et dans la migration.
*
* Pourquoi une fixture EN PLUS de la migration : `storage_type` est une entite
* managee par l'ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test apres la purge
* (referentiel necessaire au formulaire produit et a ses tests). Elle tourne dans
* TOUS les environnements (referentiel, pas une donnee de demo — miroir
* CategoryTypeFixtures).
*
* Idempotence : lookup par `code` parmi les types existants avant insertion
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
* Rejouable sans doublon meme si le purger Doctrine est desactive.
*
* Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y
* rattacher les types de stockage. Les sites sont resolus via le contrat Shared
* SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1).
* (miroir CategoryTypeFixtures). Rejouable sans doublon meme si le purger Doctrine
* est desactive.
*/
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
class StorageTypeFixtures extends Fixture
{
/**
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
* A re-seeder a reception de la liste Aurore (HP-M6-02).
* A re-seeder a reception de la liste Aurore (HP-M6-02). Doit rester aligne sur
* la migration Version20260626100000.
*
* @var array<string, string>
*/
@@ -57,27 +55,10 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
'ZONE' => 'Zone',
];
/**
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
* de lookup stable cote SitesFixtures.
*
* @var list<string>
*/
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
public function __construct(
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
private readonly SiteProviderInterface $siteProvider,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
// Index des types deja presents par code, pour ne pas creer de doublon.
@@ -86,27 +67,11 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
$existingByCode[$type->getCode()] = $type;
}
// Resolution des 3 sites par defaut via le contrat Shared (rattachement
// provisoire). Les objets resolus sont des Site managees (resolve_target_entities
// SiteInterface -> Site) : addSite() les accepte.
$defaultSites = [];
foreach (self::DEFAULT_SITE_NAMES as $name) {
$site = $this->siteProvider->findByName($name);
if (null !== $site) {
$defaultSites[] = $site;
}
}
foreach (self::TYPES as $code => $label) {
$storageType = $existingByCode[$code] ?? new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
foreach ($defaultSites as $site) {
$storageType->addSite($site);
}
$manager->persist($storageType);
}
@@ -33,32 +33,12 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S
return $this->findBy([], ['label' => 'ASC']);
}
public function createListQueryBuilder(array $siteIds = []): QueryBuilder
public function createListQueryBuilder(): QueryBuilder
{
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2). La
// relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas)
// -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la
// restriction des lignes.
$qb = $this->createQueryBuilder('st')
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2).
// Referentiel plat : tous les types, plus de filtrage par site.
return $this->createQueryBuilder('st')
->orderBy('st.label', 'ASC')
;
// ?siteId[]= : type disponible sur AU MOINS UN des sites passes (OR, RG-6.06).
// Sous-requete EXISTS correlee (meme strategie que DoctrineCategoryRepository
// / DoctrineProductRepository) pour eviter les lignes dupliquees du JOIN.
if ([] !== $siteIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(StorageType::class, 'st_si')
->join('st_si.sites', 's_si')
->where('st_si = st')
->andWhere('s_si.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('siteIds', $siteIds)
;
}
return $qb;
}
}
@@ -587,12 +587,6 @@ final class ColumnCommentsCatalog
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
],
'storage_type_site' => [
'_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.',
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.',
],
'product' => [
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
'id' => 'Identifiant interne auto-incremente.',
@@ -612,7 +606,7 @@ final class ColumnCommentsCatalog
],
'product_storage_type' => [
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).',
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, RG-6.06 ; referentiel plat).',
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
],