'FG', '[상품]' => 'FG', '[반제품]' => 'PT', '[부재료]' => 'SM', '[원재료]' => 'RM', '[무형상품]' => 'CS', ]; /** * finishing_type 약어 매핑 */ private const FINISHING_MAP = [ 'SUS마감' => 'SUS', 'EGI마감' => 'EGI', ]; /** * 경동기업 품목/단가 마이그레이션 실행 */ public function run(): void { $tenantId = DummyDataSeeder::TENANT_ID; $userId = DummyDataSeeder::USER_ID; $this->command->info('🚀 경동기업 품목/단가 마이그레이션 시작...'); $this->command->info(" 대상 테넌트: ID {$tenantId}"); // 1. 기존 데이터 삭제 $this->cleanupExistingData($tenantId); // Phase 1.0: KDunitprice → items $itemCount = $this->migrateItems($tenantId, $userId); // Phase 1.1: models → items (FG) $modelCount = $this->migrateModels($tenantId, $userId); // Phase 1.2: item_list → items (PT) $itemListCount = $this->migrateItemList($tenantId, $userId); // Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품 $bdPartsCount = $this->migrateBDmodelsParts($tenantId, $userId); // prices 생성 (모든 items 기반) $priceCount = $this->migratePrices($tenantId, $userId); // Phase 2.2: BDmodels → items.bom JSON $bomCount = $this->migrateBom($tenantId); // Phase 3.1: price_motor → items (SM) + prices $motorResult = $this->migratePriceMotor($tenantId, $userId); // Phase 3.2: price_raw_materials → items (RM) + prices $rawMatResult = $this->migratePriceRawMaterials($tenantId, $userId); $totalItems = $itemCount + $modelCount + $itemListCount + $bdPartsCount + $motorResult['items'] + $rawMatResult['items']; $totalPrices = $priceCount + $motorResult['prices'] + $rawMatResult['prices']; $this->command->info(''); $this->command->info('✅ 마이그레이션 완료:'); $this->command->info(" → items: {$totalItems}건"); $this->command->info(" - KDunitprice: {$itemCount}건"); $this->command->info(" - models: {$modelCount}건"); $this->command->info(" - item_list: {$itemListCount}건"); $this->command->info(" - BDmodels부품: {$bdPartsCount}건"); $this->command->info(" - price_motor: {$motorResult['items']}건"); $this->command->info(" - price_raw_materials: {$rawMatResult['items']}건"); $this->command->info(" → prices: {$totalPrices}건"); $this->command->info(" → BOM 연결: {$bomCount}건"); } /** * 기존 데이터 삭제 (tenant_id 기준) */ private function cleanupExistingData(int $tenantId): void { $this->command->info(''); $this->command->info('🧹 기존 데이터 삭제 중...'); // prices 먼저 삭제 (FK 관계) $priceCount = DB::table('prices')->where('tenant_id', $tenantId)->count(); DB::table('prices')->where('tenant_id', $tenantId)->delete(); $this->command->info(" → prices: {$priceCount}건 삭제"); // items 삭제 $itemCount = DB::table('items')->where('tenant_id', $tenantId)->count(); DB::table('items')->where('tenant_id', $tenantId)->delete(); $this->command->info(" → items: {$itemCount}건 삭제"); } /** * KDunitprice → items 마이그레이션 */ private function migrateItems(int $tenantId, int $userId): int { $this->command->info(''); $this->command->info('📦 KDunitprice → items 마이그레이션...'); // chandj.KDunitprice에서 데이터 조회 (is_deleted=NULL이 활성 상태) $kdItems = DB::connection('chandj') ->table('KDunitprice') ->whereNull('is_deleted') ->whereNotNull('prodcode') ->where('prodcode', '!=', '') ->get(); $this->command->info(" → 소스 데이터: {$kdItems->count()}건"); $items = []; $now = now(); $batchCount = 0; foreach ($kdItems as $kd) { $items[] = [ 'tenant_id' => $tenantId, 'item_type' => $this->mapItemType($kd->item_div), 'code' => $kd->prodcode, 'name' => $kd->item_name, 'unit' => $kd->unit, 'category_id' => null, 'process_type' => null, 'item_category' => null, 'bom' => null, 'attributes' => json_encode([ 'spec' => $kd->spec, 'item_div' => $kd->item_div, 'legacy_source' => 'KDunitprice', 'legacy_num' => $kd->num, ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; // 500건씩 배치 INSERT if (count($items) >= 500) { DB::table('items')->insert($items); $batchCount += count($items); $this->command->info(" → {$batchCount}건 완료..."); $items = []; } } // 남은 데이터 INSERT if (! empty($items)) { DB::table('items')->insert($items); $batchCount += count($items); } $this->command->info(" ✓ items: {$batchCount}건 생성 완료"); return $batchCount; } /** * items 기반 → prices 마이그레이션 */ private function migratePrices(int $tenantId, int $userId): int { $this->command->info(''); $this->command->info('💰 items → prices 마이그레이션...'); // 생성된 items 조회 $items = DB::table('items') ->where('tenant_id', $tenantId) ->get(['id', 'code', 'item_type', 'attributes']); // KDunitprice 단가 (code → unitprice) $kdPrices = DB::connection('chandj') ->table('KDunitprice') ->whereNull('is_deleted') ->whereNotNull('prodcode') ->where('prodcode', '!=', '') ->pluck('unitprice', 'prodcode'); // item_list 단가 (item_name → col13) $itemListPrices = DB::connection('chandj') ->table('item_list') ->pluck('col13', 'item_name'); $prices = []; $now = now(); $batchCount = 0; foreach ($items as $item) { $attributes = json_decode($item->attributes, true) ?? []; $legacySource = $attributes['legacy_source'] ?? ''; // 소스별 단가 결정 $unitPrice = match ($legacySource) { 'KDunitprice' => $kdPrices[$item->code] ?? 0, 'item_list' => $itemListPrices[$attributes['legacy_num'] ? $this->getItemListName($item->code) : ''] ?? $attributes['base_price'] ?? 0, 'models' => 0, // models는 단가 없음 default => 0, }; // item_list의 경우 attributes에 저장된 base_price 사용 if ($legacySource === 'item_list' && isset($attributes['base_price'])) { $unitPrice = $attributes['base_price']; } $prices[] = [ 'tenant_id' => $tenantId, 'item_type_code' => $item->item_type, 'item_id' => $item->id, 'client_group_id' => null, 'purchase_price' => 0, 'processing_cost' => null, 'loss_rate' => null, 'margin_rate' => null, 'sales_price' => $unitPrice, 'rounding_rule' => 'round', 'rounding_unit' => 1, 'supplier' => null, 'effective_from' => now()->toDateString(), 'effective_to' => null, 'note' => "{$legacySource} 마이그레이션", 'status' => 'active', 'is_final' => false, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; // 500건씩 배치 INSERT if (count($prices) >= 500) { DB::table('prices')->insert($prices); $batchCount += count($prices); $this->command->info(" → {$batchCount}건 완료..."); $prices = []; } } // 남은 데이터 INSERT if (! empty($prices)) { DB::table('prices')->insert($prices); $batchCount += count($prices); } $this->command->info(" ✓ prices: {$batchCount}건 생성 완료"); return $batchCount; } /** * PT-{name} 코드에서 name 추출 */ private function getItemListName(string $code): string { return str_starts_with($code, 'PT-') ? substr($code, 3) : ''; } /** * Phase 1.1: models → items (FG) 마이그레이션 */ private function migrateModels(int $tenantId, int $userId): int { $this->command->info(''); $this->command->info('📦 [Phase 1.1] models → items (FG) 마이그레이션...'); $models = DB::connection('chandj') ->table('models') ->where(function ($q) { $q->where('is_deleted', 0)->orWhereNull('is_deleted'); }) ->get(); $this->command->info(" → 소스 데이터: {$models->count()}건"); $items = []; $now = now(); foreach ($models as $model) { $finishingShort = self::FINISHING_MAP[$model->finishing_type] ?? 'STD'; $code = "FG-{$model->model_name}-{$model->guiderail_type}-{$finishingShort}"; $name = "{$model->model_name} {$model->major_category} {$model->finishing_type} {$model->guiderail_type}"; $items[] = [ 'tenant_id' => $tenantId, 'item_type' => 'FG', 'code' => $code, 'name' => trim($name), 'unit' => 'EA', 'category_id' => null, 'process_type' => null, 'item_category' => $model->major_category, 'bom' => null, 'attributes' => json_encode([ 'model_name' => $model->model_name, 'major_category' => $model->major_category, 'finishing_type' => $model->finishing_type, 'guiderail_type' => $model->guiderail_type, 'legacy_source' => 'models', 'legacy_model_id' => $model->model_id, ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } if (! empty($items)) { DB::table('items')->insert($items); } $this->command->info(" ✓ items (FG): {$models->count()}건 생성 완료"); return $models->count(); } /** * Phase 1.2: item_list → items (PT) 마이그레이션 */ private function migrateItemList(int $tenantId, int $userId): int { $this->command->info(''); $this->command->info('📦 [Phase 1.2] item_list → items (PT) 마이그레이션...'); $itemList = DB::connection('chandj') ->table('item_list') ->get(); $this->command->info(" → 소스 데이터: {$itemList->count()}건"); $items = []; $now = now(); foreach ($itemList as $item) { $code = "PT-{$item->item_name}"; $items[] = [ 'tenant_id' => $tenantId, 'item_type' => 'PT', 'code' => $code, 'name' => $item->item_name, 'unit' => 'EA', 'category_id' => null, 'process_type' => null, 'item_category' => null, 'bom' => null, 'attributes' => json_encode([ 'base_price' => $item->col13, 'legacy_source' => 'item_list', 'legacy_num' => $item->num, ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } if (! empty($items)) { DB::table('items')->insert($items); } $this->command->info(" ✓ items (PT): {$itemList->count()}건 생성 완료"); return $itemList->count(); } /** * item_div → item_type 매핑 */ private function mapItemType(?string $itemDiv): string { return self::ITEM_TYPE_MAP[$itemDiv] ?? 'SM'; } /** * Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품 추가 * * item_list에 없는 BDmodels.seconditem을 PT items로 생성 */ private function migrateBDmodelsParts(int $tenantId, int $userId): int { $this->command->info(''); $this->command->info('📦 [Phase 2.1] BDmodels.seconditem → items (PT) 누락 부품...'); // BDmodels에서 고유한 seconditem 목록 조회 $bdSecondItems = DB::connection('chandj') ->table('BDmodels') ->where(function ($q) { $q->where('is_deleted', 0)->orWhereNull('is_deleted'); }) ->whereNotNull('seconditem') ->where('seconditem', '!=', '') ->distinct() ->pluck('seconditem'); // 이미 존재하는 PT items 코드 조회 $existingPtCodes = DB::table('items') ->where('tenant_id', $tenantId) ->where('item_type', 'PT') ->pluck('code') ->map(fn ($code) => str_starts_with($code, 'PT-') ? substr($code, 3) : $code) ->toArray(); $items = []; $now = now(); foreach ($bdSecondItems as $secondItem) { // 이미 PT items에 있으면 스킵 if (in_array($secondItem, $existingPtCodes)) { continue; } $code = "PT-{$secondItem}"; $items[] = [ 'tenant_id' => $tenantId, 'item_type' => 'PT', 'code' => $code, 'name' => $secondItem, 'unit' => 'EA', 'category_id' => null, 'process_type' => null, 'item_category' => null, 'bom' => null, 'attributes' => json_encode([ 'legacy_source' => 'BDmodels_seconditem', ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } if (! empty($items)) { DB::table('items')->insert($items); } $this->command->info(" → 소스 데이터: {$bdSecondItems->count()}건 (중복 제외 ".count($items).'건 신규)'); $this->command->info(' ✓ items (PT): '.count($items).'건 생성 완료'); return count($items); } /** * Phase 2.2: BDmodels → items.bom JSON (FG ↔ PT 연결) * * models 기반 FG items에 BOM 연결 * bom: [{child_item_id: X, quantity: Y}, ...] */ private function migrateBom(int $tenantId): int { $this->command->info(''); $this->command->info('🔗 [Phase 2.2] BDmodels → items.bom JSON 연결...'); // PT items 조회 (code → id 매핑) $ptItems = DB::table('items') ->where('tenant_id', $tenantId) ->where('item_type', 'PT') ->pluck('id', 'code') ->toArray(); // PT- prefix 없는 버전도 매핑 추가 $ptItemsByName = []; foreach ($ptItems as $code => $id) { $name = str_starts_with($code, 'PT-') ? substr($code, 3) : $code; $ptItemsByName[$name] = $id; } // FG items 조회 (models 기반) $fgItems = DB::table('items') ->where('tenant_id', $tenantId) ->where('item_type', 'FG') ->whereNotNull('attributes') ->get(['id', 'code', 'attributes']); // BDmodels 데이터 조회 $bdModels = DB::connection('chandj') ->table('BDmodels') ->where(function ($q) { $q->where('is_deleted', 0)->orWhereNull('is_deleted'); }) ->whereNotNull('model_name') ->where('model_name', '!=', '') ->get(['model_name', 'seconditem', 'savejson']); // model_name → seconditems 그룹핑 $modelBomMap = []; foreach ($bdModels as $bd) { if (empty($bd->seconditem)) { continue; } $modelName = $bd->model_name; if (! isset($modelBomMap[$modelName])) { $modelBomMap[$modelName] = []; } // savejson에서 수량 파싱 (col8이 수량) $quantity = 1; if (! empty($bd->savejson)) { $json = json_decode($bd->savejson, true); if (is_array($json) && ! empty($json)) { // 첫 번째 항목의 col8(수량) 사용 $quantity = (int) ($json[0]['col8'] ?? 1); } } // 중복 체크 후 추가 $found = false; foreach ($modelBomMap[$modelName] as &$existing) { if ($existing['seconditem'] === $bd->seconditem) { $found = true; break; } } if (! $found) { $modelBomMap[$modelName][] = [ 'seconditem' => $bd->seconditem, 'quantity' => $quantity, ]; } } $updatedCount = 0; foreach ($fgItems as $fgItem) { $attributes = json_decode($fgItem->attributes, true) ?? []; $modelName = $attributes['model_name'] ?? null; if (empty($modelName) || ! isset($modelBomMap[$modelName])) { continue; } $bomArray = []; foreach ($modelBomMap[$modelName] as $bomItem) { $childItemId = $ptItemsByName[$bomItem['seconditem']] ?? null; if ($childItemId) { $bomArray[] = [ 'child_item_id' => $childItemId, 'quantity' => $bomItem['quantity'], ]; } } if (! empty($bomArray)) { DB::table('items') ->where('id', $fgItem->id) ->update(['bom' => json_encode($bomArray)]); $updatedCount++; } } $this->command->info(' → BDmodels 모델: '.count($modelBomMap).'개'); $this->command->info(" ✓ items.bom 연결: {$updatedCount}건 완료"); return $updatedCount; } /** * Phase 3.1: price_motor → items (SM) + prices * * price_motor JSON에서 누락된 품목만 추가 * - 제어기, 방화/방범 콘트롤박스, 스위치, 리모콘 등 * * @return array{items: int, prices: int} */ private function migratePriceMotor(int $tenantId, int $userId): array { $this->command->info(''); $this->command->info('📦 [Phase 3.1] price_motor → items (SM) 누락 품목...'); // 최신 price_motor 데이터 조회 $priceMotor = DB::connection('chandj') ->table('price_motor') ->where(function ($q) { $q->where('is_deleted', 0)->orWhereNull('is_deleted'); }) ->orderByDesc('registedate') ->first(); if (! $priceMotor || empty($priceMotor->itemList)) { $this->command->info(' → 소스 데이터 없음'); return ['items' => 0, 'prices' => 0]; } $itemList = json_decode($priceMotor->itemList, true); if (! is_array($itemList)) { $this->command->info(' → JSON 파싱 실패'); return ['items' => 0, 'prices' => 0]; } // 기존 items 이름 조회 (중복 체크용) $existingNames = DB::table('items') ->where('tenant_id', $tenantId) ->pluck('name') ->map(fn ($n) => mb_strtolower($n)) ->toArray(); $items = []; $now = now(); $newItemCodes = []; foreach ($itemList as $idx => $item) { $col1 = $item['col1'] ?? ''; // 전압/카테고리 (220, 380, 제어기, 방화, 방범) $col2 = $item['col2'] ?? ''; // 용량/품목명 $salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0'); // 모터 품목은 KDunitprice에 이미 있으므로 스킵 if (in_array($col1, ['220', '380'])) { continue; } // 품목명 생성 $name = trim("{$col1} {$col2}"); if (empty($name) || $name === ' ') { continue; } // 이미 존재하는 품목 스킵 (유사 이름 체크) $nameLower = mb_strtolower($name); $exists = false; foreach ($existingNames as $existingName) { if (str_contains($existingName, $nameLower) || str_contains($nameLower, $existingName)) { $exists = true; break; } } if ($exists) { continue; } // 코드 생성 $code = 'PM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT); $items[] = [ 'tenant_id' => $tenantId, 'item_type' => 'SM', 'code' => $code, 'name' => $name, 'unit' => 'EA', 'category_id' => null, 'process_type' => null, 'item_category' => null, 'bom' => null, 'attributes' => json_encode([ 'price_category' => $col1, 'price_spec' => $col2, 'legacy_source' => 'price_motor', ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; $newItemCodes[$code] = $salesPrice; $existingNames[] = $nameLower; // 중복 방지 } if (! empty($items)) { DB::table('items')->insert($items); } // prices 생성 $priceCount = 0; if (! empty($newItemCodes)) { $newItems = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', array_keys($newItemCodes)) ->get(['id', 'code', 'item_type']); $prices = []; foreach ($newItems as $item) { $prices[] = [ 'tenant_id' => $tenantId, 'item_type_code' => $item->item_type, 'item_id' => $item->id, 'client_group_id' => null, 'purchase_price' => 0, 'processing_cost' => null, 'loss_rate' => null, 'margin_rate' => null, 'sales_price' => $newItemCodes[$item->code], 'rounding_rule' => 'round', 'rounding_unit' => 1, 'supplier' => null, 'effective_from' => $priceMotor->registedate ?? now()->toDateString(), 'effective_to' => null, 'note' => 'price_motor 마이그레이션', 'status' => 'active', 'is_final' => false, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } if (! empty($prices)) { DB::table('prices')->insert($prices); $priceCount = count($prices); } } $this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)'); $this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료'); return ['items' => count($items), 'prices' => $priceCount]; } /** * Phase 3.2: price_raw_materials → items (RM) + prices * * price_raw_materials JSON에서 누락된 원자재 품목 추가 * * @return array{items: int, prices: int} */ private function migratePriceRawMaterials(int $tenantId, int $userId): array { $this->command->info(''); $this->command->info('📦 [Phase 3.2] price_raw_materials → items (RM) 누락 품목...'); // 최신 price_raw_materials 데이터 조회 $priceRaw = DB::connection('chandj') ->table('price_raw_materials') ->where(function ($q) { $q->where('is_deleted', 0)->orWhereNull('is_deleted'); }) ->orderByDesc('registedate') ->first(); if (! $priceRaw || empty($priceRaw->itemList)) { $this->command->info(' → 소스 데이터 없음'); return ['items' => 0, 'prices' => 0]; } $itemList = json_decode($priceRaw->itemList, true); if (! is_array($itemList)) { $this->command->info(' → JSON 파싱 실패'); return ['items' => 0, 'prices' => 0]; } // 기존 items 이름 조회 (중복 체크용) $existingNames = DB::table('items') ->where('tenant_id', $tenantId) ->pluck('name') ->map(fn ($n) => mb_strtolower($n)) ->toArray(); $items = []; $now = now(); $newItemCodes = []; foreach ($itemList as $idx => $item) { $col1 = $item['col1'] ?? ''; // 카테고리 (슬랫, 스크린) $col2 = $item['col2'] ?? ''; // 품목명 (방화, 실리카, 화이바) $salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0'); // 품목명 생성 $name = trim("{$col1} {$col2}"); if (empty($name) || $name === ' ') { continue; } // 이미 존재하는 품목 스킵 $nameLower = mb_strtolower($name); $exists = false; foreach ($existingNames as $existingName) { // 정확히 일치하거나 유사한 이름 체크 $col2Lower = mb_strtolower($col2); if (str_contains($existingName, $col2Lower) || $existingName === $nameLower) { $exists = true; break; } } if ($exists) { continue; } // 코드 생성 $code = 'RM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT); $items[] = [ 'tenant_id' => $tenantId, 'item_type' => 'RM', 'code' => $code, 'name' => $name, 'unit' => 'EA', 'category_id' => null, 'process_type' => null, 'item_category' => $col1, 'bom' => null, 'attributes' => json_encode([ 'raw_category' => $col1, 'raw_name' => $col2, 'legacy_source' => 'price_raw_materials', ]), 'attributes_archive' => null, 'options' => null, 'description' => null, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; $newItemCodes[$code] = $salesPrice; $existingNames[] = $nameLower; } if (! empty($items)) { DB::table('items')->insert($items); } // prices 생성 $priceCount = 0; if (! empty($newItemCodes)) { $newItems = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', array_keys($newItemCodes)) ->get(['id', 'code', 'item_type']); $prices = []; foreach ($newItems as $item) { $prices[] = [ 'tenant_id' => $tenantId, 'item_type_code' => $item->item_type, 'item_id' => $item->id, 'client_group_id' => null, 'purchase_price' => 0, 'processing_cost' => null, 'loss_rate' => null, 'margin_rate' => null, 'sales_price' => $newItemCodes[$item->code], 'rounding_rule' => 'round', 'rounding_unit' => 1, 'supplier' => null, 'effective_from' => $priceRaw->registedate ?? now()->toDateString(), 'effective_to' => null, 'note' => 'price_raw_materials 마이그레이션', 'status' => 'active', 'is_final' => false, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } if (! empty($prices)) { DB::table('prices')->insert($prices); $priceCount = count($prices); } } $this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)'); $this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료'); return ['items' => count($items), 'prices' => $priceCount]; } }