Compare commits

..

2 Commits

Author SHA1 Message Date
Matthieu cbc445a539 feat(catalog) : ERP-202 — export XLSX du catalogue produits (filtres liste) 2026-06-25 11:54:50 +02:00
Matthieu 8644ad79ce 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.
2026-06-25 11:41:40 +02:00
10 changed files with 832 additions and 10 deletions
+6 -1
View File
@@ -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));
}
}
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function in_array;
use function is_int;
use function is_string;
/**
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
* en prose volontairement (pas de {@see} inter-module : violerait la regle
* ABSOLUE n°1).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
* sur la route : sans cela API Platform capterait `/api/products/export.xlsx`
* comme l'item `GET /api/products/{id}.{_format}` (id="export", _format="xlsx")
* — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des produits (MEMES filtres que
* `GET /api/products` via {@see ProductProvider}, deleguee a
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
*/
#[AsController]
final class ProductExportController
{
/**
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
* stockage en base.
*/
private const array STATE_LABELS = [
Product::STATE_PURCHASE => 'Achat',
Product::STATE_SALE => 'Vendu',
Product::STATE_OTHER => 'Autre',
];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
private readonly ProductRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
) {}
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('catalog.products.view')]
public function __invoke(Request $request): Response
{
// Memes filtres que la vue liste (ProductProvider) pour que l'export
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
// jamais expose au M6 (§ 2.7).
$search = $request->query->getString('search') ?: null;
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
$categoryCode = $request->query->getString('categoryCode') ?: null;
$state = $this->readState($request->query->get('state'));
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
/** @var list<Product> $products */
$products = $this->repository
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
->getQuery()
->getResult()
;
$binary = $this->exporter->export(
'Catalogue produits',
$this->buildHeaders(),
$this->buildRows($products),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.5).
*
* @return list<string>
*/
private function buildHeaders(): array
{
return [
'Numéro',
'Nom',
'États',
'Catégorie',
'Sites',
'Types de stockage',
'Fabriqué',
'Contient mélasse',
];
}
/**
* @param list<Product> $products
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $products): iterable
{
foreach ($products as $product) {
yield [
$product->getCode(),
$product->getName(),
$this->formatStates($product),
$product->getCategory()?->getName(),
$this->formatSites($product),
$this->formatStorageTypes($product),
$product->isManufactured() ? 'Oui' : 'Non',
$product->containsMolasses() ? 'Oui' : 'Non',
];
}
}
/**
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
* Autre), joints par virgule. Une valeur inattendue est ignoree.
*/
private function formatStates(Product $product): string
{
$states = $product->getStates();
$labels = [];
foreach (self::STATE_LABELS as $code => $label) {
if (in_array($code, $states, true)) {
$labels[] = $label;
}
}
return implode(', ', $labels);
}
/**
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
* par virgule.
*/
private function formatSites(Product $product): string
{
$names = [];
foreach ($product->getSites() as $site) {
// @var Site $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Libelles des types de stockage du produit, dedupliques, tries, joints par
* virgule.
*/
private function formatStorageTypes(Product $product): string
{
$labels = [];
foreach ($product->getStorageTypes() as $storageType) {
// @var StorageType $storageType
$label = $storageType->getLabel();
if (null !== $label && '' !== $label) {
$labels[$label] = true;
}
}
return $this->joinSorted($labels);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
* autre valeur est ignoree (null).
*/
private function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
}
/**
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
* ProductProvider (tolere int ou chaine numerique).
*/
private function readIntOrNull(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -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;
}
}
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
* catalog.products.view, 401 anonyme.
*
* @internal
*/
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/products/export.xlsx';
/**
* Purge des produits + types de stockage de test AVANT le cleanup parent :
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
* donc les categories ne peuvent etre supprimees tant que des produits les
* referencent. La suppression des produits cascade les jonctions
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
*/
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Product::class)->execute();
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_CATEGORY_TYPE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithHeaderRow(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_A', 'Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="catalogue-produits-', $disposition);
self::assertMatchesRegularExpression(
'/filename="catalogue-produits-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headerCells = $grid[0];
self::assertSame('Numéro', $headerCells[0]);
self::assertSame('Nom', $headerCells[1]);
self::assertContains('États', $headerCells);
self::assertContains('Catégorie', $headerCells);
self::assertContains('Sites', $headerCells);
self::assertContains('Types de stockage', $headerCells);
self::assertContains('Fabriqué', $headerCells);
self::assertContains('Contient mélasse', $headerCells);
// Au moins une ligne de donnees (le produit seede).
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
}
public function testExportExcludesSoftDeletedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('TEST_PRD_ACTIVE', $codes);
self::assertNotContains('TEST_PRD_DELETED', $codes);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('TEST_PRD_SRCH', $codes);
self::assertNotContains('TEST_PRD_OTHER', $codes);
}
public function testExportRespectsStateFilter(): void
{
$client = $this->createAdminClient();
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
$codes = $this->codes(
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
);
self::assertContains('TEST_PRD_SALE', $codes);
self::assertNotContains('TEST_PRD_BUY', $codes);
}
public function testExportPopulatesAllBusinessColumns(): void
{
$client = $this->createAdminClient();
$site = $this->firstSite();
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
$category = $this->createCategory('test_cat_export_produit');
$this->seedProduct(
'TEST_PRD_FULL',
'Complet',
[Product::STATE_PURCHASE, Product::STATE_SALE],
true,
true,
null,
$site,
$storageType,
$category,
);
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
self::assertSame('TEST_PRD_FULL', $row[0]);
self::assertSame('Complet', $row[1]);
self::assertSame('Achat, Vendu', $row[2]);
self::assertSame((string) $category->getName(), $row[3]);
self::assertSame((string) $site->getName(), $row[4]);
self::assertSame('Tas de test', $row[5]);
self::assertSame('Oui', $row[6]);
self::assertSame('Oui', $row[7]);
}
public function testForbiddenWithoutProductsViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
* defaut). Les relations omises sont creees a la volee. Persistance directe
* via l'EM : on bypasse le Processor/Validator (non teste ici).
*
* @param list<string> $states
*/
private function seedProduct(
string $code,
string $name,
array $states = [Product::STATE_PURCHASE],
bool $manufactured = false,
bool $containsMolasses = false,
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
?Category $category = null,
): Product {
$em = $this->getEm();
$product = new Product();
$product->setCode($code);
$product->setName($name);
$product->setStates($states);
$product->setManufactured($manufactured);
$product->setContainsMolasses($containsMolasses);
$product->setCategory($category ?? $this->createCategory());
$product->addSite($site ?? $this->firstSite());
$product->addStorageType($storageType ?? $this->seedStorageType('TEST_'.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
$product->setDeletedAt($deletedAt);
$em->persist($product);
$em->flush();
return $product;
}
/**
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
*/
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
$em->persist($storageType);
$em->flush();
return $storageType;
}
/**
* Premier site seede (les sites existent en base de test, comme dans les
* autres tests d'export).
*/
private function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
return $site;
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Numéro » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function codes(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null.
*
* @return null|array<int, mixed>
*/
private function rowForCode(string $binary, string $code): ?array
{
$grid = $this->gridFromResponse($binary);
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $code) {
return $row;
}
}
return null;
}
}