[ERP-78] Refonte taxonomie Catégories : type unique CLIENT + Category.code + RG-1.03/1.29 par code (#42)
Auto Tag Develop / tag (push) Successful in 8s

Refonte de la taxonomie Catégories (décision produit 01/06) : le modèle est inversé.

## Modèle
- **UN SEUL `category_type` : CLIENT**. `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier) deviennent des `Category` rattachées à CLIENT.
- Filtrage métier sur un **`code` stable porté par `Category`** (NOT NULL, unique partiel `uq_category_code`), slug MAJUSCULE auto-généré du nom (`CategoryCodeGenerator`), figé à la création, exposé en **lecture seule**.

## Contenu
- **M0** : `Category.code` (entité + migration corrective `Version20260602100000` au namespace racine + `COMMENT ON COLUMN` + catalogue + ligne `test-db-setup`). Retrofit `Version20260528120000` rendu conscient des colonnes.
- **Seed** : type unique CLIENT, catégories codées (`Distributeur→DISTRIBUTEUR`, etc.), anciens types supprimés. Fixtures `CategoryType`/`Category`/`Client` alignées.
- **RG-1.03** : `ClientProcessor::hasCategoryCode` — un distributor/broker doit porter la `Category` de code `DISTRIBUTEUR`/`COURTIER`. Filtre liste/export `categoryType` → `categoryCode`.
- **RG-1.29** : `ClientAddress::validateCategoryCodes` — denylist des codes `DISTRIBUTEUR`/`COURTIER` sur une adresse (toute autre catégorie autorisée).
- **Specs** M0/M1 (back + front) amendées.

## Tests
`make php-cs-fixer-allow-risky` OK ; `make db-reset` OK (type unique CLIENT + 11 catégories codées, idempotent) ; `make test` **443 vert**. Ajouts : RG-1.03 courtier, génération/unicité/lecture-seule du code (`CategoryCodeTest`).

## Coordination
- #76 (#500) : RG-1.29 réécrite ici sur le nouveau modèle ; #76 ne garde que le gap 2 (mapping CHECK adresse → 422), indépendant de la taxonomie.
- ERP-68 (#486) : fixtures démo (déjà mergées via #41) adaptées ici au type unique CLIENT + codes.
- Front #480–483 : selects Catégorie / distributeur / courtier basés sur le `code` (`?categoryCode=`), plus le type.

Décisions actées avec le PO : `code` NOT NULL auto-généré (slug) ; périmètre complet (réécriture RG + fixtures déjà mergées).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #42
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #42.
This commit is contained in:
2026-06-02 08:00:42 +00:00
committed by admin malio
parent a668a8eb28
commit 00bd02858c
30 changed files with 866 additions and 188 deletions
@@ -39,7 +39,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
* — limitees aux types SECTEUR/AUTRE (RG-1.29, validateCategoryTypes, ERP-76)
* — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78)
*
* Audite (#[Auditable]) + Timestampable/Blamable.
*
@@ -87,8 +87,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/** RG-1.29 : seuls ces types de categorie qualifient une adresse physique. */
private const array ALLOWED_CATEGORY_TYPES = ['SECTEUR', 'AUTRE'];
/**
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
* Toute autre categorie du type CLIENT est autorisee.
*/
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'];
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -165,7 +169,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts;
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (validateCategoryTypes).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')]
@@ -232,18 +236,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
}
/**
* RG-1.29 : seules les categories de type SECTEUR / AUTRE qualifient une
* adresse physique. Les types DISTRIBUTEUR / COURTIER decrivent une relation
* entre clients (RG-1.03) et n'ont pas de sens sur une adresse -> 422 avec
* violation sur le champ `categories`. S'appuie sur
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog).
* RG-1.29 (ERP-78) : une adresse interdit les categories de code
* DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
* violation sur le champ `categories`. Toute autre categorie (type unique
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
* d'import du module Catalog — regle ABSOLUE n°1).
*/
#[Assert\Callback]
public function validateCategoryTypes(ExecutionContextInterface $context): void
public function validateCategoryCodes(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array($category->getCategoryTypeCode(), self::ALLOWED_CATEGORY_TYPES, true)) {
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
->atPath('categories')
->addViolation()
@@ -20,8 +20,9 @@ interface ClientRepositoryInterface
* - Tri par defaut : companyName ASC (RG-1.26).
* - $search : recherche fuzzy insensible a la casse sur companyName +
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
* - $categoryType : restreint aux clients possedant au moins une categorie
* du type donne (code). Ignore si null/vide.
* - $categoryCode : restreint aux clients possedant au moins une categorie
* 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
* la liste paginee (ClientProvider) et l'export (ClientExportController)
@@ -30,6 +31,6 @@ interface ClientRepositoryInterface
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryType = null,
?string $categoryCode = null,
): QueryBuilder;
}
@@ -457,8 +457,9 @@ final class ClientProcessor implements ProcessorInterface
/**
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
* doit referencer un client de categorie DISTRIBUTEUR (idem broker ->
* COURTIER).
* doit referencer un client portant la categorie de code DISTRIBUTEUR (idem
* 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
{
@@ -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(
'distributor',
'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(
'broker',
'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
* sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category).
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* 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) {
if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) {
if ($category instanceof CategoryInterface && $category->getCode() === $code) {
return true;
}
}
@@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* exclus au M1) — RG-1.25 ;
* - tri par defaut companyName ASC — RG-1.26 ;
* - 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 ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
*
@@ -65,13 +65,13 @@ final class ClientProvider implements ProviderInterface
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$search = $filters['search'] ?? null;
$categoryType = $filters['categoryType'] ?? null;
$categoryCode = $filters['categoryCode'] ?? null;
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
is_string($categoryType) ? $categoryType : null,
is_string($categoryCode) ? $categoryCode : null,
);
// Echappatoire ?pagination=false : collection complete sans Paginator
@@ -53,11 +53,11 @@ final class ClientExportController
{
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$search = $request->query->getString('search') ?: null;
$categoryType = $request->query->getString('categoryType') ?: null;
$categoryCode = $request->query->getString('categoryCode') ?: null;
/** @var list<Client> $clients */
$clients = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryType)
->createListQueryBuilder($includeArchived, $search, $categoryCode)
->getQuery()
->getResult()
;
@@ -54,9 +54,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD ET les validators applicatifs ERP-76 (exclusivite Prospect,
* billingEmail ssi facturation, aucune categorie DISTRIBUTEUR/COURTIER sur une
* adresse).
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse
* — RG-1.29, ERP-78).
*
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
@@ -116,7 +116,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
lastName: 'Garnier',
phonePrimary: '05 56 10 20 30',
email: 'contact@distrib-gso.fr',
categoryNames: ['Distributeur Grand Sud-Ouest'],
categoryNames: ['Distributeur'],
);
if ($gsoIsNew) {
$this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr');
@@ -131,7 +131,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
lastName: 'Léonard',
phonePrimary: '05 49 11 22 33',
email: 'contact@cabinet-leonard.fr',
categoryNames: ['Cabinet de courtage Léonard'],
categoryNames: ['Courtier'],
);
if ($leonardIsNew) {
$this->addContact($leonard, 'Sophie', 'Léonard', 'Gérante', '05 49 11 22 33', null, 'sophie.leonard@cabinet-leonard.fr');
@@ -422,11 +422,11 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
/**
* Ajoute une adresse au client (cascade persist via Client.addresses). Les
* donnees respectent les validators ERP-76 : exclusivite Prospect,
* billingEmail ssi facturation, categories limitees a SECTEUR/AUTRE.
* donnees respectent les validators : exclusivite Prospect, billingEmail ssi
* facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29).
*
* @param list<string> $siteNames au moins un site (RG-1.10)
* @param list<string> $categoryNames categories SECTEUR/AUTRE uniquement (RG-1.29)
* @param list<string> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29)
*/
private function addAddress(
Client $client,
@@ -34,7 +34,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
?string $categoryType = null,
?string $categoryCode = null,
): QueryBuilder {
$qb = $this->createQueryBuilder('c')
->andWhere('c.deletedAt IS NULL')
@@ -46,7 +46,7 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
}
$this->applySearch($qb, $search);
$this->applyCategoryType($qb, $categoryType);
$this->applyCategoryCode($qb, $categoryCode);
return $qb;
}
@@ -73,13 +73,15 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete principale.
* Restreint aux clients possedant au moins une categorie du code donne
* (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment
* 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;
}
@@ -87,12 +89,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
->where('cat2.code = :categoryCode')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
->setParameter('categoryCode', trim($categoryCode))
;
}
}