feat(sync) : add slot selection controllers, custom field sync, and position fallbacks

- Add selectedPieceId support to ComposantPieceSlotController
- Create ComposantProductSlotController and ComposantSubcomponentSlotController
- Add updateCustomFields() to SkeletonStructureService for managing CustomField entities
- Fix position/orderIndex fallback to array index in all 3 sync strategies
- Fix type comparison in ProductSyncStrategy for dual format support
- Update CLAUDE.md with new entities, controllers, and fixtures documentation
- Update frontend submodule with interactive slot selectors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 16:40:44 +01:00
parent 4072abf7ba
commit b2aff0e414
11 changed files with 1380 additions and 28 deletions

View File

@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomField;
use App\Entity\ModelType;
use App\Entity\SkeletonPieceRequirement;
use App\Entity\SkeletonProductRequirement;
use App\Entity\SkeletonSubcomponentRequirement;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
class SkeletonStructureService
@@ -60,5 +62,118 @@ class SkeletonStructureService
$req->setPosition($i);
$modelType->addSkeletonSubcomponentRequirement($req);
}
// Update custom field definitions
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
}
/**
* 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 for matching
$existingById = [];
foreach ($existingFields as $cf) {
$existingById[$cf->getId()] = $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
$existingField = null;
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
if ($fieldId && isset($existingById[$fieldId])) {
$existingField = $existingById[$fieldId];
}
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,
];
}
}