feat(catalog) : ERP-201 — référentiel StorageType exposé (filtre site) + seed Figma + catégories PRODUIT
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.
This commit is contained in:
@@ -151,7 +151,12 @@ class Product implements TimestampableInterface, BlamableInterface
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
#[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],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -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<StorageType>
|
||||
*/
|
||||
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<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
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).
|
||||
* - ITEM : lookup simple par id.
|
||||
*
|
||||
* @implements ProviderInterface<StorageType>
|
||||
*/
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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).
|
||||
*
|
||||
* ⚠ 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.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
|
||||
* A re-seeder a reception de la liste Aurore (HP-M6-02).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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<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.
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user