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>
This commit is contained in:
46
scripts/check-prod-audit-dates.php
Normal file
46
scripts/check-prod-audit-dates.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- Audit logs with customField deletions (to:null) ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT al.entityid, al.entitytype, al.diff::text as diff, al.createdat
|
||||
FROM audit_logs al
|
||||
WHERE al.diff::text LIKE '%customField%'
|
||||
AND al.diff::text LIKE '%\"to\":null%'
|
||||
ORDER BY al.createdat DESC
|
||||
LIMIT 20
|
||||
");
|
||||
echo sprintf("Found %d entries\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf("[%s] %s %s: %s\n", $r['createdat'], $r['entitytype'], $r['entityid'], substr($r['diff'], 0, 120));
|
||||
}
|
||||
|
||||
echo "\n--- Orphaned CFValues (pointing to CFs with no ModelType) ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT COUNT(*) as cnt,
|
||||
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||
ELSE 'unknown' END as entity_type
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||
GROUP BY entity_type
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: %d orphaned values\n", $r['entity_type'], $r['cnt']);
|
||||
}
|
||||
54
scripts/check-prod-missing-piece-cfs.php
Normal file
54
scripts/check-prod-missing-piece-cfs.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- ModelTypes with orphaned piece values (CFs lost) ---\n\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT mt.id, mt.name, mt.category,
|
||||
cf_orphan.name as lost_field,
|
||||
COUNT(*) as affected_pieces,
|
||||
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as with_data
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
|
||||
JOIN pieces p ON p.id = cfv.pieceid
|
||||
JOIN model_types mt ON mt.id = p.typepieceid
|
||||
WHERE cf_orphan.typecomposantid IS NULL
|
||||
AND cf_orphan.typepieceid IS NULL
|
||||
AND cf_orphan.typeproductid IS NULL
|
||||
GROUP BY mt.id, mt.name, mt.category, cf_orphan.name
|
||||
ORDER BY mt.name, cf_orphan.name
|
||||
");
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$status = $r['with_data'] > 0 ? 'HAS DATA' : 'empty';
|
||||
echo sprintf(" ModelType '%s' | field '%s' | %d pieces (%d with data) [%s]\n",
|
||||
$r['name'], $r['lost_field'], $r['affected_pieces'], $r['with_data'], $status);
|
||||
}
|
||||
|
||||
echo sprintf("\nTotal: %d ModelType/field combinations\n", count($rows));
|
||||
|
||||
// Check if these fields exist on the current ModelType
|
||||
echo "\n--- Current CFs on these ModelTypes ---\n\n";
|
||||
$mtIds = array_unique(array_column($rows, 'id'));
|
||||
foreach ($mtIds as $mtId) {
|
||||
$mtName = $conn->fetchOne("SELECT name FROM model_types WHERE id = ?", [$mtId]);
|
||||
$currentCfs = $conn->fetchAllAssociative(
|
||||
"SELECT name FROM custom_fields WHERE typepieceid = ? ORDER BY orderindex",
|
||||
[$mtId]
|
||||
);
|
||||
$cfNames = array_column($currentCfs, 'name');
|
||||
echo sprintf(" '%s': %s\n", $mtName, $cfNames ? implode(', ', $cfNames) : '(aucun CF)');
|
||||
}
|
||||
83
scripts/check-prod-orphaned-detail.php
Normal file
83
scripts/check-prod-orphaned-detail.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
// Show a sample of orphaned CFValues for pieces
|
||||
echo "--- Sample orphaned piece CFValues ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.pieceid,
|
||||
cf.id as cf_id, cf.name as cf_name,
|
||||
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||
p.name as piece_name, p.typepieceid as piece_modeltype
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
JOIN pieces p ON p.id = cfv.pieceid
|
||||
WHERE cfv.pieceid IS NOT NULL
|
||||
AND cf.typepieceid IS NULL
|
||||
ORDER BY p.name
|
||||
LIMIT 10
|
||||
");
|
||||
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" Piece '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||
$r['piece_name'], $r['cf_name'], $r['value'],
|
||||
$r['typecomposantid'] ?? 'NULL',
|
||||
$r['typepieceid'] ?? 'NULL',
|
||||
$r['typeproductid'] ?? 'NULL'
|
||||
);
|
||||
}
|
||||
|
||||
// Show a sample of orphaned CFValues for composants
|
||||
echo "\n--- Sample orphaned composant CFValues ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT cfv.id as cfv_id, cfv.value, cfv.composantid,
|
||||
cf.id as cf_id, cf.name as cf_name,
|
||||
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||
c.name as composant_name, c.typecomposantid as composant_modeltype
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
JOIN composants c ON c.id = cfv.composantid
|
||||
WHERE cfv.composantid IS NOT NULL
|
||||
AND cf.typecomposantid IS NULL
|
||||
ORDER BY c.name
|
||||
LIMIT 10
|
||||
");
|
||||
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" Composant '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||
$r['composant_name'], $r['cf_name'], $r['value'],
|
||||
$r['typecomposantid'] ?? 'NULL',
|
||||
$r['typepieceid'] ?? 'NULL',
|
||||
$r['typeproductid'] ?? 'NULL'
|
||||
);
|
||||
}
|
||||
|
||||
// Check: are there CFs with ONLY typepieceid NULL but other FKs set?
|
||||
echo "\n--- Orphaned CF FK patterns ---\n";
|
||||
$rows = $conn->fetchAllAssociative("
|
||||
SELECT
|
||||
CASE WHEN typecomposantid IS NULL THEN 'NULL' ELSE 'SET' END as composant_fk,
|
||||
CASE WHEN typepieceid IS NULL THEN 'NULL' ELSE 'SET' END as piece_fk,
|
||||
CASE WHEN typeproductid IS NULL THEN 'NULL' ELSE 'SET' END as product_fk,
|
||||
COUNT(*) as cnt
|
||||
FROM custom_fields
|
||||
GROUP BY composant_fk, piece_fk, product_fk
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" composant=%s piece=%s product=%s : %d CFs\n",
|
||||
$r['composant_fk'], $r['piece_fk'], $r['product_fk'], $r['cnt']);
|
||||
}
|
||||
36
scripts/check-prod-values.php
Normal file
36
scripts/check-prod-values.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "--- Piece 'Arbre du palier pied E1' ---\n";
|
||||
$rows = $conn->fetchAllAssociative("SELECT p.name, cfv.value, cf.name as field_name FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE p.id = 'cl3d978dd4b071daff8fb185f7' ORDER BY cf.orderindex");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||
}
|
||||
|
||||
echo "\n--- Composant 'Cage écureuil pied E8' ---\n";
|
||||
$rows = $conn->fetchAllAssociative("SELECT c.name, cfv.value, cf.name as field_name FROM composants c JOIN custom_field_values cfv ON cfv.composantid = c.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE c.id = 'cl5b5e336095de8d4ece81b2dc' ORDER BY cf.orderindex");
|
||||
foreach ($rows as $r) {
|
||||
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||
}
|
||||
|
||||
echo "\n--- Count empty piece values (ModelType Arbre) ---\n";
|
||||
$count = $conn->fetchOne("SELECT COUNT(*) FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id WHERE p.typepieceid = 'cmgujpyjf002q4705j6hv1nkk' AND (cfv.value = '' OR cfv.value IS NULL)");
|
||||
echo sprintf(" Empty values: %d\n", $count);
|
||||
|
||||
echo "\n--- Count orphaned CustomField definitions ---\n";
|
||||
$count = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||
echo sprintf(" Orphaned CFs: %d\n", $count);
|
||||
199
scripts/fix-prod-all.php
Normal file
199
scripts/fix-prod-all.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?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";
|
||||
266
scripts/fix-prod-recreate-and-migrate.php
Normal file
266
scripts/fix-prod-recreate-and-migrate.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Full prod fix:
|
||||
* 1. Re-create missing CustomField definitions on ModelTypes (from orphaned CFs that still have values)
|
||||
* 2. Migrate orphaned CFValues to the newly created CFs
|
||||
* 3. Restore deleted values from audit logs
|
||||
* 4. Clean up orphaned CFs with no remaining values
|
||||
*
|
||||
* Usage: php scripts/fix-prod-recreate-and-migrate.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";
|
||||
|
||||
$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(COALESCE(cf_orphan.required, false)) 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;
|
||||
}
|
||||
$required = !empty($def['field_required']) && 'f' !== $def['field_required'];
|
||||
$conn->executeStatement(
|
||||
"INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']})
|
||||
VALUES (?, ?, ?, ?::boolean, ?::json, ?, ?, ?)",
|
||||
[
|
||||
$newId,
|
||||
$def['field_name'],
|
||||
$def['field_type'],
|
||||
$required ? 'true' : '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";
|
||||
233
scripts/migrate-orphaned-custom-fields.php
Normal file
233
scripts/migrate-orphaned-custom-fields.php
Normal 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";
|
||||
196
scripts/restore-custom-field-values.php
Normal file
196
scripts/restore-custom-field-values.php
Normal 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";
|
||||
95
scripts/verify-prod-health.php
Normal file
95
scripts/verify-prod-health.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
|
||||
$conn = DriverManager::getConnection([
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => 'localhost',
|
||||
'port' => 5432,
|
||||
'dbname' => 'inventory',
|
||||
'user' => 'ferme_user',
|
||||
'password' => 'fermerecette',
|
||||
]);
|
||||
|
||||
echo "=== CUSTOM FIELDS HEALTH CHECK ===\n\n";
|
||||
|
||||
// 1. Orphaned CFs (should be 0)
|
||||
$orphanedCfs = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||
echo sprintf("1. Orphaned CF definitions: %d %s\n", $orphanedCfs, 0 == $orphanedCfs ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 2. Orphaned CFValues (pointing to orphaned CFs)
|
||||
$orphanedCfvs = $conn->fetchOne('
|
||||
SELECT COUNT(*) FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||
');
|
||||
echo sprintf("2. Orphaned CF values: %d %s\n", $orphanedCfvs, 0 == $orphanedCfvs ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 3. Duplicate CFValues (same entity + same field name)
|
||||
$duplicatePieces = $conn->fetchOne("
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT cfv.pieceid, cf.name, COUNT(*) as cnt
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cfv.pieceid IS NOT NULL
|
||||
GROUP BY cfv.pieceid, cf.name
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
");
|
||||
$duplicateComposants = $conn->fetchOne("
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT cfv.composantid, cf.name, COUNT(*) as cnt
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||
WHERE cfv.composantid IS NOT NULL
|
||||
GROUP BY cfv.composantid, cf.name
|
||||
HAVING COUNT(*) > 1
|
||||
) t
|
||||
");
|
||||
$totalDuplicates = $duplicatePieces + $duplicateComposants;
|
||||
echo sprintf("3. Duplicate CF values: %d %s\n", $totalDuplicates, 0 == $totalDuplicates ? '[OK]' : '[PROBLEM]');
|
||||
|
||||
// 4. Spot check known pieces
|
||||
echo "\n--- Spot checks ---\n";
|
||||
|
||||
$checks = [
|
||||
['Arbre du palier pied E1', 'cl3d978dd4b071daff8fb185f7', 'pieceid', 'diamètre', '50'],
|
||||
['Arbre du palier tête E1', 'cmkr0qjw5004s1eq6pen63x7j', 'pieceid', 'diamètre', '70'],
|
||||
['Cage écureuil pied E1', 'clbe710810fd7ccd09811957b3', 'composantid', 'Diamètre', ''],
|
||||
];
|
||||
|
||||
foreach ($checks as [$name, $id, $fk, $fieldName, $expectedValue]) {
|
||||
$row = $conn->fetchAssociative(
|
||||
"SELECT cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$fk} = ? AND cf.name = ?",
|
||||
[$id, $fieldName]
|
||||
);
|
||||
$value = $row ? $row['value'] : '(NOT FOUND)';
|
||||
$ok = '' === $expectedValue ? ('' !== $value && null !== $value) : ($value === $expectedValue);
|
||||
echo sprintf(" %s → %s = '%s' %s\n", $name, $fieldName, $value, $ok ? '[OK]' : '[CHECK]');
|
||||
}
|
||||
|
||||
// 5. Summary of empty vs filled values
|
||||
echo "\n--- Value fill rates ---\n";
|
||||
$stats = $conn->fetchAllAssociative("
|
||||
SELECT
|
||||
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||
ELSE 'unknown' END as entity_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as filled,
|
||||
COUNT(*) FILTER (WHERE cfv.value = '' OR cfv.value IS NULL) as empty
|
||||
FROM custom_field_values cfv
|
||||
GROUP BY entity_type
|
||||
ORDER BY entity_type
|
||||
");
|
||||
foreach ($stats as $s) {
|
||||
$pct = $s['total'] > 0 ? round(100 * $s['filled'] / $s['total']) : 0;
|
||||
echo sprintf(" %s: %d/%d filled (%d%%)\n", $s['entity_type'], $s['filled'], $s['total'], $pct);
|
||||
}
|
||||
|
||||
echo "\n=== DONE ===\n";
|
||||
Reference in New Issue
Block a user