diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php index e82ad37..5c87a83 100644 --- a/migrations/Version20260528120000.php +++ b/migrations/Version20260528120000.php @@ -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, + )); + } } } diff --git a/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php b/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php new file mode 100644 index 0000000..318f7c1 --- /dev/null +++ b/src/Module/Catalog/Application/Service/CategoryCodeGenerator.php @@ -0,0 +1,77 @@ + 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; + } +} diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 5040e02..3c55a94 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -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; diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 838c75d..5a43d5c 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -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) diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php index 6daa337..c97ef7c 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php @@ -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) { diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php index 68612b2..aad21d5 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php @@ -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') diff --git a/src/Shared/Domain/Contract/CategoryInterface.php b/src/Shared/Domain/Contract/CategoryInterface.php index f8f7be1..2aef070 100644 --- a/src/Shared/Domain/Contract/CategoryInterface.php +++ b/src/Shared/Domain/Contract/CategoryInterface.php @@ -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; } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index a067bcc..a60dc90 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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(), diff --git a/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php index 80bc7ed..beeb62e 100644 --- a/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php +++ b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php @@ -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); diff --git a/tests/Module/Catalog/Api/CategoryCodeTest.php b/tests/Module/Catalog/Api/CategoryCodeTest.php new file mode 100644 index 0000000..6846f4c --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryCodeTest.php @@ -0,0 +1,90 @@ +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']); + } +}