order_id) private array $orderMappings = []; // 통계 private array $stats = [ 'orders' => 0, 'order_items_screen' => 0, 'order_items_slat' => 0, 'order_items_motor' => 0, 'order_items_bend' => 0, 'order_items_etc' => 0, 'skipped' => 0, 'errors' => 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'); $offset = (int) $this->option('offset'); $this->info('=== 5130 → SAM 수주 마이그레이션 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); $this->info("Step: {$step}"); if ($limit > 0) { $this->info("Limit: {$limit}, Offset: {$offset}"); } $this->newLine(); if ($rollback) { return $this->rollbackMigration($tenantId, $dryRun); } // 기존 매핑 로드 $this->loadExistingMappings(); $steps = $step === 'all' ? ['orders', 'items', 'extra'] : [$step]; foreach ($steps as $currentStep) { $this->line(">>> Step: {$currentStep}"); match ($currentStep) { 'orders' => $this->migrateOrders($tenantId, $dryRun, $limit, $offset), 'items' => $this->migrateOrderItems($tenantId, $dryRun, $limit, $offset), 'extra' => $this->migrateExtraItems($tenantId, $dryRun, $limit, $offset), default => $this->error("Unknown step: {$currentStep}"), }; $this->newLine(); } $this->info('=== 마이그레이션 완료 ==='); $this->showSummary(); return self::SUCCESS; } /** * 기존 매핑 로드 (중복 방지) */ private function loadExistingMappings(): void { $mappings = DB::connection($this->targetDb)->table('order_id_mappings')->get(); foreach ($mappings as $mapping) { $this->orderMappings[$mapping->source_num] = $mapping->order_id; } $this->line('Loaded '.count($this->orderMappings).' existing mappings'); } /** * output → orders 마이그레이션 */ private function migrateOrders(int $tenantId, bool $dryRun, int $limit, int $offset): void { $this->info('Migrating output → orders...'); $query = DB::connection($this->sourceDb)->table('output') ->where('is_deleted', '!=', '1') ->orderBy('num'); if ($limit > 0) { $query->skip($offset)->take($limit); } $outputs = $query->get(); $this->line("Found {$outputs->count()} orders"); $bar = $this->output->createProgressBar($outputs->count()); $bar->start(); foreach ($outputs as $output) { // 이미 마이그레이션된 경우 스킵 if (isset($this->orderMappings[$output->num])) { $this->stats['skipped']++; $bar->advance(); continue; } try { $orderData = $this->mapOutputToOrder($output, $tenantId); if (! $dryRun) { $orderId = DB::connection($this->targetDb)->table('orders')->insertGetId($orderData); // 매핑 기록 DB::connection($this->targetDb)->table('order_id_mappings')->insert([ 'source_num' => $output->num, 'order_id' => $orderId, 'created_at' => now(), 'updated_at' => now(), ]); $this->orderMappings[$output->num] = $orderId; } $this->stats['orders']++; } catch (\Exception $e) { $this->stats['errors']++; $this->newLine(); $this->error("Error migrating order {$output->num}: {$e->getMessage()}"); } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("Orders migration completed: {$this->stats['orders']} created"); } /** * output → order 데이터 매핑 */ private function mapOutputToOrder(object $output, int $tenantId): array { // 상태 코드 매핑 $statusMap = [ '등록' => 'REGISTERED', '진행' => 'IN_PROGRESS', '출고' => 'SHIPPED', '완료' => 'COMPLETED', '취소' => 'CANCELLED', ]; $status = $statusMap[$output->regist_state ?? ''] ?? 'REGISTERED'; // 주문번호 생성 (5130 원본 번호 기반) $orderNo = 'ORD-5130-'.str_pad($output->num, 6, '0', STR_PAD_LEFT); // options JSON에 저장할 레거시 데이터 $options = [ 'legacy_num' => $output->num, 'legacy_parent_num' => $output->parent_num ?? null, 'legacy_con_num' => $output->con_num ?? null, 'orderman' => $output->orderman ?? null, 'receiver' => $output->receiver ?? null, 'outputplace' => $output->outputplace ?? null, 'root' => $output->root ?? null, 'steel' => $output->steel ?? null, 'motor' => $output->motor ?? null, 'delivery' => $output->delivery ?? null, 'screen_state' => $output->screen_state ?? null, 'slat_state' => $output->slat_state ?? null, 'bend_state' => $output->bend_state ?? null, 'motor_state' => $output->motor_state ?? null, 'screen_su' => $output->screen_su ?? null, 'screen_m2' => $output->screen_m2 ?? null, 'slat_su' => $output->slat_su ?? null, 'slat_m2' => $output->slat_m2 ?? null, 'secondord' => $output->secondord ?? null, 'secondordman' => $output->secondordman ?? null, 'secondordmantel' => $output->secondordmantel ?? null, 'secondordnum' => $output->secondordnum ?? null, 'prodCode' => $output->prodCode ?? null, 'warrantyNum' => $output->warrantyNum ?? null, 'warranty' => $output->warranty ?? null, 'lotNum' => $output->lotNum ?? null, 'pjnum' => $output->pjnum ?? null, 'major_category' => $output->major_category ?? null, 'position' => $output->position ?? null, 'makeWidth' => $output->makeWidth ?? null, 'makeHeight' => $output->makeHeight ?? null, 'maguriWing' => $output->maguriWing ?? null, 'ACI' => [ 'regDate' => $output->ACIregDate ?? null, 'askDate' => $output->ACIaskDate ?? null, 'doneDate' => $output->ACIdoneDate ?? null, 'memo' => $output->ACImemo ?? null, 'check' => $output->ACIcheck ?? null, 'groupCode' => $output->ACIgroupCode ?? null, 'groupName' => $output->ACIgroupName ?? null, ], 'devMode' => $output->devMode ?? null, 'displayText' => $output->displayText ?? null, 'slatcheck' => $output->slatcheck ?? null, 'partscheck' => $output->partscheck ?? null, 'requestBendingASSY' => $output->requestBendingASSY ?? null, 'source' => '5130', ]; return [ 'tenant_id' => $tenantId, 'order_no' => $orderNo, 'order_type_code' => 'STANDARD', 'status_code' => $status, 'category_code' => $output->major_category ?? null, 'received_at' => $this->parseDate($output->outdate), 'client_id' => null, // client 매핑은 별도 처리 필요 'client_name' => null, 'client_contact' => $output->phone ?? null, 'site_name' => $output->outworkplace ?? null, 'supply_amount' => $this->parseNumber($output->estimateTotal ?? '0'), 'tax_amount' => 0, 'total_amount' => $this->parseNumber($output->estimateTotal ?? '0'), 'discount_rate' => 0, 'discount_amount' => 0, 'delivery_date' => $this->parseDate($output->indate), 'memo' => $output->comment ?? null, 'remarks' => $output->updatecomment ?? null, 'options' => json_encode($options), 'created_at' => now(), 'updated_at' => now(), ]; } /** * screenlist/slatlist → order_items 마이그레이션 * 이미 마이그레이션된 orders에 대해서만 처리 */ private function migrateOrderItems(int $tenantId, bool $dryRun, int $limit, int $offset): void { $this->info('Migrating screenlist/slatlist → order_items...'); // 매핑된 source_num 목록으로 필터링 $sourceNums = array_keys($this->orderMappings); if (empty($sourceNums)) { $this->warn('No order mappings found. Run orders step first.'); return; } $query = DB::connection($this->sourceDb)->table('output') ->whereIn('num', $sourceNums) ->where(function ($q) { $q->whereRaw('LENGTH(screenlist) > 2') // '{}' 보다 긴 것만 ->orWhereRaw('LENGTH(slatlist) > 2'); }) ->orderBy('num'); $outputs = $query->get(); $this->line("Found {$outputs->count()} orders with screen/slat items"); if ($outputs->isEmpty()) { return; } $bar = $this->output->createProgressBar($outputs->count()); $bar->start(); foreach ($outputs as $output) { $orderId = $this->orderMappings[$output->num] ?? null; if (! $orderId) { $bar->advance(); continue; } // 이미 order_items가 있는지 확인 (중복 방지) $existingCount = DB::connection($this->targetDb)->table('order_items') ->where('order_id', $orderId) ->count(); if ($existingCount > 0) { $bar->advance(); continue; } // screenlist 처리 if (! empty($output->screenlist) && strlen($output->screenlist) > 2) { $this->processJsonItems($output->screenlist, $orderId, $tenantId, 'SCREEN', $dryRun); } // slatlist 처리 if (! empty($output->slatlist) && strlen($output->slatlist) > 2) { $this->processJsonItems($output->slatlist, $orderId, $tenantId, 'SLAT', $dryRun); } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("Order items migration completed: screen={$this->stats['order_items_screen']}, slat={$this->stats['order_items_slat']}"); } /** * JSON 리스트를 order_items로 변환 */ private function processJsonItems(string $json, int $orderId, int $tenantId, string $type, bool $dryRun): void { $items = json_decode($json, true); if (! is_array($items)) { return; } $serialNo = 0; foreach ($items as $item) { $serialNo++; // 빈 항목 스킵 if (empty($item['cutwidth']) && empty($item['cutheight']) && empty($item['text1'])) { continue; } $specification = ''; if (! empty($item['cutwidth']) || ! empty($item['cutheight'])) { $specification = ($item['cutwidth'] ?? '').'x'.($item['cutheight'] ?? ''); } $itemData = [ 'tenant_id' => $tenantId, 'order_id' => $orderId, 'serial_no' => $serialNo, 'item_code' => $type.'-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT), 'item_name' => $type === 'SCREEN' ? '방충망' : '슬랫', 'specification' => $specification, 'floor_code' => $item['floors'] ?? null, 'symbol_code' => $item['text1'] ?? null, 'unit' => 'EA', 'quantity' => (int) ($item['number'] ?? 1), 'unit_price' => 0, 'supply_amount' => 0, 'tax_amount' => 0, 'total_amount' => 0, 'status_code' => 'PENDING', 'remarks' => $item['memo'] ?? null, 'note' => $item['text2'] ?? null, 'sort_order' => $serialNo, 'attributes' => json_encode([ 'item_type' => $type, 'cutwidth' => $item['cutwidth'] ?? null, 'cutheight' => $item['cutheight'] ?? null, 'exititem' => $item['exititem'] ?? null, 'printside' => $item['printside'] ?? null, 'direction' => $item['direction'] ?? null, 'intervalnum' => $item['intervalnum'] ?? null, 'intervalnumsecond' => $item['intervalnumsecond'] ?? null, 'exitinterval' => $item['exitinterval'] ?? null, 'cover' => $item['cover'] ?? null, 'drawbottom1' => $item['drawbottom1'] ?? null, 'drawbottom2' => $item['drawbottom2'] ?? null, 'drawbottom3' => $item['drawbottom3'] ?? null, 'draw' => $item['draw'] ?? null, 'done_check' => $item['done_check'] ?? null, 'remain_check' => $item['remain_check'] ?? null, 'mid_check' => $item['mid_check'] ?? null, 'left_check' => $item['left_check'] ?? null, 'right_check' => $item['right_check'] ?? null, // SLAT 전용 'hinge' => $item['hinge'] ?? null, 'hingenum' => $item['hingenum'] ?? null, 'hinge_direction' => $item['hinge_direction'] ?? null, 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('order_items')->insert($itemData); } if ($type === 'SCREEN') { $this->stats['order_items_screen']++; } else { $this->stats['order_items_slat']++; } } } /** * output_extra (motorList, bendList 등) → order_items 마이그레이션 */ private function migrateExtraItems(int $tenantId, bool $dryRun, int $limit, int $offset): void { $this->info('Migrating output_extra → order_items (motor, bend, etc)...'); $query = DB::connection($this->sourceDb)->table('output_extra') ->orderBy('parent_num'); if ($limit > 0) { $query->skip($offset)->take($limit); } $extras = $query->get(); $this->line("Found {$extras->count()} output_extra records"); $bar = $this->output->createProgressBar($extras->count()); $bar->start(); foreach ($extras as $extra) { $orderId = $this->orderMappings[$extra->parent_num] ?? null; if (! $orderId) { $bar->advance(); continue; } // motorList 처리 if (! empty($extra->motorList) && $extra->motorList !== '{}') { $this->processMotorList($extra->motorList, $orderId, $tenantId, $dryRun); } // bendList 처리 if (! empty($extra->bendList) && $extra->bendList !== '{}') { $this->processBendList($extra->bendList, $orderId, $tenantId, $dryRun); } // etcList 처리 if (! empty($extra->etcList) && $extra->etcList !== '{}') { $this->processEtcList($extra->etcList, $orderId, $tenantId, $dryRun); } // orders.options 업데이트 (금액 정보) if (! $dryRun) { $this->updateOrderOptions($orderId, $extra); } $bar->advance(); } $bar->finish(); $this->newLine(); $this->info("Extra items migration completed: motor={$this->stats['order_items_motor']}, bend={$this->stats['order_items_bend']}, etc={$this->stats['order_items_etc']}"); } /** * motorList → order_items (MOTOR) */ private function processMotorList(string $json, int $orderId, int $tenantId, bool $dryRun): void { $items = json_decode($json, true); if (! is_array($items)) { return; } $serialNo = $this->getNextSerialNo($orderId); foreach ($items as $item) { if (empty($item['col1'])) { continue; } $itemData = [ 'tenant_id' => $tenantId, 'order_id' => $orderId, 'serial_no' => $serialNo++, 'item_code' => 'MOTOR-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT), 'item_name' => $item['col1'] ?? '모터', 'specification' => $item['col2'] ?? null, // 용량 'unit' => 'EA', 'quantity' => (int) ($item['col5'] ?? 1), 'unit_price' => 0, 'supply_amount' => 0, 'tax_amount' => 0, 'total_amount' => 0, 'status_code' => 'PENDING', 'sort_order' => $serialNo, 'attributes' => json_encode([ 'item_type' => 'MOTOR', 'dimension' => $item['col3'] ?? null, // 규격 'inch' => $item['col4'] ?? null, 'type' => $item['col6'] ?? null, // 형태 'option' => $item['col7'] ?? null, 'power' => $item['col8'] ?? null, // 전원 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('order_items')->insert($itemData); } $this->stats['order_items_motor']++; } } /** * bendList → order_items (BEND) */ private function processBendList(string $json, int $orderId, int $tenantId, bool $dryRun): void { $items = json_decode($json, true); if (! is_array($items)) { return; } $serialNo = $this->getNextSerialNo($orderId); foreach ($items as $item) { if (empty($item['col1'])) { continue; } $itemData = [ 'tenant_id' => $tenantId, 'order_id' => $orderId, 'serial_no' => $serialNo++, 'item_code' => 'BEND-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT), 'item_name' => $item['col1'] ?? '절곡', 'specification' => $item['col2'] ?? null, // 재질 'unit' => 'EA', 'quantity' => (int) ($item['col7'] ?? 1), 'unit_price' => 0, 'supply_amount' => 0, 'tax_amount' => 0, 'total_amount' => 0, 'status_code' => 'PENDING', 'remarks' => $item['col8'] ?? null, // 비고 'sort_order' => $serialNo, 'attributes' => json_encode([ 'item_type' => 'BEND', 'length' => $item['col3'] ?? null, 'width' => $item['col5'] ?? null, 'drawing' => $item['col6'] ?? null, // 도면이미지 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('order_items')->insert($itemData); } $this->stats['order_items_bend']++; } } /** * etcList → order_items (ETC) */ private function processEtcList(string $json, int $orderId, int $tenantId, bool $dryRun): void { $items = json_decode($json, true); if (! is_array($items)) { return; } $serialNo = $this->getNextSerialNo($orderId); foreach ($items as $item) { if (empty($item['col1'])) { continue; } $itemData = [ 'tenant_id' => $tenantId, 'order_id' => $orderId, 'serial_no' => $serialNo++, 'item_code' => 'ETC-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT), 'item_name' => $item['col1'] ?? '기타', 'specification' => $item['col2'] ?? null, 'unit' => 'EA', 'quantity' => (int) ($item['col3'] ?? 1), 'unit_price' => $this->parseNumber($item['col4'] ?? '0'), 'supply_amount' => $this->parseNumber($item['col5'] ?? '0'), 'tax_amount' => 0, 'total_amount' => $this->parseNumber($item['col5'] ?? '0'), 'status_code' => 'PENDING', 'sort_order' => $serialNo, 'attributes' => json_encode([ 'item_type' => 'ETC', 'source' => '5130', ]), 'created_at' => now(), 'updated_at' => now(), ]; if (! $dryRun) { DB::connection($this->targetDb)->table('order_items')->insert($itemData); } $this->stats['order_items_etc']++; } } /** * order의 다음 serial_no 조회 */ private function getNextSerialNo(int $orderId): int { $max = DB::connection($this->targetDb)->table('order_items') ->where('order_id', $orderId) ->max('serial_no'); return ($max ?? 0) + 1; } /** * orders.options 업데이트 (output_extra 금액 정보) */ private function updateOrderOptions(int $orderId, object $extra): void { $order = DB::connection($this->targetDb)->table('orders') ->where('id', $orderId) ->first(); if (! $order) { return; } $options = json_decode($order->options ?? '{}', true); // output_extra 금액 정보 추가 $options['estimate_first'] = $this->parseNumber($extra->EstimateFirstSum ?? '0'); $options['estimate_update'] = $this->parseNumber($extra->EstimateUpdatetSum ?? '0'); $options['estimate_diff'] = $this->parseNumber($extra->EstimateDiffer ?? '0'); $options['estimate_quantity'] = $this->parseNumber($extra->estimateSurang ?? '0'); $options['inspection_fee'] = $this->parseNumber($extra->inspectionFee ?? '0'); // 금액 필드 업데이트 $supplyAmount = $this->parseNumber($extra->estimateTotal ?? '0'); $discountRate = $this->parseNumber($extra->EstimateDiscountRate ?? '0'); $discountAmount = $this->parseNumber($extra->EstimateDiscount ?? '0'); $totalAmount = $this->parseNumber($extra->EstimateFinalSum ?? '0'); DB::connection($this->targetDb)->table('orders') ->where('id', $orderId) ->update([ 'supply_amount' => $supplyAmount ?: $order->supply_amount, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'total_amount' => $totalAmount ?: $order->total_amount, 'options' => json_encode($options), 'updated_at' => now(), ]); } /** * 롤백 (source=5130 데이터 삭제) */ private function rollbackMigration(int $tenantId, bool $dryRun): int { $this->warn('=== 롤백 모드 ==='); if (! $this->confirm('5130에서 마이그레이션된 모든 수주 데이터를 삭제하시겠습니까?')) { $this->info('롤백 취소됨'); return self::SUCCESS; } // 1. order_items 삭제 (order_id 기준) $orderIds = DB::connection($this->targetDb)->table('order_id_mappings') ->pluck('order_id') ->toArray(); if (! empty($orderIds)) { $itemCount = DB::connection($this->targetDb)->table('order_items') ->whereIn('order_id', $orderIds) ->count(); if (! $dryRun) { DB::connection($this->targetDb)->table('order_items') ->whereIn('order_id', $orderIds) ->delete(); } $this->line("Deleted {$itemCount} order_items"); // 2. orders 삭제 if (! $dryRun) { DB::connection($this->targetDb)->table('orders') ->whereIn('id', $orderIds) ->delete(); } $this->line('Deleted '.count($orderIds).' orders'); } // 3. order_id_mappings 삭제 if (! $dryRun) { DB::connection($this->targetDb)->table('order_id_mappings')->truncate(); } $this->line('Cleared order_id_mappings'); $this->info('롤백 완료'); return self::SUCCESS; } /** * 날짜 파싱 */ private function parseDate(?string $value): ?string { if (empty($value)) { return null; } try { return date('Y-m-d H:i:s', strtotime($value)); } catch (\Exception $e) { return null; } } /** * 숫자 파싱 (varchar → decimal) */ private function parseNumber(?string $value): float { if (empty($value)) { return 0; } $cleaned = preg_replace('/[^\d.-]/', '', $value); return is_numeric($cleaned) ? (float) $cleaned : 0; } /** * 요약 출력 */ private function showSummary(): void { $this->newLine(); $this->table( ['Category', 'Count'], [ ['Orders', $this->stats['orders']], ['Screen Items', $this->stats['order_items_screen']], ['Slat Items', $this->stats['order_items_slat']], ['Motor Items', $this->stats['order_items_motor']], ['Bend Items', $this->stats['order_items_bend']], ['Etc Items', $this->stats['order_items_etc']], ['Skipped', $this->stats['skipped']], ['Errors', $this->stats['errors']], ] ); } }