fd279b6c8b
When importing entities with autoincrement (Case, Charge, Invoice), validate each column exists in the target table before inserting. Columns from modules not installed on the target are silently skipped and reported in the import log summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
6.4 KiB
PHP
206 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace Espo\Modules\DataMigration\Services;
|
|
|
|
use Espo\ORM\EntityManager;
|
|
use Espo\Core\Utils\Log;
|
|
use Espo\Core\Console\IO;
|
|
|
|
class ImportService
|
|
{
|
|
/**
|
|
* Import order: entities must be imported in dependency order.
|
|
*/
|
|
private const IMPORT_ORDER = [
|
|
'User',
|
|
'Team',
|
|
'FixedActivityRate',
|
|
'Account',
|
|
'Contact',
|
|
'PricingAgreement',
|
|
'PricingAgreementRate',
|
|
'ClientRate',
|
|
'Case',
|
|
'CaseActivity',
|
|
'Invoice',
|
|
'Document',
|
|
'NhDocument',
|
|
'NhMeeting',
|
|
'NhDecision',
|
|
'NhProcess',
|
|
'NhPlea',
|
|
'NhActivity',
|
|
'NhSyncLog',
|
|
'CaseMemory',
|
|
'SignatureRequest',
|
|
'Charge',
|
|
'SignatureRequestSigner',
|
|
'SmsLog',
|
|
'Note',
|
|
'Attachment',
|
|
];
|
|
|
|
public function __construct(
|
|
private EntityManager $entityManager,
|
|
private EntityImporter $entityImporter,
|
|
private JunctionTableHandler $junctionTableHandler,
|
|
private AttachmentHandler $attachmentHandler,
|
|
private ManifestBuilder $manifestBuilder,
|
|
private Log $log
|
|
) {}
|
|
|
|
/**
|
|
* Run full import from backup directory.
|
|
*
|
|
* @param string $inputDir Backup directory path
|
|
* @param array $options {batchSize: int, preserveIds: bool, skipDuplicates: bool, dryRun: bool, verbose: bool}
|
|
* @param IO $io
|
|
*/
|
|
public function import(string $inputDir, array $options, IO $io): void
|
|
{
|
|
$batchSize = $options['batchSize'] ?? 500;
|
|
$preserveIds = $options['preserveIds'] ?? true;
|
|
$skipDuplicates = $options['skipDuplicates'] ?? false;
|
|
$dryRun = $options['dryRun'] ?? false;
|
|
$noFiles = $options['noFiles'] ?? false;
|
|
$verbose = $options['verbose'] ?? false;
|
|
|
|
$dataDir = rtrim($inputDir, '/') . '/data';
|
|
$filesDir = rtrim($inputDir, '/') . '/files';
|
|
|
|
// Read and display manifest
|
|
$manifest = $this->manifestBuilder->read($inputDir);
|
|
|
|
if ($manifest === null) {
|
|
$io->writeLine('ERROR: No manifest.json found in backup directory.');
|
|
return;
|
|
}
|
|
|
|
$io->writeLine('=== Backup Manifest ===');
|
|
$io->writeLine("Source: " . ($manifest['sourceHost'] ?? 'unknown'));
|
|
$io->writeLine("Exported at: " . ($manifest['exportedAt'] ?? 'unknown'));
|
|
$io->writeLine("EspoCRM version: " . ($manifest['espoVersion'] ?? 'unknown'));
|
|
$io->writeLine('');
|
|
|
|
if ($dryRun) {
|
|
$io->writeLine('*** DRY RUN MODE - No changes will be made ***');
|
|
$io->writeLine('');
|
|
}
|
|
|
|
$idMapper = new IdMapper();
|
|
$totalImported = 0;
|
|
$totalSkipped = 0;
|
|
$totalErrors = 0;
|
|
$results = [];
|
|
|
|
// Import entities in order
|
|
foreach (self::IMPORT_ORDER as $entityType) {
|
|
$jsonFile = $dataDir . '/' . $entityType . '.json';
|
|
|
|
if (!file_exists($jsonFile)) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->entityTypeExists($entityType)) {
|
|
$io->writeLine("Skipping {$entityType} (module not installed)");
|
|
continue;
|
|
}
|
|
|
|
$io->writeLine("Importing {$entityType}...");
|
|
|
|
$result = $this->entityImporter->import(
|
|
$entityType,
|
|
$jsonFile,
|
|
$preserveIds,
|
|
$skipDuplicates,
|
|
$idMapper,
|
|
$batchSize,
|
|
$verbose,
|
|
$dryRun,
|
|
$io
|
|
);
|
|
|
|
$results[$entityType] = $result;
|
|
$totalImported += $result['imported'];
|
|
$totalSkipped += $result['skipped'];
|
|
$totalErrors += $result['errors'];
|
|
|
|
$io->writeLine(" {$entityType}: imported={$result['imported']}, skipped={$result['skipped']}, errors={$result['errors']}");
|
|
}
|
|
|
|
// Import junction tables
|
|
$io->writeLine('');
|
|
$io->writeLine('Importing junction tables...');
|
|
|
|
if (!$dryRun) {
|
|
$this->junctionTableHandler->importJunctions($dataDir, $idMapper, $preserveIds, $io);
|
|
} else {
|
|
$io->writeLine(' (skipped in dry-run mode)');
|
|
}
|
|
|
|
// Restore attachment files
|
|
if ($noFiles) {
|
|
$io->writeLine('');
|
|
$io->writeLine('Attachment files: SKIPPED (--no-files)');
|
|
} else if (is_dir($filesDir)) {
|
|
$io->writeLine('');
|
|
$io->writeLine('Restoring attachment files...');
|
|
|
|
if (!$dryRun) {
|
|
$this->attachmentHandler->importFiles($filesDir, $idMapper, $preserveIds, $verbose, $io);
|
|
} else {
|
|
$fileCount = count(array_diff(scandir($filesDir), ['.', '..']));
|
|
$io->writeLine(" Would restore {$fileCount} files (dry-run)");
|
|
}
|
|
}
|
|
|
|
// Save ID mapping for reference
|
|
if (!$dryRun && !$preserveIds) {
|
|
$mapPath = rtrim($inputDir, '/') . '/id-mapping.json';
|
|
$idMapper->saveTo($mapPath);
|
|
$io->writeLine('');
|
|
$io->writeLine("ID mapping saved to: {$mapPath}");
|
|
}
|
|
|
|
// Summary
|
|
$io->writeLine('');
|
|
$io->writeLine('=== Import ' . ($dryRun ? 'Dry Run ' : '') . 'Complete ===');
|
|
$io->writeLine("Total imported: {$totalImported}");
|
|
$io->writeLine("Total skipped: {$totalSkipped}");
|
|
$io->writeLine("Total errors: {$totalErrors}");
|
|
|
|
if ($totalErrors > 0) {
|
|
$io->writeLine('');
|
|
$io->writeLine('Entities with errors:');
|
|
|
|
foreach ($results as $type => $r) {
|
|
if ($r['errors'] > 0) {
|
|
$io->writeLine(" {$type}: {$r['errors']} errors");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report skipped columns (missing in target DB)
|
|
$skippedColumns = $this->entityImporter->getSkippedColumns();
|
|
|
|
if (!empty($skippedColumns)) {
|
|
$io->writeLine('');
|
|
$io->writeLine('WARNING: Skipped columns not found in target database:');
|
|
|
|
foreach ($skippedColumns as $type => $columns) {
|
|
$io->writeLine(" {$type}: " . implode(', ', $columns));
|
|
}
|
|
}
|
|
}
|
|
|
|
private function entityTypeExists(string $entityType): bool
|
|
{
|
|
try {
|
|
$this->entityManager->getRDBRepository($entityType);
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|