fix(composant) : persist piece/product/subcomponent selections on creation

The ComposantProcessor now reads the structure payload from the frontend
and applies selectedPieceId/selectedProductId/selectedComponentId to the
scaffolded slots, so user selections are actually saved to the database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-23 12:04:19 +01:00
parent 826dae7712
commit 53b6abc9a8
2 changed files with 111 additions and 14 deletions

View File

@@ -131,6 +131,12 @@ class Composant
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $productSlots;
/**
* Transient — holds the structure payload sent by the frontend during creation.
* Not mapped to any column; consumed by ComposantProcessor.
*/
private ?array $pendingStructure = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['composant:read'])]
private int $version = 1;
@@ -395,6 +401,27 @@ class Composant
];
}
/**
* Called by API Platform during denormalization — stores the frontend
* structure payload so the ComposantProcessor can apply selections.
*/
public function setStructure(?array $structure): static
{
$this->pendingStructure = $structure;
return $this;
}
public function getPendingStructure(): ?array
{
return $this->pendingStructure;
}
public function clearPendingStructure(): void
{
$this->pendingStructure = null;
}
public function addProductSlot(ComposantProductSlot $productSlot): static
{
if (!$this->productSlots->contains($productSlot)) {

View File

@@ -12,15 +12,14 @@ use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
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.
* Initializes skeleton slots when a Composant is created with a typeComposant,
* and applies the piece/product/subcomponent selections from the frontend payload.
*/
final class ComposantProcessor implements ProcessorInterface
{
@@ -40,20 +39,21 @@ final class ComposantProcessor implements ProcessorInterface
return $this->decorated->process($data, $operation, $uriVariables, $context);
}
$isCreation = $operation instanceof Post;
$pendingStructure = $data->getPendingStructure();
// 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);
// On creation, scaffold slots from the ModelType skeleton + apply selections
if ($operation instanceof Post) {
$this->initializeSlotsFromSkeleton($data, $pendingStructure);
$data->clearPendingStructure();
}
return $result;
}
private function initializeSlotsFromSkeleton(Composant $composant): void
private function initializeSlotsFromSkeleton(Composant $composant, ?array $structure): void
{
$modelType = $composant->getTypeComposant();
@@ -61,44 +61,69 @@ final class ComposantProcessor implements ProcessorInterface
return;
}
// Ensure the ModelType's skeleton collections are loaded
$modelType = $this->entityManager->getRepository(ModelType::class)->find($modelType->getId());
if (!$modelType) {
return;
}
// Index selections from the frontend payload by typePieceId/typeProductId/typeComposantId
$pieceSelections = $this->indexSelections($structure['pieces'] ?? [], 'typePieceId', 'selectedPieceId');
$productSelections = $this->indexSelections($structure['products'] ?? [], 'typeProductId', 'selectedProductId');
$subSelections = $this->indexSelections($structure['subcomponents'] ?? [], 'typeComposantId', 'selectedComponentId');
$hasNewSlots = false;
// Piece slots
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
$slot = new ComposantPieceSlot();
$typePieceId = $req->getTypePiece()->getId();
$slot = new ComposantPieceSlot();
$slot->setComposant($composant);
$slot->setTypePiece($req->getTypePiece());
$slot->setPosition($req->getPosition());
$selectedId = $this->shiftSelection($pieceSelections, $typePieceId);
if ($selectedId) {
$slot->setSelectedPiece($this->entityManager->getReference(Piece::class, $selectedId));
}
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
// Product slots
foreach ($modelType->getSkeletonProductRequirements() as $req) {
$slot = new ComposantProductSlot();
$typeProductId = $req->getTypeProduct()->getId();
$slot = new ComposantProductSlot();
$slot->setComposant($composant);
$slot->setTypeProduct($req->getTypeProduct());
$slot->setFamilyCode($req->getFamilyCode());
$slot->setPosition($req->getPosition());
$selectedId = $this->shiftSelection($productSelections, $typeProductId);
if ($selectedId) {
$slot->setSelectedProduct($this->entityManager->getReference(Product::class, $selectedId));
}
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
// Subcomponent slots
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
$slot = new ComposantSubcomponentSlot();
$typeComposantId = $req->getTypeComposant()?->getId() ?? '';
$slot = new ComposantSubcomponentSlot();
$slot->setComposant($composant);
$slot->setAlias($req->getAlias());
$slot->setFamilyCode($req->getFamilyCode());
$slot->setTypeComposant($req->getTypeComposant());
$slot->setPosition($req->getPosition());
$selectedId = $this->shiftSelection($subSelections, $typeComposantId);
if ($selectedId) {
$slot->setSelectedComposant($this->entityManager->getReference(Composant::class, $selectedId));
}
$this->entityManager->persist($slot);
$hasNewSlots = true;
}
@@ -107,4 +132,49 @@ final class ComposantProcessor implements ProcessorInterface
$this->entityManager->flush();
}
}
/**
* Build an indexed map of typeId → [selectedId, selectedId, ...] from the payload.
* Supports both flat format and nested definition format from the frontend.
*
* @return array<string, list<string>>
*/
private function indexSelections(array $items, string $typeKey, string $selectionKey): array
{
$map = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
// The typeId can be at top level or inside definition
$typeId = $item['definition'][$typeKey]
?? $item[$typeKey]
?? null;
$selectedId = $item[$selectionKey] ?? null;
if (!is_string($typeId) || '' === $typeId) {
continue;
}
if (is_string($selectedId) && '' !== $selectedId) {
$map[$typeId][] = $selectedId;
}
}
return $map;
}
/**
* Pop the first selection for a given typeId (handles multiple slots of the same type).
*/
private function shiftSelection(array &$selections, string $typeId): ?string
{
if (empty($selections[$typeId])) {
return null;
}
return array_shift($selections[$typeId]);
}
}