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
This commit is contained in:
Matthieu
2026-05-27 17:05:56 +02:00
parent ba2b607fbf
commit e5fa6cb3c8
8 changed files with 354 additions and 2 deletions
+12
View File
@@ -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
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* (FK vers le referentiel statique CategoryType).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
* created_at / updated_at / created_by / updated_by sont remplies
* automatiquement par le TimestampableBlamableSubscriber.
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
* audit_log par l'AuditListener du module Core.
*
* Les Provider (filtre soft-delete) et Processor (trim, soft delete, 409)
* seront branches au ticket 0.3 (ERP-45). Au ticket 0.2, les operations
* utilisent les state Doctrine par defaut d'API Platform.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['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;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de categorie : referentiel statique classifiant les Category
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
*
* Lecture seule au M0 : seules les operations GetCollection et Get sont
* exposees, sous la meme permission que Category (catalog.categories.view).
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
* est ajoute sur chaque propriete pour que le type soit embarque dans la
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['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;
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Category;
interface CategoryRepositoryInterface
{
public function findById(int $id): ?Category;
public function save(Category $category): void;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\CategoryType;
interface CategoryTypeRepositoryInterface
{
public function findById(int $id): ?CategoryType;
/**
* @return list<CategoryType>
*/
public function findAllOrderedByLabel(): array;
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Category>
*/
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();
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CategoryType>
*/
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<CategoryType>
*/
public function findAllOrderedByLabel(): array
{
return $this->findBy([], ['label' => 'ASC']);
}
}
@@ -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