item_id) private array $idMappings = []; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $step = $this->option('step'); $rollback = $this->option('rollback'); $this->info('=== 5130 → SAM 품목 마이그레이션 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); $this->info("Step: {$step}"); $this->newLine(); if ($rollback) { return $this->rollbackMigration($tenantId, $dryRun); } // 기존 매핑 로드 $this->loadExistingMappings(); $steps = $step === 'all' ? ['models', 'parts', 'parts_sub', 'bdmodels', 'relations'] : [$step]; foreach ($steps as $currentStep) { $this->line(">>> Step: {$currentStep}"); match ($currentStep) { 'models' => $this->migrateModels($tenantId, $dryRun), 'parts' => $this->migrateParts($tenantId, $dryRun), 'parts_sub' => $this->migratePartsSub($tenantId, $dryRun), 'bdmodels' => $this->migrateBDModels($tenantId, $dryRun), 'relations' => $this->migrateRelations($tenantId, $dryRun), default => $this->error("Unknown step: {$currentStep}"), }; $this->newLine(); } $this->info('=== 마이그레이션 완료 ==='); $this->showSummary(); return self::SUCCESS; } /** * 기존 매핑 로드 (중복 방지) */ private function loadExistingMappings(): void { $mappings = DB::connection('mysql')->table('item_id_mappings')->get(); foreach ($mappings as $mapping) { $key = "{$mapping->source_table}:{$mapping->source_id}"; $this->idMappings[$key] = $mapping->item_id; } $this->line('Loaded '.count($this->idMappings).' existing mappings'); } /** * models → items (FG) */ private function migrateModels(int $tenantId, bool $dryRun): void { $this->info('Migrating models → items (FG)...'); $models = DB::connection('chandj')->table('models') ->where('is_deleted', 0) ->get(); $this->line("Found {$models->count()} models"); $bar = $this->output->createProgressBar($models->count()); $bar->start(); foreach ($models as $model) { $mappingKey = "models:{$model->model_id}"; // 이미 마이그레이션된 경우 스킵 if (isset($this->idMappings[$mappingKey])) { $bar->advance(); continue; } $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'FG', 'code' => $model->model_name, 'name' => $model->model_name, 'unit' => 'SET', 'description' => $model->description, 'attributes' => json_encode([ 'major_category' => $model->major_category, 'finishing_type' => $model->finishing_type, 'guiderail_type' => $model->guiderail_type, 'source' => '5130', 'source_table' => 'models', 'source_id' => $model->model_id, ]), 'is_active' => 1, 'created_at' => $model->created_at ?? now(), 'updated_at' => $model->updated_at ?? now(), ]; if (! $dryRun) { $itemId = DB::connection('mysql')->table('items')->insertGetId($itemData); // 매핑 기록 DB::connection('mysql')->table('item_id_mappings')->insert([ 'source_table' => 'models', 'source_id' => $model->model_id, 'item_id' => $itemId, 'created_at' => now(), 'updated_at' => now(), ]); $this->idMappings[$mappingKey] = $itemId; } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info('Models migration completed'); } /** * parts → items (PT) */ private function migrateParts(int $tenantId, bool $dryRun): void { $this->info('Migrating parts → items (PT)...'); $parts = DB::connection('chandj')->table('parts') ->where('is_deleted', 0) ->get(); $this->line("Found {$parts->count()} parts"); $bar = $this->output->createProgressBar($parts->count()); $bar->start(); foreach ($parts as $part) { $mappingKey = "parts:{$part->part_id}"; if (isset($this->idMappings[$mappingKey])) { $bar->advance(); continue; } $code = $this->generateCode('PT', $part->part_name); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'PT', 'code' => $code, 'name' => $part->part_name ?: '(이름없음)', 'unit' => $part->unit ?: 'EA', 'description' => $part->memo, 'attributes' => json_encode([ 'spec' => $part->spec, 'unit_price' => $this->parseNumber($part->unitprice), 'image_url' => $part->img_url, 'source' => '5130', 'source_table' => 'parts', 'source_id' => $part->part_id, 'parent_model_id' => $part->model_id, ]), 'is_active' => 1, 'created_at' => $part->created_at ?? now(), 'updated_at' => $part->updated_at ?? now(), ]; if (! $dryRun) { $itemId = DB::connection('mysql')->table('items')->insertGetId($itemData); DB::connection('mysql')->table('item_id_mappings')->insert([ 'source_table' => 'parts', 'source_id' => $part->part_id, 'item_id' => $itemId, 'created_at' => now(), 'updated_at' => now(), ]); $this->idMappings[$mappingKey] = $itemId; } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info('Parts migration completed'); } /** * parts_sub → items (RM) */ private function migratePartsSub(int $tenantId, bool $dryRun): void { $this->info('Migrating parts_sub → items (RM)...'); $partsSub = DB::connection('chandj')->table('parts_sub') ->where('is_deleted', 0) ->get(); $this->line("Found {$partsSub->count()} parts_sub"); $bar = $this->output->createProgressBar($partsSub->count()); $bar->start(); foreach ($partsSub as $sub) { $mappingKey = "parts_sub:{$sub->subpart_id}"; if (isset($this->idMappings[$mappingKey])) { $bar->advance(); continue; } $code = $this->generateCode('RM', $sub->subpart_name); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'RM', 'code' => $code, 'name' => $sub->subpart_name ?: '(이름없음)', 'unit' => 'EA', 'description' => null, 'attributes' => json_encode([ 'material' => $sub->material, 'bend_sum' => $this->parseNumber($sub->bendSum), 'plate_sum' => $this->parseNumber($sub->plateSum), 'final_sum' => $this->parseNumber($sub->finalSum), 'unit_price' => $this->parseNumber($sub->unitPrice), 'computed_price' => $this->parseNumber($sub->computedPrice), 'line_total' => $this->parseNumber($sub->lineTotal), 'image_url' => $sub->image_url, 'source' => '5130', 'source_table' => 'parts_sub', 'source_id' => $sub->subpart_id, 'parent_part_id' => $sub->part_id, ]), 'is_active' => 1, 'created_at' => $sub->created_at ?? now(), 'updated_at' => $sub->updated_at ?? now(), ]; if (! $dryRun) { $itemId = DB::connection('mysql')->table('items')->insertGetId($itemData); DB::connection('mysql')->table('item_id_mappings')->insert([ 'source_table' => 'parts_sub', 'source_id' => $sub->subpart_id, 'item_id' => $itemId, 'created_at' => now(), 'updated_at' => now(), ]); $this->idMappings[$mappingKey] = $itemId; } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info('Parts_sub migration completed'); } /** * BDmodels → items (PT) + savejson → items (RM) + 관계 */ private function migrateBDModels(int $tenantId, bool $dryRun): void { $this->info('Migrating BDmodels → items (PT + RM with BOM)...'); $bdmodels = DB::connection('chandj')->table('BDmodels') ->where('is_deleted', 0) ->get(); $this->line("Found {$bdmodels->count()} BDmodels"); $bar = $this->output->createProgressBar($bdmodels->count()); $bar->start(); $bomCount = 0; foreach ($bdmodels as $bd) { $mappingKey = "BDmodels:{$bd->num}"; // 이미 마이그레이션된 경우 스킵 if (isset($this->idMappings[$mappingKey])) { $bar->advance(); continue; } // BDmodels → items (PT: 부품) $code = $this->generateCode('BD', $bd->seconditem ?: $bd->model_name ?: "BD{$bd->num}"); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'PT', 'code' => $code, 'name' => $bd->seconditem ?: $bd->model_name ?: '(이름없음)', 'unit' => 'EA', 'description' => $bd->description, 'attributes' => json_encode([ 'model_name' => $bd->model_name, 'major_category' => $bd->major_category, 'spec' => $bd->spec, 'finishing_type' => $bd->finishing_type, 'check_type' => $bd->check_type, 'exit_direction' => $bd->exit_direction, 'unit_price' => $this->parseNumber($bd->unitprice), 'price_date' => $bd->priceDate, 'source' => '5130', 'source_table' => 'BDmodels', 'source_id' => $bd->num, ]), 'is_active' => 1, 'created_at' => $bd->created_at ?? now(), 'updated_at' => $bd->updated_at ?? now(), ]; $parentItemId = null; if (! $dryRun) { $parentItemId = DB::connection('mysql')->table('items')->insertGetId($itemData); DB::connection('mysql')->table('item_id_mappings')->insert([ 'source_table' => 'BDmodels', 'source_id' => $bd->num, 'item_id' => $parentItemId, 'created_at' => now(), 'updated_at' => now(), ]); $this->idMappings[$mappingKey] = $parentItemId; } // savejson → 자식 items (RM: 원자재) + 관계 if (! empty($bd->savejson)) { $bomItems = json_decode($bd->savejson, true); if (is_array($bomItems)) { $orderNo = 0; foreach ($bomItems as $bomItem) { $orderNo++; $childMappingKey = "BDmodels_bom:{$bd->num}:{$orderNo}"; if (isset($this->idMappings[$childMappingKey])) { continue; } // col1: 품명, col2: 재질, col3: 여유, col4: 전개, col5: 합계 // col6: 단가, col7: 금액, col8: 수량, col9: 총액, col10: 비고 $childName = $bomItem['col1'] ?? '(BOM항목)'; $childCode = $this->generateCode('RM', $childName.'_'.$bd->num.'_'.$orderNo); $childData = [ 'tenant_id' => $tenantId, 'item_type' => 'RM', 'code' => $childCode, 'name' => $childName, 'unit' => 'EA', 'description' => $bomItem['col10'] ?? null, 'attributes' => json_encode([ 'material' => $bomItem['col2'] ?? null, 'margin' => $this->parseNumber($bomItem['col3'] ?? null), 'unfold' => $this->parseNumber($bomItem['col4'] ?? null), 'total_length' => $this->parseNumber($bomItem['col5'] ?? null), 'unit_price' => $this->parseNumber($bomItem['col6'] ?? null), 'amount' => $this->parseNumber($bomItem['col7'] ?? null), 'quantity' => $this->parseNumber($bomItem['col8'] ?? null), 'line_total' => $this->parseNumber($bomItem['col9'] ?? null), 'source' => '5130', 'source_table' => 'BDmodels_bom', 'source_id' => "{$bd->num}:{$orderNo}", 'parent_bdmodel_id' => $bd->num, ]), 'is_active' => 1, 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { $childItemId = DB::connection('mysql')->table('items')->insertGetId($childData); // BOM 항목은 item_id_mappings에 저장하지 않음 // (source_id가 bigint이라 "17:1" 같은 복합키 저장 불가) // 대신 attributes에 source 정보가 있고 entity_relationships로 관계 추적 $this->idMappings[$childMappingKey] = $childItemId; // 관계 생성 (BDmodel → BOM item) if ($parentItemId) { DB::connection('mysql')->table('entity_relationships')->insert([ 'tenant_id' => $tenantId, 'group_id' => 1, 'parent_type' => 'items', 'parent_id' => $parentItemId, 'child_type' => 'items', 'child_id' => $childItemId, 'order_no' => $orderNo, 'metadata' => json_encode([ 'quantity' => $this->parseNumber($bomItem['col8'] ?? '1'), 'relation' => 'bom', 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]); } $bomCount++; } } } } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("BDmodels migration completed (BOM items: {$bomCount})"); } /** * 관계 마이그레이션 (entity_relationships) */ private function migrateRelations(int $tenantId, bool $dryRun): void { $this->info('Migrating relations → entity_relationships...'); // 1. models ↔ parts 관계 $this->info(' → models ↔ parts relations...'); $parts = DB::connection('chandj')->table('parts') ->where('is_deleted', 0) ->get(); $relCount = 0; foreach ($parts as $part) { $parentKey = "models:{$part->model_id}"; $childKey = "parts:{$part->part_id}"; if (! isset($this->idMappings[$parentKey]) || ! isset($this->idMappings[$childKey])) { continue; } $exists = DB::connection('mysql')->table('entity_relationships') ->where('tenant_id', $tenantId) ->where('parent_type', 'items') ->where('parent_id', $this->idMappings[$parentKey]) ->where('child_type', 'items') ->where('child_id', $this->idMappings[$childKey]) ->exists(); if (! $exists && ! $dryRun) { DB::connection('mysql')->table('entity_relationships')->insert([ 'tenant_id' => $tenantId, 'group_id' => 1, 'parent_type' => 'items', 'parent_id' => $this->idMappings[$parentKey], 'child_type' => 'items', 'child_id' => $this->idMappings[$childKey], 'order_no' => $part->part_id, 'metadata' => json_encode([ 'quantity' => $part->quantity, 'relation' => 'bom', 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]); $relCount++; } } $this->line(" Created {$relCount} model-part relations"); // 2. parts ↔ parts_sub 관계 $this->info(' → parts ↔ parts_sub relations...'); $partsSub = DB::connection('chandj')->table('parts_sub') ->where('is_deleted', 0) ->get(); $relCount = 0; foreach ($partsSub as $sub) { $parentKey = "parts:{$sub->part_id}"; $childKey = "parts_sub:{$sub->subpart_id}"; if (! isset($this->idMappings[$parentKey]) || ! isset($this->idMappings[$childKey])) { continue; } $exists = DB::connection('mysql')->table('entity_relationships') ->where('tenant_id', $tenantId) ->where('parent_type', 'items') ->where('parent_id', $this->idMappings[$parentKey]) ->where('child_type', 'items') ->where('child_id', $this->idMappings[$childKey]) ->exists(); if (! $exists && ! $dryRun) { DB::connection('mysql')->table('entity_relationships')->insert([ 'tenant_id' => $tenantId, 'group_id' => 1, 'parent_type' => 'items', 'parent_id' => $this->idMappings[$parentKey], 'child_type' => 'items', 'child_id' => $this->idMappings[$childKey], 'order_no' => $sub->subpart_id, 'metadata' => json_encode([ 'quantity' => $sub->quantity, 'relation' => 'bom', 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]); $relCount++; } } $this->line(" Created {$relCount} part-subpart relations"); $this->info('Relations migration completed'); } /** * 롤백 (source=5130 데이터 삭제) */ private function rollbackMigration(int $tenantId, bool $dryRun): int { $this->warn('=== 롤백 모드 ==='); if (! $this->confirm('5130에서 마이그레이션된 모든 데이터를 삭제하시겠습니까?')) { $this->info('롤백 취소됨'); return self::SUCCESS; } // 1. entity_relationships 삭제 $relCount = DB::connection('mysql')->table('entity_relationships') ->where('tenant_id', $tenantId) ->whereRaw("JSON_EXTRACT(metadata, '$.source') = '5130'") ->count(); if (! $dryRun) { DB::connection('mysql')->table('entity_relationships') ->where('tenant_id', $tenantId) ->whereRaw("JSON_EXTRACT(metadata, '$.source') = '5130'") ->delete(); } $this->line("Deleted {$relCount} entity_relationships"); // 2. items 삭제 (매핑 기반) $mappings = DB::connection('mysql')->table('item_id_mappings')->get(); $itemIds = $mappings->pluck('item_id')->toArray(); if (! $dryRun && ! empty($itemIds)) { DB::connection('mysql')->table('items') ->whereIn('id', $itemIds) ->delete(); } $this->line('Deleted '.count($itemIds).' items'); // 3. item_id_mappings 삭제 if (! $dryRun) { DB::connection('mysql')->table('item_id_mappings')->truncate(); } $this->line('Cleared item_id_mappings'); $this->info('롤백 완료'); return self::SUCCESS; } /** * 코드 생성 */ private function generateCode(string $prefix, ?string $name): string { if (empty($name)) { return $prefix.'-'.Str::random(8); } // 한글은 유지, 특수문자 제거, 공백→언더스코어 $code = preg_replace('/[^\p{L}\p{N}\s]/u', '', $name); $code = preg_replace('/\s+/', '_', trim($code)); $code = Str::upper($code); return $prefix.'-'.Str::limit($code, 50, ''); } /** * 숫자 파싱 (varchar → decimal) */ private function parseNumber(?string $value): ?float { if (empty($value)) { return null; } $cleaned = preg_replace('/[^\d.-]/', '', $value); return is_numeric($cleaned) ? (float) $cleaned : null; } /** * 요약 출력 */ private function showSummary(): void { $this->newLine(); $this->table( ['Source Table', 'Migrated Count'], [ ['models', count(array_filter(array_keys($this->idMappings), fn ($k) => str_starts_with($k, 'models:')))], ['parts', count(array_filter(array_keys($this->idMappings), fn ($k) => str_starts_with($k, 'parts:') && ! str_starts_with($k, 'parts_sub:')))], ['parts_sub', count(array_filter(array_keys($this->idMappings), fn ($k) => str_starts_with($k, 'parts_sub:')))], ['BDmodels', count(array_filter(array_keys($this->idMappings), fn ($k) => str_starts_with($k, 'BDmodels:') && ! str_starts_with($k, 'BDmodels_bom:')))], ['BDmodels_bom', count(array_filter(array_keys($this->idMappings), fn ($k) => str_starts_with($k, 'BDmodels_bom:')))], ] ); } }