원본ID → 새ID */ private array $pageMap = []; private array $sectionMap = []; private array $fieldMap = []; private array $categoryMap = []; /** @var array code → 새ID */ private array $itemMap = []; /** 기대 건수 (검증용) */ private array $expectedCounts = []; public function run(): void { $this->tenantId = DummyDataSeeder::TENANT_ID; $this->userId = DummyDataSeeder::USER_ID; $this->dataPath = database_path('seeders/data/kyungdong'); $this->command->info('=== 경동기업 품목 기준 데이터 시더 시작 ==='); $this->command->info(" tenant_id: {$this->tenantId}"); $this->command->newLine(); DB::transaction(function () { $this->cleanup(); $this->command->info('--- Phase 1: 독립 테이블 ---'); $this->seedItemPages(); $this->seedItemSections(); $this->seedItemFields(); $this->seedEntityRelationships(); $this->updateDisplayConditions(); $this->command->info('--- Phase 2: 기반 테이블 ---'); $this->seedCategories(); $this->seedItems(); $this->command->info('--- Phase 3: 종속 테이블 ---'); $this->seedItemDetails(); $this->seedPrices(); }); $this->verify(); $this->printSummary(); } // ────────────────────────────────────────────────────────────────── // CLEANUP // ────────────────────────────────────────────────────────────────── private function cleanup(): void { $this->command->info('CLEANUP: tenant_id='.$this->tenantId.' 데이터 삭제'); // FK 역순 삭제 // prices - tenant_id로 직접 삭제 $c = DB::table('prices')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" prices: {$c}건 삭제"); // item_details - items를 통해 삭제 $itemIds = DB::table('items')->where('tenant_id', $this->tenantId)->pluck('id'); $c = DB::table('item_details')->whereIn('item_id', $itemIds)->delete(); $this->command->info(" item_details: {$c}건 삭제"); // items $c = DB::table('items')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" items: {$c}건 삭제"); // categories - 자식 먼저 삭제 $catIds = DB::table('categories')->where('tenant_id', $this->tenantId)->pluck('id'); // 자식 (parent_id가 있는 것) 먼저 DB::table('categories') ->where('tenant_id', $this->tenantId) ->whereNotNull('parent_id') ->delete(); $c = DB::table('categories')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(' categories: '.count($catIds).'건 삭제'); // entity_relationships $c = DB::table('entity_relationships')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" entity_relationships: {$c}건 삭제"); // item_fields $c = DB::table('item_fields')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" item_fields: {$c}건 삭제"); // item_sections $c = DB::table('item_sections')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" item_sections: {$c}건 삭제"); // item_pages $c = DB::table('item_pages')->where('tenant_id', $this->tenantId)->delete(); $this->command->info(" item_pages: {$c}건 삭제"); $this->command->newLine(); } // ────────────────────────────────────────────────────────────────── // PHASE 1: 독립 테이블 // ────────────────────────────────────────────────────────────────── private function seedItemPages(): void { $rows = $this->loadJson('item_pages.json'); $this->expectedCounts['item_pages'] = count($rows); foreach ($rows as $row) { $originalId = $row['_original_id']; $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); $newId = DB::table('item_pages')->insertGetId($data); $this->pageMap[$originalId] = $newId; } $this->command->info(' item_pages: '.count($rows).'건 삽입'); } private function seedItemSections(): void { $rows = $this->loadJson('item_sections.json'); $this->expectedCounts['item_sections'] = count($rows); foreach ($rows as $row) { $originalId = $row['_original_id']; $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); $newId = DB::table('item_sections')->insertGetId($data); $this->sectionMap[$originalId] = $newId; } $this->command->info(' item_sections: '.count($rows).'건 삽입'); } private function seedItemFields(): void { $rows = $this->loadJson('item_fields.json'); $this->expectedCounts['item_fields'] = count($rows); foreach ($rows as $row) { $originalId = $row['_original_id']; $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); $newId = DB::table('item_fields')->insertGetId($data); $this->fieldMap[$originalId] = $newId; } $this->command->info(' item_fields: '.count($rows).'건 삽입'); } private function seedEntityRelationships(): void { $rows = $this->loadJson('entity_relationships.json'); $this->expectedCounts['entity_relationships'] = count($rows); $inserted = 0; foreach ($rows as $row) { $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); // parent_id 매핑 $data['parent_id'] = $this->mapEntityId($data['parent_type'], $data['parent_id']); // child_id 매핑 $data['child_id'] = $this->mapEntityId($data['child_type'], $data['child_id']); if ($data['parent_id'] === null || $data['child_id'] === null) { $this->command->warn(" entity_relationships: 매핑 실패 건 스킵 (parent_type={$row['parent_type']}, child_type={$row['child_type']})"); continue; } DB::table('entity_relationships')->insert($data); $inserted++; } $this->expectedCounts['entity_relationships'] = $inserted; $this->command->info(" entity_relationships: {$inserted}건 삽입"); } /** * item_fields의 display_condition 내 targetFieldIds를 새 ID로 치환 */ private function updateDisplayConditions(): void { if (empty($this->fieldMap)) { return; } $fields = DB::table('item_fields') ->where('tenant_id', $this->tenantId) ->whereNotNull('display_condition') ->get(['id', 'display_condition']); $updated = 0; foreach ($fields as $field) { $condition = json_decode($field->display_condition, true); if (! is_array($condition)) { continue; } $changed = false; $condition = $this->remapDisplayCondition($condition, $changed); if ($changed) { DB::table('item_fields') ->where('id', $field->id) ->update(['display_condition' => json_encode($condition)]); $updated++; } } if ($updated > 0) { $this->command->info(" display_condition 후처리: {$updated}건 업데이트"); } } /** * display_condition 내 targetFieldIds 재귀적 치환 */ private function remapDisplayCondition(array $condition, bool &$changed): array { if (isset($condition['targetFieldIds']) && is_array($condition['targetFieldIds'])) { $newIds = []; foreach ($condition['targetFieldIds'] as $oldId) { if (isset($this->fieldMap[$oldId])) { $newIds[] = $this->fieldMap[$oldId]; $changed = true; } else { $newIds[] = $oldId; } } $condition['targetFieldIds'] = $newIds; } // targetFieldId (단일) 처리 if (isset($condition['targetFieldId']) && isset($this->fieldMap[$condition['targetFieldId']])) { $condition['targetFieldId'] = $this->fieldMap[$condition['targetFieldId']]; $changed = true; } // conditions 배열 재귀 처리 if (isset($condition['conditions']) && is_array($condition['conditions'])) { foreach ($condition['conditions'] as $i => $sub) { $condition['conditions'][$i] = $this->remapDisplayCondition($sub, $changed); } } return $condition; } // ────────────────────────────────────────────────────────────────── // PHASE 2: 기반 테이블 // ────────────────────────────────────────────────────────────────── private function seedCategories(): void { $rows = $this->loadJson('categories.json'); $this->expectedCounts['categories'] = count($rows); // depth 순 삽입을 위해 parent_id 없는 것 먼저 $roots = array_filter($rows, fn ($r) => empty($r['parent_id'])); $children = array_filter($rows, fn ($r) => ! empty($r['parent_id'])); // 루트 카테고리 삽입 foreach ($roots as $row) { $originalId = $row['_original_id']; $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $data['parent_id'] = null; $this->setAuditFields($data); $newId = DB::table('categories')->insertGetId($data); $this->categoryMap[$originalId] = $newId; } // 자식 카테고리 삽입 (여러 depth 처리를 위해 최대 5회 반복) $remaining = $children; for ($pass = 0; $pass < 5 && ! empty($remaining); $pass++) { $nextRemaining = []; foreach ($remaining as $row) { $originalId = $row['_original_id']; $parentOriginalId = $row['parent_id']; if (! isset($this->categoryMap[$parentOriginalId])) { $nextRemaining[] = $row; continue; } $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $data['parent_id'] = $this->categoryMap[$parentOriginalId]; $this->setAuditFields($data); $newId = DB::table('categories')->insertGetId($data); $this->categoryMap[$originalId] = $newId; } $remaining = $nextRemaining; } if (! empty($remaining)) { $this->command->warn(' categories: '.count($remaining).'건 매핑 실패 (depth 초과)'); } $this->command->info(' categories: '.count($this->categoryMap).'건 삽입'); } private function seedItems(): void { $rows = $this->loadJson('items.json'); $this->expectedCounts['items'] = count($rows); $chunks = array_chunk($rows, 500); $total = 0; foreach ($chunks as $chunk) { $insertData = []; foreach ($chunk as $row) { $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); // category_id 매핑 if (! empty($data['category_id']) && isset($this->categoryMap[$data['category_id']])) { $data['category_id'] = $this->categoryMap[$data['category_id']]; } $insertData[] = $data; } DB::table('items')->insert($insertData); $total += count($insertData); } // code로 itemMap 구축 $this->itemMap = DB::table('items') ->where('tenant_id', $this->tenantId) ->whereNull('deleted_at') ->pluck('id', 'code') ->toArray(); $this->command->info(" items: {$total}건 삽입 (itemMap: ".count($this->itemMap).'건)'); // BOM의 child_item_code → child_item_id 변환 $this->remapBomCodes(); } /** * BOM JSON의 child_item_code를 실제 child_item_id로 변환 * * items.json의 bom 필드가 child_item_code(품목코드) 기반으로 저장되어 있으므로, * INSERT 후 itemMap(code→id)을 이용해 child_item_id로 교체한다. */ private function remapBomCodes(): void { $bomItems = DB::table('items') ->where('tenant_id', $this->tenantId) ->whereNotNull('bom') ->get(['id', 'code', 'bom']); $updated = 0; foreach ($bomItems as $item) { $bom = json_decode($item->bom, true); if (! is_array($bom)) { continue; } $changed = false; foreach ($bom as &$entry) { // child_item_code → child_item_id 변환 if (isset($entry['child_item_code'])) { $code = $entry['child_item_code']; if (isset($this->itemMap[$code])) { $entry['child_item_id'] = $this->itemMap[$code]; unset($entry['child_item_code']); $changed = true; } else { $this->command->warn(" BOM 매핑 실패: {$item->code} → {$code} (품목 미존재)"); } } } unset($entry); if ($changed) { DB::table('items')->where('id', $item->id) ->update(['bom' => json_encode($bom, JSON_UNESCAPED_UNICODE)]); $updated++; } } $this->command->info(" BOM code→id 매핑: {$updated}건 변환"); } // ────────────────────────────────────────────────────────────────── // PHASE 3: 종속 테이블 // ────────────────────────────────────────────────────────────────── private function seedItemDetails(): void { $rows = $this->loadJson('item_details.json'); $this->expectedCounts['item_details'] = count($rows); // items JSON에서 원본 item_id → code 매핑 구축 $itemsJson = $this->loadJson('items.json'); $originalIdToCode = []; foreach ($itemsJson as $item) { $originalIdToCode[$item['_original_id']] = $item['code']; } $insertData = []; $skipped = 0; foreach ($rows as $row) { $data = $this->stripMeta($row); $originalItemId = $data['item_id']; // 원본 item_id → code → 새 item_id $code = $originalIdToCode[$originalItemId] ?? null; if ($code === null || ! isset($this->itemMap[$code])) { $skipped++; continue; } $data['item_id'] = $this->itemMap[$code]; $insertData[] = $data; } if (! empty($insertData)) { foreach (array_chunk($insertData, 500) as $chunk) { DB::table('item_details')->insert($chunk); } } if ($skipped > 0) { $this->command->warn(" item_details: {$skipped}건 매핑 실패 스킵"); } $this->expectedCounts['item_details'] = count($insertData); $this->command->info(' item_details: '.count($insertData).'건 삽입'); } private function seedPrices(): void { $rows = $this->loadJson('prices.json'); $this->expectedCounts['prices'] = count($rows); // items JSON에서 원본 item_id → code 매핑 구축 $itemsJson = $this->loadJson('items.json'); $originalIdToCode = []; foreach ($itemsJson as $item) { $originalIdToCode[$item['_original_id']] = $item['code']; } $insertData = []; $skipped = 0; foreach ($rows as $row) { $data = $this->stripMeta($row); $data['tenant_id'] = $this->tenantId; $this->setAuditFields($data); $originalItemId = $data['item_id']; // 원본 item_id → code → 새 item_id $code = $originalIdToCode[$originalItemId] ?? null; if ($code === null || ! isset($this->itemMap[$code])) { $skipped++; continue; } $data['item_id'] = $this->itemMap[$code]; $insertData[] = $data; } if (! empty($insertData)) { foreach (array_chunk($insertData, 500) as $chunk) { DB::table('prices')->insert($chunk); } } if ($skipped > 0) { $this->command->warn(" prices: {$skipped}건 매핑 실패 스킵"); } $this->expectedCounts['prices'] = count($insertData); $this->command->info(' prices: '.count($insertData).'건 삽입'); } // ────────────────────────────────────────────────────────────────── // VERIFY & SUMMARY // ────────────────────────────────────────────────────────────────── private function verify(): void { $this->command->newLine(); $this->command->info('=== 검증 ==='); $tables = [ 'item_pages' => fn () => DB::table('item_pages')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), 'item_sections' => fn () => DB::table('item_sections')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), 'item_fields' => fn () => DB::table('item_fields')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), 'entity_relationships' => fn () => DB::table('entity_relationships')->where('tenant_id', $this->tenantId)->count(), 'categories' => fn () => DB::table('categories')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), 'items' => fn () => DB::table('items')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), 'item_details' => fn () => DB::table('item_details')->whereIn('item_id', DB::table('items')->where('tenant_id', $this->tenantId)->pluck('id'))->count(), 'prices' => fn () => DB::table('prices')->where('tenant_id', $this->tenantId)->whereNull('deleted_at')->count(), ]; $allOk = true; foreach ($tables as $table => $countFn) { $actual = $countFn(); $expected = $this->expectedCounts[$table] ?? '?'; $status = ($actual == $expected) ? 'OK' : 'MISMATCH'; if ($status === 'MISMATCH') { $allOk = false; $this->command->error(" {$table}: {$actual}건 (기대: {$expected}) [{$status}]"); } else { $this->command->info(" {$table}: {$actual}건 [{$status}]"); } } // entity_relationships 참조 무결성 검증 $this->verifyRelationshipIntegrity(); if ($allOk) { $this->command->newLine(); $this->command->info('모든 검증 통과!'); } } private function verifyRelationshipIntegrity(): void { $relationships = DB::table('entity_relationships') ->where('tenant_id', $this->tenantId) ->get(); $orphans = 0; foreach ($relationships as $rel) { $parentExists = $this->entityExists($rel->parent_type, $rel->parent_id); $childExists = $this->entityExists($rel->child_type, $rel->child_id); if (! $parentExists || ! $childExists) { $orphans++; } } if ($orphans > 0) { $this->command->warn(" entity_relationships 참조 무결성: {$orphans}건 고아 레코드"); } else { $this->command->info(' entity_relationships 참조 무결성: OK'); } } private function entityExists(string $type, int $id): bool { return match ($type) { 'page' => DB::table('item_pages')->where('id', $id)->exists(), 'section' => DB::table('item_sections')->where('id', $id)->exists(), 'field' => DB::table('item_fields')->where('id', $id)->exists(), 'bom' => DB::table('item_bom_items')->where('id', $id)->exists(), default => false, }; } private function printSummary(): void { $this->command->newLine(); $this->command->info('=== 경동기업 품목 기준 데이터 시더 완료 ==='); $this->command->info(' 매핑 현황:'); $this->command->info(' pageMap: '.count($this->pageMap).'건'); $this->command->info(' sectionMap: '.count($this->sectionMap).'건'); $this->command->info(' fieldMap: '.count($this->fieldMap).'건'); $this->command->info(' categoryMap: '.count($this->categoryMap).'건'); $this->command->info(' itemMap: '.count($this->itemMap).'건'); } // ────────────────────────────────────────────────────────────────── // HELPERS // ────────────────────────────────────────────────────────────────── private function loadJson(string $filename): array { $path = $this->dataPath.'/'.$filename; if (! file_exists($path)) { $this->command->error("JSON 파일 없음: {$path}"); $this->command->error('먼저 php artisan kyungdong:export-item-master 를 실행하세요.'); throw new \RuntimeException("JSON 파일 없음: {$path}"); } return json_decode(file_get_contents($path), true); } /** * _original_id, id, created_at, updated_at, deleted_at 등 메타 제거 */ private function stripMeta(array $row): array { unset( $row['_original_id'], $row['id'], $row['created_at'], $row['updated_at'], $row['deleted_at'], ); return $row; } private function setAuditFields(array &$data): void { $now = now(); $data['created_at'] = $now; $data['updated_at'] = $now; if (isset($data['created_by'])) { $data['created_by'] = $this->userId; } if (isset($data['updated_by'])) { $data['updated_by'] = $this->userId; } // deleted_by, deleted_at는 항상 제거 unset($data['deleted_by'], $data['deleted_at']); } /** * entity type에 따라 원본 ID를 새 ID로 매핑 */ private function mapEntityId(string $type, int $originalId): ?int { return match ($type) { 'page' => $this->pageMap[$originalId] ?? null, 'section' => $this->sectionMap[$originalId] ?? null, 'field' => $this->fieldMap[$originalId] ?? null, default => $originalId, // bom 등은 그대로 }; } }