Compare commits

..

5 Commits

Author SHA1 Message Date
Matthieu bc4e47d5ad feat(catalog) : expose CategoryType as read-only ApiResource with default label ASC sort 2026-05-28 11:40:40 +02:00
Matthieu ae70e90db2 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.
2026-05-28 11:40:40 +02:00
Matthieu e0575de646 feat(catalog) : add Category and CategoryType entities with Timestampable+Blamable pattern
- Category : ApiResource (5 ops), #[Auditable], TimestampableBlamableTrait +
  interfaces, asserts (NotBlank/Length sur name, NotNull sur categoryType),
  soft delete via deletedAt, groupes category:read/category:write + default:read
- CategoryType : referentiel statique en lecture seule (GetCollection + Get),
  embarque dans Category via le groupe category:read
- Repositories : interfaces Domain + impl Doctrine pour les deux entites
- doctrine.yaml : mapping ORM Catalog inconditionnel (miroir Sites) pour que
  l'ORM reconnaisse les entites ; declaration du module = ticket 0.5
- EntitiesAreTimestampableBlamableTest : CategoryType ajoute a EXCLUDED (RG-1.17)
- Index nommes declares sur les entites (match migration) ; index unique partiel
  uq_category_name_type_active possede par la migration seule
2026-05-28 11:40:40 +02:00
Matthieu 44d8c77718 ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)
Les logs montrent que chaque operation actions/cache attend ~4m30 avant
ETIMEDOUT sur le serveur de cache du runner Gitea (51.91.78.99:39531) :
- cache: npm de setup-node = tout le 'Setup Node 22' (271s)
- cache node_modules et cache .nuxt : timeouts additionnels
- cache Composer cote backend : meme risque

Node 22 est deja dans le tool-cache (install instantane), npm ci a froid
~30s, build ~20s : le caching n'apportait rien ici. A re-activer si le
serveur de cache du runner est repare.
2026-05-28 11:40:40 +02:00
Matthieu 670c58e02e ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
- remplace build:dist (nuxt generate + prerender inutile en SPA) par nuxt build
- cache node_modules sur hash du lockfile, npm ci uniquement en cache miss
- regenere les types Nuxt (postinstall) en cache hit
- cache des artefacts .nuxt / Vite avec restore-keys pour eviter le build a froid
2026-05-28 11:40:40 +02:00
7 changed files with 197 additions and 4 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.44' app.version: '0.1.42'
+12 -3
View File
@@ -10,6 +10,8 @@ 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;
@@ -33,32 +35,39 @@ 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.
* *
* Les Provider (filtre soft-delete) et Processor (trim, soft delete, 409) * Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
* seront branches au ticket 0.3 (ERP-45). Au ticket 0.2, les operations * soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
* utilisent les state Doctrine par defaut d'API Platform. * au ticket 0.3 (ERP-45).
*/ */
#[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,
), ),
], ],
)] )]
@@ -29,6 +29,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection( new GetCollection(
security: "is_granted('catalog.categories.view')", security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']], normalizationContext: ['groups' => ['category_type:read']],
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
// stable pour alimenter le <MalioSelect> du formulaire Category.
order: ['label' => 'ASC'],
), ),
new Get( new Get(
security: "is_granted('catalog.categories.view')", security: "is_granted('catalog.categories.view')",
@@ -5,10 +5,18 @@ 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;
} }
@@ -0,0 +1,76 @@
<?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,
);
}
}
}
@@ -0,0 +1,83 @@
<?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,6 +7,7 @@ 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;
/** /**
@@ -29,4 +30,17 @@ 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;
}
} }