From 017f91b416e64b24fbca9313fe929d27c633b507 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 17 Mar 2026 19:53:29 +0100 Subject: [PATCH] =?UTF-8?q?chore=20:=20add=20full=20prod=20fix=20script=20?= =?UTF-8?q?=E2=80=94=20recreate=20CFs=20+=20migrate=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/fix-prod-recreate-and-migrate.php | 265 ++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 scripts/fix-prod-recreate-and-migrate.php diff --git a/scripts/fix-prod-recreate-and-migrate.php b/scripts/fix-prod-recreate-and-migrate.php new file mode 100644 index 0000000..04ee2df --- /dev/null +++ b/scripts/fix-prod-recreate-and-migrate.php @@ -0,0 +1,265 @@ + 'pdo_pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'inventory', + 'user' => 'ferme_user', + 'password' => 'fermerecette', +]); + +echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n"; + +$createdCfCount = 0; +$migratedCount = 0; +$restoredCount = 0; +$deletedOrphanedCfv = 0; +$deletedOrphanedCf = 0; + +$entityTypes = [ + ['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfFk' => 'typepieceid'], + ['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfFk' => 'typecomposantid'], + ['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfFk' => 'typeproductid'], +]; + +// ============================================================ +// PART 1: Re-create missing CF definitions on ModelTypes +// ============================================================ +echo "--- PART 1: Re-create missing CF definitions ---\n\n"; + +foreach ($entityTypes as $et) { + // Find distinct (ModelType, field name, type) from orphaned CFs that have values + $missingDefs = $conn->fetchAllAssociative(" + SELECT e.{$et['modelTypeFk']} as model_type_id, + mt.name as model_type_name, + cf_orphan.name as field_name, + MIN(cf_orphan.type) as field_type, + BOOL_OR(cf_orphan.required) as field_required, + MIN(cf_orphan.options::text) as field_options, + MIN(cf_orphan.defaultvalue) as field_default + FROM custom_field_values cfv + JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid + JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']} + JOIN model_types mt ON mt.id = e.{$et['modelTypeFk']} + WHERE cfv.{$et['cfvFk']} IS NOT NULL + AND cf_orphan.typecomposantid IS NULL + AND cf_orphan.typepieceid IS NULL + AND cf_orphan.typeproductid IS NULL + GROUP BY e.{$et['modelTypeFk']}, mt.name, cf_orphan.name + ORDER BY mt.name, cf_orphan.name + "); + + foreach ($missingDefs as $def) { + // Check if this CF already exists on the ModelType + $exists = $conn->fetchOne( + "SELECT COUNT(*) FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ?", + [$def['model_type_id'], $def['field_name']] + ); + + if ($exists > 0) { + continue; + } + + // Get next orderIndex + $maxOrder = $conn->fetchOne( + "SELECT COALESCE(MAX(orderindex), -1) FROM custom_fields WHERE {$et['cfFk']} = ?", + [$def['model_type_id']] + ); + $nextOrder = ((int) $maxOrder) + 1; + + // Generate CUID-like ID + $newId = 'cl' . bin2hex(random_bytes(12)); + + echo sprintf(" CREATE CF: ModelType '%s' (%s) + field '%s' (type=%s)\n", + $def['model_type_name'], $et['label'], $def['field_name'], $def['field_type']); + + if (!$dryRun) { + $options = $def['field_options']; + if (null !== $options && 'null' === $options) { + $options = null; + } + $conn->executeStatement( + "INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']}) + VALUES (?, ?, ?, ?, ?::json, ?, ?, ?)", + [ + $newId, + $def['field_name'], + $def['field_type'], + $def['field_required'] ?? false, + $options, + $def['field_default'], + $nextOrder, + $def['model_type_id'], + ] + ); + } + ++$createdCfCount; + } +} + +echo sprintf("\n Created %d CF definitions\n\n", $createdCfCount); + +// ============================================================ +// PART 2: Migrate orphaned CFValues to current CFs +// ============================================================ +echo "--- PART 2: Migrate orphaned CFValues ---\n\n"; + +foreach ($entityTypes as $et) { + $orphanedValues = $conn->fetchAllAssociative(" + SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_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 + "); + + $migrated = 0; + $cleaned = 0; + + foreach ($orphanedValues as $ov) { + if (!$ov['model_type_id']) { + continue; + } + + $currentCf = $conn->fetchAssociative( + "SELECT id FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ? LIMIT 1", + [$ov['model_type_id'], $ov['field_name']] + ); + + if (!$currentCf) { + 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']) { + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]); + } + ++$migrated; + } + if (!$dryRun) { + $conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]); + } + ++$cleaned; + } else { + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]); + } + ++$migrated; + } + } + + echo sprintf(" %ss: %d migrated, %d cleaned\n", $et['label'], $migrated, $cleaned); + $migratedCount += $migrated; + $deletedOrphanedCfv += $cleaned; +} + +// ============================================================ +// PART 3: Restore values from audit logs +// ============================================================ +echo "\n--- PART 3: 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 +"); + +foreach ($deletionLogs as $log) { + $diff = json_decode($log['diff'], true); + $cfvFk = match ($log['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'] || null === $change['from'] || '' === $change['from']) { + 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 || ('' !== $cfv['value'] && null !== $cfv['value'])) { + continue; + } + + echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $log['entitytype'], $log['entityid'], $fieldName, $change['from']); + if (!$dryRun) { + $conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$change['from'], $cfv['id']]); + } + ++$restoredCount; + } +} + +echo sprintf(" Restored: %d\n", $restoredCount); + +// ============================================================ +// PART 4: Clean orphaned CFs with no values +// ============================================================ +echo "\n--- PART 4: Clean orphaned CF definitions ---\n\n"; + +$orphanedCfs = $conn->fetchAllAssociative(' + SELECT id FROM custom_fields + WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL + AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = id) +'); + +echo sprintf(" %d orphaned CFs 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("CF definitions re-created: %d\n", $createdCfCount); +echo sprintf("Values migrated: %d\n", $migratedCount); +echo sprintf("Values restored from audit: %d\n", $restoredCount); +echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv); +echo sprintf("Orphaned CFs deleted: %d\n", $deletedOrphanedCf); +echo "=== DONE ===\n";