diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php index 46d045c..e256b6f 100644 --- a/app/Services/CategoryService.php +++ b/app/Services/CategoryService.php @@ -197,15 +197,22 @@ public function tree(array $params) { $tenantId = $this->tenantId(); $onlyActive = (bool) ($params['only_active'] ?? false); + $codeGroup = $params['code_group'] ?? null; $q = Category::where('tenant_id', $tenantId) ->when($onlyActive, fn ($qq) => $qq->where('is_active', 1)) + ->when($codeGroup, fn ($qq) => $qq->where('code_group', $codeGroup)) ->orderBy('parent_id')->orderBy('sort_order')->orderBy('id') - ->get(['id', 'parent_id', 'code', 'name', 'is_active', 'sort_order']); + ->get(['id', 'parent_id', 'code', 'code_group', 'name', 'is_active', 'sort_order']); + + // 최상위 카테고리 ID 수집 (해당 code_group의 parent_id가 null이거나, 다른 code_group의 카테고리를 가리키는 경우) + $categoryIds = $q->pluck('id')->toArray(); + $rootIds = $q->filter(fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds))->pluck('id')->toArray(); $byParent = []; foreach ($q as $c) { - $byParent[$c->parent_id ?? 0][] = $c; + $parentKey = in_array($c->id, $rootIds) ? 0 : ($c->parent_id ?? 0); + $byParent[$parentKey][] = $c; } $build = function ($pid) use (&$build, &$byParent) { diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 95d8836..2b30b1b 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -851,14 +851,19 @@ public function calculateCategoryPrice( } /** - * 공정별 품목 그룹화 + * 품목 카테고리 그룹화 (동적 카테고리 시스템) * - * 품목을 process_type에 따라 그룹화합니다: - * - screen: 스크린 공정 (원단, 패널, 도장 등) - * - bending: 절곡 공정 (알루미늄, 스테인리스 등) - * - steel: 철재 공정 (철재, 강판 등) - * - electric: 전기 공정 (모터, 제어반, 전선 등) - * - assembly: 조립 공정 (볼트, 너트, 브라켓 등) + * 품목을 item_category 필드 기준으로 그룹화합니다. + * 카테고리 정보는 categories 테이블에서 code_group='item_category'로 조회합니다. + * + * 카테고리 구조: + * - BODY: 본체 + * - BENDING 하위 카테고리: + * - BENDING_GUIDE: 절곡품 - 가이드레일 + * - BENDING_CASE: 절곡품 - 케이스 + * - BENDING_BOTTOM: 절곡품 - 하단마감재 + * - MOTOR_CTRL: 모터 & 제어기 + * - ACCESSORY: 부자재 */ public function groupItemsByProcess(array $items, ?int $tenantId = null): array { @@ -868,45 +873,51 @@ public function groupItemsByProcess(array $items, ?int $tenantId = null): array return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } - // 품목 코드로 process_type 일괄 조회 + // 품목 코드로 item_category 일괄 조회 $itemCodes = array_unique(array_column($items, 'item_code')); if (empty($itemCodes)) { return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } - $processTypes = DB::table('items') + // 품목별 item_category 조회 + $itemCategories = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', $itemCodes) ->whereNull('deleted_at') - ->pluck('process_type', 'code') + ->pluck('item_category', 'code') ->toArray(); - // 그룹별 분류 - $grouped = [ - 'screen' => ['name' => '스크린 공정', 'items' => [], 'subtotal' => 0], - 'bending' => ['name' => '절곡 공정', 'items' => [], 'subtotal' => 0], - 'steel' => ['name' => '철재 공정', 'items' => [], 'subtotal' => 0], - 'electric' => ['name' => '전기 공정', 'items' => [], 'subtotal' => 0], - 'assembly' => ['name' => '조립 공정', 'items' => [], 'subtotal' => 0], - 'other' => ['name' => '기타', 'items' => [], 'subtotal' => 0], - ]; + // 카테고리 트리 조회 (item_category 코드 그룹) + $categoryTree = $this->getItemCategoryTree($tenantId); + + // 카테고리 코드 → 정보 매핑 생성 (탭 구조에 맞게) + $categoryMapping = $this->buildCategoryMapping($categoryTree); + + // 그룹별 분류를 위한 빈 구조 생성 + $grouped = []; + foreach ($categoryMapping as $code => $info) { + $grouped[$code] = ['name' => $info['name'], 'items' => [], 'subtotal' => 0]; + } + // 기타 그룹 추가 + $grouped['OTHER'] = ['name' => '기타', 'items' => [], 'subtotal' => 0]; foreach ($items as $item) { - $processType = $processTypes[$item['item_code']] ?? 'other'; + $categoryCode = $itemCategories[$item['item_code']] ?? 'OTHER'; - if (! isset($grouped[$processType])) { - $processType = 'other'; + // 매핑에 없는 카테고리는 기타로 분류 + if (! isset($grouped[$categoryCode])) { + $categoryCode = 'OTHER'; } - $grouped[$processType]['items'][] = $item; - $grouped[$processType]['subtotal'] += $item['total_price'] ?? 0; + $grouped[$categoryCode]['items'][] = $item; + $grouped[$categoryCode]['subtotal'] += $item['total_price'] ?? 0; } // 빈 그룹 제거 $grouped = array_filter($grouped, fn ($g) => ! empty($g['items'])); - $this->addDebugStep(8, '공정그룹화', [ + $this->addDebugStep(8, '카테고리그룹화', [ 'total_items' => count($items), 'groups' => array_map(fn ($g) => [ 'name' => $g['name'], @@ -919,14 +930,94 @@ public function groupItemsByProcess(array $items, ?int $tenantId = null): array } /** - * items 배열에 process_group 필드 추가 + * item_category 카테고리 트리 조회 * - * groupedItems에서 각 아이템의 소속 그룹을 찾아 process_group 필드를 추가합니다. + * categories 테이블에서 code_group='item_category'인 카테고리를 트리 구조로 조회 + */ + private function getItemCategoryTree(int $tenantId): array + { + $categories = DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', 'item_category') + ->where('is_active', 1) + ->whereNull('deleted_at') + ->orderBy('parent_id') + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'parent_id', 'code', 'name']) + ->toArray(); + + // 트리 구조로 변환 + $categoryIds = array_column($categories, 'id'); + $rootIds = array_filter($categories, fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds)); + + $byParent = []; + foreach ($categories as $c) { + $parentKey = in_array($c->id, array_column($rootIds, 'id')) ? 0 : ($c->parent_id ?? 0); + $byParent[$parentKey][] = $c; + } + + $buildTree = function ($parentId) use (&$buildTree, &$byParent) { + $nodes = $byParent[$parentId] ?? []; + + return array_map(function ($n) use ($buildTree) { + return [ + 'id' => $n->id, + 'code' => $n->code, + 'name' => $n->name, + 'children' => $buildTree($n->id), + ]; + }, $nodes); + }; + + return $buildTree(0); + } + + /** + * 카테고리 트리를 탭 구조에 맞게 매핑 생성 + * + * BENDING 카테고리의 경우 하위 카테고리를 개별 탭으로, + * 나머지는 그대로 1depth 탭으로 매핑 + */ + private function buildCategoryMapping(array $categoryTree): array + { + $mapping = []; + + foreach ($categoryTree as $category) { + if ($category['code'] === 'BENDING' && ! empty($category['children'])) { + // BENDING: 하위 카테고리를 개별 탭으로 + foreach ($category['children'] as $subCategory) { + $mapping[$subCategory['code']] = [ + 'name' => '절곡품 - '.$subCategory['name'], + 'parentCode' => 'BENDING', + ]; + } + } else { + // 나머지: 1depth 탭 + $mapping[$category['code']] = [ + 'name' => $category['name'], + 'parentCode' => null, + ]; + } + } + + return $mapping; + } + + /** + * items 배열에 카테고리 정보 필드 추가 + * + * groupedItems에서 각 아이템의 소속 그룹을 찾아 카테고리 관련 필드를 추가합니다. * 프론트엔드에서 탭별 분류에 사용됩니다. + * + * 추가되는 필드: + * - process_group: 그룹명 (레거시 호환) + * - process_group_key: 그룹키 (레거시 호환) + * - category_code: 동적 카테고리 코드 (신규 시스템) */ private function addProcessGroupToItems(array $items, array $groupedItems): array { - // 각 그룹의 아이템 코드 → 그룹명 매핑 생성 + // 각 그룹의 아이템 코드 → 그룹정보 매핑 생성 $itemCodeToGroup = []; foreach ($groupedItems as $groupKey => $group) { if (! isset($group['items']) || ! is_array($group['items'])) { @@ -940,11 +1031,12 @@ private function addProcessGroupToItems(array $items, array $groupedItems): arra } } - // items 배열에 process_group 추가 + // items 배열에 카테고리 정보 추가 return array_map(function ($item) use ($itemCodeToGroup) { - $groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'other', 'name' => '기타']; + $groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'OTHER', 'name' => '기타']; $item['process_group'] = $groupInfo['name']; - $item['process_group_key'] = $groupInfo['key']; + $item['process_group_key'] = $groupInfo['key']; // 레거시 호환 + $item['category_code'] = $groupInfo['key']; // 신규 동적 카테고리 시스템 return $item; }, $items); diff --git a/database/seeders/QuoteItemCategorySeeder.php b/database/seeders/QuoteItemCategorySeeder.php new file mode 100644 index 0000000..d8f5093 --- /dev/null +++ b/database/seeders/QuoteItemCategorySeeder.php @@ -0,0 +1,107 @@ +가이드레일,케이스,하단마감재), 모터&제어기, 부자재 + */ +class QuoteItemCategorySeeder extends Seeder +{ + public function run(): void + { + $tenantId = 1; + $codeGroup = 'item_category'; + $now = now(); + + // 1. 상위 카테고리 추가 + $parentCategories = [ + ['code' => 'BODY', 'name' => '본체', 'sort_order' => 1], + ['code' => 'BENDING', 'name' => '절곡품', 'sort_order' => 2], + ['code' => 'MOTOR_CTRL', 'name' => '모터 & 제어기', 'sort_order' => 3], + ['code' => 'ACCESSORY', 'name' => '부자재', 'sort_order' => 4], + ]; + + $parentIds = []; + foreach ($parentCategories as $cat) { + $parentIds[$cat['code']] = DB::table('categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => null, + 'code_group' => $codeGroup, + 'code' => $cat['code'], + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 2. 절곡품 하위 3개 중간 카테고리 추가 + $bendingSubCategories = [ + ['code' => 'BENDING_GUIDE', 'name' => '가이드레일', 'sort_order' => 1], + ['code' => 'BENDING_CASE', 'name' => '케이스', 'sort_order' => 2], + ['code' => 'BENDING_BOTTOM', 'name' => '하단마감재', 'sort_order' => 3], + ]; + + $bendingSubIds = []; + foreach ($bendingSubCategories as $cat) { + $bendingSubIds[$cat['code']] = DB::table('categories')->insertGetId([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentIds['BENDING'], + 'code_group' => $codeGroup, + 'code' => $cat['code'], + 'name' => $cat['name'], + 'sort_order' => $cat['sort_order'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + // 3. 기존 세부 항목들의 parent_id 업데이트 + $mappings = [ + // 본체 + 'BODY' => ['SILICA_BODY', 'WIRE_BODY', 'FIBER_BODY', 'COLUMNLESS_BODY', 'SLAT_BODY'], + + // 절곡품 > 가이드레일 + 'BENDING_GUIDE' => ['GUIDE_RAIL', 'SMOKE_SEAL'], + + // 절곡품 > 케이스 + 'BENDING_CASE' => ['SHUTTER_BOX', 'TOP_COVER', 'END_PLATE'], + + // 절곡품 > 하단마감재 + 'BENDING_BOTTOM' => ['BOTTOM_TRIM', 'HAJANG_BAR', 'SPECIAL_TRIM', 'FLOOR_CUT_PLATE'], + + // 모터 & 제어기 + 'MOTOR_CTRL' => ['MOTOR_SET', 'INTERLOCK_CTRL', 'EMBED_BACK_BOX'], + + // 부자재 + 'ACCESSORY' => ['JOINT_BAR', 'SQUARE_PIPE', 'WINDING_SHAFT', 'ANGLE', 'ROUND_BAR', 'L_BAR', 'REINF_FLAT_BAR', 'WEIGHT_FLAT_BAR'], + ]; + + foreach ($mappings as $parentCode => $childCodes) { + // 상위 카테고리 ID 찾기 + $parentId = $parentIds[$parentCode] ?? $bendingSubIds[$parentCode] ?? null; + + if ($parentId) { + DB::table('categories') + ->where('tenant_id', $tenantId) + ->where('code_group', $codeGroup) + ->whereIn('code', $childCodes) + ->whereNull('deleted_at') + ->update(['parent_id' => $parentId, 'updated_at' => $now]); + } + } + + $this->command->info('견적 품목 카테고리 시딩 완료!'); + $this->command->info('- 상위 카테고리 4개 추가'); + $this->command->info('- 절곡품 하위 카테고리 3개 추가'); + $this->command->info('- 기존 세부 항목 parent_id 연결 완료'); + } +} \ No newline at end of file