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

@@ -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`

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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;
} }
} }