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:
38
CLAUDE.md
38
CLAUDE.md
@@ -64,6 +64,14 @@ npm run build # Build production
|
|||||||
npm run lint:fix # ESLint fix
|
npm run lint:fix # ESLint fix
|
||||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||||
|
|
||||||
|
# Database / Fixtures
|
||||||
|
make db-reset # Reset database (drop + recreate schema)
|
||||||
|
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
||||||
|
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
||||||
|
make fixtures-reset # Reset DB + recharger fixtures
|
||||||
|
make import-data # Importer les dumps SQL normalisés
|
||||||
|
make cache-clear # Clear cache Symfony
|
||||||
|
|
||||||
# Release
|
# Release
|
||||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||||
```
|
```
|
||||||
@@ -101,6 +109,11 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
|||||||
### Entités Principales
|
### Entités Principales
|
||||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||||
|
|
||||||
|
#### Entités de normalisation (slots & skeleton requirements)
|
||||||
|
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||||
|
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
||||||
|
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||||
|
|
||||||
### Patterns
|
### Patterns
|
||||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||||
@@ -110,15 +123,32 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
|||||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||||
|
|
||||||
### Custom Controllers (pas API Platform)
|
### Custom Controllers (pas API Platform)
|
||||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
|
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
|
||||||
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
||||||
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
||||||
|
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
||||||
|
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
||||||
|
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
||||||
|
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
||||||
|
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
||||||
|
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
||||||
|
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
||||||
|
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
||||||
|
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
||||||
|
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
||||||
|
- `HealthCheckController` — `/api/health` (GET) : health check.
|
||||||
|
|
||||||
### Custom Fields — Architecture
|
### Custom Fields — Architecture
|
||||||
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
|
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
||||||
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
||||||
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
||||||
|
|
||||||
|
### Normalisation JSON → Tables (architecture slots)
|
||||||
|
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
|
||||||
|
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||||
|
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
|
||||||
|
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
|
||||||
|
|
||||||
### Rôles (hiérarchie)
|
### Rôles (hiérarchie)
|
||||||
```
|
```
|
||||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||||
@@ -193,8 +223,8 @@ make test-setup # Créer/mettre à jour le schéma test
|
|||||||
### Pattern de test
|
### Pattern de test
|
||||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
|
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
|
||||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
|
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||||
|
|
||||||
## URLs Locales
|
## URLs Locales
|
||||||
- API Symfony : `http://localhost:8081/api`
|
- API Symfony : `http://localhost:8081/api`
|
||||||
|
|||||||
Submodule Inventory_frontend updated: 5912216a89...271844efb1
@@ -1626,6 +1626,19 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* "when@dev"?: array{
|
||||||
|
* imports?: ImportsConfig,
|
||||||
|
* parameters?: ParametersConfig,
|
||||||
|
* services?: ServicesConfig,
|
||||||
|
* framework?: FrameworkConfig,
|
||||||
|
* twig?: TwigConfig,
|
||||||
|
* security?: SecurityConfig,
|
||||||
|
* doctrine?: DoctrineConfig,
|
||||||
|
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||||
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
|
* api_platform?: ApiPlatformConfig,
|
||||||
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1732,6 +1745,7 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
|||||||
* deprecated?: array{package:string, version:string, message?:string},
|
* deprecated?: array{package:string, version:string, message?:string},
|
||||||
* }
|
* }
|
||||||
* @psalm-type RoutesConfig = array{
|
* @psalm-type RoutesConfig = array{
|
||||||
|
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||||
|
|||||||
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\ComposantPieceSlot;
|
use App\Entity\ComposantPieceSlot;
|
||||||
|
use App\Entity\Piece;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -37,12 +38,22 @@ class ComposantPieceSlotController extends AbstractController
|
|||||||
$slot->setQuantity(max(1, (int) $payload['quantity']));
|
$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();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $this->json([
|
return $this->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'id' => $slot->getId(),
|
'id' => $slot->getId(),
|
||||||
'quantity' => $slot->getQuantity(),
|
'quantity' => $slot->getQuantity(),
|
||||||
|
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/Controller/ComposantProductSlotController.php
Normal file
54
src/Controller/ComposantProductSlotController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
54
src/Controller/ComposantSubcomponentSlotController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\CustomField;
|
||||||
use App\Entity\ModelType;
|
use App\Entity\ModelType;
|
||||||
use App\Entity\SkeletonPieceRequirement;
|
use App\Entity\SkeletonPieceRequirement;
|
||||||
use App\Entity\SkeletonProductRequirement;
|
use App\Entity\SkeletonProductRequirement;
|
||||||
use App\Entity\SkeletonSubcomponentRequirement;
|
use App\Entity\SkeletonSubcomponentRequirement;
|
||||||
|
use App\Enum\ModelCategory;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
class SkeletonStructureService
|
class SkeletonStructureService
|
||||||
@@ -60,5 +62,118 @@ class SkeletonStructureService
|
|||||||
$req->setPosition($i);
|
$req->setPosition($i);
|
||||||
$modelType->addSkeletonSubcomponentRequirement($req);
|
$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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,26 +51,30 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
|||||||
$addedCfValues = 0;
|
$addedCfValues = 0;
|
||||||
$deletedCfValues = 0;
|
$deletedCfValues = 0;
|
||||||
|
|
||||||
// Map proposed by (typeId, position) keys
|
// Map proposed by (typeId, position) keys — position defaults to array index
|
||||||
$proposedPieceKeys = [];
|
$proposedPieceKeys = [];
|
||||||
foreach ($proposedPieces as $pp) {
|
foreach ($proposedPieces as $i => $pp) {
|
||||||
$proposedPieceKeys[$pp['typePieceId'].'|'.$pp['position']] = true;
|
$pos = $pp['position'] ?? $i;
|
||||||
|
$proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proposedProductKeys = [];
|
$proposedProductKeys = [];
|
||||||
foreach ($proposedProducts as $pp) {
|
foreach ($proposedProducts as $i => $pp) {
|
||||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true;
|
$pos = $pp['position'] ?? $i;
|
||||||
|
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$proposedSubKeys = [];
|
$proposedSubKeys = [];
|
||||||
foreach ($proposedSubcomponents as $ps) {
|
foreach ($proposedSubcomponents as $i => $ps) {
|
||||||
$proposedSubKeys[$ps['typeComposantId'].'|'.$ps['position']] = true;
|
$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 = [];
|
$proposedCfByOrder = [];
|
||||||
foreach ($proposedCustomFields as $pcf) {
|
foreach ($proposedCustomFields as $i => $pcf) {
|
||||||
$proposedCfByOrder[$pcf['orderIndex']] = $pcf;
|
$order = $pcf['orderIndex'] ?? $i;
|
||||||
|
$proposedCfByOrder[$order] = $pcf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing custom fields for this model type
|
// Get existing custom fields for this model type
|
||||||
|
|||||||
@@ -41,16 +41,18 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
|||||||
$addedCfValues = 0;
|
$addedCfValues = 0;
|
||||||
$deletedCfValues = 0;
|
$deletedCfValues = 0;
|
||||||
|
|
||||||
// Map proposed products by (typeProductId, position) keys
|
// Map proposed products by (typeProductId, position) keys — position defaults to array index
|
||||||
$proposedProductKeys = [];
|
$proposedProductKeys = [];
|
||||||
foreach ($proposedProducts as $pp) {
|
foreach ($proposedProducts as $i => $pp) {
|
||||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pp['position']] = true;
|
$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 = [];
|
$proposedCfByOrder = [];
|
||||||
foreach ($proposedCustomFields as $pcf) {
|
foreach ($proposedCustomFields as $i => $pcf) {
|
||||||
$proposedCfByOrder[$pcf['orderIndex']] = $pcf;
|
$order = $pcf['orderIndex'] ?? $i;
|
||||||
|
$proposedCfByOrder[$order] = $pcf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing custom fields for this model type
|
// Get existing custom fields for this model type
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ class ProductSyncStrategy implements SyncStrategyInterface
|
|||||||
$existingByOrder[$field->getOrderIndex()] = $field;
|
$existingByOrder[$field->getOrderIndex()] = $field;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map proposed fields by orderIndex
|
// Map proposed fields by orderIndex (falls back to array index)
|
||||||
$proposedByOrder = [];
|
$proposedByOrder = [];
|
||||||
foreach ($proposedFields as $pf) {
|
foreach ($proposedFields as $i => $pf) {
|
||||||
$proposedByOrder[$pf['orderIndex']] = $pf;
|
$order = $pf['orderIndex'] ?? $i;
|
||||||
|
$proposedByOrder[$order] = $pf;
|
||||||
}
|
}
|
||||||
|
|
||||||
$addedFields = 0;
|
$addedFields = 0;
|
||||||
@@ -57,7 +58,7 @@ class ProductSyncStrategy implements SyncStrategyInterface
|
|||||||
foreach ($proposedByOrder as $orderIndex => $pf) {
|
foreach ($proposedByOrder as $orderIndex => $pf) {
|
||||||
if (!isset($existingByOrder[$orderIndex])) {
|
if (!isset($existingByOrder[$orderIndex])) {
|
||||||
++$addedFields;
|
++$addedFields;
|
||||||
} elseif ($existingByOrder[$orderIndex]->getType() !== $pf['type']) {
|
} elseif ($existingByOrder[$orderIndex]->getType() !== ($pf['type'] ?? $pf['value']['type'] ?? null)) {
|
||||||
++$modifiedFields;
|
++$modifiedFields;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user