Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fa6cb3c8 | |||
| ba2b607fbf |
@@ -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,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
|
||||
*
|
||||
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
|
||||
* ulterieurement (cf. spec-back M0 § 9 HP-1).
|
||||
*
|
||||
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
|
||||
* IS NULL : permet la recreation d'une categorie apres suppression logique
|
||||
* (cf. RG-1.07). Postgres supporte nativement le `CREATE UNIQUE INDEX ... WHERE`.
|
||||
*
|
||||
* Les 4 colonnes Timestampable/Blamable (`created_at`, `updated_at`,
|
||||
* `created_by`, `updated_by`) materialisent le pattern Shared (cf. ERP-52,
|
||||
* spec-back M0 § 2.8) : NOT NULL pour les dates (remplies par le subscriber),
|
||||
* nullable + ON DELETE SET NULL pour les FK user (creation hors contexte HTTP
|
||||
* et suppression d'un user sans bloquer les categories existantes).
|
||||
*
|
||||
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||
* Starseed n°11) : avec plusieurs migrations_paths, Doctrine Migrations 3.x
|
||||
* trie par FQCN alphabetique et non par version timestamp → l'init des tables
|
||||
* d'un module doit vivre au namespace racine pour garantir l'ordre sur base
|
||||
* vide.
|
||||
*/
|
||||
final class Version20260527164000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'M0 Catalog : tables category_type et category, index unique partiel.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE category_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE category (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
category_type_id INT NOT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_category_type
|
||||
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_category_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_category_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_category_name_type_active
|
||||
ON category (LOWER(name), category_type_id)
|
||||
WHERE deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
|
||||
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre important : `category` porte les FK vers `category_type`.
|
||||
$this->addSql('DROP TABLE category');
|
||||
$this->addSql('DROP TABLE category_type');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user