feat: initial DataMigration extension for case data backup/restore
Full case data migration tool with 3 console commands: - data-migration-export: exports all case-related entities (27 types) + files - data-migration-import: restores backup to target DB with dry-run support - data-migration-status: shows entity counts from live DB or backup Supports preserve-ids, skip-duplicates, batch processing, and attachment file copying. Imports with hooks disabled to prevent side effects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Build extension ZIP package
|
||||
set -euo pipefail
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||
|
||||
# Update version in README.md if it exists
|
||||
if [[ -f README.md ]]; then
|
||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||
fi
|
||||
echo "Building $ZIPNAME..."
|
||||
rm -f "$ZIPNAME"
|
||||
zip -r "$ZIPNAME" manifest.json files/ scripts/ \
|
||||
-x "*.DS_Store" "*__MACOSX*" "*.zip" 2>/dev/null || \
|
||||
zip -r "$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Commands;
|
||||
|
||||
use Espo\Core\Console\Command;
|
||||
use Espo\Core\Console\Command\Params;
|
||||
use Espo\Core\Console\IO;
|
||||
use Espo\Modules\DataMigration\Services\ExportService;
|
||||
|
||||
class DataMigrationExport implements Command
|
||||
{
|
||||
public function __construct(
|
||||
private ExportService $exportService
|
||||
) {}
|
||||
|
||||
public function run(Params $params, IO $io): void
|
||||
{
|
||||
$output = $params->getOption('output');
|
||||
|
||||
if (!$output) {
|
||||
$timestamp = date('Ymd-His');
|
||||
$output = 'data/backups/datamigration-' . $timestamp;
|
||||
}
|
||||
|
||||
$output = rtrim($output, '/');
|
||||
|
||||
if (is_dir($output)) {
|
||||
$io->writeLine("ERROR: Output directory already exists: {$output}");
|
||||
$io->writeLine("Please specify a new directory or remove the existing one.");
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = (int) ($params->getOption('batchSize') ?? 500);
|
||||
$verbose = $params->hasFlag('verbose');
|
||||
$noFiles = $params->hasFlag('noFiles');
|
||||
|
||||
$caseIds = null;
|
||||
$caseIdsStr = $params->getOption('caseIds');
|
||||
|
||||
if ($caseIdsStr) {
|
||||
$caseIds = array_map('trim', explode(',', $caseIdsStr));
|
||||
}
|
||||
|
||||
$caseStatus = null;
|
||||
$statusStr = $params->getOption('status');
|
||||
|
||||
if ($statusStr) {
|
||||
$caseStatus = array_map('trim', explode(',', $statusStr));
|
||||
}
|
||||
|
||||
$io->writeLine('EspoCRM Data Migration - Export');
|
||||
$io->writeLine('==============================');
|
||||
$io->writeLine("Output: {$output}");
|
||||
$io->writeLine("Batch size: {$batchSize}");
|
||||
|
||||
if ($caseIds) {
|
||||
$io->writeLine("Case filter: " . count($caseIds) . " specific IDs");
|
||||
}
|
||||
|
||||
if ($caseStatus) {
|
||||
$io->writeLine("Status filter: " . implode(', ', $caseStatus));
|
||||
}
|
||||
|
||||
if ($noFiles) {
|
||||
$io->writeLine("Files: SKIPPED (--no-files)");
|
||||
}
|
||||
|
||||
$io->writeLine('');
|
||||
|
||||
$this->exportService->export($output, [
|
||||
'batchSize' => $batchSize,
|
||||
'caseIds' => $caseIds,
|
||||
'caseStatus' => $caseStatus,
|
||||
'noFiles' => $noFiles,
|
||||
'verbose' => $verbose,
|
||||
], $io);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Commands;
|
||||
|
||||
use Espo\Core\Console\Command;
|
||||
use Espo\Core\Console\Command\Params;
|
||||
use Espo\Core\Console\IO;
|
||||
use Espo\Modules\DataMigration\Services\ImportService;
|
||||
|
||||
class DataMigrationImport implements Command
|
||||
{
|
||||
public function __construct(
|
||||
private ImportService $importService
|
||||
) {}
|
||||
|
||||
public function run(Params $params, IO $io): void
|
||||
{
|
||||
$input = $params->getOption('input');
|
||||
|
||||
if (!$input) {
|
||||
$io->writeLine('ERROR: --input option is required. Specify the backup directory path.');
|
||||
return;
|
||||
}
|
||||
|
||||
$input = rtrim($input, '/');
|
||||
|
||||
if (!is_dir($input)) {
|
||||
$io->writeLine("ERROR: Input directory not found: {$input}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file_exists($input . '/manifest.json')) {
|
||||
$io->writeLine("ERROR: No manifest.json found in: {$input}");
|
||||
$io->writeLine("This does not appear to be a valid backup directory.");
|
||||
return;
|
||||
}
|
||||
|
||||
$batchSize = (int) ($params->getOption('batchSize') ?? 500);
|
||||
$dryRun = $params->hasFlag('dryRun');
|
||||
$preserveIds = $params->hasFlag('preserveIds');
|
||||
$skipDuplicates = $params->hasFlag('skipDuplicates');
|
||||
$verbose = $params->hasFlag('verbose');
|
||||
|
||||
$io->writeLine('EspoCRM Data Migration - Import');
|
||||
$io->writeLine('==============================');
|
||||
$io->writeLine("Input: {$input}");
|
||||
$io->writeLine("Batch size: {$batchSize}");
|
||||
$io->writeLine("Preserve IDs: " . ($preserveIds ? 'YES' : 'NO'));
|
||||
$io->writeLine("Skip duplicates: " . ($skipDuplicates ? 'YES' : 'NO'));
|
||||
$io->writeLine("Dry run: " . ($dryRun ? 'YES' : 'NO'));
|
||||
$io->writeLine('');
|
||||
|
||||
$this->importService->import($input, [
|
||||
'batchSize' => $batchSize,
|
||||
'preserveIds' => $preserveIds,
|
||||
'skipDuplicates' => $skipDuplicates,
|
||||
'dryRun' => $dryRun,
|
||||
'verbose' => $verbose,
|
||||
], $io);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Commands;
|
||||
|
||||
use Espo\Core\Console\Command;
|
||||
use Espo\Core\Console\Command\Params;
|
||||
use Espo\Core\Console\IO;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Modules\DataMigration\Services\ManifestBuilder;
|
||||
|
||||
class DataMigrationStatus implements Command
|
||||
{
|
||||
private const ENTITY_TYPES = [
|
||||
'Case',
|
||||
'Account',
|
||||
'Contact',
|
||||
'CaseActivity',
|
||||
'Charge',
|
||||
'Invoice',
|
||||
'Document',
|
||||
'NhMeeting',
|
||||
'NhDecision',
|
||||
'NhProcess',
|
||||
'NhPlea',
|
||||
'NhActivity',
|
||||
'NhDocument',
|
||||
'NhSyncLog',
|
||||
'CaseMemory',
|
||||
'SignatureRequest',
|
||||
'SignatureRequestSigner',
|
||||
'SmsLog',
|
||||
'PricingAgreement',
|
||||
'PricingAgreementRate',
|
||||
'FixedActivityRate',
|
||||
'ClientRate',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private ManifestBuilder $manifestBuilder
|
||||
) {}
|
||||
|
||||
public function run(Params $params, IO $io): void
|
||||
{
|
||||
$inputDir = $params->getOption('input');
|
||||
|
||||
if ($inputDir) {
|
||||
$this->showBackupStatus($inputDir, $io);
|
||||
} else {
|
||||
$this->showLiveStatus($io);
|
||||
}
|
||||
}
|
||||
|
||||
private function showLiveStatus(IO $io): void
|
||||
{
|
||||
$io->writeLine('EspoCRM Data Migration - Live Database Status');
|
||||
$io->writeLine('=============================================');
|
||||
$io->writeLine('');
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach (self::ENTITY_TYPES as $entityType) {
|
||||
try {
|
||||
$count = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->count();
|
||||
|
||||
if ($count > 0) {
|
||||
$io->writeLine(sprintf(" %-30s %d", $entityType, $count));
|
||||
$total += $count;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Entity type doesn't exist (module not installed)
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeLine('');
|
||||
$io->writeLine("Total records: {$total}");
|
||||
|
||||
// Count attachments on disk
|
||||
$uploadDir = 'data/upload/';
|
||||
|
||||
if (is_dir($uploadDir)) {
|
||||
$fileCount = 0;
|
||||
$totalSize = 0;
|
||||
|
||||
$files = scandir($uploadDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $uploadDir . $file;
|
||||
|
||||
if (is_file($path)) {
|
||||
$fileCount++;
|
||||
$totalSize += filesize($path);
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeLine('');
|
||||
$io->writeLine("Attachment files: {$fileCount} (" . $this->formatBytes($totalSize) . ")");
|
||||
}
|
||||
}
|
||||
|
||||
private function showBackupStatus(string $inputDir, IO $io): void
|
||||
{
|
||||
$inputDir = rtrim($inputDir, '/');
|
||||
|
||||
$io->writeLine('EspoCRM Data Migration - Backup Status');
|
||||
$io->writeLine('======================================');
|
||||
$io->writeLine("Path: {$inputDir}");
|
||||
$io->writeLine('');
|
||||
|
||||
$manifest = $this->manifestBuilder->read($inputDir);
|
||||
|
||||
if ($manifest === null) {
|
||||
$io->writeLine('ERROR: No manifest.json found.');
|
||||
return;
|
||||
}
|
||||
|
||||
$io->writeLine("Source: " . ($manifest['sourceHost'] ?? 'unknown'));
|
||||
$io->writeLine("Exported at: " . ($manifest['exportedAt'] ?? 'unknown'));
|
||||
$io->writeLine("EspoCRM version: " . ($manifest['espoVersion'] ?? 'unknown'));
|
||||
$io->writeLine('');
|
||||
|
||||
if (!empty($manifest['modules'])) {
|
||||
$io->writeLine('Modules:');
|
||||
|
||||
foreach ($manifest['modules'] as $module => $version) {
|
||||
$io->writeLine(" {$module}: {$version}");
|
||||
}
|
||||
|
||||
$io->writeLine('');
|
||||
}
|
||||
|
||||
$io->writeLine('Entity counts:');
|
||||
|
||||
foreach ($manifest['entityCounts'] ?? [] as $type => $count) {
|
||||
if ($count > 0) {
|
||||
$io->writeLine(sprintf(" %-30s %d", $type, $count));
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeLine('');
|
||||
$io->writeLine("Attachment files: " . ($manifest['totalAttachmentFiles'] ?? 0));
|
||||
$io->writeLine("Attachment size: " . $this->formatBytes($manifest['totalAttachmentSizeBytes'] ?? 0));
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1073741824) {
|
||||
return round($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
}
|
||||
|
||||
if ($bytes >= 1024) {
|
||||
return round($bytes / 1024, 2) . ' KB';
|
||||
}
|
||||
|
||||
return $bytes . ' B';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"dataMigrationExport": {
|
||||
"className": "Espo\\Modules\\DataMigration\\Commands\\DataMigrationExport",
|
||||
"listed": true,
|
||||
"allowedOptions": ["output", "batchSize", "caseIds", "status"],
|
||||
"allowedFlags": ["dryRun", "noFiles", "verbose"]
|
||||
},
|
||||
"dataMigrationImport": {
|
||||
"className": "Espo\\Modules\\DataMigration\\Commands\\DataMigrationImport",
|
||||
"listed": true,
|
||||
"allowedOptions": ["input", "batchSize"],
|
||||
"allowedFlags": ["dryRun", "preserveIds", "skipDuplicates", "verbose"]
|
||||
},
|
||||
"dataMigrationStatus": {
|
||||
"className": "Espo\\Modules\\DataMigration\\Commands\\DataMigrationStatus",
|
||||
"listed": true,
|
||||
"allowedOptions": ["input"],
|
||||
"allowedFlags": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"order": 99,
|
||||
"version": "1.0.0",
|
||||
"requires": {
|
||||
"espocrm": ">=8.0.0"
|
||||
},
|
||||
"description": "Data migration tool for case data backup and restore"
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\Core\Console\IO;
|
||||
|
||||
class AttachmentHandler
|
||||
{
|
||||
private const UPLOAD_DIR = 'data/upload/';
|
||||
|
||||
/**
|
||||
* Export attachment files to the backup directory.
|
||||
*
|
||||
* @param string[] $attachmentIds
|
||||
* @param string $outputDir Backup files/ directory
|
||||
* @param bool $verbose
|
||||
* @param IO|null $io
|
||||
* @return array{count: int, size: int} Number of files copied and total bytes
|
||||
*/
|
||||
public function exportFiles(
|
||||
array $attachmentIds,
|
||||
string $outputDir,
|
||||
bool $verbose = false,
|
||||
?IO $io = null
|
||||
): array {
|
||||
$filesDir = rtrim($outputDir, '/');
|
||||
|
||||
if (!is_dir($filesDir)) {
|
||||
mkdir($filesDir, 0755, true);
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$totalSize = 0;
|
||||
$missing = 0;
|
||||
|
||||
foreach ($attachmentIds as $id) {
|
||||
$sourcePath = self::UPLOAD_DIR . $id;
|
||||
|
||||
if (!file_exists($sourcePath)) {
|
||||
$missing++;
|
||||
|
||||
if ($verbose && $io) {
|
||||
$io->writeLine(" WARNING: Attachment file not found: {$id}");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$destPath = $filesDir . '/' . $id;
|
||||
copy($sourcePath, $destPath);
|
||||
|
||||
$size = filesize($sourcePath);
|
||||
$totalSize += $size;
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($io) {
|
||||
$io->writeLine(" Attachments: copied {$count} files (" . $this->formatBytes($totalSize) . ")");
|
||||
|
||||
if ($missing > 0) {
|
||||
$io->writeLine(" WARNING: {$missing} attachment files not found on disk");
|
||||
}
|
||||
}
|
||||
|
||||
return ['count' => $count, 'size' => $totalSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import attachment files from backup to data/upload/.
|
||||
*
|
||||
* @param string $inputDir Backup files/ directory
|
||||
* @param IdMapper $idMapper
|
||||
* @param bool $preserveIds
|
||||
* @param bool $verbose
|
||||
* @param IO|null $io
|
||||
* @return int Number of files restored
|
||||
*/
|
||||
public function importFiles(
|
||||
string $inputDir,
|
||||
IdMapper $idMapper,
|
||||
bool $preserveIds = true,
|
||||
bool $verbose = false,
|
||||
?IO $io = null
|
||||
): int {
|
||||
$filesDir = rtrim($inputDir, '/');
|
||||
|
||||
if (!is_dir($filesDir)) {
|
||||
if ($io) {
|
||||
$io->writeLine(" No files/ directory in backup, skipping attachment restore.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!is_dir(self::UPLOAD_DIR)) {
|
||||
mkdir(self::UPLOAD_DIR, 0755, true);
|
||||
}
|
||||
|
||||
$files = scandir($filesDir);
|
||||
$count = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePath = $filesDir . '/' . $file;
|
||||
|
||||
if (!is_file($sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($preserveIds) {
|
||||
$destId = $file;
|
||||
} else {
|
||||
$destId = $idMapper->get('Attachment', $file) ?? $file;
|
||||
}
|
||||
|
||||
$destPath = self::UPLOAD_DIR . $destId;
|
||||
|
||||
if (!file_exists($destPath)) {
|
||||
copy($sourcePath, $destPath);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($io) {
|
||||
$io->writeLine(" Attachments: restored {$count} files");
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1073741824) {
|
||||
return round($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
}
|
||||
|
||||
if ($bytes >= 1024) {
|
||||
return round($bytes / 1024, 2) . ' KB';
|
||||
}
|
||||
|
||||
return $bytes . ' B';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Console\IO;
|
||||
|
||||
class EntityExporter
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export entities of a given type matching the where clause, writing to a JSON file.
|
||||
*
|
||||
* @param string $entityType
|
||||
* @param array $where ORM where conditions
|
||||
* @param string $outputDir Backup data/ directory
|
||||
* @param int $batchSize
|
||||
* @param bool $verbose
|
||||
* @param IO|null $io
|
||||
* @return int Number of records exported
|
||||
*/
|
||||
public function export(
|
||||
string $entityType,
|
||||
array $where,
|
||||
string $outputDir,
|
||||
int $batchSize = 500,
|
||||
bool $verbose = false,
|
||||
?IO $io = null
|
||||
): int {
|
||||
$filePath = rtrim($outputDir, '/') . '/' . $entityType . '.json';
|
||||
$records = [];
|
||||
$offset = 0;
|
||||
$total = 0;
|
||||
|
||||
while (true) {
|
||||
$query = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->where($where)
|
||||
->limit($offset, $batchSize);
|
||||
|
||||
$collection = $query->find();
|
||||
$count = 0;
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
$record = $this->entityToArray($entity);
|
||||
$records[] = $record;
|
||||
$count++;
|
||||
$total++;
|
||||
}
|
||||
|
||||
if ($count === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($verbose && $io) {
|
||||
$io->writeLine(" {$entityType}: exported {$total} records...");
|
||||
}
|
||||
|
||||
$offset += $batchSize;
|
||||
|
||||
if ($count < $batchSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'entityType' => $entityType,
|
||||
'count' => $total,
|
||||
'exportedAt' => date('c'),
|
||||
'records' => $records,
|
||||
];
|
||||
|
||||
file_put_contents(
|
||||
$filePath,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an entity to a plain associative array using getValueMap().
|
||||
*/
|
||||
private function entityToArray($entity): array
|
||||
{
|
||||
$valueMap = $entity->getValueMap();
|
||||
|
||||
return (array) $valueMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Console\IO;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use PDO;
|
||||
|
||||
class EntityImporter
|
||||
{
|
||||
/**
|
||||
* Fields to remap as foreign keys per entity type.
|
||||
* Format: entityType => [field => referencedEntityType]
|
||||
*/
|
||||
private const FK_FIELDS = [
|
||||
'Case' => [
|
||||
'accountId' => 'Account',
|
||||
'contactId' => 'Contact',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
'cPayerId' => 'Account',
|
||||
'cBeneficiaryId' => 'Contact',
|
||||
'cPricingAgreementId' => 'PricingAgreement',
|
||||
],
|
||||
'CaseActivity' => [
|
||||
'caseId' => 'Case',
|
||||
'accountId' => 'Account',
|
||||
'invoiceId' => 'Invoice',
|
||||
'chargeId' => 'Charge',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'Charge' => [
|
||||
'caseId' => 'Case',
|
||||
'accountId' => 'Account',
|
||||
'caseActivityId' => 'CaseActivity',
|
||||
'invoiceId' => 'Invoice',
|
||||
'fixedActivityRateId' => 'FixedActivityRate',
|
||||
'pricingAgreementRateId' => 'PricingAgreementRate',
|
||||
'legalAidPayerAccountId' => 'Account',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'Invoice' => [
|
||||
'caseId' => 'Case',
|
||||
'accountId' => 'Account',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'Document' => [
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'Note' => [
|
||||
'parentId' => '_parent', // special: uses parentType
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'Attachment' => [
|
||||
'parentId' => '_parent',
|
||||
'createdById' => 'User',
|
||||
],
|
||||
'NhMeeting' => ['caseId' => 'Case'],
|
||||
'NhDecision' => ['caseId' => 'Case', 'nhDocumentId' => 'NhDocument'],
|
||||
'NhProcess' => ['caseId' => 'Case', 'nhDocumentId' => 'NhDocument'],
|
||||
'NhPlea' => ['caseId' => 'Case', 'nhDocumentId' => 'NhDocument'],
|
||||
'NhActivity' => ['caseId' => 'Case', 'taskId' => 'Task'],
|
||||
'NhDocument' => ['caseId' => 'Case'],
|
||||
'NhSyncLog' => ['caseId' => 'Case'],
|
||||
'CaseMemory' => [
|
||||
'caseId' => 'Case',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'SignatureRequest' => [
|
||||
'caseId' => 'Case',
|
||||
'documentId' => 'Document',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
'signedDocumentId' => 'Attachment',
|
||||
],
|
||||
'SignatureRequestSigner' => [
|
||||
'signatureRequestId' => 'SignatureRequest',
|
||||
'contactId' => 'Contact',
|
||||
'userId' => 'User',
|
||||
],
|
||||
'SmsLog' => [
|
||||
'parentId' => '_parent',
|
||||
'createdById' => 'User',
|
||||
],
|
||||
'PricingAgreement' => [
|
||||
'accountId' => 'Account',
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
'PricingAgreementRate' => [
|
||||
'pricingAgreementId' => 'PricingAgreement',
|
||||
],
|
||||
'ClientRate' => [
|
||||
'accountId' => 'Account',
|
||||
],
|
||||
'Contact' => [
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
'accountId' => 'Account',
|
||||
],
|
||||
'Account' => [
|
||||
'assignedUserId' => 'User',
|
||||
'createdById' => 'User',
|
||||
'modifiedById' => 'User',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields to skip during import (non-storable, computed, etc.).
|
||||
*/
|
||||
private const SKIP_FIELDS = [
|
||||
'deleted',
|
||||
'totalBillableAmount',
|
||||
'unbilledAmount',
|
||||
'contactRole',
|
||||
'contactPartyOrder',
|
||||
'caseRole',
|
||||
'casePartyOrder',
|
||||
'cLastActivityIndicator',
|
||||
'signerCount',
|
||||
'signedCount',
|
||||
];
|
||||
|
||||
/**
|
||||
* Entity types that have an autoincrement 'number' field.
|
||||
*/
|
||||
private const AUTOINCREMENT_ENTITIES = ['Case', 'Charge', 'Invoice'];
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Import a single entity type from its JSON file.
|
||||
*
|
||||
* @return array{imported: int, skipped: int, errors: int}
|
||||
*/
|
||||
public function import(
|
||||
string $entityType,
|
||||
string $jsonFilePath,
|
||||
bool $preserveIds,
|
||||
bool $skipDuplicates,
|
||||
IdMapper $idMapper,
|
||||
int $batchSize,
|
||||
bool $verbose = false,
|
||||
bool $dryRun = false,
|
||||
?IO $io = null
|
||||
): array {
|
||||
if (!file_exists($jsonFilePath)) {
|
||||
return ['imported' => 0, 'skipped' => 0, 'errors' => 0];
|
||||
}
|
||||
|
||||
$content = json_decode(file_get_contents($jsonFilePath), true);
|
||||
|
||||
if (!$content || empty($content['records'])) {
|
||||
return ['imported' => 0, 'skipped' => 0, 'errors' => 0];
|
||||
}
|
||||
|
||||
$records = $content['records'];
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$hasAutoIncrement = in_array($entityType, self::AUTOINCREMENT_ENTITIES);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$oldId = $record['id'] ?? null;
|
||||
|
||||
if (!$oldId) {
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if ($preserveIds) {
|
||||
$existing = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->where(['id' => $oldId])
|
||||
->findOne();
|
||||
|
||||
if ($existing) {
|
||||
if ($skipDuplicates) {
|
||||
$skipped++;
|
||||
$idMapper->add($entityType, $oldId, $oldId);
|
||||
continue;
|
||||
}
|
||||
|
||||
$errors++;
|
||||
|
||||
if ($verbose && $io) {
|
||||
$io->writeLine(" ERROR: {$entityType} ID {$oldId} already exists");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$newId = $preserveIds ? $oldId : $idMapper->generateId();
|
||||
$idMapper->add($entityType, $oldId, $newId);
|
||||
$imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$newId = $this->importRecord($entityType, $record, $preserveIds, $idMapper, $hasAutoIncrement);
|
||||
$idMapper->add($entityType, $oldId, $newId);
|
||||
$imported++;
|
||||
} catch (\Exception $e) {
|
||||
$errors++;
|
||||
|
||||
if ($verbose && $io) {
|
||||
$io->writeLine(" ERROR importing {$entityType} {$oldId}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset autoincrement counter
|
||||
if (!$dryRun && $hasAutoIncrement && $imported > 0) {
|
||||
$this->resetAutoIncrement($entityType);
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors];
|
||||
}
|
||||
|
||||
private function importRecord(
|
||||
string $entityType,
|
||||
array $record,
|
||||
bool $preserveIds,
|
||||
IdMapper $idMapper,
|
||||
bool $hasAutoIncrement
|
||||
): string {
|
||||
$oldId = $record['id'];
|
||||
$newId = $preserveIds ? $oldId : $idMapper->generateId();
|
||||
|
||||
// Remap foreign keys if not preserving IDs
|
||||
if (!$preserveIds) {
|
||||
$fkMap = self::FK_FIELDS[$entityType] ?? [];
|
||||
|
||||
foreach ($fkMap as $field => $refType) {
|
||||
if (!isset($record[$field]) || $record[$field] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($refType === '_parent') {
|
||||
// Polymorphic: use parentType to determine entity type
|
||||
$parentTypeField = str_replace('Id', 'Type', $field);
|
||||
$parentType = $record[$parentTypeField] ?? null;
|
||||
|
||||
if ($parentType) {
|
||||
$mapped = $idMapper->get($parentType, $record[$field]);
|
||||
|
||||
if ($mapped) {
|
||||
$record[$field] = $mapped;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$mapped = $idMapper->get($refType, $record[$field]);
|
||||
|
||||
if ($mapped) {
|
||||
$record[$field] = $mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For entities with autoincrement number, use raw SQL
|
||||
if ($hasAutoIncrement) {
|
||||
$this->insertWithRawSql($entityType, $record, $newId);
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
// Use ORM for standard entities
|
||||
$entity = $this->entityManager->getRDBRepository($entityType)->getNew();
|
||||
$entity->set('id', $newId);
|
||||
|
||||
foreach ($record as $field => $value) {
|
||||
if ($field === 'id' || in_array($field, self::SKIP_FIELDS)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->set($field, $value);
|
||||
}
|
||||
|
||||
$this->entityManager->saveEntity($entity, [
|
||||
'silent' => true,
|
||||
'skipHooks' => true,
|
||||
'noStream' => true,
|
||||
'skipAll' => true,
|
||||
]);
|
||||
|
||||
return $newId;
|
||||
}
|
||||
|
||||
private function insertWithRawSql(string $entityType, array $record, string $newId): void
|
||||
{
|
||||
$tableName = $this->entityManager
|
||||
->getQueryComposer()
|
||||
->toDb($entityType);
|
||||
|
||||
// Convert camelCase fields to snake_case for SQL
|
||||
$columns = [];
|
||||
$values = [];
|
||||
$placeholders = [];
|
||||
|
||||
$record['id'] = $newId;
|
||||
|
||||
foreach ($record as $field => $value) {
|
||||
if (in_array($field, self::SKIP_FIELDS)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-storable array/object fields that come from getValueMap
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// JSON fields should be stored as JSON strings
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
$column = $this->camelCaseToUnderscore($field);
|
||||
$columns[] = "`{$column}`";
|
||||
$values[] = $value;
|
||||
$placeholders[] = '?';
|
||||
}
|
||||
|
||||
$columnStr = implode(', ', $columns);
|
||||
$placeholderStr = implode(', ', $placeholders);
|
||||
|
||||
$sql = "INSERT INTO `{$tableName}` ({$columnStr}) VALUES ({$placeholderStr})";
|
||||
|
||||
try {
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($values);
|
||||
} catch (\Exception $e) {
|
||||
// If column mismatch, try with just known columns
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function resetAutoIncrement(string $entityType): void
|
||||
{
|
||||
$tableName = $this->entityManager
|
||||
->getQueryComposer()
|
||||
->toDb($entityType);
|
||||
|
||||
try {
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$sql = "SELECT MAX(`number`) as max_num FROM `{$tableName}`";
|
||||
$sth = $pdo->query($sql);
|
||||
$row = $sth->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row && $row['max_num']) {
|
||||
$next = (int) $row['max_num'] + 1;
|
||||
$pdo->exec("ALTER TABLE `{$tableName}` AUTO_INCREMENT = {$next}");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Non-critical, log and continue
|
||||
}
|
||||
}
|
||||
|
||||
private function camelCaseToUnderscore(string $str): string
|
||||
{
|
||||
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $str));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Console\IO;
|
||||
use PDO;
|
||||
|
||||
class ExportService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private EntityExporter $entityExporter,
|
||||
private JunctionTableHandler $junctionTableHandler,
|
||||
private AttachmentHandler $attachmentHandler,
|
||||
private ManifestBuilder $manifestBuilder,
|
||||
private Config $config,
|
||||
private Log $log
|
||||
) {}
|
||||
|
||||
public function export(string $outputDir, array $options, IO $io): void
|
||||
{
|
||||
$batchSize = $options['batchSize'] ?? 500;
|
||||
$filterCaseIds = $options['caseIds'] ?? null;
|
||||
$filterStatus = $options['caseStatus'] ?? null;
|
||||
$noFiles = $options['noFiles'] ?? false;
|
||||
$verbose = $options['verbose'] ?? false;
|
||||
|
||||
$dataDir = $outputDir . '/data';
|
||||
$filesDir = $outputDir . '/files';
|
||||
mkdir($dataDir, 0755, true);
|
||||
|
||||
if (!$noFiles) {
|
||||
mkdir($filesDir, 0755, true);
|
||||
}
|
||||
|
||||
$entityCounts = [];
|
||||
$entityIdsByType = [];
|
||||
|
||||
$io->writeLine('Starting export...');
|
||||
$io->writeLine('');
|
||||
|
||||
// === Determine Case IDs ===
|
||||
$caseWhere = [];
|
||||
|
||||
if ($filterCaseIds) {
|
||||
$caseWhere['id'] = $filterCaseIds;
|
||||
}
|
||||
|
||||
if ($filterStatus) {
|
||||
$caseWhere['status'] = $filterStatus;
|
||||
}
|
||||
|
||||
$caseIds = $this->collectIds('Case', $caseWhere);
|
||||
$entityIdsByType['Case'] = $caseIds;
|
||||
$io->writeLine("Found " . count($caseIds) . " cases to export.");
|
||||
|
||||
if (empty($caseIds)) {
|
||||
$io->writeLine('No cases found. Nothing to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
// === Layer 0: Users, Teams, FixedActivityRate ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 0] Reference entities...');
|
||||
|
||||
$referencedUserIds = $this->collectReferencedUserIds($caseIds);
|
||||
$entityIdsByType['User'] = $referencedUserIds;
|
||||
|
||||
if (!empty($referencedUserIds)) {
|
||||
$entityCounts['User'] = $this->entityExporter->export(
|
||||
'User', ['id' => $referencedUserIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
$referencedTeamIds = $this->collectReferencedTeamIds($caseIds);
|
||||
$entityIdsByType['Team'] = $referencedTeamIds;
|
||||
|
||||
if (!empty($referencedTeamIds)) {
|
||||
$entityCounts['Team'] = $this->entityExporter->export(
|
||||
'Team', ['id' => $referencedTeamIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->entityTypeExists('FixedActivityRate')) {
|
||||
$entityCounts['FixedActivityRate'] = $this->entityExporter->export(
|
||||
'FixedActivityRate', [], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
// === Layer 1: Accounts and Contacts ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 1] Accounts and Contacts...');
|
||||
|
||||
$accountIds = $this->collectReferencedAccountIds($caseIds);
|
||||
$entityIdsByType['Account'] = $accountIds;
|
||||
|
||||
if (!empty($accountIds)) {
|
||||
$entityCounts['Account'] = $this->entityExporter->export(
|
||||
'Account', ['id' => $accountIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
$contactIds = $this->collectReferencedContactIds($caseIds);
|
||||
$entityIdsByType['Contact'] = $contactIds;
|
||||
|
||||
if (!empty($contactIds)) {
|
||||
$entityCounts['Contact'] = $this->entityExporter->export(
|
||||
'Contact', ['id' => $contactIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
// === Layer 2: Billing rates ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 2] Billing rates...');
|
||||
|
||||
if ($this->entityTypeExists('PricingAgreement') && !empty($accountIds)) {
|
||||
$entityCounts['PricingAgreement'] = $this->entityExporter->export(
|
||||
'PricingAgreement', ['accountId' => $accountIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
|
||||
$paIds = $this->collectIds('PricingAgreement', ['accountId' => $accountIds]);
|
||||
$entityIdsByType['PricingAgreement'] = $paIds;
|
||||
|
||||
if (!empty($paIds) && $this->entityTypeExists('PricingAgreementRate')) {
|
||||
$entityCounts['PricingAgreementRate'] = $this->entityExporter->export(
|
||||
'PricingAgreementRate', ['pricingAgreementId' => $paIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->entityTypeExists('ClientRate') && !empty($accountIds)) {
|
||||
$entityCounts['ClientRate'] = $this->entityExporter->export(
|
||||
'ClientRate', ['accountId' => $accountIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
// === Layer 3: Cases ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 3] Cases...');
|
||||
|
||||
$entityCounts['Case'] = $this->entityExporter->export(
|
||||
'Case', ['id' => $caseIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
|
||||
// === Layer 4: Direct Case children ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 4] Case children...');
|
||||
|
||||
$layer4Entities = [
|
||||
'CaseActivity', 'Invoice', 'NhMeeting', 'NhDecision',
|
||||
'NhProcess', 'NhPlea', 'NhActivity', 'NhDocument',
|
||||
'NhSyncLog', 'CaseMemory', 'SignatureRequest',
|
||||
];
|
||||
|
||||
foreach ($layer4Entities as $entityType) {
|
||||
if (!$this->entityTypeExists($entityType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityCounts[$entityType] = $this->entityExporter->export(
|
||||
$entityType, ['caseId' => $caseIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
|
||||
$entityIdsByType[$entityType] = $this->collectIds($entityType, ['caseId' => $caseIds]);
|
||||
}
|
||||
|
||||
// Document - linked via junction table
|
||||
if ($this->entityTypeExists('Document')) {
|
||||
$documentIds = $this->collectDocumentIdsForCases($caseIds);
|
||||
$entityIdsByType['Document'] = $documentIds;
|
||||
|
||||
if (!empty($documentIds)) {
|
||||
$entityCounts['Document'] = $this->entityExporter->export(
|
||||
'Document', ['id' => $documentIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Layer 5: Second-level children ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 5] Second-level children...');
|
||||
|
||||
if ($this->entityTypeExists('Charge')) {
|
||||
$entityCounts['Charge'] = $this->entityExporter->export(
|
||||
'Charge', ['caseId' => $caseIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
$entityIdsByType['Charge'] = $this->collectIds('Charge', ['caseId' => $caseIds]);
|
||||
}
|
||||
|
||||
if ($this->entityTypeExists('SignatureRequestSigner')) {
|
||||
$srIds = $entityIdsByType['SignatureRequest'] ?? [];
|
||||
|
||||
if (!empty($srIds)) {
|
||||
$entityCounts['SignatureRequestSigner'] = $this->entityExporter->export(
|
||||
'SignatureRequestSigner', ['signatureRequestId' => $srIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->entityTypeExists('SmsLog')) {
|
||||
$entityCounts['SmsLog'] = $this->entityExporter->export(
|
||||
'SmsLog', ['parentType' => 'Case', 'parentId' => $caseIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
// === Layer 6: Notes and Attachments ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Layer 6] Notes and Attachments...');
|
||||
|
||||
$entityCounts['Note'] = $this->entityExporter->export(
|
||||
'Note', ['parentType' => 'Case', 'parentId' => $caseIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
|
||||
$attachmentIds = $this->collectAllAttachmentIds($dataDir);
|
||||
$entityIdsByType['Attachment'] = $attachmentIds;
|
||||
|
||||
if (!empty($attachmentIds)) {
|
||||
$entityCounts['Attachment'] = $this->entityExporter->export(
|
||||
'Attachment', ['id' => $attachmentIds], $dataDir, $batchSize, $verbose, $io
|
||||
);
|
||||
}
|
||||
|
||||
// === Junction tables ===
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Junctions] Relationship tables...');
|
||||
$this->junctionTableHandler->exportJunctions($caseIds, $entityIdsByType, $dataDir, $io);
|
||||
|
||||
// === Files ===
|
||||
$fileCount = 0;
|
||||
$fileSize = 0;
|
||||
|
||||
if (!$noFiles && !empty($attachmentIds)) {
|
||||
$io->writeLine('');
|
||||
$io->writeLine('[Files] Copying attachment files...');
|
||||
$result = $this->attachmentHandler->exportFiles($attachmentIds, $filesDir, $verbose, $io);
|
||||
$fileCount = $result['count'];
|
||||
$fileSize = $result['size'];
|
||||
}
|
||||
|
||||
// === Manifest ===
|
||||
$this->manifestBuilder->build($outputDir, $entityCounts, $fileCount, $fileSize);
|
||||
|
||||
$io->writeLine('');
|
||||
$io->writeLine('=== Export Complete ===');
|
||||
$io->writeLine("Output: {$outputDir}");
|
||||
|
||||
foreach ($entityCounts as $type => $count) {
|
||||
if ($count > 0) {
|
||||
$io->writeLine(" {$type}: {$count}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function collectIds(string $entityType, array $where): array
|
||||
{
|
||||
$ids = [];
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->select(['id'])
|
||||
->where($where)
|
||||
->find();
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
$ids[] = $entity->getId();
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function collectReferencedUserIds(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
$inList = $this->buildInList($caseIds);
|
||||
|
||||
// From Cases - all user reference fields
|
||||
$sql = "SELECT DISTINCT assigned_user_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0
|
||||
UNION
|
||||
SELECT DISTINCT created_by_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0
|
||||
UNION
|
||||
SELECT DISTINCT modified_by_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0";
|
||||
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
if ($row[0]) {
|
||||
$userIds[$row[0]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// From child entities
|
||||
$childTables = ['case_activity', 'charge', 'invoice'];
|
||||
|
||||
foreach ($childTables as $table) {
|
||||
if (!$this->tableExists($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = "SELECT DISTINCT assigned_user_id FROM `{$table}` WHERE case_id IN ({$inList}) AND deleted = 0
|
||||
UNION
|
||||
SELECT DISTINCT created_by_id FROM `{$table}` WHERE case_id IN ({$inList}) AND deleted = 0";
|
||||
|
||||
try {
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
if ($row[0]) {
|
||||
$userIds[$row[0]] = true;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Table may not have these columns
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($userIds);
|
||||
}
|
||||
|
||||
private function collectReferencedTeamIds(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$inList = $this->buildInList($caseIds);
|
||||
|
||||
$sql = "SELECT DISTINCT team_id FROM `entity_team` WHERE entity_type = 'Case' AND entity_id IN ({$inList}) AND deleted = 0";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
$teamIds = [];
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
if ($row[0]) {
|
||||
$teamIds[] = $row[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $teamIds;
|
||||
}
|
||||
|
||||
private function collectReferencedAccountIds(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$inList = $this->buildInList($caseIds);
|
||||
$accountIds = [];
|
||||
|
||||
$sql = "SELECT DISTINCT account_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0 AND account_id IS NOT NULL";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
$accountIds[$row[0]] = true;
|
||||
}
|
||||
|
||||
// cPayerId from GreenInvoiceBilling
|
||||
try {
|
||||
$sql = "SELECT DISTINCT c_payer_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0 AND c_payer_id IS NOT NULL";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
$accountIds[$row[0]] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Column may not exist
|
||||
}
|
||||
|
||||
return array_keys($accountIds);
|
||||
}
|
||||
|
||||
private function collectReferencedContactIds(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$inList = $this->buildInList($caseIds);
|
||||
$contactIds = [];
|
||||
|
||||
$sql = "SELECT DISTINCT contact_id FROM `case` WHERE id IN ({$inList}) AND deleted = 0 AND contact_id IS NOT NULL";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
$contactIds[$row[0]] = true;
|
||||
}
|
||||
|
||||
$sql = "SELECT DISTINCT contact_id FROM `case_contact` WHERE case_id IN ({$inList}) AND deleted = 0";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
$contactIds[$row[0]] = true;
|
||||
}
|
||||
|
||||
return array_keys($contactIds);
|
||||
}
|
||||
|
||||
private function collectDocumentIdsForCases(array $caseIds): array
|
||||
{
|
||||
if (empty($caseIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$inList = $this->buildInList($caseIds);
|
||||
|
||||
$sql = "SELECT DISTINCT document_id FROM `case_document` WHERE case_id IN ({$inList}) AND deleted = 0";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
$ids = [];
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_NUM)) {
|
||||
$ids[] = $row[0];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function collectAllAttachmentIds(string $dataDir): array
|
||||
{
|
||||
$attachmentIds = [];
|
||||
$files = glob($dataDir . '/*.json');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$basename = basename($file);
|
||||
|
||||
if (str_starts_with($basename, '_junction_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = json_decode(file_get_contents($file), true);
|
||||
|
||||
if (!$content || empty($content['records'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($content['records'] as $record) {
|
||||
// Known file fields
|
||||
$knownFileFields = ['fileId', 'signedDocumentId'];
|
||||
|
||||
foreach ($knownFileFields as $ff) {
|
||||
if (!empty($record[$ff])) {
|
||||
$attachmentIds[$record[$ff]] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentsIds (attachmentMultiple)
|
||||
if (!empty($record['attachmentsIds']) && is_array($record['attachmentsIds'])) {
|
||||
foreach ($record['attachmentsIds'] as $attId) {
|
||||
$attachmentIds[$attId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Note data field
|
||||
if (isset($record['data'])) {
|
||||
$data = is_array($record['data']) ? $record['data'] : (array) $record['data'];
|
||||
|
||||
if (!empty($data['attachmentsIds']) && is_array($data['attachmentsIds'])) {
|
||||
foreach ($data['attachmentsIds'] as $attId) {
|
||||
$attachmentIds[$attId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($attachmentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a quoted IN list from an array of IDs.
|
||||
*/
|
||||
private function buildInList(array $ids): string
|
||||
{
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
return implode(',', array_map(fn($id) => $pdo->quote($id), $ids));
|
||||
}
|
||||
|
||||
private function entityTypeExists(string $entityType): bool
|
||||
{
|
||||
try {
|
||||
$this->entityManager->getRDBRepository($entityType);
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function tableExists(string $table): bool
|
||||
{
|
||||
try {
|
||||
$this->entityManager->getSqlExecutor()->execute("SELECT 1 FROM `{$table}` LIMIT 1");
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\Core\Utils\Util;
|
||||
|
||||
class IdMapper
|
||||
{
|
||||
/** @var array<string, array<string, string>> [entityType][oldId] => newId */
|
||||
private array $map = [];
|
||||
|
||||
public function add(string $entityType, string $oldId, string $newId): void
|
||||
{
|
||||
$this->map[$entityType][$oldId] = $newId;
|
||||
}
|
||||
|
||||
public function get(string $entityType, ?string $oldId): ?string
|
||||
{
|
||||
if ($oldId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->map[$entityType][$oldId] ?? null;
|
||||
}
|
||||
|
||||
public function has(string $entityType, string $oldId): bool
|
||||
{
|
||||
return isset($this->map[$entityType][$oldId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve an ID across multiple entity types.
|
||||
* Useful for polymorphic parent fields.
|
||||
*/
|
||||
public function resolve(?string $oldId): ?string
|
||||
{
|
||||
if ($oldId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->map as $entries) {
|
||||
if (isset($entries[$oldId])) {
|
||||
return $entries[$oldId];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function generateId(): string
|
||||
{
|
||||
return Util::generateId();
|
||||
}
|
||||
|
||||
public function getMap(): array
|
||||
{
|
||||
return $this->map;
|
||||
}
|
||||
|
||||
public function saveTo(string $path): void
|
||||
{
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($this->map, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
public function loadFrom(string $path): void
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
|
||||
if (is_array($data)) {
|
||||
$this->map = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?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;
|
||||
$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;
|
||||
}
|
||||
|
||||
$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 (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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Console\IO;
|
||||
use PDO;
|
||||
|
||||
class JunctionTableHandler
|
||||
{
|
||||
private const TEAM_ENTITY_TYPES = [
|
||||
'Case', 'CaseActivity', 'Invoice', 'Charge',
|
||||
'Document', 'SignatureRequest', 'CaseMemory', 'PricingAgreement',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Export junction tables to JSON files.
|
||||
*/
|
||||
public function exportJunctions(
|
||||
array $caseIds,
|
||||
array $entityIdsByType,
|
||||
string $outputDir,
|
||||
?IO $io = null
|
||||
): array {
|
||||
$counts = [];
|
||||
|
||||
if (empty($caseIds)) {
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
// case_contact
|
||||
$inList = $this->buildInList($caseIds);
|
||||
$counts['case_contact'] = $this->exportTableQuery(
|
||||
$pdo,
|
||||
'case_contact',
|
||||
"SELECT * FROM `case_contact` WHERE `case_id` IN ({$inList}) AND `deleted` = 0",
|
||||
$outputDir
|
||||
);
|
||||
|
||||
// case_document
|
||||
$counts['case_document'] = $this->exportTableQuery(
|
||||
$pdo,
|
||||
'case_document',
|
||||
"SELECT * FROM `case_document` WHERE `case_id` IN ({$inList}) AND `deleted` = 0",
|
||||
$outputDir
|
||||
);
|
||||
|
||||
// entity_team - for all tracked entity types
|
||||
$allTeamRecords = [];
|
||||
|
||||
foreach (self::TEAM_ENTITY_TYPES as $entityType) {
|
||||
$ids = $entityIdsByType[$entityType] ?? [];
|
||||
|
||||
if (empty($ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entityInList = $this->buildInList($ids);
|
||||
$quotedType = $pdo->quote($entityType);
|
||||
|
||||
$sql = "SELECT * FROM `entity_team` WHERE `entity_type` = {$quotedType} AND `entity_id` IN ({$entityInList}) AND `deleted` = 0";
|
||||
$sth = $pdo->query($sql);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$allTeamRecords[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$filePath = rtrim($outputDir, '/') . '/_junction_entity_team.json';
|
||||
file_put_contents(
|
||||
$filePath,
|
||||
json_encode([
|
||||
'table' => 'entity_team',
|
||||
'count' => count($allTeamRecords),
|
||||
'exportedAt' => date('c'),
|
||||
'records' => $allTeamRecords,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
$counts['entity_team'] = count($allTeamRecords);
|
||||
|
||||
if ($io) {
|
||||
foreach ($counts as $table => $count) {
|
||||
$io->writeLine(" Junction {$table}: {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import junction table records from backup.
|
||||
*/
|
||||
public function importJunctions(
|
||||
string $inputDir,
|
||||
IdMapper $idMapper,
|
||||
bool $preserveIds = true,
|
||||
?IO $io = null
|
||||
): void {
|
||||
$this->importCaseContact($inputDir, $idMapper, $preserveIds, $io);
|
||||
$this->importCaseDocument($inputDir, $idMapper, $preserveIds, $io);
|
||||
$this->importEntityTeam($inputDir, $idMapper, $preserveIds, $io);
|
||||
}
|
||||
|
||||
private function exportTableQuery(PDO $pdo, string $table, string $sql, string $outputDir): int
|
||||
{
|
||||
$sth = $pdo->query($sql);
|
||||
$records = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$filePath = rtrim($outputDir, '/') . '/_junction_' . $table . '.json';
|
||||
file_put_contents(
|
||||
$filePath,
|
||||
json_encode([
|
||||
'table' => $table,
|
||||
'count' => count($records),
|
||||
'exportedAt' => date('c'),
|
||||
'records' => $records,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
|
||||
return count($records);
|
||||
}
|
||||
|
||||
private function importCaseContact(string $inputDir, IdMapper $idMapper, bool $preserveIds, ?IO $io): void
|
||||
{
|
||||
$data = $this->readJunctionFile($inputDir, 'case_contact');
|
||||
|
||||
if ($data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$count = 0;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT IGNORE INTO `case_contact` (`case_id`, `contact_id`, `role`, `party_order`, `deleted`) VALUES (?, ?, ?, ?, 0)"
|
||||
);
|
||||
|
||||
foreach ($data['records'] as $row) {
|
||||
$caseId = $preserveIds ? $row['case_id'] : ($idMapper->get('Case', $row['case_id']) ?? $row['case_id']);
|
||||
$contactId = $preserveIds ? $row['contact_id'] : ($idMapper->get('Contact', $row['contact_id']) ?? $row['contact_id']);
|
||||
|
||||
$stmt->execute([$caseId, $contactId, $row['role'] ?? null, $row['party_order'] ?? null]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($io) {
|
||||
$io->writeLine(" Junction case_contact: imported {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
private function importCaseDocument(string $inputDir, IdMapper $idMapper, bool $preserveIds, ?IO $io): void
|
||||
{
|
||||
$data = $this->readJunctionFile($inputDir, 'case_document');
|
||||
|
||||
if ($data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$count = 0;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT IGNORE INTO `case_document` (`case_id`, `document_id`, `deleted`) VALUES (?, ?, 0)"
|
||||
);
|
||||
|
||||
foreach ($data['records'] as $row) {
|
||||
$caseId = $preserveIds ? $row['case_id'] : ($idMapper->get('Case', $row['case_id']) ?? $row['case_id']);
|
||||
$documentId = $preserveIds ? $row['document_id'] : ($idMapper->get('Document', $row['document_id']) ?? $row['document_id']);
|
||||
|
||||
$stmt->execute([$caseId, $documentId]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($io) {
|
||||
$io->writeLine(" Junction case_document: imported {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
private function importEntityTeam(string $inputDir, IdMapper $idMapper, bool $preserveIds, ?IO $io): void
|
||||
{
|
||||
$data = $this->readJunctionFile($inputDir, 'entity_team');
|
||||
|
||||
if ($data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
$count = 0;
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT IGNORE INTO `entity_team` (`entity_id`, `entity_type`, `team_id`, `deleted`) VALUES (?, ?, ?, 0)"
|
||||
);
|
||||
|
||||
foreach ($data['records'] as $row) {
|
||||
$entityType = $row['entity_type'];
|
||||
$entityId = $preserveIds ? $row['entity_id'] : ($idMapper->get($entityType, $row['entity_id']) ?? $row['entity_id']);
|
||||
$teamId = $preserveIds ? $row['team_id'] : ($idMapper->get('Team', $row['team_id']) ?? $row['team_id']);
|
||||
|
||||
$stmt->execute([$entityId, $entityType, $teamId]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
if ($io) {
|
||||
$io->writeLine(" Junction entity_team: imported {$count} records");
|
||||
}
|
||||
}
|
||||
|
||||
private function readJunctionFile(string $inputDir, string $table): ?array
|
||||
{
|
||||
$path = rtrim($inputDir, '/') . '/_junction_' . $table . '.json';
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode(file_get_contents($path), true);
|
||||
}
|
||||
|
||||
private function buildInList(array $ids): string
|
||||
{
|
||||
$pdo = $this->entityManager->getPDO();
|
||||
|
||||
return implode(',', array_map(fn($id) => $pdo->quote($id), $ids));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\DataMigration\Services;
|
||||
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Module;
|
||||
|
||||
class ManifestBuilder
|
||||
{
|
||||
private const EXTENSION_VERSION = '1.0.0';
|
||||
|
||||
private const TRACKED_MODULES = [
|
||||
'LegalCrm',
|
||||
'NetHamishpat',
|
||||
'GreenInvoiceBilling',
|
||||
'SmartAssistant',
|
||||
'DigitalSignature',
|
||||
'SmsIntegration',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private Module $module
|
||||
) {}
|
||||
|
||||
public function build(string $outputDir, array $entityCounts, int $fileCount, int $fileSize): void
|
||||
{
|
||||
$modules = [];
|
||||
|
||||
foreach (self::TRACKED_MODULES as $mod) {
|
||||
$moduleData = $this->module->get([$mod]);
|
||||
|
||||
if ($moduleData) {
|
||||
$modules[$mod] = $moduleData['version'] ?? 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'version' => self::EXTENSION_VERSION,
|
||||
'espoVersion' => $this->config->get('version', 'unknown'),
|
||||
'exportedAt' => date('c'),
|
||||
'sourceHost' => $this->config->get('siteUrl', 'unknown'),
|
||||
'entityCounts' => $entityCounts,
|
||||
'totalAttachmentFiles' => $fileCount,
|
||||
'totalAttachmentSizeBytes' => $fileSize,
|
||||
'modules' => $modules,
|
||||
];
|
||||
|
||||
$path = rtrim($outputDir, '/') . '/manifest.json';
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
public function read(string $inputDir): ?array
|
||||
{
|
||||
$path = rtrim($inputDir, '/') . '/manifest.json';
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
|
||||
return json_decode($content, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "DataMigration",
|
||||
"version": "1.0.0",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"php": [
|
||||
">=8.1"
|
||||
],
|
||||
"releaseDate": "2026-03-28",
|
||||
"author": "klear",
|
||||
"description": "Case data migration: export/import all case-related data between EspoCRM instances"
|
||||
}
|
||||
Reference in New Issue
Block a user