diff --git a/app/Console/Commands/Migrate5130Bom.php b/app/Console/Commands/Migrate5130Bom.php new file mode 100644 index 0000000..bbc87c9 --- /dev/null +++ b/app/Console/Commands/Migrate5130Bom.php @@ -0,0 +1,235 @@ + 'FG-SCR-001', + 'STEEL' => 'FG-STL-001', + 'BENDING' => 'FG-BND-001', + ]; + + /** + * 로드된 BOM 템플릿 캐시 + */ + private array $bomTemplates = []; + + public function handle(): int + { + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info(' 5130 → SAM BOM 마이그레이션'); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->newLine(); + + $dryRun = $this->option('dry-run'); + $code = $this->option('code'); + $category = $this->option('category'); + $tenantId = (int) $this->option('tenant-id'); + + if ($dryRun) { + $this->warn('🔍 DRY-RUN 모드: 실제 변경 없이 시뮬레이션합니다.'); + $this->newLine(); + } + + // 1. BOM 템플릿 로드 + $this->info('📥 Step 1: BOM 템플릿 로드'); + if (! $this->loadBomTemplates($tenantId)) { + $this->error('BOM 템플릿 로드 실패'); + + return Command::FAILURE; + } + + // 2. 대상 품목 조회 + $this->info('📥 Step 2: 대상 품목 조회'); + $items = $this->getTargetItems($tenantId, $code, $category); + + if ($items->isEmpty()) { + $this->info('✅ 처리할 대상 품목이 없습니다.'); + + return Command::SUCCESS; + } + + $this->info(" 대상 품목 수: {$items->count()}건"); + $this->newLine(); + + // 3. BOM 적용 + $this->info('🔄 Step 3: BOM 적용'); + $stats = [ + 'total' => $items->count(), + 'success' => 0, + 'skipped' => 0, + 'failed' => 0, + ]; + + $this->output->progressStart($items->count()); + + foreach ($items as $item) { + $result = $this->applyBomToItem($item, $dryRun); + + if ($result === 'success') { + $stats['success']++; + } elseif ($result === 'skipped') { + $stats['skipped']++; + } else { + $stats['failed']++; + } + + $this->output->progressAdvance(); + } + + $this->output->progressFinish(); + $this->newLine(); + + // 4. 결과 출력 + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info('📊 처리 결과'); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + $this->table( + ['항목', '건수'], + [ + ['전체 대상', $stats['total']], + ['✅ 성공', $stats['success']], + ['⏭️ 스킵 (템플릿 없음)', $stats['skipped']], + ['❌ 실패', $stats['failed']], + ] + ); + + if ($dryRun) { + $this->newLine(); + $this->warn('🔍 DRY-RUN 모드였습니다. 실제 적용하려면 --dry-run 옵션을 제거하세요.'); + } + + return Command::SUCCESS; + } + + /** + * BOM 템플릿 로드 + */ + private function loadBomTemplates(int $tenantId): bool + { + foreach ($this->templateSources as $category => $sourceCode) { + $sourceItem = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', $sourceCode) + ->first(['bom']); + + if ($sourceItem && $sourceItem->bom) { + $bom = json_decode($sourceItem->bom, true); + if (is_array($bom) && count($bom) > 0) { + $this->bomTemplates[$category] = $sourceItem->bom; + $this->info(" ✅ {$category}: {$sourceCode} 템플릿 로드됨 (" . count($bom) . "개 항목)"); + } else { + $this->warn(" ⚠️ {$category}: {$sourceCode} BOM이 비어있음"); + } + } else { + $this->warn(" ⚠️ {$category}: {$sourceCode} 템플릿 없음"); + } + } + + $this->newLine(); + + return count($this->bomTemplates) > 0; + } + + /** + * 대상 품목 조회 (BOM이 NULL인 FG 완제품) + */ + private function getTargetItems(int $tenantId, ?string $code, ?string $category) + { + $query = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('item_type', 'FG') + ->whereIn('item_category', array_keys($this->bomTemplates)) + ->where(function ($q) { + $q->whereNull('bom') + ->orWhere('bom', '[]') + ->orWhere('bom', 'null') + ->orWhereRaw('JSON_LENGTH(bom) = 0'); + }); + + if ($code) { + $query->where('code', $code); + } + + if ($category) { + $query->where('item_category', strtoupper($category)); + } + + return $query->get(['id', 'code', 'name', 'item_category']); + } + + /** + * 개별 품목에 BOM 적용 + */ + private function applyBomToItem(object $item, bool $dryRun): string + { + $category = $item->item_category; + + // 템플릿 확인 + if (! isset($this->bomTemplates[$category])) { + if ($this->output->isVerbose()) { + $this->warn(" ⏭️ [{$item->code}] {$item->name} - {$category} 템플릿 없음"); + } + + return 'skipped'; + } + + $bomJson = $this->bomTemplates[$category]; + + if ($dryRun) { + if ($this->output->isVerbose()) { + $this->info(" 📋 [{$item->code}] {$item->name} → {$category} 템플릿 적용 예정"); + } + + return 'success'; + } + + // 실제 업데이트 + try { + DB::table('items') + ->where('id', $item->id) + ->update([ + 'bom' => $bomJson, + 'updated_at' => now(), + ]); + + if ($this->output->isVerbose()) { + $this->info(" ✅ [{$item->code}] {$item->name} → BOM 적용 완료"); + } + + return 'success'; + } catch (\Exception $e) { + $this->error(" ❌ [{$item->code}] 오류: " . $e->getMessage()); + + return 'failed'; + } + } +} \ No newline at end of file diff --git a/app/Console/Commands/Migrate5130Orders.php b/app/Console/Commands/Migrate5130Orders.php new file mode 100644 index 0000000..311bcc0 --- /dev/null +++ b/app/Console/Commands/Migrate5130Orders.php @@ -0,0 +1,787 @@ + 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']], + ] + ); + } +} diff --git a/app/Console/Commands/Migrate5130PriceItems.php b/app/Console/Commands/Migrate5130PriceItems.php new file mode 100644 index 0000000..9fe4a17 --- /dev/null +++ b/app/Console/Commands/Migrate5130PriceItems.php @@ -0,0 +1,582 @@ + ['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}개 품목 마이그레이션 완료!"); + } +} diff --git a/app/Console/Commands/Verify5130Calculation.php b/app/Console/Commands/Verify5130Calculation.php new file mode 100644 index 0000000..89474ea --- /dev/null +++ b/app/Console/Commands/Verify5130Calculation.php @@ -0,0 +1,217 @@ +info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info(' 5130 → SAM 견적 계산 검증'); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->newLine(); + + // 입력 파라미터 + $W0 = (float) $this->option('W0'); + $H0 = (float) $this->option('H0'); + $qty = (int) $this->option('qty'); + $productType = $this->option('type'); + $tenantId = (int) $this->option('tenant-id'); + $finishedGoodsCode = $this->option('finished-goods'); + $verboseMode = $this->option('verbose-mode'); + + $this->info("📥 입력 파라미터:"); + $this->table( + ['항목', '값'], + [ + ['개구부 폭 (W0)', "{$W0} mm"], + ['개구부 높이 (H0)', "{$H0} mm"], + ['수량 (QTY)', $qty], + ['제품 유형', $productType], + ['테넌트 ID', $tenantId], + ] + ); + $this->newLine(); + + // 1. Legacy5130Calculator로 계산 (5130 호환 모드) + $this->info('🔄 Step 1: Legacy5130Calculator 계산 (5130 호환)'); + $legacyResult = Legacy5130Calculator::calculateEstimate($W0, $H0, $qty, $productType); + + $this->table( + ['항목', '값'], + [ + ['제작 폭 (W1)', "{$legacyResult['calculated']['W1']} mm"], + ['제작 높이 (H1)', "{$legacyResult['calculated']['H1']} mm"], + ['면적 (M)', "{$legacyResult['calculated']['area_m2']} m²"], + ['중량 (K)', "{$legacyResult['calculated']['weight_kg']} kg"], + ['브라켓 인치', "{$legacyResult['calculated']['bracket_inch']} inch"], + ['모터 용량', $legacyResult['motor']['capacity']], + ['브라켓 사이즈', $legacyResult['motor']['bracket_dimensions']], + ] + ); + $this->newLine(); + + // 2. SAM FormulaEvaluatorService로 계산 + $this->info('🔄 Step 2: SAM FormulaEvaluatorService 계산'); + + if ($finishedGoodsCode) { + // BOM 기반 계산 + $samResult = $formulaEvaluator->calculateBomWithDebug( + $finishedGoodsCode, + ['W0' => $W0, 'H0' => $H0, 'QTY' => $qty], + $tenantId + ); + + if (! $samResult['success']) { + $this->error("SAM 계산 실패: " . ($samResult['error'] ?? '알 수 없는 오류')); + + return Command::FAILURE; + } + + $samVariables = $samResult['variables']; + } else { + // 직접 계산 (변수만) + $samVariables = $this->calculateSamVariables($W0, $H0, $productType); + $samResult = ['success' => true, 'variables' => $samVariables]; + } + + $this->table( + ['항목', '값'], + [ + ['제작 폭 (W1)', ($samVariables['W1'] ?? 'N/A').' mm'], + ['제작 높이 (H1)', ($samVariables['H1'] ?? 'N/A').' mm'], + ['면적 (M)', round($samVariables['M'] ?? 0, 4).' m²'], + ['중량 (K)', round($samVariables['K'] ?? 0, 2).' kg'], + ] + ); + $this->newLine(); + + // 3. 결과 비교 + $this->info('🔍 Step 3: 결과 비교'); + + $comparison = [ + ['W1 (제작 폭)', $legacyResult['calculated']['W1'], $samVariables['W1'] ?? 0], + ['H1 (제작 높이)', $legacyResult['calculated']['H1'], $samVariables['H1'] ?? 0], + ['M (면적)', $legacyResult['calculated']['area_m2'], round($samVariables['M'] ?? 0, 4)], + ['K (중량)', $legacyResult['calculated']['weight_kg'], round($samVariables['K'] ?? 0, 2)], + ]; + + $allMatch = true; + $comparisonTable = []; + + foreach ($comparison as [$name, $legacy, $sam]) { + $diff = abs($legacy - $sam); + $percentDiff = $legacy != 0 ? ($diff / abs($legacy)) * 100 : 0; + $match = $percentDiff < 1; // 1% 이내 허용 + + if (! $match) { + $allMatch = false; + } + + $comparisonTable[] = [ + $name, + $legacy, + $sam, + round($diff, 4), + round($percentDiff, 2).'%', + $match ? '✅ 일치' : '❌ 불일치', + ]; + } + + $this->table( + ['항목', '5130 (Legacy)', 'SAM', '차이', '차이율', '결과'], + $comparisonTable + ); + $this->newLine(); + + // 4. 최종 결과 + if ($allMatch) { + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info(' ✅ 검증 성공: 5130과 SAM 계산 결과가 일치합니다.'); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + } else { + $this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->error(' ❌ 검증 실패: 5130과 SAM 계산 결과가 불일치합니다.'); + $this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + } + + // 상세 출력 모드 + if ($verboseMode) { + $this->newLine(); + $this->info('📊 상세 정보 (Legacy5130Calculator):'); + $this->line(json_encode($legacyResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + if ($finishedGoodsCode && isset($samResult['debug_steps'])) { + $this->newLine(); + $this->info('📊 상세 정보 (SAM Debug Steps):'); + foreach ($samResult['debug_steps'] as $step) { + $this->line("Step {$step['step']}: {$step['name']}"); + $this->line(json_encode($step['data'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + } + } + } + + return $allMatch ? Command::SUCCESS : Command::FAILURE; + } + + /** + * SAM 방식으로 변수 계산 (BOM 없이) + */ + private function calculateSamVariables(float $W0, float $H0, string $productType): array + { + // 마진값 결정 + if (strtolower($productType) === 'steel') { + $marginW = 110; + $marginH = 350; + } else { + $marginW = 140; + $marginH = 350; + } + + $W1 = $W0 + $marginW; + $H1 = $H0 + $marginH; + $M = ($W1 * $H1) / 1000000; + + // 중량 계산 + if (strtolower($productType) === 'steel') { + $K = $M * 25; + } else { + $K = $M * 2 + ($W0 / 1000) * 14.17; + } + + return [ + 'W0' => $W0, + 'H0' => $H0, + 'W1' => $W1, + 'H1' => $H1, + 'W' => $W1, + 'H' => $H1, + 'M' => $M, + 'K' => $K, + ]; + } +} \ No newline at end of file diff --git a/app/Helpers/Legacy5130Calculator.php b/app/Helpers/Legacy5130Calculator.php new file mode 100644 index 0000000..90392d8 --- /dev/null +++ b/app/Helpers/Legacy5130Calculator.php @@ -0,0 +1,509 @@ + 1.2, + 1.55 => 1.6, + ]; + + /** + * SUS(스테인리스) 두께 매핑 + * 5130: 1.15 → 1.2, 1.55 → 1.5 + */ + public const SUS_THICKNESS_MAP = [ + 1.15 => 1.2, + 1.55 => 1.5, + ]; + + // ========================================================================= + // 모터 용량 상수 + // ========================================================================= + + /** + * 스크린 모터 용량 테이블 + * [min_weight, max_weight, min_bracket_inch, max_bracket_inch] => capacity + */ + public const SCREEN_MOTOR_CAPACITY = [ + // 150K: 중량 ≤20, 브라켓인치 ≤6 + ['weight_max' => 20, 'bracket_max' => 6, 'capacity' => '150K'], + // 200K: 중량 ≤35, 브라켓인치 ≤8 + ['weight_max' => 35, 'bracket_max' => 8, 'capacity' => '200K'], + // 300K: 중량 ≤50, 브라켓인치 ≤10 + ['weight_max' => 50, 'bracket_max' => 10, 'capacity' => '300K'], + // 400K: 중량 ≤70, 브라켓인치 ≤12 + ['weight_max' => 70, 'bracket_max' => 12, 'capacity' => '400K'], + // 500K: 중량 ≤90, 브라켓인치 ≤14 + ['weight_max' => 90, 'bracket_max' => 14, 'capacity' => '500K'], + // 600K: 그 이상 + ['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '600K'], + ]; + + /** + * 철재 모터 용량 테이블 + */ + public const STEEL_MOTOR_CAPACITY = [ + // 300K: 중량 ≤40, 브라켓인치 ≤8 + ['weight_max' => 40, 'bracket_max' => 8, 'capacity' => '300K'], + // 400K: 중량 ≤60, 브라켓인치 ≤10 + ['weight_max' => 60, 'bracket_max' => 10, 'capacity' => '400K'], + // 500K: 중량 ≤80, 브라켓인치 ≤12 + ['weight_max' => 80, 'bracket_max' => 12, 'capacity' => '500K'], + // 600K: 중량 ≤100, 브라켓인치 ≤14 + ['weight_max' => 100, 'bracket_max' => 14, 'capacity' => '600K'], + // 800K: 중량 ≤150, 브라켓인치 ≤16 + ['weight_max' => 150, 'bracket_max' => 16, 'capacity' => '800K'], + // 1000K: 그 이상 + ['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '1000K'], + ]; + + /** + * 브라켓 사이즈 매핑 (용량 → 사이즈) + */ + public const BRACKET_SIZE_MAP = [ + '150K' => ['width' => 450, 'height' => 280], + '200K' => ['width' => 480, 'height' => 300], + '300K' => ['width' => 530, 'height' => 320], + '400K' => ['width' => 530, 'height' => 320], + '500K' => ['width' => 600, 'height' => 350], + '600K' => ['width' => 600, 'height' => 350], + '800K' => ['width' => 690, 'height' => 390], + '1000K' => ['width' => 690, 'height' => 390], + ]; + + // ========================================================================= + // 두께 정규화 + // ========================================================================= + + /** + * 두께 정규화 (5130 방식) + * + * @param string $material 재질 (EGI, SUS) + * @param float $thickness 입력 두께 + * @return float 정규화된 두께 + */ + public static function normalizeThickness(string $material, float $thickness): float + { + $material = strtoupper($material); + + if ($material === 'EGI' && isset(self::EGI_THICKNESS_MAP[$thickness])) { + return self::EGI_THICKNESS_MAP[$thickness]; + } + + if ($material === 'SUS' && isset(self::SUS_THICKNESS_MAP[$thickness])) { + return self::SUS_THICKNESS_MAP[$thickness]; + } + + return $thickness; + } + + // ========================================================================= + // 면적 계산 + // ========================================================================= + + /** + * 면적 계산 (mm² → m²) + * + * 5130 방식: (length × width) / 1,000,000 + * + * @param float $length 길이 (mm) + * @param float $width 너비 (mm) + * @return float 면적 (m²) + */ + public static function calculateArea(float $length, float $width): float + { + return ($length * $width) / 1000000; + } + + /** + * 면적 계산 (개구부 → 제작 사이즈) + * + * @param float $W0 개구부 폭 (mm) + * @param float $H0 개구부 높이 (mm) + * @param string $productType 제품 유형 (screen, steel) + * @return array ['W1' => 제작폭, 'H1' => 제작높이, 'area' => 면적(m²)] + */ + public static function calculateManufacturingSize(float $W0, float $H0, string $productType = 'screen'): array + { + $productType = strtolower($productType); + + // 마진 값 결정 + if ($productType === 'steel') { + $marginW = 110; + $marginH = 350; + } else { + // screen (기본값) + $marginW = 140; + $marginH = 350; + } + + $W1 = $W0 + $marginW; + $H1 = $H0 + $marginH; + $area = self::calculateArea($W1, $H1); + + return [ + 'W1' => $W1, + 'H1' => $H1, + 'area' => $area, + ]; + } + + // ========================================================================= + // 중량 계산 + // ========================================================================= + + /** + * 중량 계산 (5130 방식) + * + * @param float $W0 개구부 폭 (mm) + * @param float $area 면적 (m²) + * @param string $productType 제품 유형 (screen, steel) + * @return float 중량 (kg) + */ + public static function calculateWeight(float $W0, float $area, string $productType = 'screen'): float + { + $productType = strtolower($productType); + + if ($productType === 'steel') { + // 철재: 면적 × 25 kg/m² + return $area * 25; + } + + // 스크린: (면적 × 2) + (폭(m) × 14.17) + return ($area * 2) + (($W0 / 1000) * 14.17); + } + + // ========================================================================= + // 절곡품 단가 계산 + // ========================================================================= + + /** + * 절곡품 단가 계산 (5130 getBendPlatePrice 호환) + * + * @param string $material 재질 (EGI, SUS) + * @param float $thickness 두께 (mm) + * @param float $length 길이 (mm) + * @param float $width 너비 (mm) + * @param int $qty 수량 + * @param float $unitPricePerM2 단위 면적당 단가 (원/m²) + * @return array ['area' => 면적, 'total' => 총액] + */ + public static function getBendPlatePrice( + string $material, + float $thickness, + float $length, + float $width, + int $qty, + float $unitPricePerM2 + ): array { + // 1. 두께 정규화 + $normalizedThickness = self::normalizeThickness($material, $thickness); + + // 2. 면적 계산 (mm² → m²) + $areaM2 = self::calculateArea($length, $width); + + // 3. 총액 계산 (절삭 - Math.floor 호환) + $total = floor($unitPricePerM2 * $areaM2 * $qty); + + return [ + 'material' => $material, + 'original_thickness' => $thickness, + 'normalized_thickness' => $normalizedThickness, + 'length' => $length, + 'width' => $width, + 'area_m2' => $areaM2, + 'qty' => $qty, + 'unit_price_per_m2' => $unitPricePerM2, + 'total' => $total, + ]; + } + + // ========================================================================= + // 모터 용량 계산 + // ========================================================================= + + /** + * 모터 용량 계산 (5130 calculateMotorSpec 호환) + * + * @param float $weight 중량 (kg) + * @param float $bracketInch 브라켓 인치 + * @param string $productType 제품 유형 (screen, steel) + * @return array ['capacity' => 용량, 'bracket_size' => 브라켓 사이즈] + */ + public static function calculateMotorSpec(float $weight, float $bracketInch, string $productType = 'screen'): array + { + $productType = strtolower($productType); + + // 용량 테이블 선택 + $capacityTable = ($productType === 'steel') + ? self::STEEL_MOTOR_CAPACITY + : self::SCREEN_MOTOR_CAPACITY; + + // 용량 결정 (경계값은 상위 용량 적용) + $capacity = null; + foreach ($capacityTable as $entry) { + if ($weight <= $entry['weight_max'] && $bracketInch <= $entry['bracket_max']) { + $capacity = $entry['capacity']; + break; + } + } + + // 기본값 (마지막 항목) + if ($capacity === null) { + $lastEntry = end($capacityTable); + $capacity = $lastEntry['capacity']; + } + + // 브라켓 사이즈 조회 + $bracketSize = self::BRACKET_SIZE_MAP[$capacity] ?? ['width' => 530, 'height' => 320]; + + return [ + 'product_type' => $productType, + 'weight' => $weight, + 'bracket_inch' => $bracketInch, + 'capacity' => $capacity, + 'bracket_size' => $bracketSize, + 'bracket_dimensions' => "{$bracketSize['width']}×{$bracketSize['height']}", + ]; + } + + /** + * 품목 코드로 제품 유형 판별 (5130 방식) + * + * @param string $itemCode 품목 코드 (예: KS-001, ST-002) + * @return string 제품 유형 (screen, steel) + */ + public static function detectProductType(string $itemCode): string + { + $prefix = strtoupper(substr($itemCode, 0, 2)); + + // KS, KW로 시작하면 스크린 + if (in_array($prefix, ['KS', 'KW'])) { + return 'screen'; + } + + // 그 외는 철재 + return 'steel'; + } + + // ========================================================================= + // 가이드레일/샤프트/파이프 계산 + // ========================================================================= + + /** + * 가이드레일 수량 계산 (5130 calculateGuidrail 호환) + * + * @param float $height 높이 (mm) + * @param float $standardLength 기본 길이 (mm, 기본값 3490) + * @return int 가이드레일 수량 + */ + public static function calculateGuiderailQty(float $height, float $standardLength = 3490): int + { + if ($standardLength <= 0) { + return 1; + } + + return (int) ceil($height / $standardLength); + } + + // ========================================================================= + // 비인정 자재 단가 계산 + // ========================================================================= + + /** + * 비인정 스크린 단가 계산 + * + * @param float $width 너비 (mm) + * @param float $height 높이 (mm) + * @param int $qty 수량 + * @param float $unitPricePerM2 단위 면적당 단가 (원/m²) + * @return array ['area' => 면적, 'total' => 총액] + */ + public static function calculateUnapprovedScreenPrice( + float $width, + float $height, + int $qty, + float $unitPricePerM2 + ): array { + $areaM2 = self::calculateArea($width, $height); + $total = floor($unitPricePerM2 * $areaM2 * $qty); + + return [ + 'width' => $width, + 'height' => $height, + 'area_m2' => $areaM2, + 'qty' => $qty, + 'unit_price_per_m2' => $unitPricePerM2, + 'total' => $total, + ]; + } + + /** + * 철재 스라트 비인정 단가 계산 + * + * @param string $type 유형 (방화셔터, 방범셔터, 단열셔터, 이중파이프, 조인트바) + * @param float $width 너비 (mm) + * @param float $height 높이 (mm) + * @param int $qty 수량 + * @param float $unitPrice 단가 + * @return array ['surang' => 수량, 'total' => 총액] + */ + public static function calculateUnapprovedSteelSlatPrice( + string $type, + float $width, + float $height, + int $qty, + float $unitPrice + ): array { + // 면적 기준 유형 + $areaBased = ['방화셔터', '방범셔터', '단열셔터', '이중파이프']; + + if (in_array($type, $areaBased)) { + // 면적 × 수량 + $areaM2 = self::calculateArea($width, $height); + $surang = $areaM2 * $qty; + } else { + // 수량 기준 (조인트바 등) + $surang = $qty; + } + + $total = floor($unitPrice * $surang); + + return [ + 'type' => $type, + 'width' => $width, + 'height' => $height, + 'qty' => $qty, + 'surang' => $surang, + 'unit_price' => $unitPrice, + 'total' => $total, + ]; + } + + // ========================================================================= + // 전체 견적 계산 (통합) + // ========================================================================= + + /** + * 전체 견적 계산 (5130 호환 모드) + * + * 5130의 계산 로직을 그대로 재현하여 동일한 결과를 반환합니다. + * + * @param float $W0 개구부 폭 (mm) + * @param float $H0 개구부 높이 (mm) + * @param int $qty 수량 + * @param string $productType 제품 유형 (screen, steel) + * @return array 계산 결과 + */ + public static function calculateEstimate( + float $W0, + float $H0, + int $qty, + string $productType = 'screen' + ): array { + // 1. 제작 사이즈 계산 + $size = self::calculateManufacturingSize($W0, $H0, $productType); + $W1 = $size['W1']; + $H1 = $size['H1']; + $area = $size['area']; + + // 2. 중량 계산 + $weight = self::calculateWeight($W0, $area, $productType); + + // 3. 브라켓 인치 계산 (폭 기준, 25.4mm = 1inch) + $bracketInch = ceil($W1 / 25.4); + + // 4. 모터 용량 계산 + $motorSpec = self::calculateMotorSpec($weight, $bracketInch, $productType); + + return [ + 'input' => [ + 'W0' => $W0, + 'H0' => $H0, + 'qty' => $qty, + 'product_type' => $productType, + ], + 'calculated' => [ + 'W1' => $W1, + 'H1' => $H1, + 'area_m2' => round($area, 4), + 'weight_kg' => round($weight, 2), + 'bracket_inch' => $bracketInch, + ], + 'motor' => $motorSpec, + ]; + } + + // ========================================================================= + // 검증 유틸리티 + // ========================================================================= + + /** + * 5130 계산 결과와 비교 검증 + * + * @param array $samResult SAM 계산 결과 + * @param array $legacy5130Result 5130 계산 결과 + * @param float $tolerance 허용 오차 (기본 0.01 = 1%) + * @return array ['match' => bool, 'differences' => array] + */ + public static function validateAgainstLegacy( + array $samResult, + array $legacy5130Result, + float $tolerance = 0.01 + ): array { + $differences = []; + + // 비교할 필드 목록 + $compareFields = ['W1', 'H1', 'area_m2', 'weight_kg', 'total']; + + foreach ($compareFields as $field) { + $samValue = $samResult[$field] ?? $samResult['calculated'][$field] ?? null; + $legacyValue = $legacy5130Result[$field] ?? null; + + if ($samValue === null || $legacyValue === null) { + continue; + } + + $diff = abs($samValue - $legacyValue); + $percentDiff = $legacyValue != 0 ? ($diff / abs($legacyValue)) : ($diff > 0 ? 1 : 0); + + if ($percentDiff > $tolerance) { + $differences[$field] = [ + 'sam' => $samValue, + 'legacy' => $legacyValue, + 'diff' => $diff, + 'percent_diff' => round($percentDiff * 100, 2).'%', + ]; + } + } + + return [ + 'match' => empty($differences), + 'differences' => $differences, + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Construction/ContractFromBiddingRequest.php b/app/Http/Requests/Construction/ContractFromBiddingRequest.php new file mode 100644 index 0000000..e385dd4 --- /dev/null +++ b/app/Http/Requests/Construction/ContractFromBiddingRequest.php @@ -0,0 +1,67 @@ + 'nullable|string|max:255', + + // 거래처 정보 (입찰에서 가져오므로 optional) + 'partner_id' => 'nullable|integer', + 'partner_name' => 'nullable|string|max:255', + + // 담당자 정보 + 'contract_manager_id' => 'nullable|integer', + 'contract_manager_name' => 'nullable|string|max:100', + 'construction_pm_id' => 'nullable|integer', + 'construction_pm_name' => 'nullable|string|max:100', + + // 계약 상세 (입찰에서 가져오므로 optional) + 'total_locations' => 'nullable|integer|min:0', + 'contract_amount' => 'nullable|numeric|min:0', + 'contract_start_date' => 'nullable|date', + 'contract_end_date' => 'nullable|date|after_or_equal:contract_start_date', + + // 상태 정보 + 'status' => [ + 'nullable', + Rule::in([Contract::STATUS_PENDING, Contract::STATUS_COMPLETED]), + ], + 'stage' => [ + 'nullable', + Rule::in([ + Contract::STAGE_ESTIMATE_SELECTED, + Contract::STAGE_ESTIMATE_PROGRESS, + Contract::STAGE_DELIVERY, + Contract::STAGE_INSTALLATION, + Contract::STAGE_INSPECTION, + Contract::STAGE_OTHER, + ]), + ], + + // 기타 + 'remarks' => 'nullable|string', + 'is_active' => 'nullable|boolean', + ]; + } + + public function messages(): array + { + return [ + 'contract_end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '계약종료일', 'date' => '계약시작일']), + ]; + } +} diff --git a/database/migrations/2026_01_19_155301_add_work_order_id_to_shipments_table.php b/database/migrations/2026_01_19_155301_add_work_order_id_to_shipments_table.php new file mode 100644 index 0000000..5c2b0d0 --- /dev/null +++ b/database/migrations/2026_01_19_155301_add_work_order_id_to_shipments_table.php @@ -0,0 +1,34 @@ +foreignId('work_order_id') + ->nullable() + ->after('order_id') + ->comment('작업지시 ID'); + + $table->index(['tenant_id', 'work_order_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('shipments', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'work_order_id']); + $table->dropColumn('work_order_id'); + }); + } +}; diff --git a/database/migrations/2026_01_19_202830_create_order_id_mappings_table.php b/database/migrations/2026_01_19_202830_create_order_id_mappings_table.php new file mode 100644 index 0000000..8280778 --- /dev/null +++ b/database/migrations/2026_01_19_202830_create_order_id_mappings_table.php @@ -0,0 +1,38 @@ +id(); + $table->bigInteger('source_num')->unsigned()->comment('5130 output.num'); + $table->bigInteger('order_id')->unsigned()->comment('SAM orders.id'); + $table->timestamps(); + + $table->unique('source_num'); + $table->index('order_id'); + + $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_id_mappings'); + } +};