[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
@@ -83,6 +83,9 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$category = new Category();
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
$category->setCode('TEST_'.strtoupper($suffix));
$category->setCategoryType($type);
if (null !== $deletedAt) {
$category->setDeletedAt($deletedAt);
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Application\Service\CategoryCodeGenerator;
use App\Shared\Infrastructure\Database\CategoryCodeSql;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Garde-fou ERP-78 : l'expression SQL de slug (CategoryCodeSql, utilisee par le
* backfill de la migration Version20260602100000) doit produire EXACTEMENT le
* meme code que le generateur applicatif (CategoryCodeGenerator::slugify), sur
* tout le domaine de noms francais / Latin-1.
*
* Verrouille la cause racine du bug initial : deux implementations d'un meme
* slug qui derivent silencieusement (« Independant » -> IND_PENDANT cote SQL
* faute de translitteration des accents, vs INDEPENDANT cote PHP). On ne couvre
* volontairement PAS les ligatures (`Œ`, `ß`) : `translate()` est 1->1 et ne
* peut produire `OE`/`SS` ; elles sont hors du domaine des categories CLIENT.
*
* @internal
*/
final class CategoryCodeSqlSlugTest extends KernelTestCase
{
/**
* Noms representatifs du domaine reel : accents, cedille, apostrophe,
* separateurs varies, parentheses, majuscules accentuees.
*
* @return iterable<string, array{string}>
*/
public static function nameProvider(): iterable
{
yield 'sans accent' => ['Distributeur'];
yield 'tiret' => ['Agro-alimentaire'];
yield 'slash' => ['Transport/Logistique'];
yield 'accent aigu' => ['Indépendant'];
yield 'apostrophe + accent' => ["L'Oréal"];
yield 'esperluette' => ['Forêt & Bûcheron'];
yield 'cedille majuscule' => ['Ça va'];
yield 'accents multiples' => ['Naïve façade'];
yield 'circonflexe' => ["Côte d'Azur"];
yield 'parentheses' => ['Zone (Sud)'];
}
#[DataProvider('nameProvider')]
public function testSqlSlugMatchesPhpSlug(string $name): void
{
self::bootKernel();
$container = self::getContainer();
/** @var Connection $conn */
$conn = $container->get('doctrine')->getConnection();
/** @var CategoryCodeGenerator $generator */
$generator = $container->get(CategoryCodeGenerator::class);
// Evaluation pure de l'expression (aucune table requise) : le nom est
// passe en parametre lie a la place de la colonne.
$sqlSlug = $conn->fetchOne(
'SELECT '.CategoryCodeSql::slugExpression(':name'),
['name' => $name],
);
self::assertSame(
$generator->slugify($name),
$sqlSlug,
sprintf('SQL et PHP doivent produire le meme slug pour "%s".', $name),
);
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Tests ERP-78 : le `code` technique stable de Category.
*
* Cas couverts :
* - POST : le code est auto-genere (slug MAJUSCULE du nom) et expose en lecture ;
* - le code est en lecture seule : un `code` envoye dans le payload est ignore
* (genere depuis le nom) ;
* - deux noms produisant le meme slug recoivent des codes distincts (suffixe).
*
* @internal
*/
final class CategoryCodeTest extends AbstractCatalogApiTestCase
{
public function testPostGeneratesAndExposesCode(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
$payload = $response->toArray();
// Slug MAJUSCULE du nom, separateurs non alphanumeriques -> `_`.
self::assertSame(
strtoupper(self::TEST_CATEGORY_PREFIX).'AGRO_ALIMENTAIRE',
$payload['code'],
);
}
public function testCodeIsReadOnlyAndIgnoredFromPayload(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
'categoryType' => '/api/category_types/'.$type->getId(),
// Le client tente d'imposer un code : doit etre ignore.
'code' => 'CLIENT_FORGED',
],
]);
self::assertResponseStatusCodeSame(201);
$payload = $response->toArray();
self::assertNotSame('CLIENT_FORGED', $payload['code']);
self::assertSame(strtoupper(self::TEST_CATEGORY_PREFIX).'READONLY', $payload['code']);
}
public function testCollidingSlugsGetDistinctCodes(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
// Deux noms differents (donc autorises par uq_category_name_type_active)
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
$first = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
'categoryType' => '/api/category_types/'.$type->getId(),
],
])->toArray();
$second = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
'categoryType' => '/api/category_types/'.$type->getId(),
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertNotSame($first['code'], $second['code']);
self::assertStringEndsWith('_2', (string) $second['code']);
}
}