feat: 견적 V2 동적 카테고리 시스템 구현
- CategoryService: tree 메서드에 code_group 필터 지원 추가 - FormulaEvaluatorService: 하드코딩된 process_type을 동적 카테고리로 변경 - groupItemsByProcess(): item_category 필드 기반 그룹화 - getItemCategoryTree(): DB에서 카테고리 트리 조회 - buildCategoryMapping(): BENDING 하위 카테고리 처리 - addProcessGroupToItems(): category_code 필드 추가 (레거시 호환 유지) - QuoteItemCategorySeeder: 품목 카테고리 초기 데이터 시더 추가 - BODY, BENDING(하위 3개), MOTOR_CTRL, ACCESSORY Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -197,15 +197,22 @@ public function tree(array $params)
|
|||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$onlyActive = (bool) ($params['only_active'] ?? false);
|
$onlyActive = (bool) ($params['only_active'] ?? false);
|
||||||
|
$codeGroup = $params['code_group'] ?? null;
|
||||||
|
|
||||||
$q = Category::where('tenant_id', $tenantId)
|
$q = Category::where('tenant_id', $tenantId)
|
||||||
->when($onlyActive, fn ($qq) => $qq->where('is_active', 1))
|
->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')
|
->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 = [];
|
$byParent = [];
|
||||||
foreach ($q as $c) {
|
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) {
|
$build = function ($pid) use (&$build, &$byParent) {
|
||||||
|
|||||||
@@ -851,14 +851,19 @@ public function calculateCategoryPrice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공정별 품목 그룹화
|
* 품목 카테고리 그룹화 (동적 카테고리 시스템)
|
||||||
*
|
*
|
||||||
* 품목을 process_type에 따라 그룹화합니다:
|
* 품목을 item_category 필드 기준으로 그룹화합니다.
|
||||||
* - screen: 스크린 공정 (원단, 패널, 도장 등)
|
* 카테고리 정보는 categories 테이블에서 code_group='item_category'로 조회합니다.
|
||||||
* - bending: 절곡 공정 (알루미늄, 스테인리스 등)
|
*
|
||||||
* - steel: 철재 공정 (철재, 강판 등)
|
* 카테고리 구조:
|
||||||
* - electric: 전기 공정 (모터, 제어반, 전선 등)
|
* - BODY: 본체
|
||||||
* - assembly: 조립 공정 (볼트, 너트, 브라켓 등)
|
* - BENDING 하위 카테고리:
|
||||||
|
* - BENDING_GUIDE: 절곡품 - 가이드레일
|
||||||
|
* - BENDING_CASE: 절곡품 - 케이스
|
||||||
|
* - BENDING_BOTTOM: 절곡품 - 하단마감재
|
||||||
|
* - MOTOR_CTRL: 모터 & 제어기
|
||||||
|
* - ACCESSORY: 부자재
|
||||||
*/
|
*/
|
||||||
public function groupItemsByProcess(array $items, ?int $tenantId = null): array
|
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]];
|
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 품목 코드로 process_type 일괄 조회
|
// 품목 코드로 item_category 일괄 조회
|
||||||
$itemCodes = array_unique(array_column($items, 'item_code'));
|
$itemCodes = array_unique(array_column($items, 'item_code'));
|
||||||
|
|
||||||
if (empty($itemCodes)) {
|
if (empty($itemCodes)) {
|
||||||
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
|
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
|
||||||
}
|
}
|
||||||
|
|
||||||
$processTypes = DB::table('items')
|
// 품목별 item_category 조회
|
||||||
|
$itemCategories = DB::table('items')
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->whereIn('code', $itemCodes)
|
->whereIn('code', $itemCodes)
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->pluck('process_type', 'code')
|
->pluck('item_category', 'code')
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// 그룹별 분류
|
// 카테고리 트리 조회 (item_category 코드 그룹)
|
||||||
$grouped = [
|
$categoryTree = $this->getItemCategoryTree($tenantId);
|
||||||
'screen' => ['name' => '스크린 공정', 'items' => [], 'subtotal' => 0],
|
|
||||||
'bending' => ['name' => '절곡 공정', 'items' => [], 'subtotal' => 0],
|
// 카테고리 코드 → 정보 매핑 생성 (탭 구조에 맞게)
|
||||||
'steel' => ['name' => '철재 공정', 'items' => [], 'subtotal' => 0],
|
$categoryMapping = $this->buildCategoryMapping($categoryTree);
|
||||||
'electric' => ['name' => '전기 공정', 'items' => [], 'subtotal' => 0],
|
|
||||||
'assembly' => ['name' => '조립 공정', 'items' => [], 'subtotal' => 0],
|
// 그룹별 분류를 위한 빈 구조 생성
|
||||||
'other' => ['name' => '기타', 'items' => [], 'subtotal' => 0],
|
$grouped = [];
|
||||||
];
|
foreach ($categoryMapping as $code => $info) {
|
||||||
|
$grouped[$code] = ['name' => $info['name'], 'items' => [], 'subtotal' => 0];
|
||||||
|
}
|
||||||
|
// 기타 그룹 추가
|
||||||
|
$grouped['OTHER'] = ['name' => '기타', 'items' => [], 'subtotal' => 0];
|
||||||
|
|
||||||
foreach ($items as $item) {
|
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[$categoryCode]['items'][] = $item;
|
||||||
$grouped[$processType]['subtotal'] += $item['total_price'] ?? 0;
|
$grouped[$categoryCode]['subtotal'] += $item['total_price'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 빈 그룹 제거
|
// 빈 그룹 제거
|
||||||
$grouped = array_filter($grouped, fn ($g) => ! empty($g['items']));
|
$grouped = array_filter($grouped, fn ($g) => ! empty($g['items']));
|
||||||
|
|
||||||
$this->addDebugStep(8, '공정그룹화', [
|
$this->addDebugStep(8, '카테고리그룹화', [
|
||||||
'total_items' => count($items),
|
'total_items' => count($items),
|
||||||
'groups' => array_map(fn ($g) => [
|
'groups' => array_map(fn ($g) => [
|
||||||
'name' => $g['name'],
|
'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
|
private function addProcessGroupToItems(array $items, array $groupedItems): array
|
||||||
{
|
{
|
||||||
// 각 그룹의 아이템 코드 → 그룹명 매핑 생성
|
// 각 그룹의 아이템 코드 → 그룹정보 매핑 생성
|
||||||
$itemCodeToGroup = [];
|
$itemCodeToGroup = [];
|
||||||
foreach ($groupedItems as $groupKey => $group) {
|
foreach ($groupedItems as $groupKey => $group) {
|
||||||
if (! isset($group['items']) || ! is_array($group['items'])) {
|
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) {
|
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'] = $groupInfo['name'];
|
||||||
$item['process_group_key'] = $groupInfo['key'];
|
$item['process_group_key'] = $groupInfo['key']; // 레거시 호환
|
||||||
|
$item['category_code'] = $groupInfo['key']; // 신규 동적 카테고리 시스템
|
||||||
|
|
||||||
return $item;
|
return $item;
|
||||||
}, $items);
|
}, $items);
|
||||||
|
|||||||
107
database/seeders/QuoteItemCategorySeeder.php
Normal file
107
database/seeders/QuoteItemCategorySeeder.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 견적 품목 카테고리 시더
|
||||||
|
*
|
||||||
|
* 상위 카테고리 추가 및 기존 세부 항목 연결
|
||||||
|
* - 본체, 절곡품(>가이드레일,케이스,하단마감재), 모터&제어기, 부자재
|
||||||
|
*/
|
||||||
|
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 연결 완료');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user