diff --git a/app/Console/Commands/Migrate5130Items.php b/app/Console/Commands/Migrate5130Items.php new file mode 100644 index 0000000..8f7376e --- /dev/null +++ b/app/Console/Commands/Migrate5130Items.php @@ -0,0 +1,652 @@ + 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:')))], + ] + ); + } +} diff --git a/config/database.php b/config/database.php index 8910562..0059456 100644 --- a/config/database.php +++ b/config/database.php @@ -62,6 +62,23 @@ ]) : [], ], + // 5130 레거시 DB (chandj) + 'chandj' => [ + 'driver' => 'mysql', + 'host' => env('CHANDJ_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('CHANDJ_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('CHANDJ_DB_DATABASE', 'chandj'), + 'username' => env('CHANDJ_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('CHANDJ_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => false, + 'engine' => null, + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'),