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) <noreply@anthropic.com>
234 lines
8.3 KiB
PHP
234 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Migrate CustomFieldValues from orphaned CustomField definitions to current ones.
|
|
*
|
|
* When SkeletonStructureService::updateCustomFields() runs without IDs from the frontend,
|
|
* it deletes old CustomField definitions and creates new ones. But the FK on custom_fields
|
|
* is SET NULL (not CASCADE), so old CFs become orphaned (all type FKs = NULL) and their
|
|
* CFValues still exist but point to the wrong CF.
|
|
*
|
|
* This script:
|
|
* 1. For each entity (composant, piece, product) with CFValues pointing to orphaned CFs,
|
|
* find the matching current CF by name on the entity's ModelType
|
|
* 2. Reassign the CFValue to the current CF
|
|
* 3. Delete orphaned CF definitions that no longer have any values
|
|
*
|
|
* Usage: php scripts/migrate-orphaned-custom-fields.php [--dry-run]
|
|
*/
|
|
|
|
require_once __DIR__.'/../vendor/autoload.php';
|
|
|
|
use Doctrine\DBAL\DriverManager;
|
|
|
|
$dryRun = in_array('--dry-run', $argv, true);
|
|
|
|
$env = getenv('APP_ENV') ?: 'local';
|
|
$conn = DriverManager::getConnection(match ($env) {
|
|
'prod' => [
|
|
'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";
|