[ERP-78] Refonte taxonomie Catégories : type unique CLIENT + Category.code + RG-1.03/1.29 par code #42

Merged
malio merged 7 commits from feature/ERP-78-refonte-taxonomie-categories into develop 2026-06-02 08:00:43 +00:00
8 changed files with 129 additions and 47 deletions
Showing only changes of commit 596f716076 - Show all commits
@@ -20,8 +20,9 @@ interface ClientRepositoryInterface
* - Tri par defaut : companyName ASC (RG-1.26). * - Tri par defaut : companyName ASC (RG-1.26).
* - $search : recherche fuzzy insensible a la casse sur companyName + * - $search : recherche fuzzy insensible a la casse sur companyName +
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide. * lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
* - $categoryType : restreint aux clients possedant au moins une categorie * - $categoryCode : restreint aux clients possedant au moins une categorie
* du type donne (code). Ignore si null/vide. * du code donne (ERP-78 : filtrage par code de Category, plus par type).
* Ignore si null/vide.
* *
* Filtrage centralise ICI (et non dans les providers/controllers) pour que * Filtrage centralise ICI (et non dans les providers/controllers) pour que
* la liste paginee (ClientProvider) et l'export (ClientExportController) * la liste paginee (ClientProvider) et l'export (ClientExportController)
@@ -30,6 +31,6 @@ interface ClientRepositoryInterface
public function createListQueryBuilder( public function createListQueryBuilder(
bool $includeArchived = false, bool $includeArchived = false,
?string $search = null, ?string $search = null,
?string $categoryType = null, ?string $categoryCode = null,
): QueryBuilder; ): QueryBuilder;
} }
@@ -457,8 +457,9 @@ final class ClientProcessor implements ProcessorInterface
/** /**
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
* doit referencer un client de categorie DISTRIBUTEUR (idem broker -> * doit referencer un client portant la categorie de code DISTRIBUTEUR (idem
* COURTIER). * broker -> COURTIER). Depuis ERP-78, le filtrage se fait sur le code de la
* Category (et non plus sur le type, devenu unique CLIENT).
*/ */
private function validateDistributorBroker(Client $data): void private function validateDistributorBroker(Client $data): void
{ {
@@ -473,7 +474,7 @@ final class ClientProcessor implements ProcessorInterface
); );
} }
if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) { if (null !== $distributor && !$this->hasCategoryCode($distributor, 'DISTRIBUTEUR')) {
$this->throwViolation( $this->throwViolation(
'distributor', 'distributor',
'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.', 'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
@@ -481,7 +482,7 @@ final class ClientProcessor implements ProcessorInterface
); );
} }
if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) { if (null !== $broker && !$this->hasCategoryCode($broker, 'COURTIER')) {
$this->throwViolation( $this->throwViolation(
'broker', 'broker',
'Le courtier référencé doit être un client de catégorie COURTIER.', 'Le courtier référencé doit être un client de catégorie COURTIER.',
@@ -530,13 +531,13 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* Vrai si au moins une categorie du client porte le type donne. S'appuie * Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category). * CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1).
*/ */
private function hasCategoryType(Client $client, string $typeCode): bool private function hasCategoryCode(Client $client, string $code): bool
{ {
foreach ($client->getCategories() as $category) { foreach ($client->getCategories() as $category) {
if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) { if ($category instanceof CategoryInterface && $category->getCode() === $code) {
return true; return true;
} }
} }
@@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* exclus au M1) — RG-1.25 ; * exclus au M1) — RG-1.25 ;
* - tri par defaut companyName ASC — RG-1.26 ; * - tri par defaut companyName ASC — RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et * - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ; * ?categoryCode=<code> (clients ayant >= 1 categorie de ce code — ERP-78) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ; * - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination. * echappatoire ?pagination=false pour alimenter un <select> sans pagination.
* *
@@ -65,13 +65,13 @@ final class ClientProvider implements ProviderInterface
$filters = $context['filters'] ?? []; $filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false); $includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$search = $filters['search'] ?? null; $search = $filters['search'] ?? null;
$categoryType = $filters['categoryType'] ?? null; $categoryCode = $filters['categoryCode'] ?? null;
// Filtrage delegue au repository (logique partagee avec l'export XLSX). // Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder( $qb = $this->repository->createListQueryBuilder(
$includeArchived, $includeArchived,
is_string($search) ? $search : null, is_string($search) ? $search : null,
is_string($categoryType) ? $categoryType : null, is_string($categoryCode) ? $categoryCode : null,
); );
// Echappatoire ?pagination=false : collection complete sans Paginator // Echappatoire ?pagination=false : collection complete sans Paginator
@@ -53,11 +53,11 @@ final class ClientExportController
{ {
$includeArchived = $this->readBool($request->query->get('includeArchived')); $includeArchived = $this->readBool($request->query->get('includeArchived'));
$search = $request->query->getString('search') ?: null; $search = $request->query->getString('search') ?: null;
$categoryType = $request->query->getString('categoryType') ?: null; $categoryCode = $request->query->getString('categoryCode') ?: null;
/** @var list<Client> $clients */ /** @var list<Client> $clients */
$clients = $this->repository $clients = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryType) ->createListQueryBuilder($includeArchived, $search, $categoryCode)
->getQuery() ->getQuery()
->getResult() ->getResult()
; ;
@@ -34,7 +34,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
public function createListQueryBuilder( public function createListQueryBuilder(
bool $includeArchived = false, bool $includeArchived = false,
?string $search = null, ?string $search = null,
?string $categoryType = null, ?string $categoryCode = null,
): QueryBuilder { ): QueryBuilder {
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
->andWhere('c.deletedAt IS NULL') ->andWhere('c.deletedAt IS NULL')
@@ -46,7 +46,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
} }
$this->applySearch($qb, $search); $this->applySearch($qb, $search);
$this->applyCategoryType($qb, $categoryType); $this->applyCategoryCode($qb, $categoryCode);
return $qb; return $qb;
} }
@@ -73,13 +73,15 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
} }
/** /**
* Restreint aux clients possedant au moins une categorie du type donne. * Restreint aux clients possedant au moins une categorie du code donne
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas * (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment
* perturber le DISTINCT / ORDER BY de la requete principale. * les selects « distributeur » (categoryCode=DISTRIBUTEUR) et « courtier »
* (COURTIER) cote front (RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la
* collection M2M) pour ne pas perturber le DISTINCT / ORDER BY principal.
*/ */
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void private function applyCategoryCode(QueryBuilder $qb, ?string $categoryCode): void
{ {
if (null === $categoryType || '' === trim($categoryType)) { if (null === $categoryCode || '' === trim($categoryCode)) {
return; return;
} }
@@ -87,12 +89,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
->select('c2.id') ->select('c2.id')
->from(Client::class, 'c2') ->from(Client::class, 'c2')
->join('c2.categories', 'cat2') ->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2') ->where('cat2.code = :categoryCode')
->where('ct2.code = :categoryType')
; ;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL())) $qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType)) ->setParameter('categoryCode', trim($categoryCode))
; ;
} }
} }
@@ -17,13 +17,17 @@ use DateTimeImmutable;
* Base des tests fonctionnels du module Commercial (M1 — repertoire clients). * Base des tests fonctionnels du module Commercial (M1 — repertoire clients).
* *
* Etend la base Core : ajoute des factories pour seeder vite des categories * Etend la base Core : ajoute des factories pour seeder vite des categories
* typees (DISTRIBUTEUR / COURTIER / SECTEUR) et des clients, plus un helper * codees (DISTRIBUTEUR / COURTIER / SECTEUR...) sous le type unique CLIENT et
* d'authentification admin. * des clients, plus un helper d'authentification admin.
*
* Refonte taxonomie ERP-78 : il n'y a plus qu'un type CLIENT ; le code metier
* vit desormais sur la Category. `createCategory($code)` est un fetch-or-create
* PAR CODE (idempotent) sous CLIENT — deux clients d'un meme test partagent ainsi
* la categorie de meme code sans violer l'index unique partiel uq_category_code.
* *
* Cleanup : tearDown purge clients, categories `test_cli_cat_*` et users/roles * Cleanup : tearDown purge clients, categories `test_cli_cat_*` et users/roles
* `test_*`. Les category_types business sont fetch-or-create (idempotents) et * `test_*`. Le type CLIENT est fetch-or-create (idempotent) et laisse en place.
* laisses en place (pas de DELETE pour ne pas entrer en conflit avec d'autres * Pas de DAMA en local -> purge manuelle obligatoire.
* suites). Pas de DAMA en local -> purge manuelle obligatoire.
* *
* @internal * @internal
*/ */
@@ -31,6 +35,14 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
{ {
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_'; protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
/**
* Codes pilotant les RG (RG-1.03 distributor/broker, RG-1.29 adresse) : ils
* doivent matcher exactement, donc createCategory() les fetch-or-create par
* code. Les autres codes sont traites comme de simples libelles generiques et
* produisent une categorie a code UNIQUE (cf. createCategory).
*/
private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER'];
protected function tearDown(): void protected function tearDown(): void
{ {
$this->cleanupCommercialTestData(); $this->cleanupCommercialTestData();
@@ -43,20 +55,20 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
} }
/** /**
* Recupere (ou cree) un CategoryType par son code metier. Idempotent : la * Recupere (ou cree) le type unique CLIENT (refonte ERP-78). Idempotent : la
* contrainte d'unicite sur category_type.code interdit les doublons. * contrainte d'unicite sur category_type.code interdit les doublons.
*/ */
protected function createCategoryType(string $code): CategoryType protected function clientCategoryType(): CategoryType
{ {
$em = $this->getEm(); $em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]); $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
if (null !== $existing) { if (null !== $existing) {
return $existing; return $existing;
} }
$type = new CategoryType(); $type = new CategoryType();
$type->setCode($code); $type->setCode('CLIENT');
$type->setLabel(ucfirst(strtolower($code))); $type->setLabel('Client');
$em->persist($type); $em->persist($type);
$em->flush(); $em->flush();
@@ -64,15 +76,38 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
} }
/** /**
* Cree une Category de test rattachee a un type metier donne (code). * Cree une Category de test sous le type unique CLIENT (ERP-78).
*
* - Code RG (DISTRIBUTEUR / COURTIER) : fetch-or-create par code EXACT — le
* code doit matcher la regle de gestion, et l'appel repete dans un test
* renvoie la meme categorie (pas de violation de uq_category_code).
* - Autre code (SECTEUR, AUTRE, ...) : simple libelle generique -> categorie
* a code UNIQUE (suffixe aleatoire). Garantit que deux categories
* « generiques » d'un meme test sont DISTINCTES (ex: detection de
* changement de categorie dans les tests RBAC).
*/ */
protected function createCategory(string $typeCode = 'SECTEUR'): Category protected function createCategory(string $code = 'SECTEUR'): Category
{ {
$em = $this->getEm(); $em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
if (in_array($code, self::RG_EXACT_CODES, true)) {
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$effectiveCode = $code;
$name = self::TEST_CATEGORY_PREFIX.strtolower($code);
} else {
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$effectiveCode = strtoupper($code).'_'.strtoupper($suffix);
$name = self::TEST_CATEGORY_PREFIX.strtolower($code).'_'.$suffix;
}
$category = new Category(); $category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.$suffix); $category->setName($name);
$category->setCategoryType($this->createCategoryType($typeCode)); $category->setCode($effectiveCode);
$category->setCategoryType($this->clientCategoryType());
$em->persist($category); $em->persist($category);
$em->flush(); $em->flush();
@@ -81,9 +116,10 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
/** /**
* Seede directement un Client en base (sans passer par l'API), pour les * Seede directement un Client en base (sans passer par l'API), pour les
* tests de liste / archivage. Le client porte une categorie SECTEUR. * tests de liste / archivage. Le client porte une categorie du code donne
* (defaut SECTEUR — categorie generique non interdite sur adresse).
*/ */
protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity protected function seedClient(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): ClientEntity
{ {
$em = $this->getEm(); $em = $this->getEm();
$client = new ClientEntity(); $client = new ClientEntity();
@@ -93,7 +129,7 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
$client->setLastName('Seed'); $client->setLastName('Seed');
$client->setPhonePrimary('0102030405'); $client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test'); $client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryTypeCode)); $client->addCategory($this->createCategory($categoryCode));
$client->setIsArchived($isArchived); $client->setIsArchived($isArchived);
if ($isArchived) { if ($isArchived) {
$client->setArchivedAt(new DateTimeImmutable()); $client->setArchivedAt(new DateTimeImmutable());
@@ -175,6 +175,49 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
} }
public function testPostBrokerReferencingNonBrokerReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Bad Broker Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'badbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$notBroker->getId(),
],
]);
// RG-1.03 (le broker doit porter la categorie de code COURTIER)
self::assertResponseStatusCodeSame(422);
}
public function testPostValidBrokerReturns201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$broker = $this->seedClient('Vrai Courtier', false, 'COURTIER');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Client Avec Courtier',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$broker->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testListSortedByCompanyNameAscAndExcludesArchived(): void public function testListSortedByCompanyNameAscAndExcludesArchived(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -74,14 +74,14 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
self::assertNotContains('OTHER BETA', $names); self::assertNotContains('OTHER BETA', $names);
} }
public function testExportRespectsCategoryTypeFilter(): void public function testExportRespectsCategoryCodeFilter(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$this->seedClient('Distrib Co', false, 'DISTRIBUTEUR'); $this->seedClient('Distrib Co', false, 'DISTRIBUTEUR');
$this->seedClient('Secteur Co', false, 'SECTEUR'); $this->seedClient('Secteur Co', false, 'SECTEUR');
$names = $this->companyNames( $names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?categoryType=DISTRIBUTEUR')->getContent(), $client->request('GET', self::EXPORT_URL.'?categoryCode=DISTRIBUTEUR')->getContent(),
); );
self::assertContains('DISTRIB CO', $names); self::assertContains('DISTRIB CO', $names);