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

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Scaffold missing composant slots for existing composants that have
* a typeComposant with skeleton requirements but no corresponding slots.
*/
final class Version20260323100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Scaffold missing composant slots from skeleton requirements for existing composants';
}
public function up(Schema $schema): void
{
// Piece slots
$this->addSql(<<<'SQL'
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", quantity, position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr."typepieceid",
1,
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_piece_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_piece_slots cps
WHERE cps."composantid" = c.id AND cps."typepieceid" = spr."typepieceid"
)
SQL);
// Product slots
$this->addSql(<<<'SQL'
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "familycode", position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr."typeproductid",
spr."familycode",
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_product_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_product_slots cps
WHERE cps."composantid" = c.id AND cps."typeproductid" = spr."typeproductid"
)
SQL);
// Subcomponent slots
$this->addSql(<<<'SQL'
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr.alias,
spr."familycode",
spr."typecomposantid",
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_subcomponent_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_subcomponent_slots css
WHERE css."composantid" = c.id
AND COALESCE(css."typecomposantid", '') = COALESCE(spr."typecomposantid", '')
AND COALESCE(css.alias, '') = COALESCE(spr.alias, '')
)
SQL);
}
public function down(Schema $schema): void
{
// No-op: slots created by this migration are valid data
}
}

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();
}
}
}