[ERP-44] Créer les entités Category et CategoryType (#15)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Objectif Couche Domain DDD du module Catalog (ticket M0 · position 0.2). Crée les entités `Category` et `CategoryType`, leurs repositories, et branche le pattern Timestampable + Blamable Shared. > **Mode stacked PR** : cible `feature/ERP-43-migrer-tables-category`. Quand la MR ERP-43 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop. ## Contenu - **`Category`** : `#[ApiResource]` (GetCollection, Get, Post, Patch, Delete), `#[Auditable]`, `TimestampableBlamableTrait` + interfaces, asserts (`NotBlank`/`Length` sur `name`, `NotNull` sur `categoryType`), soft delete via `deletedAt`. Provider/Processor branchés au ticket 0.3 (ERP-45). - **`CategoryType`** : référentiel statique en lecture seule (GetCollection + Get), embarqué dans `Category` via le groupe `category:read`. Pas de Trait — whitelisté dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (RG-1.17). - **Repositories** : interfaces Domain + implémentations Doctrine. - **`config/packages/doctrine.yaml`** : mapping ORM `Catalog` inconditionnel (miroir de `Sites`) — nécessaire pour que l'ORM reconnaisse les entités. La déclaration du module (`config/modules.php`) reste pour le ticket 0.5 (ERP-47). - Groupes : `category:read` / `category:write` + `default:read` (expose les 4 colonnes du Trait). ## Notes techniques - Index nommés déclarés sur les entités 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 possédé par la seule migration : Doctrine ORM ne sait pas exprimer un index fonctionnel + partiel. Seul diff résiduel de `doctrine:schema:validate`. ## Tests - `make php-cs-fixer-allow-risky` ✓ - `make test` ✓ (248 tests, 0 échec) - `make db-reset` ✓ - `debug:router` ✓ (7 routes exposées) - `doctrine:schema:validate` : mapping correct --------- Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: #15 Reviewed-by: Autin <tristan@yuno.malio.fr> 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 #15.
This commit is contained in:
@@ -54,6 +54,18 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||||
prefix: 'App\Module\Sites\Domain\Entity'
|
prefix: 'App\Module\Sites\Domain\Entity'
|
||||||
alias: Sites
|
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:
|
controller_resolver:
|
||||||
auto_mapping: false
|
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;
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
use App\Module\Core\Domain\Entity\Permission;
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
use App\Module\Core\Domain\Entity\Role;
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
@@ -45,15 +46,18 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
* - Permission : idem Role (synchronise, pas pilote utilisateur).
|
* - Permission : idem Role (synchronise, pas pilote utilisateur).
|
||||||
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
|
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
|
||||||
* v2 (cf. HP-10).
|
* 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)
|
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||||
* s'ajoutent ici avec une justification.
|
|
||||||
*/
|
*/
|
||||||
private const EXCLUDED = [
|
private const EXCLUDED = [
|
||||||
User::class,
|
User::class,
|
||||||
Role::class,
|
Role::class,
|
||||||
Permission::class,
|
Permission::class,
|
||||||
Site::class,
|
Site::class,
|
||||||
|
CategoryType::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user