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) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-17 19:08:10 +01:00
parent add3a9a21f
commit d17e832d8d
4 changed files with 438 additions and 5 deletions

View File

@@ -0,0 +1,233 @@
<?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";

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* Script to restore custom field values lost during ModelType sync.
*
* Problem: SkeletonStructureService::updateCustomFields() deletes and recreates
* CustomField definitions when frontend doesn't send IDs. This cascades to
* deleting all CustomFieldValues.
*
* This script:
* 1. Pieces: Restores values from audit_logs (the "from" values in deletion diffs)
* 2. Composants: Removes duplicate empty CustomFieldValues created by sync
*
* Usage: php scripts/restore-custom-field-values.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";
// ============================================================
// 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";

View File

@@ -192,10 +192,12 @@ class SkeletonStructureService
['orderIndex' => 'ASC'] ['orderIndex' => 'ASC']
); );
// Index existing by ID for matching // Index existing by ID and by name for matching
$existingById = []; $existingById = [];
$existingByName = [];
foreach ($existingFields as $cf) { foreach ($existingFields as $cf) {
$existingById[$cf->getId()] = $cf; $existingById[$cf->getId()] = $cf;
$existingByName[$cf->getName()] = $cf;
} }
$processedIds = []; $processedIds = [];
@@ -204,11 +206,13 @@ class SkeletonStructureService
// Normalize both formats to a common shape // Normalize both formats to a common shape
$normalized = $this->normalizeCustomFieldData($fieldData, $i); $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; $existingField = null;
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null; $fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
if ($fieldId && isset($existingById[$fieldId])) { if ($fieldId && isset($existingById[$fieldId])) {
$existingField = $existingById[$fieldId]; $existingField = $existingById[$fieldId];
} elseif (isset($existingByName[$normalized['name']]) && !isset($processedIds[$existingByName[$normalized['name']]->getId()])) {
$existingField = $existingByName[$normalized['name']];
} }
if ($existingField) { if ($existingField) {