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)); } /** * 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; } /** * 카테고리 트리 전체 조회 */ 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 필수) */ 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; $active = $params['active'] ?? null; // item_type 필수 검증 if (! $itemType) { throw new BadRequestHttpException(__('error.item_type_required')); } // 동적 테이블 라우팅 $query = $this->newQuery($itemType) ->with(['category:id,name', 'details']); // 검색어 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); // 날짜 형식 변환 $paginator->setCollection( $paginator->getCollection()->transform(function ($item) { $arr = $item->toArray(); $arr['created_at'] = $item->created_at ? $item->created_at->format('Y-m-d') : null; 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 $itemType 품목 유형 (필수) */ public function show(int $id, string $itemType): Model { // 동적 테이블 라우팅 $modelInfo = $this->getModelInfoByItemType($itemType); $query = $this->newQuery($itemType); // items 테이블인 경우 관계 로드 if ($modelInfo['source_table'] === 'items') { $query->with(['category:id,name', 'details']); } $item = $query->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } return $item; } /** * 수정 (동적 테이블 라우팅) * * @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에 병합 $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']); } // 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')); } } // 테이블 업데이트 $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')); } // 동적 테이블 라우팅 $query = $this->newQuery($itemType); 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 품목 유형 (필수) * @param bool $includeBom BOM 포함 여부 */ public function showByCode(string $code, string $itemType, bool $includeBom = false): Model { // 동적 테이블 라우팅 $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, ?int $clientId = null, ?string $priceDate = null): array { $item = $this->show($id, $itemType); $data = $item->toArray(); // PricingService로 가격 조회 try { $pricingService = app(\App\Services\Pricing\PricingService::class); $itemTypeCode = in_array(strtoupper($itemType), ['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)); } }