Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ecaeb8330 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.45'
|
app.version: '0.1.44'
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
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\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
@@ -35,39 +33,32 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
|
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
|
||||||
* audit_log par l'AuditListener du module Core.
|
* audit_log par l'AuditListener du module Core.
|
||||||
*
|
*
|
||||||
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
|
* Les Provider (filtre soft-delete) et Processor (trim, soft delete, 409)
|
||||||
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
|
* seront branches au ticket 0.3 (ERP-45). Au ticket 0.2, les operations
|
||||||
* au ticket 0.3 (ERP-45).
|
* utilisent les state Doctrine par defaut d'API Platform.
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
security: "is_granted('catalog.categories.view')",
|
security: "is_granted('catalog.categories.view')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
provider: CategoryProvider::class,
|
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('catalog.categories.view')",
|
security: "is_granted('catalog.categories.view')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
provider: CategoryProvider::class,
|
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('catalog.categories.manage')",
|
security: "is_granted('catalog.categories.manage')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['category:write']],
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
security: "is_granted('catalog.categories.manage')",
|
security: "is_granted('catalog.categories.manage')",
|
||||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['category:write']],
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
provider: CategoryProvider::class,
|
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('catalog.categories.manage')",
|
security: "is_granted('catalog.categories.manage')",
|
||||||
provider: CategoryProvider::class,
|
|
||||||
processor: CategoryProcessor::class,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -5,18 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Module\Catalog\Domain\Repository;
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
|
|
||||||
interface CategoryRepositoryInterface
|
interface CategoryRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Category;
|
public function findById(int $id): ?Category;
|
||||||
|
|
||||||
public function save(Category $category): void;
|
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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ namespace App\Module\Catalog\Infrastructure\Doctrine;
|
|||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,17 +29,4 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
|||||||
$this->getEntityManager()->persist($category);
|
$this->getEntityManager()->persist($category);
|
||||||
$this->getEntityManager()->flush();
|
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user