syncPieceRequirements($modelType, $structure['pieces'] ?? []); // Update product requirements in-place $this->syncProductRequirements($modelType, $structure['products'] ?? []); // Update subcomponent requirements in-place $this->syncSubcomponentRequirements($modelType, $structure['subcomponents'] ?? []); // Update custom field definitions $this->updateCustomFields($modelType, $structure['customFields'] ?? []); } /** * @param array $proposedPieces */ private function syncPieceRequirements(ModelType $modelType, array $proposedPieces): void { $existing = $modelType->getSkeletonPieceRequirements()->toArray(); // Index existing by typeId for matching $existingByTypeId = []; foreach ($existing as $req) { $existingByTypeId[$req->getTypePiece()->getId()][] = $req; } $matched = []; $toCreate = []; foreach ($proposedPieces as $i => $pieceData) { $typeId = $pieceData['typePieceId']; if (!empty($existingByTypeId[$typeId])) { // Reuse existing requirement, update position $req = array_shift($existingByTypeId[$typeId]); $req->setPosition($i); $matched[spl_object_id($req)] = true; } else { $toCreate[] = ['data' => $pieceData, 'position' => $i]; } } // Remove unmatched existing requirements foreach ($existing as $req) { if (!isset($matched[spl_object_id($req)])) { $modelType->removeSkeletonPieceRequirement($req); } } // Create new requirements foreach ($toCreate as $item) { $req = new SkeletonPieceRequirement(); $req->setModelType($modelType); $req->setTypePiece($this->em->getReference(ModelType::class, $item['data']['typePieceId'])); $req->setPosition($item['position']); $modelType->addSkeletonPieceRequirement($req); } } /** * @param array $proposedProducts */ private function syncProductRequirements(ModelType $modelType, array $proposedProducts): void { $existing = $modelType->getSkeletonProductRequirements()->toArray(); $existingByTypeId = []; foreach ($existing as $req) { $existingByTypeId[$req->getTypeProduct()->getId()][] = $req; } $matched = []; $toCreate = []; foreach ($proposedProducts as $i => $prodData) { $typeId = $prodData['typeProductId']; if (!empty($existingByTypeId[$typeId])) { $req = array_shift($existingByTypeId[$typeId]); $req->setFamilyCode($prodData['familyCode'] ?? null); $req->setPosition($i); $matched[spl_object_id($req)] = true; } else { $toCreate[] = ['data' => $prodData, 'position' => $i]; } } foreach ($existing as $req) { if (!isset($matched[spl_object_id($req)])) { $modelType->removeSkeletonProductRequirement($req); } } foreach ($toCreate as $item) { $req = new SkeletonProductRequirement(); $req->setModelType($modelType); $req->setTypeProduct($this->em->getReference(ModelType::class, $item['data']['typeProductId'])); $req->setFamilyCode($item['data']['familyCode'] ?? null); $req->setPosition($item['position']); $modelType->addSkeletonProductRequirement($req); } } /** * @param array $proposedSubs */ private function syncSubcomponentRequirements(ModelType $modelType, array $proposedSubs): void { $existing = $modelType->getSkeletonSubcomponentRequirements()->toArray(); $existingByTypeId = []; foreach ($existing as $req) { $key = $req->getTypeComposant()?->getId() ?? ''; $existingByTypeId[$key][] = $req; } $matched = []; $toCreate = []; foreach ($proposedSubs as $i => $subData) { $typeId = $subData['typeComposantId'] ?? ''; if (!empty($existingByTypeId[$typeId])) { $req = array_shift($existingByTypeId[$typeId]); $req->setAlias($subData['alias'] ?? ''); $req->setFamilyCode($subData['familyCode'] ?? ''); if (!empty($subData['typeComposantId'])) { $req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId'])); } $req->setPosition($i); $matched[spl_object_id($req)] = true; } else { $toCreate[] = ['data' => $subData, 'position' => $i]; } } foreach ($existing as $req) { if (!isset($matched[spl_object_id($req)])) { $modelType->removeSkeletonSubcomponentRequirement($req); } } foreach ($toCreate as $item) { $req = new SkeletonSubcomponentRequirement(); $req->setModelType($modelType); $req->setAlias($item['data']['alias'] ?? ''); $req->setFamilyCode($item['data']['familyCode'] ?? ''); if (!empty($item['data']['typeComposantId'])) { $req->setTypeComposant($this->em->getReference(ModelType::class, $item['data']['typeComposantId'])); } $req->setPosition($item['position']); $modelType->addSkeletonSubcomponentRequirement($req); } } /** * Sync CustomField entities for this ModelType. * Handles two frontend formats: * - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?} * - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}. */ private function updateCustomFields(ModelType $modelType, array $proposedFields): void { // Determine which FK to use based on category $category = $modelType->getCategory(); $fkField = match ($category) { ModelCategory::COMPONENT => 'typeComposant', ModelCategory::PIECE => 'typePiece', ModelCategory::PRODUCT => 'typeProduct', }; // Load existing custom fields $existingFields = $this->em->getRepository(CustomField::class)->findBy( [$fkField => $modelType], ['orderIndex' => 'ASC'] ); // Index existing by ID and by name for matching $existingById = []; $existingByName = []; foreach ($existingFields as $cf) { $existingById[$cf->getId()] = $cf; $existingByName[$cf->getName()] = $cf; } $processedIds = []; foreach ($proposedFields as $i => $fieldData) { // Normalize both formats to a common shape $normalized = $this->normalizeCustomFieldData($fieldData, $i); // Try to match an existing field by ID first, then by name as fallback $existingField = null; $fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null; if ($fieldId && isset($existingById[$fieldId])) { $existingField = $existingById[$fieldId]; } elseif (isset($existingByName[$normalized['name']]) && !isset($processedIds[$existingByName[$normalized['name']]->getId()])) { $existingField = $existingByName[$normalized['name']]; } if ($existingField) { // Update existing field $existingField->setName($normalized['name']); $existingField->setType($normalized['type']); $existingField->setRequired($normalized['required']); $existingField->setOptions($normalized['options']); $existingField->setDefaultValue($normalized['defaultValue']); $existingField->setOrderIndex($normalized['orderIndex']); $processedIds[$existingField->getId()] = true; } else { // Create new field $cf = new CustomField(); $cf->setName($normalized['name']); $cf->setType($normalized['type']); $cf->setRequired($normalized['required']); $cf->setOptions($normalized['options']); $cf->setDefaultValue($normalized['defaultValue']); $cf->setOrderIndex($normalized['orderIndex']); match ($category) { ModelCategory::COMPONENT => $cf->setTypeComposant($modelType), ModelCategory::PIECE => $cf->setTypePiece($modelType), ModelCategory::PRODUCT => $cf->setTypeProduct($modelType), }; $this->em->persist($cf); } } // Remove orphaned fields (exist in DB but not in proposed) foreach ($existingFields as $cf) { if (!isset($processedIds[$cf->getId()])) { $this->em->remove($cf); } } } /** * Normalize frontend custom field data to a common shape. * * @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int} */ private function normalizeCustomFieldData(array $fieldData, int $index): array { // COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}} if (isset($fieldData['key'], $fieldData['value'])) { $value = $fieldData['value']; return [ 'name' => $fieldData['key'], 'type' => $value['type'] ?? 'text', 'required' => (bool) ($value['required'] ?? false), 'options' => $value['options'] ?? null, 'defaultValue' => $value['defaultValue'] ?? null, 'orderIndex' => $index, ]; } // PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?} return [ 'name' => $fieldData['name'] ?? '', 'type' => $fieldData['type'] ?? 'text', 'required' => (bool) ($fieldData['required'] ?? false), 'options' => $fieldData['options'] ?? null, 'defaultValue' => $fieldData['defaultValue'] ?? null, 'orderIndex' => $fieldData['orderIndex'] ?? $index, ]; } }