From 8644ad79cec26a0a376230ce7f9428fb062bb27b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 25 Jun 2026 11:41:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20ERP-201=20=E2=80=94=20r?= =?UTF-8?q?=C3=A9f=C3=A9rentiel=20StorageType=20expos=C3=A9=20(filtre=20si?= =?UTF-8?q?te)=20+=20seed=20Figma=20+=20cat=C3=A9gories=20PRODUIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint StorageType (lecture seule) : - StorageTypeProvider : tri label ASC, filtre ?siteId[]= (EXISTS corrélé, RG-6.06), pagination Hydra + échappatoire ?pagination=false (référentiel borné) ; - createListQueryBuilder ajouté au repository (interface + impl) ; - provider câblé sur GetCollection + Get de l'entité StorageType. Seed (fixtures idempotentes par lookup code, miroir CategoryTypeFixtures) : - StorageTypeFixtures : 10 types Figma (PROVISOIRE HP-M6-02), rattachés aux 3 sites (86/17/82) via le contrat Shared SiteProviderInterface (pas d'import inter-module) ; - CategoryTypeFixtures : ajout du type PRODUIT (réaligne dev/test sur le seed migration ERP-198) ; - CategoryFixtures : 4 catégories PRODUIT de démo (Céréales, Oléagineux, Aliments du bétail, Engrais). Fix dette ERP-198/199 : mapping ORM de product.states aligné sur la colonne JSONB de la migration (options jsonb=true). Sans ça, schema:update tentait ALTER states TYPE JSON et cassait le CHECK jsonb_array_length -> make db-reset / test-db-setup en échec. make db-reset OK (fixtures idempotentes, données vérifiées), make test vert (873), php-cs-fixer OK. --- src/Module/Catalog/Domain/Entity/Product.php | 7 +- .../Catalog/Domain/Entity/StorageType.php | 10 +- .../StorageTypeRepositoryInterface.php | 14 ++- .../State/Provider/StorageTypeProvider.php | 94 ++++++++++++++ .../DataFixtures/CategoryFixtures.php | 14 ++- .../DataFixtures/CategoryTypeFixtures.php | 9 +- .../DataFixtures/StorageTypeFixtures.php | 115 ++++++++++++++++++ .../DoctrineStorageTypeRepository.php | 30 +++++ 8 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php create mode 100644 src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php diff --git a/src/Module/Catalog/Domain/Entity/Product.php b/src/Module/Catalog/Domain/Entity/Product.php index ac9219f..143d758 100644 --- a/src/Module/Catalog/Domain/Entity/Product.php +++ b/src/Module/Catalog/Domain/Entity/Product.php @@ -151,7 +151,12 @@ class Product implements TimestampableInterface, BlamableInterface * * @var list */ - #[ORM\Column(type: 'json')] + // jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la + // migration (spec § 2.3 + CHECK chk_product_states_not_empty via + // jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente + // un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu) + // et fait echouer make db-reset / test-db-setup. + #[ORM\Column(type: 'json', options: ['jsonb' => true])] #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')] #[Assert\Choice( choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER], diff --git a/src/Module/Catalog/Domain/Entity/StorageType.php b/src/Module/Catalog/Domain/Entity/StorageType.php index 5652af6..41cc563 100644 --- a/src/Module/Catalog/Domain/Entity/StorageType.php +++ b/src/Module/Catalog/Domain/Entity/StorageType.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Domain\Entity; use ApiPlatform\Metadata\ApiResource; 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; @@ -38,16 +39,19 @@ 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). new GetCollection( security: "is_granted('catalog.products.view')", normalizationContext: ['groups' => ['storage_type:read']], - // Tri alphabetique stable pour alimenter le multi-select du formulaire - // produit (§ 4.2). Le filtre ?siteId[]= est branche en ERP-201. - order: ['label' => 'ASC'], + provider: StorageTypeProvider::class, ), new Get( security: "is_granted('catalog.products.view')", normalizationContext: ['groups' => ['storage_type:read']], + provider: StorageTypeProvider::class, ), ], )] diff --git a/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php index c57d713..293ca7a 100644 --- a/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Module\Catalog\Domain\Repository; use App\Module\Catalog\Domain\Entity\StorageType; +use Doctrine\ORM\QueryBuilder; interface StorageTypeRepositoryInterface { @@ -12,10 +13,19 @@ interface StorageTypeRepositoryInterface /** * Tous les types de stockage tries par libelle (alimente le multi-select du - * formulaire produit — § 4.2). Le filtrage par site (?siteId[]=, RG-6.06) est - * branche cote provider en ERP-201. + * formulaire produit — § 4.2). * * @return list */ public function findAllOrderedByLabel(): array; + + /** + * 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 $siteIds + */ + public function createListQueryBuilder(array $siteIds = []): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php new file mode 100644 index 0000000..c8f53ee --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php @@ -0,0 +1,94 @@ + + */ +final class StorageTypeProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')] + private readonly StorageTypeRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null + { + if ($operation instanceof CollectionOperationInterface) { + $qb = $this->repository->createListQueryBuilder($this->readSiteIds($context)); + + // Echappatoire ?pagination=false : collection complete sans Paginator + // (alimentation du multi-select, referentiel borne). + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // Pas de fetch-join to-many (sites non serialisee) -> Paginator simple. + return new Paginator(new DoctrinePaginator($qb->getQuery())); + } + + // Get unitaire. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + 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 + */ + 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/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index 794ff64..bdd3c0c 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -20,8 +20,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type * ADRESSE porte les categories des blocs adresse (Siege, Contact issues, - * Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte - * un `code` stable. + * Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte + * les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments + * du betail, Engrais). Chaque categorie porte un `code` stable. * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). @@ -88,6 +89,15 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface 'Approvisionnement' => 'APPROVISIONNEMENT', 'Méthaniseur' => 'METHANISEUR', ], + // M6 (ERP-201) : categories produit alimentant le select du formulaire + // produit (filtre ?typeCode=PRODUIT). Codes = slug MAJUSCULE deterministe + // (meme sortie que CategoryCodeGenerator). Provisoires, a affiner avec le metier. + 'PRODUIT' => [ + 'Céréales' => 'CEREALES', + 'Oléagineux' => 'OLEAGINEUX', + 'Aliments du bétail' => 'ALIMENTS_DU_BETAIL', + 'Engrais' => 'ENGRAIS', + ], ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index 2b45d7c..8d58871 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -29,6 +29,10 @@ use Doctrine\Persistence\ObjectManager; * dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir * de la migration Version20260625100000. * + * M6 (ERP-201) : ajout du type PRODUIT (code PRODUIT, label « Produit »), taxonomie + * des categories produit du catalogue (Cereales, Oleagineux...). Mirroir du seed de + * la migration Version20260625110000 (ERP-198) : re-aligne dev/test apres purge. + * * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * entite managee par l ORM, donc le purger Doctrine la vide avant chaque * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la @@ -45,14 +49,15 @@ class CategoryTypeFixtures extends Fixture /** * Source unique des types : code technique => libelle FR. Doit rester aligne * sur le seed des migrations Version20260602100000 (CLIENT), - * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et - * Version20260625100000 (ADRESSE). + * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE), + * Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198). */ private const TYPES = [ 'CLIENT' => 'Client', 'FOURNISSEUR' => 'Fournisseur', 'PRESTATAIRE' => 'Prestataire', 'ADRESSE' => 'Adresse', + 'PRODUIT' => 'Produit', ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php new file mode 100644 index 0000000..f8f1010 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php @@ -0,0 +1,115 @@ + libelle FR. + * A re-seeder a reception de la liste Aurore (HP-M6-02). + * + * @var array + */ + private const TYPES = [ + 'BOISSEAU' => 'Boisseau', + 'BOISSEAU_DOSAGE' => 'Boisseau dosage', + 'CASE' => 'Case', + 'CELLULE' => 'Cellule', + 'CONTAINER' => 'Container', + 'CUVE_MELASSE' => 'Cuve mélasse', + 'STOCKAGE_BIG_BAG' => 'Stockage big bag', + 'STOCKAGE_PALETTE' => 'Stockage palette', + 'TAS' => 'Tas', + 'ZONE' => 'Zone', + ]; + + /** + * Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle + * de lookup stable cote SitesFixtures. + * + * @var list + */ + private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic']; + + public function __construct( + private readonly StorageTypeRepositoryInterface $storageTypeRepository, + private readonly SiteProviderInterface $siteProvider, + ) {} + + /** + * @return array + */ + 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. + $existingByCode = []; + foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) { + $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); + } + + $manager->flush(); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php index 46962ef..dfff050 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine; use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -31,4 +32,33 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S { return $this->findBy([], ['label' => 'ASC']); } + + public function createListQueryBuilder(array $siteIds = []): 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') + ->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; + } }