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

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantPieceSlot;
use App\Entity\Piece;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -37,12 +38,22 @@ class ComposantPieceSlotController extends AbstractController
$slot->setQuantity(max(1, (int) $payload['quantity']));
}
if (array_key_exists('selectedPieceId', $payload)) {
if (null === $payload['selectedPieceId']) {
$slot->setSelectedPiece(null);
} else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
$slot->setSelectedPiece($piece);
}
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'id' => $slot->getId(),
'quantity' => $slot->getQuantity(),
'success' => true,
'id' => $slot->getId(),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantProductSlot;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/composant-product-slots')]
class ComposantProductSlotController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/{id}', name: 'composant_product_slot_patch', methods: ['PATCH'])]
public function patch(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$slot = $this->entityManager->find(ComposantProductSlot::class, $id);
if (!$slot) {
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
}
if (array_key_exists('selectedProductId', $payload)) {
if (null === $payload['selectedProductId']) {
$slot->setSelectedProduct(null);
} else {
$product = $this->entityManager->find(Product::class, $payload['selectedProductId']);
$slot->setSelectedProduct($product);
}
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'id' => $slot->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\ComposantSubcomponentSlot;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/composant-subcomponent-slots')]
class ComposantSubcomponentSlotController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/{id}', name: 'composant_subcomponent_slot_patch', methods: ['PATCH'])]
public function patch(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id);
if (!$slot) {
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
}
if (array_key_exists('selectedComposantId', $payload)) {
if (null === $payload['selectedComposantId']) {
$slot->setSelectedComposant(null);
} else {
$composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']);
$slot->setSelectedComposant($composant);
}
}
$this->entityManager->flush();
return $this->json([
'success' => true,
'id' => $slot->getId(),
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
]);
}
}

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,
];
}
}

View File

@@ -51,26 +51,30 @@ class ComposantSyncStrategy implements SyncStrategyInterface
$addedCfValues = 0;
$deletedCfValues = 0;
// Map proposed by (typeId, position) keys
// Map proposed by (typeId, position) keys — position defaults to array index
$proposedPieceKeys = [];
foreach ($proposedPieces as $pp) {
$proposedPieceKeys[$pp['typePieceId'].'|'.$pp['position']] = true;
foreach ($proposedPieces as $i => $pp) {
$pos = $pp['position'] ?? $i;
$proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true;
}
$proposedProductKeys = [];
foreach ($proposedProducts as $pp) {
$proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true;
foreach ($proposedProducts as $i => $pp) {
$pos = $pp['position'] ?? $i;
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
}
$proposedSubKeys = [];
foreach ($proposedSubcomponents as $ps) {
$proposedSubKeys[$ps['typeComposantId'].'|'.$ps['position']] = true;
foreach ($proposedSubcomponents as $i => $ps) {
$pos = $ps['position'] ?? $i;
$proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true;
}
// Map proposed custom fields by orderIndex
// Map proposed custom fields by orderIndex (falls back to array index)
$proposedCfByOrder = [];
foreach ($proposedCustomFields as $pcf) {
$proposedCfByOrder[$pcf['orderIndex']] = $pcf;
foreach ($proposedCustomFields as $i => $pcf) {
$order = $pcf['orderIndex'] ?? $i;
$proposedCfByOrder[$order] = $pcf;
}
// Get existing custom fields for this model type

View File

@@ -41,16 +41,18 @@ class PieceSyncStrategy implements SyncStrategyInterface
$addedCfValues = 0;
$deletedCfValues = 0;
// Map proposed products by (typeProductId, position) keys
// Map proposed products by (typeProductId, position) keys — position defaults to array index
$proposedProductKeys = [];
foreach ($proposedProducts as $pp) {
$proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true;
foreach ($proposedProducts as $i => $pp) {
$pos = $pp['position'] ?? $i;
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
}
// Map proposed custom fields by orderIndex
// Map proposed custom fields by orderIndex (falls back to array index)
$proposedCfByOrder = [];
foreach ($proposedCustomFields as $pcf) {
$proposedCfByOrder[$pcf['orderIndex']] = $pcf;
foreach ($proposedCustomFields as $i => $pcf) {
$order = $pcf['orderIndex'] ?? $i;
$proposedCfByOrder[$order] = $pcf;
}
// Get existing custom fields for this model type

View File

@@ -43,10 +43,11 @@ class ProductSyncStrategy implements SyncStrategyInterface
$existingByOrder[$field->getOrderIndex()] = $field;
}
// Map proposed fields by orderIndex
// Map proposed fields by orderIndex (falls back to array index)
$proposedByOrder = [];
foreach ($proposedFields as $pf) {
$proposedByOrder[$pf['orderIndex']] = $pf;
foreach ($proposedFields as $i => $pf) {
$order = $pf['orderIndex'] ?? $i;
$proposedByOrder[$order] = $pf;
}
$addedFields = 0;
@@ -57,7 +58,7 @@ class ProductSyncStrategy implements SyncStrategyInterface
foreach ($proposedByOrder as $orderIndex => $pf) {
if (!isset($existingByOrder[$orderIndex])) {
++$addedFields;
} elseif ($existingByOrder[$orderIndex]->getType() !== $pf['type']) {
} elseif ($existingByOrder[$orderIndex]->getType() !== ($pf['type'] ?? $pf['value']['type'] ?? null)) {
++$modifiedFields;
}
}