From 956925190089c96b6035d11598945b6eac990f52 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 28 May 2026 09:03:26 +0200 Subject: [PATCH] feat(catalog) : implement CategoryProvider and CategoryProcessor with soft delete and 409 mapping - CategoryProvider applique le filtre soft-delete par defaut (RG-1.08), respecte ?includeDeleted=true (RG-1.09), trie par name ASC (RG-1.10) et renvoie 404 sur Get d'une soft-deleted hors flag (RG-1.11). Cable aussi sur Patch+Delete pour fermer la fuite sur PATCH. - CategoryProcessor : trim du name au POST/PATCH (RG-1.03), conversion DELETE en UPDATE avec deletedAt=now() via persist_processor (RG-1.12), mapping UniqueConstraintViolationException -> HTTP 409 avec le message attendu (RG-1.07). - Cablage des Provider/Processor dans #[ApiResource] de Category. - DoctrineCategoryRepository expose createListQueryBuilder($includeDeleted) pour le Provider. --- src/Module/Catalog/Domain/Entity/Category.php | 15 +++- .../CategoryRepositoryInterface.php | 8 ++ .../State/Processor/CategoryProcessor.php | 76 +++++++++++++++++ .../State/Provider/CategoryProvider.php | 83 +++++++++++++++++++ .../Doctrine/DoctrineCategoryRepository.php | 14 ++++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 118c991..b1ac374 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -10,6 +10,8 @@ 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; @@ -33,32 +35,39 @@ use Symfony\Component\Validator\Constraints as Assert; * - `#[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. + * 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, ), ], )] diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 6b765f8..838c75d 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -5,10 +5,18 @@ 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; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php new file mode 100644 index 0000000..6daa337 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php @@ -0,0 +1,76 @@ + + */ +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, + ); + } + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php new file mode 100644 index 0000000..97a351b --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php @@ -0,0 +1,83 @@ + + */ +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; + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php index f0d1fa3..68612b2 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineCategoryRepository.php @@ -7,6 +7,7 @@ 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; /** @@ -29,4 +30,17 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate $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; + } }