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>
197 lines
6.8 KiB
PHP
197 lines
6.8 KiB
PHP
<?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";
|