tenantId(); $cacheKey = "{$tenantId}_{$itemType}"; if (isset($this->modelCache[$cacheKey])) { return $this->modelCache[$cacheKey]; } $page = ItemPage::where('tenant_id', $tenantId) ->where('item_type', $itemType) ->where('is_active', true) ->first(); if (! $page) { throw new BadRequestHttpException(__('error.invalid_item_type')); } $modelClass = $page->getTargetModelClass(); if (! $modelClass) { throw new BadRequestHttpException(__('error.invalid_source_table')); } $this->modelCache[$cacheKey] = [ 'model' => $modelClass, 'page' => $page, 'source_table' => $page->source_table, ]; return $this->modelCache[$cacheKey]; } /** * item_type으로 Model 인스턴스 생성 */ private function newModelInstance(string $itemType): Model { $info = $this->getModelInfoByItemType($itemType); return new $info['model']; } /** * item_type으로 Query Builder 생성 */ private function newQuery(string $itemType) { $info = $this->getModelInfoByItemType($itemType); $modelClass = $info['model']; return $modelClass::query() ->where('tenant_id', $this->tenantId()) ->where('item_type', strtoupper($itemType)); } /** * group_id로 해당 그룹의 item_type 목록 조회 * * @param int $groupId 그룹 코드 (code_group='group', code='1' → group_id=1) * @return array item_type 코드 배열 ['FG', 'PT', 'SM', 'RM', 'CS'] */ private function getItemTypesByGroupId(int $groupId): array { // 1. group_id로 그룹 레코드 찾기 (code_group='group', code=group_id) $group = \DB::table('common_codes') ->where('code_group', 'group') ->where('code', (string) $groupId) ->where('is_active', true) ->first(); if (! $group) { return []; } // 2. 해당 그룹의 id를 parent_id로 가진 item_type 조회 return \DB::table('common_codes') ->where('code_group', 'item_type') ->where('parent_id', $group->id) ->where('is_active', true) ->pluck('code') ->toArray(); } /** * 여러 item_type으로 Query Builder 생성 */ private function newQueryForTypes(array $itemTypes) { return \App\Models\Items\Item::query() ->where('tenant_id', $this->tenantId()) ->whereIn('item_type', array_map('strtoupper', $itemTypes)); } /** * 콤마 구분 item_type 문자열을 배열로 파싱 * * @param string|null $itemType 콤마 구분 item_type (예: "FG,PT") * @return array 정규화된 item_type 배열 */ private function parseItemTypes(?string $itemType): array { if (! $itemType) { return []; } return array_filter(array_map('trim', explode(',', strtoupper($itemType)))); } /** * 여러 item_type이 같은 group_id에 속하는지 검증 * * @param array $itemTypes item_type 배열 * @return int 공통 group_id * * @throws BadRequestHttpException 다른 group_id에 속하면 예외 */ private function validateItemTypesInSameGroup(array $itemTypes): int { if (empty($itemTypes)) { throw new BadRequestHttpException(__('error.item_type_required')); } // item_type 코드들의 parent_id (group) 조회 $groupCodes = \DB::table('common_codes as t') ->join('common_codes as g', 't.parent_id', '=', 'g.id') ->where('t.code_group', 'item_type') ->whereIn('t.code', $itemTypes) ->where('t.is_active', true) ->where('g.code_group', 'group') ->distinct() ->pluck('g.code') ->toArray(); // 존재하지 않는 item_type이 있는 경우 if (count($groupCodes) === 0) { throw new BadRequestHttpException(__('error.invalid_item_type')); } // 여러 그룹에 속한 경우 if (count($groupCodes) > 1) { throw new BadRequestHttpException(__('error.item_types_must_be_same_group')); } return (int) $groupCodes[0]; } /** * items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반) */ private function getKnownFields(): array { $tenantId = $this->tenantId(); // 1. 기본 고정 필드 $baseFields = [ 'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', 'category_id', 'bom', 'attributes', 'attributes_archive', 'options', 'description', 'is_active', 'created_by', 'updated_by', 'deleted_by', 'created_at', 'updated_at', 'deleted_at', ]; // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 $columnFields = ItemField::where('tenant_id', $tenantId) ->where('source_table', 'items') ->where('storage_type', 'column') ->whereNotNull('field_key') ->pluck('field_key') ->toArray(); // 3. API 전용 필드 $apiFields = ['type_code']; return array_unique(array_merge($baseFields, $columnFields, $apiFields)); } /** * 정의된 필드 외의 동적 필드를 options로 추출 */ private function extractDynamicOptions(array $params): array { $knownFields = $this->getKnownFields(); $dynamicOptions = []; foreach ($params as $key => $value) { if (! in_array($key, $knownFields) && $value !== null && $value !== '') { $dynamicOptions[$key] = $value; } } return $dynamicOptions; } /** * 기존 options 배열과 동적 필드를 병합 */ private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array { if (! is_array($existingOptions) || empty($existingOptions)) { return $dynamicOptions; } $isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1); if ($isAssoc) { return array_merge($existingOptions, $dynamicOptions); } foreach ($dynamicOptions as $key => $value) { $existingOptions[] = ['label' => $key, 'value' => $value]; } return $existingOptions; } /** * options 입력을 [{label, value, unit}] 형태로 정규화 */ private function normalizeOptions(?array $in): ?array { if (! $in) { return null; } $isAssoc = array_keys($in) !== range(0, count($in) - 1); if ($isAssoc) { $out = []; foreach ($in as $k => $v) { $label = trim((string) $k); $value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE); if ($label !== '' || $value !== '') { $out[] = ['label' => $label, 'value' => $value, 'unit' => '']; } } return $out ?: null; } $out = []; foreach ($in as $a) { if (! is_array($a)) { continue; } $label = trim((string) ($a['label'] ?? '')); $value = trim((string) ($a['value'] ?? '')); $unit = trim((string) ($a['unit'] ?? '')); if ($label === '' && $value === '') { continue; } $out[] = ['label' => $label, 'value' => $value, 'unit' => $unit]; } return $out ?: null; } /** * BOM 검증 (순환 참조 방지) * * @param array|null $bom BOM 데이터 * @param int|null $itemId 현재 품목 ID (수정 시) */ private function validateBom(?array $bom, ?int $itemId = null): void { if (empty($bom)) { return; } foreach ($bom as $entry) { $childItemId = $entry['child_item_id'] ?? null; // 자기 자신 참조 방지 if ($itemId && $childItemId == $itemId) { throw new BadRequestHttpException(__('error.item.self_reference_bom')); } } } /** * 카테고리 트리 전체 조회 */ public function getCategory($request) { $parentId = $request->parentId ?? null; return $this->fetchCategoryTree($parentId); } /** * 내부 재귀 함수 (하위 카테고리 트리 구조로 구성) */ protected function fetchCategoryTree(?int $parentId = null) { $tenantId = $this->tenantId(); $query = Category::query() ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->when( is_null($parentId), fn ($q) => $q->whereNull('parent_id'), fn ($q) => $q->where('parent_id', $parentId) ) ->where('is_active', 1) ->orderBy('sort_order'); $categories = $query->get(); foreach ($categories as $category) { $children = $this->fetchCategoryTree($category->id); $category->setRelation('children', $children); } return $categories; } /** * 목록/검색 (동적 테이블 라우팅) * * @param array $params 검색 파라미터 (item_type/group_id 없으면 group_id=1 기본값) */ public function index(array $params): LengthAwarePaginator { $size = (int) ($params['size'] ?? $params['per_page'] ?? 20); $q = trim((string) ($params['q'] ?? $params['search'] ?? '')); $categoryId = $params['category_id'] ?? null; $itemType = $params['item_type'] ?? null; $groupId = $params['group_id'] ?? null; $active = $params['active'] ?? null; // item_type 또는 group_id 없으면 group_id = 1 기본값 적용 if (! $itemType && ! $groupId) { $groupId = 1; } // group_id로 조회 시 해당 그룹의 모든 item_type 조회 if ($groupId && ! $itemType) { $itemTypes = $this->getItemTypesByGroupId((int) $groupId); if (empty($itemTypes)) { throw new BadRequestHttpException(__('error.invalid_group_id')); } $query = $this->newQueryForTypes($itemTypes) ->with(['category:id,name', 'details', 'files']); } else { // item_type 조회 (단일 또는 콤마 구분 멀티) $itemTypes = $this->parseItemTypes($itemType); if (count($itemTypes) === 1) { // 단일 item_type $query = $this->newQuery($itemTypes[0]) ->with(['category:id,name', 'details', 'files']); } else { // 멀티 item_type - 같은 그룹인지 검증 $this->validateItemTypesInSameGroup($itemTypes); $query = $this->newQueryForTypes($itemTypes) ->with(['category:id,name', 'details', 'files']); } } // 검색어 if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") ->orWhere('code', 'like', "%{$q}%") ->orWhere('description', 'like', "%{$q}%"); }); } // 카테고리 if ($categoryId) { $query->where('category_id', (int) $categoryId); } // 활성 상태 if ($active !== null && $active !== '') { $query->where('is_active', (bool) $active); } $paginator = $query->orderBy('id')->paginate($size); // 날짜 형식 변환, files 그룹화, options 펼침 $paginator->setCollection( $paginator->getCollection()->transform(function ($item) { $arr = $item->toArray(); $arr['created_at'] = $item->created_at ? $item->created_at->format('Y-m-d') : null; // files를 field_key별로 그룹화 $arr['files'] = $this->groupFilesByFieldKey($arr['files'] ?? []); // options를 최상위 레벨로 펼침 (동적 필드) $arr = $this->flattenOptionsToResponse($arr); return $arr; }) ); return $paginator; } /** * 생성 (동적 테이블 라우팅) * * @param array $data 생성 데이터 (item_type 필수) */ public function store(array $data): Model { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // item_type 필수 검증 $itemType = $data['item_type'] ?? null; if (! $itemType) { throw new BadRequestHttpException(__('error.item_type_required')); } // 동적 모델 정보 조회 $modelInfo = $this->getModelInfoByItemType($itemType); $modelClass = $modelInfo['model']; // 동적 필드를 options에 병합 $dynamicOptions = $this->extractDynamicOptions($data); if (! empty($dynamicOptions)) { $existingOptions = $data['options'] ?? []; $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); } // options 정규화 if (isset($data['options'])) { $data['options'] = $this->normalizeOptions($data['options']); } // tenant별 code 유니크 체크 (동적 테이블) $dup = $modelClass::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->exists(); if ($dup) { throw new BadRequestHttpException(__('error.duplicate_key')); } // 테이블 데이터 준비 $itemData = [ 'tenant_id' => $tenantId, 'item_type' => strtoupper($itemType), 'code' => $data['code'], 'name' => $data['name'], 'unit' => $data['unit'] ?? null, 'category_id' => $data['category_id'] ?? null, 'bom' => $data['bom'] ?? null, 'attributes' => $data['attributes'] ?? null, 'options' => $data['options'] ?? null, 'description' => $data['description'] ?? null, 'is_active' => $data['is_active'] ?? true, 'created_by' => $userId, ]; $item = $modelClass::create($itemData); // item_details 테이블 데이터 (items 테이블인 경우에만) if ($modelInfo['source_table'] === 'items') { $detailData = $this->extractDetailData($data); $detailData['item_id'] = $item->id; ItemDetail::create($detailData); $item->load('details'); } return $item; } /** * 단건 조회 (동적 테이블 라우팅) * * @param int $id 품목 ID * @param string|null $itemType 품목 유형 (선택적 - 없으면 ID로만 조회) * @return array 품목 데이터 (files 그룹화 포함) */ public function show(int $id, ?string $itemType = null): array { // item_type이 없으면 items 테이블에서 직접 조회 if (! $itemType) { $item = Item::with(['category:id,name', 'details', 'files']) ->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } return $this->formatItemResponse($item); } // 동적 테이블 라우팅 $modelInfo = $this->getModelInfoByItemType($itemType); $query = $this->newQuery($itemType); // items 테이블인 경우 관계 로드 if ($modelInfo['source_table'] === 'items') { $query->with(['category:id,name', 'details', 'files']); } $item = $query->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } return $this->formatItemResponse($item); } /** * 품목 응답 포맷 (files 그룹화 + options 펼침) */ private function formatItemResponse(Model $item): array { $arr = $item->toArray(); // 날짜 포맷 $arr['created_at'] = $item->created_at ? $item->created_at->format('Y-m-d') : null; // files를 field_key별로 그룹화 $arr['files'] = $this->groupFilesByFieldKey($arr['files'] ?? []); // options를 최상위 레벨로 펼침 (동적 필드) $arr = $this->flattenOptionsToResponse($arr); return $arr; } /** * options 배열을 최상위 레벨로 펼침 * * [{label: "field1", value: "val1"}, ...] → {"field1": "val1", ...} */ private function flattenOptionsToResponse(array $arr): array { $options = $arr['options'] ?? []; unset($arr['options']); if (! is_array($options) || empty($options)) { return $arr; } // [{label, value, unit}] 형태의 배열을 펼침 foreach ($options as $opt) { if (isset($opt['label']) && $opt['label'] !== '') { $key = $opt['label']; // 기존 필드와 충돌 방지 if (! isset($arr[$key])) { $arr[$key] = $opt['value'] ?? ''; } } } return $arr; } /** * 수정 (동적 테이블 라우팅) * * @param int $id 품목 ID * @param array $data 수정 데이터 (item_type 필수) */ public function update(int $id, array $data): Model { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // item_type 필수 검증 $itemType = $data['item_type'] ?? null; if (! $itemType) { throw new BadRequestHttpException(__('error.item_type_required')); } // 동적 모델 정보 조회 $modelInfo = $this->getModelInfoByItemType($itemType); $modelClass = $modelInfo['model']; $item = $this->newQuery($itemType)->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } // 동적 필드를 options에 병합 (기존 item의 options 기반) $dynamicOptions = $this->extractDynamicOptions($data); // 기존 options를 배열 형태로 변환 (label → value 맵) $existingOptionsMap = []; if (is_array($item->options)) { foreach ($item->options as $opt) { if (isset($opt['label'])) { $existingOptionsMap[$opt['label']] = $opt['value'] ?? ''; } } } // 새 동적 필드와 기존 options 병합 if (! empty($dynamicOptions)) { // 새 동적 필드로 기존 값 덮어쓰기 foreach ($dynamicOptions as $key => $value) { $existingOptionsMap[$key] = $value; } } // 명시적으로 전달된 options 처리 if (isset($data['options']) && is_array($data['options'])) { // 배열 형태의 options 병합 foreach ($data['options'] as $opt) { if (isset($opt['label'])) { $existingOptionsMap[$opt['label']] = $opt['value'] ?? ''; } } } // 최종 options 설정 (병합된 맵이 비어있지 않으면) if (! empty($existingOptionsMap)) { $data['options'] = $this->normalizeOptions($existingOptionsMap); } // code 변경 시 중복 체크 (동적 테이블) if (isset($data['code']) && $data['code'] !== $item->code) { $dup = $modelClass::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->exists(); if ($dup) { throw new BadRequestHttpException(__('error.duplicate_key')); } } // BOM 검증 (순환 참조 방지) if (isset($data['bom'])) { $this->validateBom($data['bom'], $id); } // 테이블 업데이트 $itemData = array_intersect_key($data, array_flip([ 'item_type', 'code', 'name', 'unit', 'category_id', 'bom', 'attributes', 'options', 'description', 'is_active', ])); $itemData['updated_by'] = $userId; if (isset($itemData['item_type'])) { $itemData['item_type'] = strtoupper($itemData['item_type']); } $item->update($itemData); // item_details 테이블 업데이트 (items 테이블인 경우에만) if ($modelInfo['source_table'] === 'items') { $detailData = $this->extractDetailData($data); if (! empty($detailData)) { $item->details()->updateOrCreate( ['item_id' => $item->id], $detailData ); } $item->load('details'); } return $item->refresh(); } /** * 삭제 (동적 테이블 라우팅, soft delete) * * @param int $id 품목 ID * @param string $itemType 품목 유형 (필수) */ public function destroy(int $id, string $itemType): void { $userId = $this->apiUserId(); // 동적 테이블 라우팅 $item = $this->newQuery($itemType)->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } $item->deleted_by = $userId; $item->save(); $item->delete(); } /** * 간편 검색 (동적 테이블 라우팅, 모달/드롭다운) * * @param array $params 검색 파라미터 (item_type 필수, 콤마 구분 멀티 지원) */ public function search(array $params) { $q = trim((string) ($params['q'] ?? '')); $limit = (int) ($params['limit'] ?? 20); $itemType = $params['item_type'] ?? null; // item_type 필수 검증 if (! $itemType) { throw new BadRequestHttpException(__('error.item_type_required')); } // item_type 파싱 (단일 또는 콤마 구분 멀티) $itemTypes = $this->parseItemTypes($itemType); if (count($itemTypes) === 1) { // 단일 item_type - 동적 테이블 라우팅 $query = $this->newQuery($itemTypes[0]); } else { // 멀티 item_type - 같은 그룹인지 검증 $this->validateItemTypesInSameGroup($itemTypes); $query = $this->newQueryForTypes($itemTypes); } if ($q !== '') { $query->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") ->orWhere('code', 'like', "%{$q}%"); }); } return $query->orderBy('name') ->limit($limit) ->get(['id', 'code', 'name', 'item_type', 'category_id']); } /** * 활성/비활성 토글 (동적 테이블 라우팅) * * @param int $id 품목 ID * @param string $itemType 품목 유형 (필수) */ public function toggle(int $id, string $itemType): array { $userId = $this->apiUserId(); // 동적 테이블 라우팅 $item = $this->newQuery($itemType)->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } $item->is_active = ! $item->is_active; $item->updated_by = $userId; $item->save(); return ['id' => $item->id, 'is_active' => $item->is_active]; } /** * code 기반 품목 조회 (동적 테이블 라우팅, BOM 포함 옵션) * * @param string $code 품목 코드 * @param string $itemType 품목 유형 (없으면 items 테이블에서 직접 검색) * @param bool $includeBom BOM 포함 여부 */ public function showByCode(string $code, string $itemType, bool $includeBom = false): Model { // item_type 없으면 items 테이블에서 직접 검색 if (empty($itemType)) { $item = Item::where('tenant_id', $this->tenantId()) ->where('code', $code) ->with(['category:id,name', 'details']) ->first(); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } if ($includeBom && ! empty($item->bom) && method_exists($item, 'loadBomChildren')) { $item->loadBomChildren(); } return $item; } // 동적 테이블 라우팅 $modelInfo = $this->getModelInfoByItemType($itemType); $query = $this->newQuery($itemType)->where('code', $code); // items 테이블인 경우 관계 로드 if ($modelInfo['source_table'] === 'items') { $query->with(['category:id,name', 'details']); } $item = $query->first(); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } // BOM 포함 시 child items 로드 (items 테이블인 경우에만) if ($includeBom && ! empty($item->bom) && method_exists($item, 'loadBomChildren')) { $item->loadBomChildren(); } return $item; } /** * 일괄 삭제 (동적 테이블 라우팅, soft delete) * * @param array $ids 품목 ID 배열 * @param string $itemType 품목 유형 (필수) */ public function batchDestroy(array $ids, string $itemType): int { $userId = $this->apiUserId(); // 동적 테이블 라우팅 $items = $this->newQuery($itemType) ->whereIn('id', $ids) ->get(); if ($items->isEmpty()) { throw new BadRequestHttpException(__('error.not_found')); } $deletedCount = 0; foreach ($items as $item) { $item->deleted_by = $userId; $item->save(); $item->delete(); $deletedCount++; } return $deletedCount; } /** * 가격 정보 포함 조회 (동적 테이블 라우팅) * * @param int $id 품목 ID * @param string $itemType 품목 유형 (필수) * @param int|null $clientId 거래처 ID * @param string|null $priceDate 가격 기준일 */ public function showWithPrice(int $id, ?string $itemType = null, ?int $clientId = null, ?string $priceDate = null): array { // show()가 이제 array를 반환 $data = $this->show($id, $itemType); // PricingService로 가격 조회 try { $pricingService = app(\App\Services\Pricing\PricingService::class); // item_type에서 가격 유형 결정 (데이터에서 추출) $actualItemType = $data['item_type'] ?? $itemType ?? 'FG'; $itemTypeCode = in_array(strtoupper($actualItemType), ['FG', 'PT']) ? 'PRODUCT' : 'MATERIAL'; $data['prices'] = [ 'sale' => $pricingService->getPriceByType($itemTypeCode, $id, 'SALE', $clientId, $priceDate), 'purchase' => $pricingService->getPriceByType($itemTypeCode, $id, 'PURCHASE', $clientId, $priceDate), ]; } catch (\Exception $e) { $data['prices'] = ['sale' => null, 'purchase' => null]; } return $data; } /** * item_details 데이터 추출 */ private function extractDetailData(array $data): array { $detailFields = [ // Products 전용 'is_sellable', 'is_purchasable', 'is_producible', 'safety_stock', 'lead_time', 'is_variable_size', 'product_category', 'part_type', 'bending_diagram', 'bending_details', 'specification_file', 'specification_file_name', 'certification_file', 'certification_file_name', 'certification_number', 'certification_start_date', 'certification_end_date', // Materials 전용 'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks', ]; return array_intersect_key($data, array_flip($detailFields)); } /** * files 배열을 field_key별로 그룹화 * * @param array $files 파일 배열 * @return array field_key별로 그룹화된 파일 배열 */ private function groupFilesByFieldKey(array $files): array { if (empty($files)) { return []; } $grouped = []; foreach ($files as $file) { $key = $file['field_key'] ?? 'default'; if (! isset($grouped[$key])) { $grouped[$key] = []; } $grouped[$key][] = [ 'id' => $file['id'], 'file_name' => $file['display_name'] ?? $file['original_name'] ?? $file['file_name'] ?? '', 'file_path' => $file['file_path'] ?? '', ]; } return $grouped; } }