option('dry-run'); $fresh = $this->option('fresh'); $this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ==='); $this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다'); $this->newLine(); DB::beginTransaction(); try { // --fresh: 기존 EST-* 항목 삭제 if ($fresh) { $this->cleanExistingEstItems($dryRun); } // 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철) $this->migrateBDModels($dryRun); // 2. price_motor (모터 + 제어기) $this->migrateMotors($dryRun); // 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등) $this->migrateRawMaterials($dryRun); // 4. price_shaft (감기샤프트) $this->migrateShafts($dryRun); // 5. price_pipe (각파이프) $this->migratePipes($dryRun); // 6. price_angle (앵글) $this->migrateAngles($dryRun); // 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완) $this->migrateSmokeBan($dryRun); if ($dryRun) { DB::rollBack(); $this->warn('[DRY RUN] 롤백 완료'); } else { DB::commit(); $this->info('커밋 완료'); } $this->newLine(); $this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}건"); return Command::SUCCESS; } catch (\Exception $e) { DB::rollBack(); $this->error("오류: {$e->getMessage()}"); $this->error($e->getTraceAsString()); return Command::FAILURE; } } /** * 기존 EST-* 항목 삭제 (--fresh 옵션) */ private function cleanExistingEstItems(bool $dryRun): void { $this->info('--- 기존 EST-* 항목 삭제 ---'); $items = DB::table('items') ->where('tenant_id', self::TENANT_ID) ->where('code', 'LIKE', 'EST-%') ->whereNull('deleted_at') ->get(['id', 'code']); foreach ($items as $item) { $this->line(" [삭제] {$item->code}"); if (! $dryRun) { DB::table('prices')->where('item_id', $item->id)->delete(); DB::table('item_details')->where('item_id', $item->id)->delete(); DB::table('items')->where('id', $item->id)->delete(); } $this->deleted++; } } /** * chandj.BDmodels → items + item_details + prices */ private function migrateBDModels(bool $dryRun): void { $this->info('--- BDmodels (절곡품) ---'); $rows = DB::connection('chandj')->select(" SELECT model_name, seconditem, finishing_type, spec, unitprice, description FROM BDmodels WHERE is_deleted = 0 ORDER BY model_name, seconditem, finishing_type, spec "); foreach ($rows as $row) { $modelName = trim($row->model_name ?? ''); $secondItem = trim($row->seconditem ?? ''); $finishingType = trim($row->finishing_type ?? ''); $spec = trim($row->spec ?? ''); $unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0'); // finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI' $finishingType = str_replace('마감', '', $finishingType); if (empty($secondItem) || $unitPrice <= 0) { $this->skipped++; continue; } $codeParts = ['BD', $secondItem]; if ($modelName) { $codeParts[] = $modelName; } if ($finishingType) { $codeParts[] = $finishingType; } if ($spec) { $codeParts[] = $spec; } $code = implode('-', $codeParts); $nameParts = [$secondItem]; if ($modelName) { $nameParts[] = $modelName; } if ($finishingType) { $nameParts[] = $finishingType; } if ($spec) { $nameParts[] = $spec; } $name = implode(' ', $nameParts); $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'bdmodels', partType: $secondItem, specification: $spec ?: null, attributes: array_filter([ 'model_name' => $modelName ?: null, 'finishing_type' => $finishingType ?: null, 'bdmodel_source' => 'BDmodels', 'description' => $row->description ?: null, ]), salesPrice: $unitPrice, note: 'chandj.BDmodels', dryRun: $dryRun ); } } /** * chandj.price_motor → 모터 + 제어기 * * col1: 전압 (220, 380, 제어기, 방화, 방범) * col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등) * col13: 판매가 */ private function migrateMotors(bool $dryRun): void { $this->info('--- price_motor (모터/제어기) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범 $name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등 $price = (int) str_replace(',', '', $item['col13'] ?? '0'); if (empty($name) || $price <= 0) { $this->skipped++; continue; } // 카테고리 분류 if (in_array($category, ['220', '380'])) { $productCategory = 'motor'; $code = "EST-MOTOR-{$category}V-{$name}"; $displayName = "모터 {$name} ({$category}V)"; $partType = $name; } elseif ($category === '제어기') { $productCategory = 'controller'; $code = "EST-CTRL-{$name}"; $displayName = "제어기 {$name}"; $partType = $name; } else { // 방화, 방범 등 $productCategory = 'controller'; $code = "EST-CTRL-{$category}-{$name}"; $displayName = "{$category} {$name}"; $partType = "{$category} {$name}"; } $this->upsertEstimateItem( code: $code, name: $displayName, productCategory: $productCategory, partType: $partType, specification: null, attributes: ['voltage' => $category, 'source' => 'price_motor'], salesPrice: (float) $price, note: 'chandj.price_motor', dryRun: $dryRun ); } } /** * chandj.price_raw_materials → 원자재 * * col1: 카테고리 (슬랫, 스크린) * col2: 품명 (방화, 실리카, 화이바, 와이어 등) * col13: 판매단가 */ private function migrateRawMaterials(bool $dryRun): void { $this->info('--- price_raw_materials (원자재) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $category = trim($item['col1'] ?? ''); $name = trim($item['col2'] ?? ''); $price = (int) str_replace(',', '', $item['col13'] ?? '0'); if (empty($name) || $price <= 0) { $this->skipped++; continue; } $code = "EST-RAW-{$category}-{$name}"; $displayName = "{$category} {$name}"; $this->upsertEstimateItem( code: $code, name: $displayName, productCategory: 'raw_material', partType: $name, specification: $category, attributes: ['category' => $category, 'source' => 'price_raw_materials'], salesPrice: (float) $price, note: 'chandj.price_raw_materials', dryRun: $dryRun ); } } /** * chandj.price_shaft → 감기샤프트 * * col4: 인치 (3, 4, 5, 6, 8, 10, 12) * col10: 길이 (m) * col19: 판매가 */ private function migrateShafts(bool $dryRun): void { $this->info('--- price_shaft (감기샤프트) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $inch = trim($item['col4'] ?? ''); $lengthM = trim($item['col10'] ?? ''); $price = (int) str_replace(',', '', $item['col19'] ?? '0'); if (empty($inch) || empty($lengthM) || $price <= 0) { $this->skipped++; continue; } $code = "EST-SHAFT-{$inch}-{$lengthM}"; $name = "감기샤프트 {$inch}인치 {$lengthM}m"; $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'shaft', partType: $inch, specification: $lengthM, attributes: ['source' => 'price_shaft'], salesPrice: (float) $price, note: 'chandj.price_shaft', dryRun: $dryRun ); } } /** * chandj.price_pipe → 각파이프 * * col4: 두께 (1.4, 2) * col2: 길이 (3,000 / 6,000) * col8: 판매가 */ private function migratePipes(bool $dryRun): void { $this->info('--- price_pipe (각파이프) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $thickness = trim($item['col4'] ?? ''); $length = (int) str_replace(',', '', $item['col2'] ?? '0'); $price = (int) str_replace(',', '', $item['col8'] ?? '0'); if (empty($thickness) || $length <= 0 || $price <= 0) { $this->skipped++; continue; } $code = "EST-PIPE-{$thickness}-{$length}"; $name = "각파이프 {$thickness}T {$length}mm"; $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'pipe', partType: $thickness, specification: (string) $length, attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'], salesPrice: (float) $price, note: 'chandj.price_pipe', dryRun: $dryRun ); } } /** * chandj.price_angle → 앵글 (bracket + main 분리) * * bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등) * - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가 * * main angle (부자재용): col2가 숫자 (4 등) * - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가 */ private function migrateAngles(bool $dryRun): void { $this->info('--- price_angle (앵글) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $col2 = trim($item['col2'] ?? ''); $col3 = trim($item['col3'] ?? ''); $col4 = trim($item['col4'] ?? ''); $col10 = trim($item['col10'] ?? ''); $price = (int) str_replace(',', '', $item['col19'] ?? '0'); if ($price <= 0) { $this->skipped++; continue; } // col2가 숫자이면 main angle, 텍스트이면 bracket angle if (is_numeric($col2)) { // Main angle (부자재용): col4=앵글3T, col10=2.5 if (empty($col4) || empty($col10)) { $this->skipped++; continue; } $code = "EST-ANGLE-MAIN-{$col4}-{$col10}"; $name = "앵글 {$col4} {$col10}m"; $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'angle_main', partType: $col4, specification: $col10, attributes: ['source' => 'price_angle'], salesPrice: (float) $price, note: 'chandj.price_angle (main)', dryRun: $dryRun ); } else { // Bracket angle (모터 받침용): col2=스크린용, col3=380*180 if (empty($col2)) { $this->skipped++; continue; } $code = "EST-ANGLE-BRACKET-{$col2}"; $name = "모터받침 앵글 {$col2}"; $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'angle_bracket', partType: $col2, specification: $col3 ?: null, attributes: [ 'angle_type' => $col4, 'source' => 'price_angle', ], salesPrice: (float) $price, note: 'chandj.price_angle (bracket)', dryRun: $dryRun ); } } } /** * chandj.price_smokeban → 연기차단재 * * col2: 용도 (레일용, 케이스용) * col11: 판매가 */ private function migrateSmokeBan(bool $dryRun): void { $this->info('--- price_smokeban (연기차단재) ---'); $row = DB::connection('chandj')->selectOne( "SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); if (! $row) { return; } $items = json_decode($row->itemList, true); foreach ($items as $item) { $usage = trim($item['col2'] ?? ''); $price = (int) str_replace(',', '', $item['col11'] ?? '0'); if (empty($usage) || $price <= 0) { $this->skipped++; continue; } $code = "EST-SMOKE-{$usage}"; $name = "연기차단재 {$usage}"; $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'smokeban', partType: $usage, specification: null, attributes: ['source' => 'price_smokeban'], salesPrice: (float) $price, note: 'chandj.price_smokeban', dryRun: $dryRun ); } } /** * 견적 품목 생성 또는 가격 업데이트 */ private function upsertEstimateItem( string $code, string $name, string $productCategory, string $partType, ?string $specification, array $attributes, float $salesPrice, string $note, bool $dryRun ): void { $existing = DB::table('items') ->where('tenant_id', self::TENANT_ID) ->where('code', $code) ->whereNull('deleted_at') ->first(); if ($existing) { // 가격 업데이트 $currentPrice = DB::table('prices') ->where('item_id', $existing->id) ->where('status', 'active') ->orderByDesc('id') ->value('sales_price'); if ((float) $currentPrice === $salesPrice) { $this->skipped++; return; } $this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . " → " . number_format($salesPrice)); if (! $dryRun) { // 기존 가격 비활성화 DB::table('prices') ->where('item_id', $existing->id) ->where('status', 'active') ->update(['status' => 'inactive', 'updated_at' => now()]); // 새 가격 추가 DB::table('prices')->insert([ 'tenant_id' => self::TENANT_ID, 'item_type_code' => 'PT', 'item_id' => $existing->id, 'sales_price' => $salesPrice, 'effective_from' => now()->toDateString(), 'status' => 'active', 'note' => $note, 'created_at' => now(), 'updated_at' => now(), ]); } $this->updated++; return; } // 신규 생성 $this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice)); if ($dryRun) { $this->created++; return; } $now = now(); $itemId = DB::table('items')->insertGetId([ 'tenant_id' => self::TENANT_ID, 'item_type' => 'PT', 'code' => $code, 'name' => $name, 'unit' => 'EA', 'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE), 'is_active' => true, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('item_details')->insert([ 'item_id' => $itemId, 'product_category' => $productCategory, 'part_type' => $partType, 'specification' => $specification, 'item_name' => $name, 'is_purchasable' => true, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('prices')->insert([ 'tenant_id' => self::TENANT_ID, 'item_type_code' => 'PT', 'item_id' => $itemId, 'sales_price' => $salesPrice, 'effective_from' => $now->toDateString(), 'status' => 'active', 'note' => $note, 'created_at' => $now, 'updated_at' => $now, ]); $this->created++; } }