e08eca7eb3
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>
232 lines
7.1 KiB
PHP
232 lines
7.1 KiB
PHP
<?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));
|
|
}
|
|
}
|