[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
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Service;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Symfony\Component\String\Slugger\AsciiSlugger;
/**
* Genere le code technique stable d'une Category a partir de son nom (ERP-78).
*
* Regle (decision produit 02/06) : `code` est obligatoire et auto-genere — un
* slug MAJUSCULE du nom, sans accent, separateurs non alphanumeriques reduits a
* `_`, borne a 50 caracteres (longueur colonne). Exemples :
* - « Distributeur » -> DISTRIBUTEUR
* - « Agro-alimentaire » -> AGRO_ALIMENTAIRE
* - « Transport/Logistique » -> TRANSPORT_LOGISTIQUE
*
* Le code est FIGE a la creation (jamais recalcule sur renommage) afin de rester
* une cle deterministe stable entre environnements (RG-1.03 / RG-1.29 cote M1).
*
* Unicite : l'index partiel `uq_category_code` (WHERE deleted_at IS NULL) impose
* l'unicite parmi les categories actives. Deux noms distincts peuvent produire
* le meme slug (« Agro alimentaire » / « Agro-alimentaire ») : on suffixe alors
* le code par `_2`, `_3`... jusqu'a obtenir un code libre.
*/
final class CategoryCodeGenerator
{
/** Longueur maximale de la colonne `category.code`. */
private const int MAX_LENGTH = 50;
private readonly AsciiSlugger $slugger;
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository,
) {
$this->slugger = new AsciiSlugger();
}
/**
* Slug brut (sans garantie d'unicite) — utile pour les seeds deterministes.
*/
public function slugify(string $name): string
{
$slug = $this->slugger->slug($name, '_')->upper()->toString();
// Borne a la longueur colonne, puis retire un eventuel `_` terminal
// introduit par la troncature.
$slug = substr($slug, 0, self::MAX_LENGTH);
$slug = trim($slug, '_');
// Garde-fou : un nom uniquement compose de caracteres non alphanumeriques
// (theorique, le nom est NotBlank + Length>=2) donnerait un slug vide.
return '' === $slug ? 'CATEGORY' : $slug;
}
/**
* Code unique parmi les categories actives : slug du nom, suffixe `_N` en
* cas de collision. `$excludeId` ignore la categorie courante (PATCH).
*/
public function generateUnique(string $name, ?int $excludeId = null): string
{
$base = $this->slugify($name);
$candidate = $base;
$suffix = 2;
while ($this->categoryRepository->existsActiveByCode($candidate, $excludeId)) {
$suffixStr = '_'.$suffix;
// Retronque la base pour que `base + suffixe` tienne dans 50 caracteres.
$candidate = substr($base, 0, self::MAX_LENGTH - strlen($suffixStr)).$suffixStr;
++$suffix;
}
return $candidate;
}
}
+30 -4
View File
@@ -74,10 +74,11 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
// index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
@@ -109,6 +110,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read', 'category:write'])]
private ?string $name = null;
// Code technique stable (slug MAJUSCULE du nom) — NOT NULL + unique parmi les
// actifs (index partiel `uq_category_code` possede par la migration). Genere
// par le CategoryProcessor a la creation puis fige (jamais recalcule sur
// renommage) : sert de cle metier deterministe (RG-1.03 / RG-1.29). Lecture
// seule cote API (hors groupe category:write) : le front filtre dessus mais
// ne le saisit pas.
#[ORM\Column(length: 50)]
#[Groups(['category:read'])]
private ?string $code = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
@@ -141,6 +152,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this;
}
/**
* Implemente CategoryInterface : code technique stable de la categorie.
*/
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getCategoryType(): ?CategoryType
{
return $this->categoryType;
@@ -13,6 +13,13 @@ interface CategoryRepositoryInterface
public function save(Category $category): void;
/**
* Vrai si une categorie active (deleted_at IS NULL) porte deja ce code.
* `$excludeId` exclut une categorie precise du test (cas PATCH). Sert a
* garantir l'unicite du code generee par le CategoryCodeGenerator (ERP-78).
*/
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Application\Service\CategoryCodeGenerator;
use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
@@ -16,10 +17,13 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Processor Category : applique les regles de gestion en ecriture.
*
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
* (collision sur l'index partiel uq_category_name_type_active) est traduite
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - POST / PATCH : trim du nom (RG-1.03) ; a la CREATION, generation du `code`
* technique stable (slug MAJUSCULE du nom, unique parmi les actifs — ERP-78)
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
* UniqueConstraintViolationException remontee par Postgres (collision sur
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
* le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -32,6 +36,7 @@ final class CategoryProcessor implements ProcessorInterface
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly CategoryCodeGenerator $codeGenerator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -62,6 +67,14 @@ final class CategoryProcessor implements ProcessorInterface
$data->setName(trim($data->getName()));
}
// ERP-78 : le code est genere a la CREATION puis fige. On le (re)genere
// uniquement s'il est absent (POST, ou entite seedee sans code) — un PATCH
// sur une categorie existante conserve son code. Genere depuis le nom
// (NotBlank, deja trimme), unique parmi les actifs.
if (null === $data->getCode() && null !== $data->getName()) {
$data->setCode($this->codeGenerator->generateUnique($data->getName(), $data->getId()));
}
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -14,19 +14,19 @@ use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Catalog : ~12 categories de demonstration reparties
* sur les 4 types metier (DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE). Alimente le
* repertoire clients (ClientFixtures, module Commercial) avec des donnees
* realistes couvrant les categorisations RG-1.03 (DISTRIBUTEUR/COURTIER) et
* RG-1.29 (SECTEUR/AUTRE sur adresse).
* Fixtures dev/test du module Catalog : ~11 categories de demonstration, toutes
* rattachees au type unique CLIENT (refonte taxonomie ERP-78). 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).
*
* Depend de CategoryTypeFixtures : les 4 CategoryType doivent etre seedes avant
* de pouvoir y rattacher des Category.
* Depend de CategoryTypeFixtures : le type CLIENT doit etre seede avant de
* pouvoir y rattacher des Category.
*
* Idempotence : lookup par (name, categoryType) parmi les categories non
* supprimees (deletedAt null), coherent avec l'index unique partiel
* uq_category_name_type_active (LOWER(name), category_type_id WHERE deleted_at
* IS NULL). Rejouable sans doublon meme si le purger Doctrine est desactive.
* Idempotence : lookup par `code` parmi les categories non supprimees (deletedAt
* null), coherent avec l'index unique partiel uq_category_code (code WHERE
* deleted_at IS NULL). Rejouable sans doublon meme si le purger Doctrine est
* desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu.
@@ -34,39 +34,33 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* categories (prefixe dedie) et comptent sur une table `category` vierge — y
* injecter 12 categories de demo casserait comptages et cleanups FK
* injecter des categories de demo casserait comptages et cleanups FK
* (client_category). Cf. ClientFixtures (meme garde-fou).
*/
class CategoryFixtures extends Fixture implements DependentFixtureInterface
{
/** Code du type unique (cf. CategoryTypeFixtures, migration ERP-78). */
private const string CLIENT_TYPE_CODE = 'CLIENT';
/**
* Source unique des categories de demonstration : code de type metier =>
* liste de noms. Les noms sont stockes tels quels (l'unicite est
* case-insensitive cote index).
* Source unique des categories de demonstration : nom => code stable. Les 4
* premieres (Distributeur / Courtier / Secteur / Autre) sont les categories
* « systeme » reportees des anciens types ; leurs codes pilotent les RG.
*
* @var array<string, list<string>>
* @var array<string, string>
*/
private const CATEGORIES = [
'SECTEUR' => [
'BTP',
'Industrie',
'Agro-alimentaire',
'Transport/Logistique',
'Services',
],
'DISTRIBUTEUR' => [
'Distributeur Grand Sud-Ouest',
'Distributeur National Premium',
'Grossiste régional',
],
'COURTIER' => [
'Cabinet de courtage Léonard',
'Cabinet de courtage Bernard',
],
'AUTRE' => [
'Indépendant',
'Association',
],
'Distributeur' => 'DISTRIBUTEUR',
'Courtier' => 'COURTIER',
'Secteur' => 'SECTEUR',
'Autre' => 'AUTRE',
'BTP' => 'BTP',
'Industrie' => 'INDUSTRIE',
'Agro-alimentaire' => 'AGRO_ALIMENTAIRE',
'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE',
'Services' => 'SERVICES',
'Association' => 'ASSOCIATION',
'Indépendant' => 'INDEPENDANT',
];
public function __construct(
@@ -90,41 +84,39 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
return;
}
// Index des types metier par code (CategoryTypeFixtures les a seedes).
$typesByCode = [];
$clientType = null;
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
$typesByCode[$type->getCode()] = $type;
if (self::CLIENT_TYPE_CODE === $type->getCode()) {
$clientType = $type;
break;
}
}
foreach (self::CATEGORIES as $typeCode => $names) {
$type = $typesByCode[$typeCode] ?? null;
if (!$type instanceof CategoryType) {
// Misconfiguration : CategoryTypeFixtures n'a pas tourne avant.
throw new RuntimeException(sprintf(
'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.',
$typeCode,
));
}
if (!$clientType instanceof CategoryType) {
// Misconfiguration : CategoryTypeFixtures n'a pas tourne avant.
throw new RuntimeException(
'CategoryTypeFixtures doit avoir seede le type "CLIENT" avant CategoryFixtures.',
);
}
foreach ($names as $name) {
$this->ensureCategory($manager, $name, $type);
}
foreach (self::CATEGORIES as $name => $code) {
$this->ensureCategory($manager, $name, $code, $clientType);
}
$manager->flush();
}
/**
* Cree la categorie (name, type) si elle n'existe pas encore parmi les
* categories actives, sinon la laisse en place. Lookup aligne sur l'index
* unique partiel (nom + type, hors soft-deleted).
* Cree la categorie (name, code) sous le type CLIENT si son code n'existe pas
* encore parmi les categories actives, sinon la laisse en place. Lookup
* aligne sur l'index unique partiel uq_category_code.
*/
private function ensureCategory(ObjectManager $manager, string $name, CategoryType $type): void
private function ensureCategory(ObjectManager $manager, string $name, string $code, CategoryType $type): void
{
$existing = $manager->getRepository(Category::class)->findOneBy([
'name' => $name,
'categoryType' => $type,
'deletedAt' => null,
'code' => $code,
'deletedAt' => null,
]);
if (null !== $existing) {
@@ -133,6 +125,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
$category = new Category();
$category->setName($name);
$category->setCode($code);
$category->setCategoryType($type);
$manager->persist($category);
}
@@ -10,17 +10,19 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed des types de categorie metier (M1).
* Fixtures du module Catalog : seed du type de categorie (M1).
*
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
* Refonte taxonomie ERP-78 : le modele n'a plus qu'UN SEUL `category_type`,
* CLIENT (code CLIENT, label « Client »). Distributeur / Courtier / Secteur /
* Autre (et les categories metier fines) sont desormais des `Category` codees
* rattachees a ce type (cf. CategoryFixtures + migration Version20260602100000).
*
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
* 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
* migration disparaitrait apres `make db-reset` / setup de test. Le seed
* migration couvre la prod (ou les fixtures ne tournent pas) ; cette fixture
* re-aligne dev et test. Les deux chemins produisent un etat identique.
*
* Idempotence : lookup par `code` parmi les types existants avant insertion,
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
@@ -29,14 +31,11 @@ use Doctrine\Persistence\ObjectManager;
class CategoryTypeFixtures extends Fixture
{
/**
* Source unique des 4 types metier : code technique => libelle FR.
* Doit rester aligne sur le seed de la migration Version20260601000000.
* Source unique du type : code technique => libelle FR. Doit rester aligne
* sur le seed de la migration Version20260602100000 (type unique CLIENT).
*/
private const TYPES = [
'DISTRIBUTEUR' => 'Distributeur',
'COURTIER' => 'Courtier',
'SECTEUR' => 'Secteur',
'AUTRE' => 'Autre',
'CLIENT' => 'Client',
];
public function __construct(
@@ -31,6 +31,23 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$this->getEntityManager()->flush();
}
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('c')
->select('1')
->andWhere('c.code = :code')
->andWhere('c.deletedAt IS NULL')
->setParameter('code', $code)
->setMaxResults(1)
;
if (null !== $excludeId) {
$qb->andWhere('c.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return [] !== $qb->getQuery()->getResult();
}
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
@@ -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))
;
}
}
@@ -20,13 +20,25 @@ interface CategoryInterface
public function getName(): ?string;
/**
* Code technique stable de la categorie (Category::code), ou null si non
* encore renseigne. Slug MAJUSCULE derive du nom a la creation, fige ensuite.
* Expose pour permettre a un module tiers de filtrer/valider par categorie
* metier sans dependre du libelle (`name`) ni de l'`id` (non deterministe
* entre environnements) ni importer la classe concrete Category (regle
* ABSOLUE n°1). Pilote, cote M1 Commercial :
* - RG-1.03 : un distributor doit referencer un client portant la categorie
* de code DISTRIBUTEUR (resp. COURTIER pour broker) ;
* - RG-1.29 : une adresse interdit les categories de code DISTRIBUTEUR /
* COURTIER (relations entre clients, pas des attributs d'adresse).
*/
public function getCode(): ?string;
/**
* Code du type de categorie rattache (CategoryType::code), ou null si la
* categorie n'a pas de type. Expose pour permettre a un module tiers de
* raisonner sur le type metier (ex: M1 Commercial — RG-1.03 : un distributor
* doit referencer un client categorise DISTRIBUTEUR ; RG-1.29 : categorie
* d'adresse limitee a SECTEUR/AUTRE) sans importer la classe concrete
* Category (regle ABSOLUE n°1).
* categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
* type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
* Conserve pour l'affichage / la retrocompatibilite.
*/
public function getCategoryTypeCode(): ?string;
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
/**
* Miroir SQL de `CategoryCodeGenerator::slugify()` (module Catalog, ERP-78).
*
* Le `code` d'une `Category` est un slug MAJUSCULE deterministe du nom. A
* l'execution (POST/PATCH API), il est genere en PHP par `CategoryCodeGenerator`
* via `AsciiSlugger`. Mais la migration corrective `Version20260602100000` doit
* backfiller le `code` des categories pre-existantes en SQL pur (le backfill
* tourne dans le plan `addSql`, sans acces aux services applicatifs).
*
* Deux implementations d'un meme slug = risque de derive : un nom accentue
* comme « Independant » doit produire le MEME code (`INDEPENDANT`) quel que soit
* le chemin. Cette classe est la SOURCE UNIQUE de l'expression SQL ; son egalite
* avec le générateur PHP est verrouillee par `CategoryCodeSqlSlugTest`.
*
* Domaine couvert : noms francais / Latin-1 (tous les accents, minuscule +
* majuscule, translitteres vers l'ASCII comme le fait `AsciiSlugger`). Limite
* connue et assumee : les ligatures (`Œ`->`OE`, `ß`->`SS`) ne sont PAS gerees
* par `translate()` (mapping 1->1 uniquement) ; elles n'apparaissent pas dans
* les noms de categories CLIENT et le backfill ne s'execute de toute facon que
* sur des bases dev deja peuplees (en prod la table `category` est vide).
*/
final class CategoryCodeSql
{
/** Longueur maximale de la colonne `category.code` (cf. CategoryCodeGenerator). */
private const int MAX_LENGTH = 50;
/**
* Accents Latin-1 (minuscules puis majuscules) translitteres vers leur
* equivalent ASCII minuscule — `UPPER()` repasse tout en majuscule ensuite.
* `translate()` mappe caractere a caractere : `ACCENT_FROM` et `ACCENT_TO`
* doivent avoir EXACTEMENT le meme nombre de caracteres.
*/
private const string ACCENT_FROM = 'àâäáãåçéèêëíìîïñóòôöõúùûüýÿÀÂÄÁÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸ';
private const string ACCENT_TO = 'aaaaaaceeeeiiiinooooouuuuyyaaaaaaceeeeiiiinooooouuuuyy';
/**
* Expression SQL produisant le slug du `$column` donne (ex: `name`, `c.name`).
* Reproduit fidelement `CategoryCodeGenerator::slugify` : translitteration des
* accents, separateurs non alphanumeriques reduits a `_`, MAJUSCULE, borne a
* 50, `_` de bord retires, fallback `CATEGORY` si vide.
*/
public static function slugExpression(string $column): string
{
return sprintf(
"COALESCE(NULLIF(TRIM(BOTH '_' FROM "
."LEFT(UPPER(REGEXP_REPLACE(translate(%s, '%s', '%s'), '[^A-Za-z0-9]+', '_', 'g')), %d)"
."), ''), 'CATEGORY')",
$column,
self::ACCENT_FROM,
self::ACCENT_TO,
self::MAX_LENGTH,
);
}
}
@@ -53,6 +53,7 @@ final class ColumnCommentsCatalog
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
'id' => 'Identifiant interne auto-incremente.',
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
] + self::timestampableBlamableComments(),