[ERP-78] Refonte taxonomie Catégories : type unique CLIENT + Category.code + RG-1.03/1.29 par code #42
@@ -39,20 +39,40 @@ final class Version20260528120000 extends AbstractMigration
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Ne commente que les tables deja presentes a ce stade de la chaine de
|
||||
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
|
||||
// figurent desormais dans le catalogue partage mais leurs tables
|
||||
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
|
||||
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
|
||||
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
|
||||
// "relation X does not exist".
|
||||
$existingTables = array_values(array_filter(
|
||||
array_keys(ColumnCommentsCatalog::comments()),
|
||||
static fn (string $table): bool => $schema->hasTable($table),
|
||||
));
|
||||
// Ne commente que les tables ET colonnes deja presentes a ce stade de la
|
||||
// chaine de migrations. Les tables des modules crees plus tard (M1
|
||||
// Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table
|
||||
// existante (ex: category.code, ERP-78 06-02) figurent desormais dans le
|
||||
// catalogue partage mais n'existent pas encore ici : elles posent leur
|
||||
// propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou
|
||||
// indispensable (table + colonne), sinon enrichir le catalogue casse ce
|
||||
// retrofit avec un "relation/column X does not exist".
|
||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||
if (!$schema->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
|
||||
$this->addSql($sql);
|
||||
$dbTable = $schema->getTable($table);
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
foreach ($entries as $column => $description) {
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dbTable->hasColumn($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+17
-4
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user