From 80fabcae9102f72f33417a63282048fc75cd272d Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Thu, 28 May 2026 09:44:43 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-45]=20Impl=C3=A9menter=20Provider=20et=20P?= =?UTF-8?q?rocessor=20Category=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Mode stacked PR — cible `feature/ERP-44-creer-entites-category` > ⚠ Cette MR a pour base la branche ERP-44 (en review). Quand ERP-44 sera mergée sur develop, **repointer la cible vers develop**. ## Résumé - Provider `CategoryProvider` : filtre soft-delete par défaut (RG-1.08), `?includeDeleted=true` (RG-1.09), tri name ASC (RG-1.10), 404 si soft-deleted hors flag (RG-1.11). - Processor `CategoryProcessor` : trim du `name` (RG-1.03), conversion DELETE → UPDATE (RG-1.12), mapping `UniqueConstraintViolationException` → HTTP 409 avec message exact (RG-1.07). - Câblage Provider/Processor dans `#[ApiResource]` de `Category`. Provider câblé aussi sur Patch + Delete (au-delà du scope strict du ticket) pour fermer la fuite RG-1.11 sur PATCH. - `DoctrineCategoryRepository` expose `createListQueryBuilder($includeDeleted)`. ## Décisions notables - **Filtre soft-delete via QueryBuilder** (choix `a` du ticket) : pas de filtre Doctrine global, lisibilité directe. - **Pas de `remove_processor` injecté** : la DELETE est convertie en UPDATE via le `persist_processor`. API Platform 4 utilise le processor déclaré sur l'opération sans fallback automatique. - **Provider sur Patch + Delete aussi** : décision prise pendant le dev pour fermer une fuite RG-1.11 sur PATCH. Coût : 2 lignes dans `Category.php`. ## Tests - `make php-cs-fixer-allow-risky` ✓ - `make test` ✓ (248 tests, 0 régression — pas de test métier Category, c'est ERP-48) - Tests manuels curl ✓ — 8 cas RG-1.03 → RG-1.13 validés (détail dans le ticket Lesstime #45) ## Tickets - Lesstime : #45 (ERP-45) → En review - Position M0 : 0.3 - Spec : `docs/specs/M0-categories/spec-back.md` § 4.1 + § 4.3 + § 4.4 + § 4.5 ## Suite - ERP-46 (0.4 CategoryType lecture seule) — base : cette branche --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/17 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- 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; + } }