From 53b6abc9a8113a9241860596163dfe24bde310e1 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 23 Mar 2026 12:04:19 +0100 Subject: [PATCH] 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) --- src/Entity/Composant.php | 27 +++++++++ src/State/ComposantProcessor.php | 98 +++++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/Entity/Composant.php b/src/Entity/Composant.php index 9fe6fd5..656daa1 100644 --- a/src/Entity/Composant.php +++ b/src/Entity/Composant.php @@ -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)) { diff --git a/src/State/ComposantProcessor.php b/src/State/ComposantProcessor.php index eb87125..e99e1a4 100644 --- a/src/State/ComposantProcessor.php +++ b/src/State/ComposantProcessor.php @@ -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> + */ + 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]); + } }