From 38777b7de05f839fc702bf48c0eea23ba1d56970 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 17 Mar 2026 20:10:48 +0100 Subject: [PATCH] fix(custom-fields) : prevent data loss on ModelType save + restoration scripts Backend: match existing CustomField by name as fallback when ID is not provided, preventing deletion and recreation of field definitions (which cascade-deletes values). Includes restoration/migration scripts for prod: - restore-custom-field-values.php: restores piece values from audit logs - migrate-orphaned-custom-fields.php: migrates values from orphaned CFs - fix-prod-all.php: combined fix (migrate + restore + cleanup) - fix-prod-recreate-and-migrate.php: full fix (recreate missing CFs + migrate + restore) - check-prod-*.php: diagnostic scripts Co-Authored-By: Claude Opus 4.6 (1M context) --- Inventory_frontend | 2 +- scripts/check-prod-audit-dates.php | 46 ++++ scripts/check-prod-missing-piece-cfs.php | 54 +++++ scripts/check-prod-orphaned-detail.php | 83 +++++++ scripts/check-prod-values.php | 36 +++ scripts/fix-prod-all.php | 199 +++++++++++++++ scripts/fix-prod-recreate-and-migrate.php | 266 +++++++++++++++++++++ scripts/migrate-orphaned-custom-fields.php | 233 ++++++++++++++++++ scripts/restore-custom-field-values.php | 196 +++++++++++++++ scripts/verify-prod-health.php | 95 ++++++++ src/Service/SkeletonStructureService.php | 12 +- 11 files changed, 1217 insertions(+), 5 deletions(-) create mode 100644 scripts/check-prod-audit-dates.php create mode 100644 scripts/check-prod-missing-piece-cfs.php create mode 100644 scripts/check-prod-orphaned-detail.php create mode 100644 scripts/check-prod-values.php create mode 100644 scripts/fix-prod-all.php create mode 100644 scripts/fix-prod-recreate-and-migrate.php create mode 100644 scripts/migrate-orphaned-custom-fields.php create mode 100644 scripts/restore-custom-field-values.php create mode 100644 scripts/verify-prod-health.php diff --git a/Inventory_frontend b/Inventory_frontend index d4fc0f1..db630e3 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit d4fc0f1fee370e1a073c0685b141303d5fce48e9 +Subproject commit db630e315b2a5bd3ccd89abaa1c3bce1f9a78ef5 diff --git a/scripts/check-prod-audit-dates.php b/scripts/check-prod-audit-dates.php new file mode 100644 index 0000000..52dcbd7 --- /dev/null +++ b/scripts/check-prod-audit-dates.php @@ -0,0 +1,46 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo "--- Audit logs with customField deletions (to:null) ---\n"; +$rows = $conn->fetchAllAssociative(" + SELECT al.entityid, al.entitytype, al.diff::text as diff, al.createdat + FROM audit_logs al + WHERE al.diff::text LIKE '%customField%' + AND al.diff::text LIKE '%\"to\":null%' + ORDER BY al.createdat DESC + LIMIT 20 +"); +echo sprintf("Found %d entries\n\n", count($rows)); +foreach ($rows as $r) { + echo sprintf("[%s] %s %s: %s\n", $r['createdat'], $r['entitytype'], $r['entityid'], substr($r['diff'], 0, 120)); +} + +echo "\n--- Orphaned CFValues (pointing to CFs with no ModelType) ---\n"; +$rows = $conn->fetchAllAssociative(" + SELECT COUNT(*) as cnt, + CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece' + WHEN cfv.composantid IS NOT NULL THEN 'composant' + WHEN cfv.productid IS NOT NULL THEN 'product' + ELSE 'unknown' END as entity_type + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL + GROUP BY entity_type +"); +foreach ($rows as $r) { + echo sprintf(" %s: %d orphaned values\n", $r['entity_type'], $r['cnt']); +} diff --git a/scripts/check-prod-missing-piece-cfs.php b/scripts/check-prod-missing-piece-cfs.php new file mode 100644 index 0000000..5af58ae --- /dev/null +++ b/scripts/check-prod-missing-piece-cfs.php @@ -0,0 +1,54 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo "--- ModelTypes with orphaned piece values (CFs lost) ---\n\n"; +$rows = $conn->fetchAllAssociative(" + SELECT mt.id, mt.name, mt.category, + cf_orphan.name as lost_field, + COUNT(*) as affected_pieces, + COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as with_data + FROM custom_field_values cfv + JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid + JOIN pieces p ON p.id = cfv.pieceid + JOIN model_types mt ON mt.id = p.typepieceid + WHERE cf_orphan.typecomposantid IS NULL + AND cf_orphan.typepieceid IS NULL + AND cf_orphan.typeproductid IS NULL + GROUP BY mt.id, mt.name, mt.category, cf_orphan.name + ORDER BY mt.name, cf_orphan.name +"); + +foreach ($rows as $r) { + $status = $r['with_data'] > 0 ? 'HAS DATA' : 'empty'; + echo sprintf(" ModelType '%s' | field '%s' | %d pieces (%d with data) [%s]\n", + $r['name'], $r['lost_field'], $r['affected_pieces'], $r['with_data'], $status); +} + +echo sprintf("\nTotal: %d ModelType/field combinations\n", count($rows)); + +// Check if these fields exist on the current ModelType +echo "\n--- Current CFs on these ModelTypes ---\n\n"; +$mtIds = array_unique(array_column($rows, 'id')); +foreach ($mtIds as $mtId) { + $mtName = $conn->fetchOne("SELECT name FROM model_types WHERE id = ?", [$mtId]); + $currentCfs = $conn->fetchAllAssociative( + "SELECT name FROM custom_fields WHERE typepieceid = ? ORDER BY orderindex", + [$mtId] + ); + $cfNames = array_column($currentCfs, 'name'); + echo sprintf(" '%s': %s\n", $mtName, $cfNames ? implode(', ', $cfNames) : '(aucun CF)'); +} diff --git a/scripts/check-prod-orphaned-detail.php b/scripts/check-prod-orphaned-detail.php new file mode 100644 index 0000000..58ac54a --- /dev/null +++ b/scripts/check-prod-orphaned-detail.php @@ -0,0 +1,83 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +// Show a sample of orphaned CFValues for pieces +echo "--- Sample orphaned piece CFValues ---\n"; +$rows = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.pieceid, + cf.id as cf_id, cf.name as cf_name, + cf.typecomposantid, cf.typepieceid, cf.typeproductid, + p.name as piece_name, p.typepieceid as piece_modeltype + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + JOIN pieces p ON p.id = cfv.pieceid + WHERE cfv.pieceid IS NOT NULL + AND cf.typepieceid IS NULL + ORDER BY p.name + LIMIT 10 +"); +echo sprintf("Found %d (limited to 10)\n\n", count($rows)); +foreach ($rows as $r) { + echo sprintf(" Piece '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n", + $r['piece_name'], $r['cf_name'], $r['value'], + $r['typecomposantid'] ?? 'NULL', + $r['typepieceid'] ?? 'NULL', + $r['typeproductid'] ?? 'NULL' + ); +} + +// Show a sample of orphaned CFValues for composants +echo "\n--- Sample orphaned composant CFValues ---\n"; +$rows = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.composantid, + cf.id as cf_id, cf.name as cf_name, + cf.typecomposantid, cf.typepieceid, cf.typeproductid, + c.name as composant_name, c.typecomposantid as composant_modeltype + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + JOIN composants c ON c.id = cfv.composantid + WHERE cfv.composantid IS NOT NULL + AND cf.typecomposantid IS NULL + ORDER BY c.name + LIMIT 10 +"); +echo sprintf("Found %d (limited to 10)\n\n", count($rows)); +foreach ($rows as $r) { + echo sprintf(" Composant '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n", + $r['composant_name'], $r['cf_name'], $r['value'], + $r['typecomposantid'] ?? 'NULL', + $r['typepieceid'] ?? 'NULL', + $r['typeproductid'] ?? 'NULL' + ); +} + +// Check: are there CFs with ONLY typepieceid NULL but other FKs set? +echo "\n--- Orphaned CF FK patterns ---\n"; +$rows = $conn->fetchAllAssociative(" + SELECT + CASE WHEN typecomposantid IS NULL THEN 'NULL' ELSE 'SET' END as composant_fk, + CASE WHEN typepieceid IS NULL THEN 'NULL' ELSE 'SET' END as piece_fk, + CASE WHEN typeproductid IS NULL THEN 'NULL' ELSE 'SET' END as product_fk, + COUNT(*) as cnt + FROM custom_fields + GROUP BY composant_fk, piece_fk, product_fk + ORDER BY cnt DESC +"); +foreach ($rows as $r) { + echo sprintf(" composant=%s piece=%s product=%s : %d CFs\n", + $r['composant_fk'], $r['piece_fk'], $r['product_fk'], $r['cnt']); +} diff --git a/scripts/check-prod-values.php b/scripts/check-prod-values.php new file mode 100644 index 0000000..b91bc28 --- /dev/null +++ b/scripts/check-prod-values.php @@ -0,0 +1,36 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo "--- Piece 'Arbre du palier pied E1' ---\n"; +$rows = $conn->fetchAllAssociative("SELECT p.name, cfv.value, cf.name as field_name FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE p.id = 'cl3d978dd4b071daff8fb185f7' ORDER BY cf.orderindex"); +foreach ($rows as $r) { + echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']); +} + +echo "\n--- Composant 'Cage écureuil pied E8' ---\n"; +$rows = $conn->fetchAllAssociative("SELECT c.name, cfv.value, cf.name as field_name FROM composants c JOIN custom_field_values cfv ON cfv.composantid = c.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE c.id = 'cl5b5e336095de8d4ece81b2dc' ORDER BY cf.orderindex"); +foreach ($rows as $r) { + echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']); +} + +echo "\n--- Count empty piece values (ModelType Arbre) ---\n"; +$count = $conn->fetchOne("SELECT COUNT(*) FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id WHERE p.typepieceid = 'cmgujpyjf002q4705j6hv1nkk' AND (cfv.value = '' OR cfv.value IS NULL)"); +echo sprintf(" Empty values: %d\n", $count); + +echo "\n--- Count orphaned CustomField definitions ---\n"; +$count = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL'); +echo sprintf(" Orphaned CFs: %d\n", $count); diff --git a/scripts/fix-prod-all.php b/scripts/fix-prod-all.php new file mode 100644 index 0000000..9703e64 --- /dev/null +++ b/scripts/fix-prod-all.php @@ -0,0 +1,199 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n"; + +$migratedCount = 0; +$restoredCount = 0; +$deletedOrphanedCfv = 0; +$deletedOrphanedCf = 0; +$skippedCount = 0; + +// ============================================================ +// PART 1: Migrate orphaned CFValues to current CFs +// ============================================================ +echo "--- PART 1: Migrate orphaned CFValues ---\n\n"; + +$entityTypes = [ + ['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfModelTypeFk' => 'typepieceid'], + ['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfModelTypeFk' => 'typecomposantid'], + ['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfModelTypeFk' => 'typeproductid'], +]; + +foreach ($entityTypes as $et) { + // Find orphaned CFValues: the CF has ALL 3 FKs NULL + $orphanedValues = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id, + cf_old.id as old_cf_id, cf_old.name as field_name, + e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id + FROM custom_field_values cfv + JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid + JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']} + WHERE cfv.{$et['cfvFk']} IS NOT NULL + AND cf_old.typecomposantid IS NULL + AND cf_old.typepieceid IS NULL + AND cf_old.typeproductid IS NULL + ORDER BY e.name, cf_old.name + "); + + echo sprintf(" %ss: %d orphaned values\n", $et['label'], count($orphanedValues)); + + foreach ($orphanedValues as $ov) { + if (!$ov['model_type_id']) { + ++$skippedCount; + continue; + } + + $currentCf = $conn->fetchAssociative( + "SELECT id FROM custom_fields WHERE {$et['cfModelTypeFk']} = ? AND name = ? LIMIT 1", + [$ov['model_type_id'], $ov['field_name']] + ); + + if (!$currentCf) { + // No matching CF on current ModelType — skip but keep value + ++$skippedCount; + continue; + } + + $existingValue = $conn->fetchAssociative( + "SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?", + [$ov['entity_id'], $currentCf['id']] + ); + + if ($existingValue) { + if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) { + echo sprintf(" MIGRATE: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']); + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]); + } + ++$migratedCount; + } + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]); + } + ++$deletedOrphanedCfv; + } else { + echo sprintf(" REASSIGN: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']); + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]); + } + ++$migratedCount; + } + } +} + +// ============================================================ +// PART 2: Restore composant values from audit logs +// ============================================================ +echo "\n--- PART 2: Restore values from audit logs ---\n\n"; + +$deletionLogs = $conn->fetchAllAssociative(" + SELECT al.entityid, al.entitytype, al.diff::text as diff + FROM audit_logs al + WHERE al.diff::text LIKE '%customField%' + AND al.diff::text LIKE '%\"to\":null%' + ORDER BY al.createdat DESC +"); + +echo sprintf(" Found %d audit entries with deleted values\n", count($deletionLogs)); + +foreach ($deletionLogs as $log) { + $diff = json_decode($log['diff'], true); + $entityType = $log['entitytype']; + + $cfvFk = match ($entityType) { + 'piece' => 'pieceid', + 'composant' => 'composantid', + 'product' => 'productid', + default => null, + }; + if (!$cfvFk) { + continue; + } + + foreach ($diff as $key => $change) { + if (!str_starts_with($key, 'customField:')) { + continue; + } + if (null !== $change['to']) { + continue; + } + $oldValue = $change['from']; + if (null === $oldValue || '' === $oldValue) { + continue; + } + + $fieldName = substr($key, strlen('customField:')); + + $cfv = $conn->fetchAssociative( + "SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?", + [$log['entityid'], $fieldName] + ); + + if (!$cfv) { + continue; + } + + if ('' !== $cfv['value'] && null !== $cfv['value']) { + continue; + } + + echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $entityType, $log['entityid'], $fieldName, $oldValue); + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$oldValue, $cfv['id']]); + } + ++$restoredCount; + } +} + +// ============================================================ +// PART 3: Clean orphaned CF definitions +// ============================================================ +echo "\n--- PART 3: Clean orphaned CF definitions ---\n\n"; + +$orphanedCfs = $conn->fetchAllAssociative(' + SELECT cf.id FROM custom_fields cf + WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL + AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id) +'); + +echo sprintf(" %d orphaned CF definitions to delete\n", count($orphanedCfs)); +foreach ($orphanedCfs as $cf) { + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]); + } + ++$deletedOrphanedCf; +} + +echo sprintf("\n=== SUMMARY ===\n"); +echo sprintf("Values migrated/reassigned: %d\n", $migratedCount); +echo sprintf("Values restored from audit: %d\n", $restoredCount); +echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv); +echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedOrphanedCf); +echo sprintf("Skipped (no matching CF on ModelType): %d\n", $skippedCount); +echo "=== DONE ===\n"; diff --git a/scripts/fix-prod-recreate-and-migrate.php b/scripts/fix-prod-recreate-and-migrate.php new file mode 100644 index 0000000..5fd3a61 --- /dev/null +++ b/scripts/fix-prod-recreate-and-migrate.php @@ -0,0 +1,266 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n"; + +$createdCfCount = 0; +$migratedCount = 0; +$restoredCount = 0; +$deletedOrphanedCfv = 0; +$deletedOrphanedCf = 0; + +$entityTypes = [ + ['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfFk' => 'typepieceid'], + ['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfFk' => 'typecomposantid'], + ['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfFk' => 'typeproductid'], +]; + +// ============================================================ +// PART 1: Re-create missing CF definitions on ModelTypes +// ============================================================ +echo "--- PART 1: Re-create missing CF definitions ---\n\n"; + +foreach ($entityTypes as $et) { + // Find distinct (ModelType, field name, type) from orphaned CFs that have values + $missingDefs = $conn->fetchAllAssociative(" + SELECT e.{$et['modelTypeFk']} as model_type_id, + mt.name as model_type_name, + cf_orphan.name as field_name, + MIN(cf_orphan.type) as field_type, + BOOL_OR(COALESCE(cf_orphan.required, false)) as field_required, + MIN(cf_orphan.options::text) as field_options, + MIN(cf_orphan.defaultvalue) as field_default + FROM custom_field_values cfv + JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid + JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']} + JOIN model_types mt ON mt.id = e.{$et['modelTypeFk']} + WHERE cfv.{$et['cfvFk']} IS NOT NULL + AND cf_orphan.typecomposantid IS NULL + AND cf_orphan.typepieceid IS NULL + AND cf_orphan.typeproductid IS NULL + GROUP BY e.{$et['modelTypeFk']}, mt.name, cf_orphan.name + ORDER BY mt.name, cf_orphan.name + "); + + foreach ($missingDefs as $def) { + // Check if this CF already exists on the ModelType + $exists = $conn->fetchOne( + "SELECT COUNT(*) FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ?", + [$def['model_type_id'], $def['field_name']] + ); + + if ($exists > 0) { + continue; + } + + // Get next orderIndex + $maxOrder = $conn->fetchOne( + "SELECT COALESCE(MAX(orderindex), -1) FROM custom_fields WHERE {$et['cfFk']} = ?", + [$def['model_type_id']] + ); + $nextOrder = ((int) $maxOrder) + 1; + + // Generate CUID-like ID + $newId = 'cl' . bin2hex(random_bytes(12)); + + echo sprintf(" CREATE CF: ModelType '%s' (%s) + field '%s' (type=%s)\n", + $def['model_type_name'], $et['label'], $def['field_name'], $def['field_type']); + + if (!$dryRun) { + $options = $def['field_options']; + if (null !== $options && 'null' === $options) { + $options = null; + } + $required = !empty($def['field_required']) && 'f' !== $def['field_required']; + $conn->executeStatement( + "INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']}) + VALUES (?, ?, ?, ?::boolean, ?::json, ?, ?, ?)", + [ + $newId, + $def['field_name'], + $def['field_type'], + $required ? 'true' : 'false', + $options, + $def['field_default'], + $nextOrder, + $def['model_type_id'], + ] + ); + } + ++$createdCfCount; + } +} + +echo sprintf("\n Created %d CF definitions\n\n", $createdCfCount); + +// ============================================================ +// PART 2: Migrate orphaned CFValues to current CFs +// ============================================================ +echo "--- PART 2: Migrate orphaned CFValues ---\n\n"; + +foreach ($entityTypes as $et) { + $orphanedValues = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id, + cf_old.name as field_name, + e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id + FROM custom_field_values cfv + JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid + JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']} + WHERE cfv.{$et['cfvFk']} IS NOT NULL + AND cf_old.typecomposantid IS NULL + AND cf_old.typepieceid IS NULL + AND cf_old.typeproductid IS NULL + ORDER BY e.name, cf_old.name + "); + + $migrated = 0; + $cleaned = 0; + + foreach ($orphanedValues as $ov) { + if (!$ov['model_type_id']) { + continue; + } + + $currentCf = $conn->fetchAssociative( + "SELECT id FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ? LIMIT 1", + [$ov['model_type_id'], $ov['field_name']] + ); + + if (!$currentCf) { + continue; + } + + $existingValue = $conn->fetchAssociative( + "SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?", + [$ov['entity_id'], $currentCf['id']] + ); + + if ($existingValue) { + if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) { + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]); + } + ++$migrated; + } + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]); + } + ++$cleaned; + } else { + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]); + } + ++$migrated; + } + } + + echo sprintf(" %ss: %d migrated, %d cleaned\n", $et['label'], $migrated, $cleaned); + $migratedCount += $migrated; + $deletedOrphanedCfv += $cleaned; +} + +// ============================================================ +// PART 3: Restore values from audit logs +// ============================================================ +echo "\n--- PART 3: Restore values from audit logs ---\n\n"; + +$deletionLogs = $conn->fetchAllAssociative(" + SELECT al.entityid, al.entitytype, al.diff::text as diff + FROM audit_logs al + WHERE al.diff::text LIKE '%customField%' + AND al.diff::text LIKE '%\"to\":null%' + ORDER BY al.createdat DESC +"); + +foreach ($deletionLogs as $log) { + $diff = json_decode($log['diff'], true); + $cfvFk = match ($log['entitytype']) { + 'piece' => 'pieceid', + 'composant' => 'composantid', + 'product' => 'productid', + default => null, + }; + if (!$cfvFk) { + continue; + } + + foreach ($diff as $key => $change) { + if (!str_starts_with($key, 'customField:')) { + continue; + } + if (null !== $change['to'] || null === $change['from'] || '' === $change['from']) { + continue; + } + + $fieldName = substr($key, strlen('customField:')); + $cfv = $conn->fetchAssociative( + "SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?", + [$log['entityid'], $fieldName] + ); + + if (!$cfv || ('' !== $cfv['value'] && null !== $cfv['value'])) { + continue; + } + + echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $log['entitytype'], $log['entityid'], $fieldName, $change['from']); + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$change['from'], $cfv['id']]); + } + ++$restoredCount; + } +} + +echo sprintf(" Restored: %d\n", $restoredCount); + +// ============================================================ +// PART 4: Clean orphaned CFs with no values +// ============================================================ +echo "\n--- PART 4: Clean orphaned CF definitions ---\n\n"; + +$orphanedCfs = $conn->fetchAllAssociative(' + SELECT id FROM custom_fields + WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL + AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = id) +'); + +echo sprintf(" %d orphaned CFs to delete\n", count($orphanedCfs)); +foreach ($orphanedCfs as $cf) { + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]); + } + ++$deletedOrphanedCf; +} + +echo sprintf("\n=== SUMMARY ===\n"); +echo sprintf("CF definitions re-created: %d\n", $createdCfCount); +echo sprintf("Values migrated: %d\n", $migratedCount); +echo sprintf("Values restored from audit: %d\n", $restoredCount); +echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv); +echo sprintf("Orphaned CFs deleted: %d\n", $deletedOrphanedCf); +echo "=== DONE ===\n"; diff --git a/scripts/migrate-orphaned-custom-fields.php b/scripts/migrate-orphaned-custom-fields.php new file mode 100644 index 0000000..50f3496 --- /dev/null +++ b/scripts/migrate-orphaned-custom-fields.php @@ -0,0 +1,233 @@ + [ + 'driver' => 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => getenv('DB_NAME') ?: 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', + ], + default => [ + 'driver' => 'pdo_pgsql', + 'host' => 'db', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'root', + 'password' => 'root', + ], +}); + +echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n"; + +$migratedCount = 0; +$deletedCfCount = 0; +$skippedCount = 0; +$conflictCount = 0; + +// Process each entity type +$entityTypes = [ + [ + 'label' => 'composant', + 'entityTable' => 'composants', + 'cfvFk' => 'composantid', + 'modelTypeFk' => 'typecomposantid', + 'cfModelTypeFk' => 'typecomposantid', + ], + [ + 'label' => 'piece', + 'entityTable' => 'pieces', + 'cfvFk' => 'pieceid', + 'modelTypeFk' => 'typepieceid', + 'cfModelTypeFk' => 'typepieceid', + ], + [ + 'label' => 'product', + 'entityTable' => 'products', + 'cfvFk' => 'productid', + 'modelTypeFk' => 'typeproductid', + 'cfModelTypeFk' => 'typeproductid', + ], +]; + +foreach ($entityTypes as $et) { + echo sprintf("--- Processing %ss ---\n\n", $et['label']); + + // Find all CFValues pointing to orphaned CFs for this entity type + $orphanedValues = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id, + cf_old.id as old_cf_id, cf_old.name as field_name, + e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id + FROM custom_field_values cfv + JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid + JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']} + WHERE cfv.{$et['cfvFk']} IS NOT NULL + AND cf_old.{$et['cfModelTypeFk']} IS NULL + AND cf_old.typecomposantid IS NULL + AND cf_old.typepieceid IS NULL + AND cf_old.typeproductid IS NULL + ORDER BY e.name, cf_old.name + "); + + echo sprintf(" Found %d orphaned custom field values.\n", count($orphanedValues)); + + foreach ($orphanedValues as $ov) { + if (!$ov['model_type_id']) { + echo sprintf(" SKIP: %s '%s' has no ModelType\n", $et['label'], $ov['entity_name']); + ++$skippedCount; + + continue; + } + + // Find the current CF definition on the ModelType with the same name + $currentCf = $conn->fetchAssociative(" + SELECT id FROM custom_fields + WHERE {$et['cfModelTypeFk']} = ? AND name = ? + LIMIT 1 + ", [$ov['model_type_id'], $ov['field_name']]); + + if (!$currentCf) { + echo sprintf( + " WARNING: No current CF '%s' on ModelType %s for %s '%s'\n", + $ov['field_name'], + $ov['model_type_id'], + $et['label'], + $ov['entity_name'] + ); + ++$skippedCount; + + continue; + } + + // Check if entity already has a CFValue for this current CF + $existingValue = $conn->fetchAssociative(" + SELECT id, value FROM custom_field_values + WHERE {$et['cfvFk']} = ? AND customfieldid = ? + ", [$ov['entity_id'], $currentCf['id']]); + + if ($existingValue) { + // Current CF already has a value for this entity + if ('' !== $existingValue['value'] && null !== $existingValue['value']) { + // Both have values — conflict, skip + if ('' !== $ov['value'] && null !== $ov['value'] && $ov['value'] !== $existingValue['value']) { + echo sprintf( + " CONFLICT: %s '%s' field '%s': old='%s' vs current='%s' — keeping current\n", + $et['label'], + $ov['entity_name'], + $ov['field_name'], + $ov['value'], + $existingValue['value'] + ); + ++$conflictCount; + } + // Delete the orphaned value (current one is kept) + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]); + } + echo sprintf( + " DELETE orphaned: %s '%s' field '%s' (current has value '%s')\n", + $et['label'], + $ov['entity_name'], + $ov['field_name'], + $existingValue['value'] + ); + } else { + // Current value is empty, orphaned has data — update the current one and delete orphaned + if ('' !== $ov['value'] && null !== $ov['value']) { + echo sprintf( + " MIGRATE: %s '%s' field '%s' = '%s'\n", + $et['label'], + $ov['entity_name'], + $ov['field_name'], + $ov['value'] + ); + if (!$dryRun) { + $conn->executeStatement( + 'UPDATE custom_field_values SET value = ? WHERE id = ?', + [$ov['value'], $existingValue['id']] + ); + } + ++$migratedCount; + } + // Delete the orphaned CFV + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]); + } + } + } else { + // No current CFV exists — reassign the orphaned one to the current CF + echo sprintf( + " REASSIGN: %s '%s' field '%s' = '%s'\n", + $et['label'], + $ov['entity_name'], + $ov['field_name'], + $ov['value'] + ); + if (!$dryRun) { + $conn->executeStatement( + 'UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', + [$currentCf['id'], $ov['cfv_id']] + ); + } + ++$migratedCount; + } + } + + echo "\n"; +} + +// Clean up orphaned CF definitions with no remaining values +echo "--- Cleaning up orphaned CustomField definitions ---\n\n"; + +$orphanedCfs = $conn->fetchAllAssociative(' + SELECT cf.id, cf.name + FROM custom_fields cf + WHERE cf.typecomposantid IS NULL + AND cf.typepieceid IS NULL + AND cf.typeproductid IS NULL + AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id) + ORDER BY cf.name +'); + +echo sprintf("Found %d orphaned CustomField definitions with no values.\n", count($orphanedCfs)); + +foreach ($orphanedCfs as $cf) { + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]); + } + ++$deletedCfCount; +} + +echo sprintf("\n=== SUMMARY ===\n"); +echo sprintf("Values migrated/reassigned: %d\n", $migratedCount); +echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedCfCount); +echo sprintf("Skipped: %d\n", $skippedCount); +echo sprintf("Conflicts (kept current): %d\n", $conflictCount); +echo "=== DONE ===\n"; diff --git a/scripts/restore-custom-field-values.php b/scripts/restore-custom-field-values.php new file mode 100644 index 0000000..c25d25d --- /dev/null +++ b/scripts/restore-custom-field-values.php @@ -0,0 +1,196 @@ + [ + 'driver' => 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => getenv('DB_NAME') ?: 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', + ], + default => [ + 'driver' => 'pdo_pgsql', + 'host' => 'db', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'root', + 'password' => 'root', + ], +}); + +echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n"; + +// ============================================================ +// PART 1: Restore piece custom field values from audit logs +// ============================================================ +echo "--- PART 1: Restoring piece custom field values ---\n\n"; + +// Find all deletion audit entries (where values went from X to null on 2026-03-13) +$deletionLogs = $conn->fetchAllAssociative(" + SELECT al.entityid, al.diff::text as diff, p.name as piece_name + FROM audit_logs al + JOIN pieces p ON p.id = al.entityid + WHERE al.entitytype = 'piece' + AND al.action = 'update' + AND al.diff::text LIKE '%\"to\":null%' + AND al.diff::text LIKE '%customField%' + AND al.createdat >= '2026-03-13' + ORDER BY p.name +"); + +echo sprintf("Found %d pieces with deleted custom field values.\n\n", count($deletionLogs)); + +$restoredCount = 0; +$errorCount = 0; + +foreach ($deletionLogs as $log) { + $pieceId = $log['entityid']; + $pieceName = $log['piece_name']; + $diff = json_decode($log['diff'], true); + + foreach ($diff as $key => $change) { + if (!str_starts_with($key, 'customField:')) { + continue; + } + if (null !== $change['to']) { + continue; // Not a deletion + } + $oldValue = $change['from']; + if (null === $oldValue || '' === $oldValue) { + continue; // Nothing to restore + } + + $fieldName = substr($key, strlen('customField:')); + + // Find the current CustomFieldValue for this piece + field name + $cfv = $conn->fetchAssociative(' + SELECT cfv.id, cfv.value, cf.name as field_name + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + WHERE cfv.pieceid = ? + AND cf.name = ? + ', [$pieceId, $fieldName]); + + if (!$cfv) { + echo sprintf(" WARNING: No CustomFieldValue found for piece '%s' field '%s' — skipping\n", $pieceName, $fieldName); + ++$errorCount; + + continue; + } + + if ('' !== $cfv['value'] && null !== $cfv['value']) { + echo sprintf(" SKIP: Piece '%s' field '%s' already has value '%s' (would restore '%s')\n", $pieceName, $fieldName, $cfv['value'], $oldValue); + + continue; + } + + echo sprintf(" RESTORE: Piece '%s' field '%s' = '%s'\n", $pieceName, $fieldName, $oldValue); + + if (!$dryRun) { + $conn->executeStatement( + 'UPDATE custom_field_values SET value = ? WHERE id = ?', + [$oldValue, $cfv['id']] + ); + } + ++$restoredCount; + } +} + +echo sprintf("\nPieces: %d values restored, %d errors.\n\n", $restoredCount, $errorCount); + +// ============================================================ +// PART 2: Remove duplicate empty composant CustomFieldValues +// ============================================================ +echo "--- PART 2: Cleaning duplicate composant custom field values ---\n\n"; + +// Find composants that have duplicate CFVs (same composantid + same field name, one with value and one empty) +$duplicates = $conn->fetchAllAssociative(" + SELECT cfv_empty.id as empty_cfv_id, c.name as composant_name, cf_empty.name as field_name, + cfv_filled.value as existing_value + FROM custom_field_values cfv_empty + JOIN custom_fields cf_empty ON cf_empty.id = cfv_empty.customfieldid + JOIN composants c ON c.id = cfv_empty.composantid + JOIN custom_field_values cfv_filled ON cfv_filled.composantid = cfv_empty.composantid + JOIN custom_fields cf_filled ON cf_filled.id = cfv_filled.customfieldid + WHERE cfv_empty.composantid IS NOT NULL + AND cfv_empty.value = '' + AND cf_empty.name = cf_filled.name + AND cfv_filled.value != '' + AND cfv_filled.id != cfv_empty.id + ORDER BY c.name, cf_empty.name +"); + +echo sprintf("Found %d duplicate empty custom field values on composants.\n\n", count($duplicates)); + +$deletedDuplicates = 0; +foreach ($duplicates as $dup) { + echo sprintf( + " DELETE empty duplicate: Composant '%s' field '%s' (has value '%s' in other record)\n", + $dup['composant_name'], + $dup['field_name'], + $dup['existing_value'] + ); + + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['empty_cfv_id']]); + } + ++$deletedDuplicates; +} + +// Also find composants with duplicate empty CFVs (both empty, same field name - keep one, delete the other) +$emptyDuplicates = $conn->fetchAllAssociative(" + SELECT cfv2.id as duplicate_id, c.name as composant_name, cf2.name as field_name + FROM custom_field_values cfv1 + JOIN custom_fields cf1 ON cf1.id = cfv1.customfieldid + JOIN custom_field_values cfv2 ON cfv2.composantid = cfv1.composantid AND cfv2.id > cfv1.id + JOIN custom_fields cf2 ON cf2.id = cfv2.customfieldid + JOIN composants c ON c.id = cfv1.composantid + WHERE cfv1.composantid IS NOT NULL + AND cfv1.value = '' + AND cfv2.value = '' + AND cf1.name = cf2.name + ORDER BY c.name, cf2.name +"); + +echo sprintf("\nFound %d duplicate empty-empty custom field values on composants.\n\n", count($emptyDuplicates)); + +foreach ($emptyDuplicates as $dup) { + echo sprintf( + " DELETE empty-empty duplicate: Composant '%s' field '%s'\n", + $dup['composant_name'], + $dup['field_name'] + ); + + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['duplicate_id']]); + } + ++$deletedDuplicates; +} + +echo sprintf("\nComposants: %d duplicate values removed.\n", $deletedDuplicates); + +echo "\n=== DONE ===\n"; diff --git a/scripts/verify-prod-health.php b/scripts/verify-prod-health.php new file mode 100644 index 0000000..c7dfe67 --- /dev/null +++ b/scripts/verify-prod-health.php @@ -0,0 +1,95 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo "=== CUSTOM FIELDS HEALTH CHECK ===\n\n"; + +// 1. Orphaned CFs (should be 0) +$orphanedCfs = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL'); +echo sprintf("1. Orphaned CF definitions: %d %s\n", $orphanedCfs, 0 == $orphanedCfs ? '[OK]' : '[PROBLEM]'); + +// 2. Orphaned CFValues (pointing to orphaned CFs) +$orphanedCfvs = $conn->fetchOne(' + SELECT COUNT(*) FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL +'); +echo sprintf("2. Orphaned CF values: %d %s\n", $orphanedCfvs, 0 == $orphanedCfvs ? '[OK]' : '[PROBLEM]'); + +// 3. Duplicate CFValues (same entity + same field name) +$duplicatePieces = $conn->fetchOne(" + SELECT COUNT(*) FROM ( + SELECT cfv.pieceid, cf.name, COUNT(*) as cnt + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + WHERE cfv.pieceid IS NOT NULL + GROUP BY cfv.pieceid, cf.name + HAVING COUNT(*) > 1 + ) t +"); +$duplicateComposants = $conn->fetchOne(" + SELECT COUNT(*) FROM ( + SELECT cfv.composantid, cf.name, COUNT(*) as cnt + FROM custom_field_values cfv + JOIN custom_fields cf ON cf.id = cfv.customfieldid + WHERE cfv.composantid IS NOT NULL + GROUP BY cfv.composantid, cf.name + HAVING COUNT(*) > 1 + ) t +"); +$totalDuplicates = $duplicatePieces + $duplicateComposants; +echo sprintf("3. Duplicate CF values: %d %s\n", $totalDuplicates, 0 == $totalDuplicates ? '[OK]' : '[PROBLEM]'); + +// 4. Spot check known pieces +echo "\n--- Spot checks ---\n"; + +$checks = [ + ['Arbre du palier pied E1', 'cl3d978dd4b071daff8fb185f7', 'pieceid', 'diamètre', '50'], + ['Arbre du palier tête E1', 'cmkr0qjw5004s1eq6pen63x7j', 'pieceid', 'diamètre', '70'], + ['Cage écureuil pied E1', 'clbe710810fd7ccd09811957b3', 'composantid', 'Diamètre', ''], +]; + +foreach ($checks as [$name, $id, $fk, $fieldName, $expectedValue]) { + $row = $conn->fetchAssociative( + "SELECT cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$fk} = ? AND cf.name = ?", + [$id, $fieldName] + ); + $value = $row ? $row['value'] : '(NOT FOUND)'; + $ok = '' === $expectedValue ? ('' !== $value && null !== $value) : ($value === $expectedValue); + echo sprintf(" %s → %s = '%s' %s\n", $name, $fieldName, $value, $ok ? '[OK]' : '[CHECK]'); +} + +// 5. Summary of empty vs filled values +echo "\n--- Value fill rates ---\n"; +$stats = $conn->fetchAllAssociative(" + SELECT + CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece' + WHEN cfv.composantid IS NOT NULL THEN 'composant' + WHEN cfv.productid IS NOT NULL THEN 'product' + ELSE 'unknown' END as entity_type, + COUNT(*) as total, + COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as filled, + COUNT(*) FILTER (WHERE cfv.value = '' OR cfv.value IS NULL) as empty + FROM custom_field_values cfv + GROUP BY entity_type + ORDER BY entity_type +"); +foreach ($stats as $s) { + $pct = $s['total'] > 0 ? round(100 * $s['filled'] / $s['total']) : 0; + echo sprintf(" %s: %d/%d filled (%d%%)\n", $s['entity_type'], $s['filled'], $s['total'], $pct); +} + +echo "\n=== DONE ===\n"; diff --git a/src/Service/SkeletonStructureService.php b/src/Service/SkeletonStructureService.php index c2970d4..cd9b8e9 100644 --- a/src/Service/SkeletonStructureService.php +++ b/src/Service/SkeletonStructureService.php @@ -192,10 +192,12 @@ class SkeletonStructureService ['orderIndex' => 'ASC'] ); - // Index existing by ID for matching - $existingById = []; + // Index existing by ID and by name for matching + $existingById = []; + $existingByName = []; foreach ($existingFields as $cf) { - $existingById[$cf->getId()] = $cf; + $existingById[$cf->getId()] = $cf; + $existingByName[$cf->getName()] = $cf; } $processedIds = []; @@ -204,11 +206,13 @@ class SkeletonStructureService // Normalize both formats to a common shape $normalized = $this->normalizeCustomFieldData($fieldData, $i); - // Try to match an existing field by ID + // Try to match an existing field by ID first, then by name as fallback $existingField = null; $fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null; if ($fieldId && isset($existingById[$fieldId])) { $existingField = $existingById[$fieldId]; + } elseif (isset($existingByName[$normalized['name']]) && !isset($processedIds[$existingByName[$normalized['name']]->getId()])) { + $existingField = $existingByName[$normalized['name']]; } if ($existingField) {