Files
Inventory/scripts/fix-prod-all.php
Matthieu 38777b7de0 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) <noreply@anthropic.com>
2026-03-17 20:24:37 +01:00

200 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
/**
* Combined fix script for prod:
* 1. Migrate orphaned CFValues to current CFs (by name match)
* 2. Restore deleted composant values from audit logs
* 3. Clean up orphaned CF definitions
*
* Usage: php scripts/fix-prod-all.php [--dry-run]
*/
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$dryRun = in_array('--dry-run', $argv, true);
$conn = DriverManager::getConnection([
'driver' => '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";