fix(composant) : scaffold skeleton slots on creation + explicit unique constraint errors

- Add ComposantProcessor: initializes piece/product/subcomponent slots
  from ModelType skeleton requirements when a composant is created
- UniqueConstraintSubscriber: priority 256, French error messages,
  constraint name detection for specific feedback
- Migration: scaffold missing slots for existing composants in prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-23 11:48:23 +01:00
parent 38777b7de0
commit 826dae7712
5 changed files with 225 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ComposantRepository;
use App\State\ComposantProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -33,7 +34,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ComposantProcessor::class),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),

View File

@@ -16,7 +16,7 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => 'onKernelException',
KernelEvents::EXCEPTION => ['onKernelException', 256],
];
}
@@ -28,10 +28,17 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
return;
}
$constraint = $this->detectConstraintName($exception);
$error = match ($constraint) {
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
default => 'Un élément avec cette valeur existe déjà.',
};
$event->setResponse(new JsonResponse(
[
'success' => false,
'error' => 'nom duplique',
'success' => false,
'error' => $error,
'constraint' => $constraint,
],
JsonResponse::HTTP_CONFLICT
));
@@ -47,4 +54,15 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
return null;
}
private function detectConstraintName(UniqueConstraintViolationException $exception): ?string
{
$message = $exception->getMessage();
if (preg_match('/constraint\s+"([^"]+)"/', $message, $matches)) {
return $matches[1];
}
return null;
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Composant;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ModelType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Initializes skeleton slots when a Composant is created with a typeComposant.
*
* Reads the SkeletonPieceRequirements, SkeletonProductRequirements and
* SkeletonSubcomponentRequirements from the ModelType and creates matching
* ComposantPieceSlot / ComposantProductSlot / ComposantSubcomponentSlot rows.
*/
final class ComposantProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $decorated,
private readonly EntityManagerInterface $entityManager,
) {}
/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Composant) {
return $this->decorated->process($data, $operation, $uriVariables, $context);
}
$isCreation = $operation instanceof Post;
// Persist the entity first
$result = $this->decorated->process($data, $operation, $uriVariables, $context);
// On creation, scaffold slots from the ModelType skeleton
if ($isCreation) {
$this->initializeSlotsFromSkeleton($data);
}
return $result;
}
private function initializeSlotsFromSkeleton(Composant $composant): void
{
$modelType = $composant->getTypeComposant();
if (!$modelType) {
return;
}
// Ensure the ModelType's skeleton collections are loaded
$modelType = $this->entityManager->getRepository(ModelType::class)->find($modelType->getId());
if (!$modelType) {
return;
}
$hasNewSlots = false;
// Piece slots
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
$slot = new ComposantPieceSlot();
$slot->setComposant($composant);
$slot->setTypePiece($req->getTypePiece());
$slot->setPosition($req->getPosition());
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
// Product slots
foreach ($modelType->getSkeletonProductRequirements() as $req) {
$slot = new ComposantProductSlot();
$slot->setComposant($composant);
$slot->setTypeProduct($req->getTypeProduct());
$slot->setFamilyCode($req->getFamilyCode());
$slot->setPosition($req->getPosition());
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
// Subcomponent slots
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
$slot = new ComposantSubcomponentSlot();
$slot->setComposant($composant);
$slot->setAlias($req->getAlias());
$slot->setFamilyCode($req->getFamilyCode());
$slot->setTypeComposant($req->getTypeComposant());
$slot->setPosition($req->getPosition());
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
if ($hasNewSlots) {
$this->entityManager->flush();
}
}
}