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
|
||||
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
|
||||
./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
|
||||
`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
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **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
|
||||
|
||||
### 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.
|
||||
- `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
|
||||
- **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)
|
||||
- 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)
|
||||
```
|
||||
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
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- 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()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## URLs Locales
|
||||
- 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,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* 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{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1732,6 +1745,7 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
* deprecated?: array{package:string, version:string, message?:string},
|
||||
* }
|
||||
* @psalm-type RoutesConfig = array{
|
||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@test"?: array<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;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user