From e5fa6cb3c867e453f39765e859305edf241a4c32 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 27 May 2026 17:05:56 +0200 Subject: [PATCH] feat(catalog) : add Category and CategoryType entities with Timestampable+Blamable pattern - Category : ApiResource (5 ops), #[Auditable], TimestampableBlamableTrait + interfaces, asserts (NotBlank/Length sur name, NotNull sur categoryType), soft delete via deletedAt, groupes category:read/category:write + default:read - CategoryType : referentiel statique en lecture seule (GetCollection + Get), embarque dans Category via le groupe category:read - Repositories : interfaces Domain + impl Doctrine pour les deux entites - doctrine.yaml : mapping ORM Catalog inconditionnel (miroir Sites) pour que l'ORM reconnaisse les entites ; declaration du module = ticket 0.5 - EntitiesAreTimestampableBlamableTest : CategoryType ajoute a EXCLUDED (RG-1.17) - Index nommes declares sur les entites (match migration) ; index unique partiel uq_category_name_type_active possede par la migration seule --- config/packages/doctrine.yaml | 12 ++ src/Module/Catalog/Domain/Entity/Category.php | 152 ++++++++++++++++++ .../Catalog/Domain/Entity/CategoryType.php | 87 ++++++++++ .../CategoryRepositoryInterface.php | 14 ++ .../CategoryTypeRepositoryInterface.php | 17 ++ .../Doctrine/DoctrineCategoryRepository.php | 32 ++++ .../DoctrineCategoryTypeRepository.php | 34 ++++ .../EntitiesAreTimestampableBlamableTest.php | 8 +- 8 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 src/Module/Catalog/Domain/Entity/Category.php create mode 100644 src/Module/Catalog/Domain/Entity/CategoryType.php create mode 100644 src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php create mode 100644 src/Module/Catalog/Domain/Repository/CategoryTypeRepositoryInterface.php create mode 100644 src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php create mode 100644 src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryTypeRepository.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c7ade33..e2488bd 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -54,6 +54,18 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity' prefix: 'App\Module\Sites\Domain\Entity' alias: Sites + # Mapping inconditionnel du module Catalog (meme logique que Sites) : + # la structure DB (category, category_type) existe meme si + # CatalogModule::class n'est pas encore wire dans config/modules.php + # (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre + # les entites pour que le schema soit en phase ; l'activation + # fonctionnelle passe exclusivement par config/modules.php. + Catalog: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity' + prefix: 'App\Module\Catalog\Domain\Entity' + alias: Catalog controller_resolver: auto_mapping: false diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php new file mode 100644 index 0000000..118c991 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -0,0 +1,152 @@ + ['category:read', 'default:read']], + ), + new Get( + security: "is_granted('catalog.categories.view')", + normalizationContext: ['groups' => ['category:read', 'default:read']], + ), + new Post( + security: "is_granted('catalog.categories.manage')", + normalizationContext: ['groups' => ['category:read', 'default:read']], + denormalizationContext: ['groups' => ['category:write']], + ), + new Patch( + security: "is_granted('catalog.categories.manage')", + normalizationContext: ['groups' => ['category:read', 'default:read']], + denormalizationContext: ['groups' => ['category:write']], + ), + new Delete( + security: "is_granted('catalog.categories.manage')", + ), + ], +)] +#[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. +#[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'])] +#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] +#[Auditable] +class Category implements TimestampableInterface, BlamableInterface +{ + // === Timestampable + Blamable === + // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs + // getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber + // les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration + // manuelle de ces proprietes ici. + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['category:read'])] + private ?int $id = null; + + #[ORM\Column(length: 120)] + #[Assert\NotBlank(message: 'Le nom est obligatoire.')] + #[Assert\Length(min: 2, max: 120)] + #[Groups(['category:read', 'category:write'])] + private ?string $name = 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.')] + #[Groups(['category:read', 'category:write'])] + private ?CategoryType $categoryType = null; + + /** + * Soft delete : null = active, valeur = supprimee logiquement le {date}. + * Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE, + * via le CategoryProcessor (ticket 0.3), pose la valeur. + */ + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + #[Groups(['category:read'])] + private ?DateTimeImmutable $deletedAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getCategoryType(): ?CategoryType + { + return $this->categoryType; + } + + public function setCategoryType(?CategoryType $categoryType): static + { + $this->categoryType = $categoryType; + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/src/Module/Catalog/Domain/Entity/CategoryType.php b/src/Module/Catalog/Domain/Entity/CategoryType.php new file mode 100644 index 0000000..797fd65 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/CategoryType.php @@ -0,0 +1,87 @@ + ['category_type:read']], + ), + new Get( + security: "is_granted('catalog.categories.view')", + normalizationContext: ['groups' => ['category_type:read']], + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)] +#[ORM\Table(name: 'category_type')] +// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission). +#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])] +class CategoryType +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['category_type:read', 'category:read'])] + private ?int $id = null; + + #[ORM\Column(length: 40)] + #[Groups(['category_type:read', 'category:read'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['category_type:read', 'category:read'])] + private ?string $label = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } +} diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php new file mode 100644 index 0000000..6b765f8 --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ + public function findAllOrderedByLabel(): array; +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php new file mode 100644 index 0000000..f0d1fa3 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php @@ -0,0 +1,32 @@ + + */ +class DoctrineCategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Category::class); + } + + public function findById(int $id): ?Category + { + return $this->find($id); + } + + public function save(Category $category): void + { + $this->getEntityManager()->persist($category); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryTypeRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryTypeRepository.php new file mode 100644 index 0000000..9723fc1 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryTypeRepository.php @@ -0,0 +1,34 @@ + + */ +class DoctrineCategoryTypeRepository extends ServiceEntityRepository implements CategoryTypeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, CategoryType::class); + } + + public function findById(int $id): ?CategoryType + { + return $this->find($id); + } + + /** + * @return list + */ + public function findAllOrderedByLabel(): array + { + return $this->findBy([], ['label' => 'ASC']); + } +} diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index e6fd407..20c7e26 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Architecture; +use App\Module\Catalog\Domain\Entity\CategoryType; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; @@ -45,15 +46,18 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * - Permission : idem Role (synchronise, pas pilote utilisateur). * - Site : referentiel admin-managed, a integrer dans un futur module Sites * v2 (cf. HP-10). + * - CategoryType : referentiel statique (codes de typage des categories), + * pas de besoin de tracabilite user-driven (cree par migration/seed, + * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. * - * Les futurs referentiels statiques (ex: CategoryType au ticket 0.2) - * s'ajoutent ici avec une justification. + * Les futurs referentiels statiques s'ajoutent ici avec une justification. */ private const EXCLUDED = [ User::class, Role::class, Permission::class, Site::class, + CategoryType::class, ]; public function testAllBusinessEntitiesImplementBothInterfaces(): void