From d17e832d8d6b4d6a273d3d2a1672be812fd47499 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 17 Mar 2026 19:08:10 +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 scripts for prod: - restore-custom-field-values.php: restores piece values from audit logs - migrate-orphaned-custom-fields.php: migrates values from orphaned CFs to current ones Co-Authored-By: Claude Opus 4.6 (1M context) --- Inventory_frontend | 2 +- scripts/migrate-orphaned-custom-fields.php | 233 +++++++++++++++++++++ scripts/restore-custom-field-values.php | 196 +++++++++++++++++ src/Service/SkeletonStructureService.php | 12 +- 4 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 scripts/migrate-orphaned-custom-fields.php create mode 100644 scripts/restore-custom-field-values.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/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/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) {