Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ed775cf86 | |||
| c9460f9787 |
@@ -54,18 +54,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.45'
|
app.version: '0.1.41'
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
<?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\ApiPlatform\State\Processor\CategoryProcessor;
|
|
||||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
|
|
||||||
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
|
|
||||||
* au ticket 0.3 (ERP-45).
|
|
||||||
*/
|
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
security: "is_granted('catalog.categories.view')",
|
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
|
||||||
provider: CategoryProvider::class,
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('catalog.categories.view')",
|
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
|
||||||
provider: CategoryProvider::class,
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
security: "is_granted('catalog.categories.manage')",
|
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
|
||||||
denormalizationContext: ['groups' => ['category:write']],
|
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('catalog.categories.manage')",
|
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
|
||||||
denormalizationContext: ['groups' => ['category:write']],
|
|
||||||
provider: CategoryProvider::class,
|
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('catalog.categories.manage')",
|
|
||||||
provider: CategoryProvider::class,
|
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Catalog\Domain\Repository;
|
|
||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
|
|
||||||
interface CategoryRepositoryInterface
|
|
||||||
{
|
|
||||||
public function findById(int $id): ?Category;
|
|
||||||
|
|
||||||
public function save(Category $category): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
|
||||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
|
||||||
* - Tri : name ASC (RG-1.10).
|
|
||||||
*/
|
|
||||||
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
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).
|
|
||||||
* - 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
|
|
||||||
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<Category, null|Category>
|
|
||||||
*/
|
|
||||||
final class CategoryProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof Category) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
|
|
||||||
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
$data->setDeletedAt(new DateTimeImmutable());
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
|
||||||
// Par construction, le soft delete ne peut pas violer l'index
|
|
||||||
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
|
|
||||||
// On laisse remonter en 500 pour signaler une anomalie reelle.
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
|
|
||||||
if (null !== $data->getName()) {
|
|
||||||
$data->setName(trim($data->getName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
|
||||||
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
|
|
||||||
throw new HttpException(
|
|
||||||
409,
|
|
||||||
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
|
|
||||||
$e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProviderInterface;
|
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
|
||||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
|
|
||||||
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
|
|
||||||
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
|
|
||||||
* sans le flag (RG-1.11, via retour null).
|
|
||||||
*
|
|
||||||
* Choix d'implementation : QueryBuilder via le repository custom plutot
|
|
||||||
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
|
|
||||||
* Avantage : pas de magie globale, lisibilite directe du code, controle
|
|
||||||
* fin du flag includeDeleted par requete.
|
|
||||||
*
|
|
||||||
* @implements ProviderInterface<Category>
|
|
||||||
*/
|
|
||||||
final class CategoryProvider implements ProviderInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
|
||||||
private readonly CategoryRepositoryInterface $repository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
|
||||||
{
|
|
||||||
$includeDeleted = $this->readIncludeDeleted($context);
|
|
||||||
|
|
||||||
if ($operation instanceof CollectionOperationInterface) {
|
|
||||||
return $this->repository
|
|
||||||
->createListQueryBuilder($includeDeleted)
|
|
||||||
->getQuery()
|
|
||||||
->getResult()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
|
||||||
$id = $uriVariables['id'] ?? null;
|
|
||||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$category = $this->repository->findById((int) $id);
|
|
||||||
if (null === $category) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
|
|
||||||
if (!$includeDeleted && null !== $category->getDeletedAt()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $category;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit le flag includeDeleted depuis les filtres API Platform.
|
|
||||||
* Accepte "true" / "1" / true (booleen).
|
|
||||||
*/
|
|
||||||
private function readIncludeDeleted(array $context): bool
|
|
||||||
{
|
|
||||||
$raw = $context['filters']['includeDeleted'] ?? false;
|
|
||||||
|
|
||||||
if (is_bool($raw)) {
|
|
||||||
return $raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($raw)) {
|
|
||||||
return in_array(strtolower($raw), ['true', '1'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?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\ORM\QueryBuilder;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
|
|
||||||
{
|
|
||||||
$qb = $this->createQueryBuilder('c')
|
|
||||||
->orderBy('c.name', 'ASC')
|
|
||||||
;
|
|
||||||
|
|
||||||
if (!$includeDeleted) {
|
|
||||||
$qb->andWhere('c.deletedAt IS NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $qb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?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,7 +4,6 @@ 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;
|
||||||
@@ -46,18 +45,15 @@ 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 s'ajoutent ici avec une justification.
|
* Les futurs referentiels statiques (ex: CategoryType au ticket 0.2)
|
||||||
|
* 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