['total' => 0, 'migrated' => 0, 'skipped' => 0], 'raw_materials' => ['total' => 0, 'migrated' => 0, 'skipped' => 0], 'bend' => ['total' => 0, 'migrated' => 0, 'skipped' => 0], ]; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $step = $this->option('step'); $rollback = $this->option('rollback'); $limit = (int) $this->option('limit'); $this->info('╔══════════════════════════════════════════════════════════════╗'); $this->info('║ 5130 가격표 → SAM items 마이그레이션 ║'); $this->info('╚══════════════════════════════════════════════════════════════╝'); $this->newLine(); $this->info("📌 Tenant ID: {$tenantId}"); $this->info('📌 Mode: '.($dryRun ? '🔍 DRY-RUN (시뮬레이션)' : '⚡ LIVE')); $this->info("📌 Step: {$step}"); if ($limit > 0) { $this->warn("📌 Limit: {$limit} records (테스트 모드)"); } $this->newLine(); if ($rollback) { return $this->rollbackMigration($tenantId, $dryRun); } $steps = $step === 'all' ? ['kdunitprice', 'raw_materials', 'bend'] : [$step]; foreach ($steps as $currentStep) { $this->line('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info(">>> Step: {$currentStep}"); $this->newLine(); match ($currentStep) { 'kdunitprice' => $this->migrateKDunitprice($tenantId, $dryRun, $limit), 'raw_materials' => $this->migratePriceRawMaterials($tenantId, $dryRun, $limit), 'bend' => $this->migratePriceBend($tenantId, $dryRun, $limit), default => $this->error("Unknown step: {$currentStep}"), }; $this->newLine(); } $this->showSummary(); return self::SUCCESS; } /** * KDunitprice → items (SM, RM, CS) */ private function migrateKDunitprice(int $tenantId, bool $dryRun, int $limit): void { $this->info('📦 Migrating KDunitprice → items (SM, RM, CS)...'); $query = DB::connection($this->sourceDb)->table('KDunitprice') ->whereNull('is_deleted') ->orWhere('is_deleted', 0); if ($limit > 0) { $query->limit($limit); } $items = $query->get(); $this->stats['kdunitprice']['total'] = $items->count(); $this->line("Found {$items->count()} records"); $bar = $this->output->createProgressBar($items->count()); $bar->start(); foreach ($items as $item) { // 이미 존재하는지 확인 (code 기준) $exists = DB::connection($this->targetDb)->table('items') ->where('tenant_id', $tenantId) ->where('code', $item->prodcode) ->whereNull('deleted_at') ->exists(); if ($exists) { $this->stats['kdunitprice']['skipped']++; $bar->advance(); continue; } // item_type 결정 $itemType = $this->determineItemType($item->item_div); // 단가 파싱 (콤마 제거) $unitPrice = $this->parseNumber($item->unitprice); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => $itemType, 'item_category' => $item->item_div, 'code' => $item->prodcode, 'name' => $item->item_name ?: '(이름없음)', 'unit' => $item->unit ?: 'EA', 'attributes' => json_encode([ 'spec' => $item->spec, 'unit_price' => $unitPrice, 'update_log' => $item->update_log, 'source' => '5130', 'source_table' => 'KDunitprice', 'source_id' => $item->num, ], JSON_UNESCAPED_UNICODE), 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('items')->insert($itemData); } $this->stats['kdunitprice']['migrated']++; $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("✅ KDunitprice 완료: {$this->stats['kdunitprice']['migrated']} migrated, {$this->stats['kdunitprice']['skipped']} skipped"); } /** * price_raw_materials → items (RM) * 최신 버전(registedate)만 마이그레이션 */ private function migratePriceRawMaterials(int $tenantId, bool $dryRun, int $limit): void { $this->info('📦 Migrating price_raw_materials → items (RM)...'); // 최신 registedate 조회 $latestRecord = DB::connection($this->sourceDb)->table('price_raw_materials') ->whereNull('is_deleted') ->orWhere('is_deleted', 0) ->orderBy('registedate', 'desc') ->first(); if (! $latestRecord) { $this->warn('No records found in price_raw_materials'); return; } $this->line("Latest version: {$latestRecord->registedate}"); $itemList = json_decode($latestRecord->itemList ?? '[]', true); if (empty($itemList)) { $this->warn('Empty itemList in latest record'); return; } $totalItems = count($itemList); if ($limit > 0 && $limit < $totalItems) { $itemList = array_slice($itemList, 0, $limit); } $this->stats['raw_materials']['total'] = count($itemList); $this->line('Found '.count($itemList).' items in itemList'); $bar = $this->output->createProgressBar(count($itemList)); $bar->start(); $orderNo = 0; foreach ($itemList as $row) { $orderNo++; // 코드 생성: col14가 있으면 사용, 없으면 자동생성 $code = ! empty($row['col14']) ? $row['col14'] : $this->generateCode('RM', $latestRecord->registedate, $orderNo); // 이미 존재하는지 확인 $exists = DB::connection($this->targetDb)->table('items') ->where('tenant_id', $tenantId) ->where('code', $code) ->whereNull('deleted_at') ->exists(); if ($exists) { $this->stats['raw_materials']['skipped']++; $bar->advance(); continue; } // 품목명 생성 $name = trim(($row['col1'] ?? '').' '.($row['col2'] ?? '')) ?: '(이름없음)'; // item_category 결정 $itemCategory = $this->determineRawMaterialCategory($row['col1'] ?? ''); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'RM', 'item_category' => $itemCategory, 'code' => $code, 'name' => $name, 'unit' => 'kg', 'attributes' => json_encode([ 'product_type' => $row['col1'] ?? null, 'sub_type' => $row['col2'] ?? null, 'length' => $this->parseNumber($row['col3'] ?? null), 'thickness' => $this->parseNumber($row['col4'] ?? null), 'specific_gravity' => $this->parseNumber($row['col5'] ?? null), 'area_per_unit' => $this->parseNumber($row['col6'] ?? null), 'weight_per_sqm' => $this->parseNumber($row['col7'] ?? null), 'purchase_price_per_kg' => $this->parseNumber($row['col8'] ?? null), 'price_per_sqm_with_loss' => $this->parseNumber($row['col9'] ?? null), 'processing_cost' => $this->parseNumber($row['col10'] ?? null), 'mimi' => $this->parseNumber($row['col11'] ?? null), 'total' => $this->parseNumber($row['col12'] ?? null), 'total_with_labor' => $this->parseNumber($row['col13'] ?? null), 'non_certified_code' => $row['col14'] ?? null, 'non_certified_price' => $this->parseNumber($row['col15'] ?? null), 'source' => '5130', 'source_table' => 'price_raw_materials', 'source_id' => $latestRecord->num, 'source_version' => $latestRecord->registedate, 'source_order' => $orderNo, ], JSON_UNESCAPED_UNICODE), 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('items')->insert($itemData); } $this->stats['raw_materials']['migrated']++; $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("✅ price_raw_materials 완료: {$this->stats['raw_materials']['migrated']} migrated, {$this->stats['raw_materials']['skipped']} skipped"); } /** * price_bend → items (PT) * 최신 버전(registedate)만 마이그레이션 */ private function migratePriceBend(int $tenantId, bool $dryRun, int $limit): void { $this->info('📦 Migrating price_bend → items (PT)...'); // 최신 registedate 조회 $latestRecord = DB::connection($this->sourceDb)->table('price_bend') ->whereNull('is_deleted') ->orWhere('is_deleted', 0) ->orderBy('registedate', 'desc') ->first(); if (! $latestRecord) { $this->warn('No records found in price_bend'); return; } $this->line("Latest version: {$latestRecord->registedate}"); $itemList = json_decode($latestRecord->itemList ?? '[]', true); if (empty($itemList)) { $this->warn('Empty itemList in latest record'); return; } $totalItems = count($itemList); if ($limit > 0 && $limit < $totalItems) { $itemList = array_slice($itemList, 0, $limit); } $this->stats['bend']['total'] = count($itemList); $this->line('Found '.count($itemList).' items in itemList'); $bar = $this->output->createProgressBar(count($itemList)); $bar->start(); $orderNo = 0; foreach ($itemList as $row) { $orderNo++; // 코드 자동생성 $code = $this->generateCode('PT', $latestRecord->registedate, $orderNo); // 이미 존재하는지 확인 $exists = DB::connection($this->targetDb)->table('items') ->where('tenant_id', $tenantId) ->where('code', $code) ->whereNull('deleted_at') ->exists(); if ($exists) { $this->stats['bend']['skipped']++; $bar->advance(); continue; } // 품목명 생성 $name = trim(($row['col1'] ?? '').' '.($row['col2'] ?? '')) ?: '(이름없음)'; // item_category 결정 (STEEL/ALUMINUM 등) $itemCategory = $this->determineBendCategory($row['col1'] ?? ''); $itemData = [ 'tenant_id' => $tenantId, 'item_type' => 'PT', 'item_category' => $itemCategory, 'code' => $code, 'name' => $name, 'unit' => '㎡', 'attributes' => json_encode([ 'material_type' => $row['col1'] ?? null, 'material_sub' => $row['col2'] ?? null, 'width' => $this->parseNumber($row['col3'] ?? null), 'length' => $this->parseNumber($row['col4'] ?? null), 'thickness' => $this->parseNumber($row['col5'] ?? null), 'specific_gravity' => $this->parseNumber($row['col6'] ?? null), 'area' => $this->parseNumber($row['col7'] ?? null), 'weight' => $this->parseNumber($row['col8'] ?? null), 'purchase_price_per_kg' => $this->parseNumber($row['col9'] ?? null), 'loss_premium_price' => $this->parseNumber($row['col10'] ?? null), 'material_cost' => $this->parseNumber($row['col11'] ?? null), 'selling_price' => $this->parseNumber($row['col12'] ?? null), 'processing_cost_per_sqm' => $this->parseNumber($row['col13'] ?? null), 'processing_cost' => $this->parseNumber($row['col14'] ?? null), 'total' => $this->parseNumber($row['col15'] ?? null), 'price_per_sqm' => $this->parseNumber($row['col16'] ?? null), 'selling_price_per_sqm' => $this->parseNumber($row['col17'] ?? null), 'price_per_kg' => $this->parseNumber($row['col18'] ?? null), 'source' => '5130', 'source_table' => 'price_bend', 'source_id' => $latestRecord->num, 'source_version' => $latestRecord->registedate, 'source_order' => $orderNo, ], JSON_UNESCAPED_UNICODE), 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('items')->insert($itemData); } $this->stats['bend']['migrated']++; $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("✅ price_bend 완료: {$this->stats['bend']['migrated']} migrated, {$this->stats['bend']['skipped']} skipped"); } /** * item_type 결정 (KDunitprice용) * * 5130 item_div → SAM item_type 매핑: * - [원재료] → RM (원자재) * - [부재료] → SM (부자재) * - [상품], [제품] → FG (완제품) * - [반제품] → PT (부품) * - [무형상품] → CS (소모품/서비스) * * 주의: 조건 순서가 중요함 (더 구체적인 조건을 먼저 체크) * - '반제품'을 '제품'보다 먼저 체크 * - '무형상품'을 '상품'보다 먼저 체크 */ private function determineItemType(?string $itemDiv): string { if (empty($itemDiv)) { return 'SM'; } $lower = mb_strtolower($itemDiv); // 1. 부품 판별 (반제품) - '제품'보다 먼저! if (str_contains($lower, '반제품')) { return 'PT'; } // 2. 소모품 판별 (무형상품) - '상품'보다 먼저! if (str_contains($lower, '무형') || str_contains($lower, '소모품') || str_contains($lower, 'consumable') || str_contains($lower, '소모')) { return 'CS'; } // 3. 완제품 판별 (상품, 제품) if (str_contains($lower, '상품') || str_contains($lower, '제품')) { return 'FG'; } // 4. 원자재 판별 (원재료) if (str_contains($lower, '원자재') || str_contains($lower, '원재료') || str_contains($lower, 'raw') || str_contains($lower, '원료')) { return 'RM'; } // 5. 기본값: 부자재 (부재료 포함) return 'SM'; } /** * 원자재 카테고리 결정 */ private function determineRawMaterialCategory(?string $productType): string { if (empty($productType)) { return 'GENERAL'; } $lower = mb_strtolower($productType); if (str_contains($lower, '슬랫') || str_contains($lower, 'slat')) { return 'SLAT'; } if (str_contains($lower, '알루미늄') || str_contains($lower, 'aluminum') || str_contains($lower, 'al')) { return 'ALUMINUM'; } if (str_contains($lower, '스틸') || str_contains($lower, 'steel')) { return 'STEEL'; } return 'GENERAL'; } /** * 절곡 카테고리 결정 */ private function determineBendCategory(?string $materialType): string { if (empty($materialType)) { return 'GENERAL'; } $lower = mb_strtolower($materialType); if (str_contains($lower, '스틸') || str_contains($lower, 'steel') || str_contains($lower, '철')) { return 'STEEL'; } if (str_contains($lower, '알루미늄') || str_contains($lower, 'aluminum') || str_contains($lower, 'al')) { return 'ALUMINUM'; } return 'BENDING'; } /** * 코드 생성 */ private function generateCode(string $prefix, ?string $date, int $orderNo): string { $dateStr = $date ? str_replace('-', '', $date) : date('Ymd'); return sprintf('%s-%s-%03d', $prefix, $dateStr, $orderNo); } /** * 숫자 파싱 (콤마, 공백 제거) */ private function parseNumber(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (float) $value; } $cleaned = preg_replace('/[^\d.-]/', '', (string) $value); return is_numeric($cleaned) ? (float) $cleaned : null; } /** * 롤백 */ private function rollbackMigration(int $tenantId, bool $dryRun): int { $this->warn('╔══════════════════════════════════════════════════════════════╗'); $this->warn('║ ⚠️ 롤백 모드 ║'); $this->warn('╚══════════════════════════════════════════════════════════════╝'); if (! $this->confirm('5130 가격표에서 마이그레이션된 모든 데이터를 삭제하시겠습니까?')) { $this->info('롤백 취소됨'); return self::SUCCESS; } $tables = ['KDunitprice', 'price_raw_materials', 'price_bend']; foreach ($tables as $table) { $count = DB::connection($this->targetDb)->table('items') ->where('tenant_id', $tenantId) ->whereRaw("JSON_EXTRACT(attributes, '$.source_table') = ?", [$table]) ->count(); if (! $dryRun) { DB::connection($this->targetDb)->table('items') ->where('tenant_id', $tenantId) ->whereRaw("JSON_EXTRACT(attributes, '$.source_table') = ?", [$table]) ->delete(); } $this->line("Deleted {$count} items from {$table}"); } $this->info('✅ 롤백 완료'); return self::SUCCESS; } /** * 요약 출력 */ private function showSummary(): void { $this->newLine(); $this->info('╔══════════════════════════════════════════════════════════════╗'); $this->info('║ 📊 마이그레이션 결과 요약 ║'); $this->info('╚══════════════════════════════════════════════════════════════╝'); $this->table( ['Source Table', 'Total', 'Migrated', 'Skipped'], [ ['KDunitprice', $this->stats['kdunitprice']['total'], $this->stats['kdunitprice']['migrated'], $this->stats['kdunitprice']['skipped']], ['price_raw_materials', $this->stats['raw_materials']['total'], $this->stats['raw_materials']['migrated'], $this->stats['raw_materials']['skipped']], ['price_bend', $this->stats['bend']['total'], $this->stats['bend']['migrated'], $this->stats['bend']['skipped']], ] ); $totalMigrated = $this->stats['kdunitprice']['migrated'] + $this->stats['raw_materials']['migrated'] + $this->stats['bend']['migrated']; $this->newLine(); $this->info("🎉 총 {$totalMigrated}개 품목 마이그레이션 완료!"); } }