tenantId(); // 1. SystemFields에서 테이블 고정 컬럼 $systemFields = SystemFields::getReservedKeys($sourceTable); // 2. ItemField에서 storage_type='column'인 필드의 field_key 조회 $columnFields = ItemField::where('tenant_id', $tenantId) ->where('source_table', $sourceTable) ->where('storage_type', 'column') ->whereNotNull('field_key') ->pluck('field_key') ->toArray(); // 3. 추가적인 API 전용 필드 $apiFields = ['item_type', 'type_code', 'bom', 'product_type']; return array_unique(array_merge($systemFields, $columnFields, $apiFields)); } /** * 동적 필드 추출 */ private function extractDynamicOptions(array $params, string $sourceTable): array { $knownFields = $this->getKnownFields($sourceTable); $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; } /** * 동적 필드를 options에 병합하고 정규화 */ private function processDynamicOptions(array &$data, string $sourceTable): void { $dynamicOptions = $this->extractDynamicOptions($data, $sourceTable); if (! empty($dynamicOptions)) { $existingOptions = $data['options'] ?? []; $data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions); } if (isset($data['options'])) { $data['options'] = $this->normalizeOptions($data['options']); } } /** * 통합 품목 조회 (materials + products UNION) * * @param array $filters 필터 조건 (type, search, category_id, page, size) * @param int $perPage 페이지당 항목 수 * @param bool $includeDeleted soft delete 포함 여부 * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator */ public function getItems(array $filters = [], int $perPage = 20, bool $includeDeleted = false) { $tenantId = $this->tenantId(); // 필터 파라미터 추출 $types = $filters['type'] ?? ['FG', 'PT', 'SM', 'RM', 'CS']; $search = trim($filters['search'] ?? $filters['q'] ?? ''); $categoryId = $filters['category_id'] ?? null; $isActive = $filters['is_active'] ?? null; // null: 전체, true/1: 활성만, false/0: 비활성만 // 타입을 배열로 변환 (문자열인 경우 쉼표로 분리) if (is_string($types)) { $types = array_map('trim', explode(',', $types)); } // Product 타입 (FG, PT) $productTypes = array_intersect(['FG', 'PT'], $types); // Material 타입 (SM, RM, CS) $materialTypes = array_intersect(['SM', 'RM', 'CS'], $types); // Products 쿼리 (FG, PT 타입만) $productsQuery = null; if (! empty($productTypes)) { $productsQuery = Product::query() ->where('tenant_id', $tenantId) ->whereIn('product_type', $productTypes); // is_active 필터 적용 (null이면 전체 조회) if ($isActive !== null) { $productsQuery->where('is_active', filter_var($isActive, FILTER_VALIDATE_BOOLEAN) ? 1 : 0); } $productsQuery->select([ 'id', 'product_type as item_type', 'code', 'name', DB::raw('NULL as specification'), 'unit', 'category_id', 'product_type as type_code', 'attributes', 'is_active', 'created_at', 'deleted_at', ]); // soft delete 포함 if ($includeDeleted) { $productsQuery->withTrashed(); } // 검색 조건 if ($search !== '') { $productsQuery->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('code', 'like', "%{$search}%"); }); } // 카테고리 필터 if ($categoryId) { $productsQuery->where('category_id', (int) $categoryId); } } // Materials 쿼리 (SM, RM, CS 타입) $materialsQuery = null; if (! empty($materialTypes)) { $materialsQuery = Material::query() ->where('tenant_id', $tenantId) ->whereIn('material_type', $materialTypes); // is_active 필터 적용 (null이면 전체 조회) if ($isActive !== null) { $materialsQuery->where('is_active', filter_var($isActive, FILTER_VALIDATE_BOOLEAN) ? 1 : 0); } $materialsQuery->select([ 'id', 'material_type as item_type', 'material_code as code', 'name', 'specification', 'unit', 'category_id', 'material_type as type_code', 'attributes', 'is_active', 'created_at', 'deleted_at', ]); // soft delete 포함 if ($includeDeleted) { $materialsQuery->withTrashed(); } // 검색 조건 if ($search !== '') { $materialsQuery->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('item_name', 'like', "%{$search}%") ->orWhere('material_code', 'like', "%{$search}%") ->orWhere('search_tag', 'like', "%{$search}%"); }); } // 카테고리 필터 if ($categoryId) { $materialsQuery->where('category_id', (int) $categoryId); } } // 각 쿼리 실행 후 merge (UNION 바인딩 문제 방지) $items = collect([]); if ($productsQuery) { $products = $productsQuery->get(); $items = $items->merge($products); } if ($materialsQuery) { $materials = $materialsQuery->get(); $items = $items->merge($materials); } // 정렬 (created_at 기준 내림차순) $items = $items->sortByDesc('created_at')->values(); // attributes 플랫 전개 $items = $items->map(function ($item) { $data = $item->toArray(); $attributes = $data['attributes'] ?? []; if (is_string($attributes)) { $attributes = json_decode($attributes, true) ?? []; } unset($data['attributes']); return array_merge($data, $attributes); }); // 페이지네이션 처리 $page = request()->input('page', 1); $offset = ($page - 1) * $perPage; $total = $items->count(); $paginatedItems = $items->slice($offset, $perPage)->values(); return new \Illuminate\Pagination\LengthAwarePaginator( $paginatedItems, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()] ); } /** * 단일 품목 조회 * * @param int $id 품목 ID * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) * @param bool $includePrice 가격 정보 포함 여부 * @param int|null $clientId 고객 ID (가격 조회 시) * @param string|null $priceDate 기준일 (가격 조회 시) * @return array 품목 데이터 (item_type, prices 포함) */ public function getItem( int $id, string $itemType = 'FG', bool $includePrice = false, ?int $clientId = null, ?string $priceDate = null ): array { $tenantId = $this->tenantId(); $itemType = strtoupper($itemType); // item_type으로 source_table 결정 if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { $material = Material::query() ->with('category:id,name') ->where('tenant_id', $tenantId) ->find($id); if (! $material) { throw new NotFoundHttpException(__('error.not_found')); } $data = $this->flattenAttributes($material->toArray()); $data['item_type'] = $itemType; $data['code'] = $material->material_code; $data['type_code'] = $material->material_type; // 가격 정보 추가 if ($includePrice) { $data['prices'] = $this->fetchPrices('MATERIAL', $id, $clientId, $priceDate); } return $data; } // Product (FG, PT) $product = Product::query() ->with('category:id,name') ->where('tenant_id', $tenantId) ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.not_found')); } $data = $this->flattenAttributes($product->toArray()); $data['item_type'] = $itemType; $data['type_code'] = $product->product_type; // 가격 정보 추가 if ($includePrice) { $data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate); } return $data; } /** * attributes/options JSON 필드를 최상위로 플랫 전개 */ private function flattenAttributes(array $data): array { // attributes 플랫 전개 $attributes = $data['attributes'] ?? []; if (is_string($attributes)) { $attributes = json_decode($attributes, true) ?? []; } unset($data['attributes']); // options 플랫 전개 ([{label, value, unit}] 형태 → {label: value} 형태로 변환) $options = $data['options'] ?? []; if (is_string($options)) { $options = json_decode($options, true) ?? []; } $flatOptions = []; if (is_array($options)) { foreach ($options as $opt) { if (is_array($opt) && isset($opt['label'])) { $flatOptions[$opt['label']] = $opt['value'] ?? ''; } } } // options는 원본 유지 (프론트에서 필요할 수 있음) return array_merge($data, $attributes, $flatOptions); } /** * 품목의 판매가/매입가 조회 * * @param string $itemType 'PRODUCT' | 'MATERIAL' * @param int $itemId 품목 ID * @param int|null $clientId 고객 ID * @param string|null $priceDate 기준일 * @return array ['sale' => array, 'purchase' => array] */ private function fetchPrices(string $itemType, int $itemId, ?int $clientId, ?string $priceDate): array { // PricingService DI가 없으므로 직접 생성 $pricingService = app(\App\Services\Pricing\PricingService::class); $salePrice = $pricingService->getPriceByType($itemType, $itemId, 'SALE', $clientId, $priceDate); $purchasePrice = $pricingService->getPriceByType($itemType, $itemId, 'PURCHASE', $clientId, $priceDate); return [ 'sale' => $salePrice, 'purchase' => $purchasePrice, ]; } /** * 품목 생성 (타입에 따라 Products 또는 Materials 테이블에 저장) * * - FG, PT → Products 테이블 * - SM, RM, CS → Materials 테이블 * * @param array $data 검증된 데이터 */ public function createItem(array $data): Product|Material { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $itemType = strtoupper($data['product_type'] ?? 'FG'); // Materials 타입 (SM, RM, CS) if (in_array($itemType, ['SM', 'RM', 'CS'])) { return $this->createMaterial($data, $tenantId, $userId, $itemType); } // Products 타입 (FG, PT) - 기본값 return $this->createProduct($data, $tenantId, $userId); } /** * Product 생성 (FG, PT) */ private function createProduct(array $data, int $tenantId, int $userId): Product { // 품목 코드 중복 체크 $code = $data['code'] ?? ''; $existingProduct = Product::withTrashed() ->where('tenant_id', $tenantId) ->where('code', $code) ->first(); if ($existingProduct) { throw new DuplicateCodeException($code, $existingProduct->id); } // 동적 필드를 options에 병합 $this->processDynamicOptions($data, 'products'); $payload = $data; $payload['tenant_id'] = $tenantId; $payload['created_by'] = $userId; $payload['is_active'] = $payload['is_active'] ?? true; $payload['is_sellable'] = $payload['is_sellable'] ?? true; $payload['is_purchasable'] = $payload['is_purchasable'] ?? false; $payload['is_producible'] = $payload['is_producible'] ?? false; return Product::create($payload); } /** * Material 생성 (SM, RM, CS) */ private function createMaterial(array $data, int $tenantId, int $userId, string $materialType): Material { // 품목 코드 중복 체크 $code = $data['code'] ?? $data['material_code'] ?? ''; $existingMaterial = Material::withTrashed() ->where('tenant_id', $tenantId) ->where('material_code', $code) ->first(); if ($existingMaterial) { throw new DuplicateCodeException($code, $existingMaterial->id); } // 동적 필드를 options에 병합 $this->processDynamicOptions($data, 'materials'); $payload = [ 'tenant_id' => $tenantId, 'created_by' => $userId, 'material_type' => $materialType, 'material_code' => $code, 'name' => $data['name'], 'unit' => $data['unit'], 'category_id' => $data['category_id'] ?? null, 'item_name' => $data['item_name'] ?? $data['name'], 'specification' => $data['specification'] ?? $data['description'] ?? null, 'is_inspection' => $data['is_inspection'] ?? 'N', 'search_tag' => $data['search_tag'] ?? null, 'remarks' => $data['remarks'] ?? null, 'attributes' => $data['attributes'] ?? null, 'options' => $data['options'] ?? null, 'is_active' => $data['is_active'] ?? true, ]; return Material::create($payload); } /** * 품목 수정 (Product/Material 통합) * * @param int $id 품목 ID * @param array $data 검증된 데이터 (item_type 필수) */ public function updateItem(int $id, array $data): Product|Material { $tenantId = $this->tenantId(); $itemType = strtoupper($data['item_type'] ?? 'FG'); if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { return $this->updateMaterial($id, $data); } return $this->updateProduct($id, $data); } /** * Product 수정 (FG, PT) */ private function updateProduct(int $id, array $data): Product { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $product = Product::query() ->where('tenant_id', $tenantId) ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.not_found')); } // 코드 변경 시 중복 체크 if (isset($data['code']) && $data['code'] !== $product->code) { $existingProduct = Product::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->where('id', '!=', $product->id) ->first(); if ($existingProduct) { throw new DuplicateCodeException($data['code'], $existingProduct->id); } } // 동적 필드를 options에 병합 $this->processDynamicOptions($data, 'products'); // item_type은 DB 필드가 아니므로 제거 unset($data['item_type']); $data['updated_by'] = $userId; $product->update($data); return $product->refresh(); } /** * Material 수정 (SM, RM, CS) */ private function updateMaterial(int $id, array $data): Material { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $material = Material::query() ->where('tenant_id', $tenantId) ->find($id); if (! $material) { throw new NotFoundHttpException(__('error.not_found')); } // 코드 변경 시 중복 체크 $newCode = $data['material_code'] ?? $data['code'] ?? null; if ($newCode && $newCode !== $material->material_code) { $existingMaterial = Material::query() ->where('tenant_id', $tenantId) ->where('material_code', $newCode) ->where('id', '!=', $material->id) ->first(); if ($existingMaterial) { throw new DuplicateCodeException($newCode, $existingMaterial->id); } $data['material_code'] = $newCode; } // 동적 필드를 options에 병합 $this->processDynamicOptions($data, 'materials'); // item_type, code는 DB 필드가 아니므로 제거 unset($data['item_type'], $data['code']); $data['updated_by'] = $userId; $material->update($data); return $material->refresh(); } /** * 품목 삭제 (Product/Material 통합, Soft Delete) * * - 이미 삭제된 품목은 404 반환 * - 다른 BOM의 구성품으로 사용 중이면 삭제 불가 * * @param int $id 품목 ID * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) */ public function deleteItem(int $id, string $itemType = 'FG'): void { $tenantId = $this->tenantId(); $itemType = strtoupper($itemType); if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { $this->deleteMaterial($id); return; } $this->deleteProduct($id); } /** * Product 삭제 (FG, PT) */ private function deleteProduct(int $id): void { $tenantId = $this->tenantId(); $product = Product::query() ->where('tenant_id', $tenantId) ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.item.not_found')); } // BOM 구성품으로 사용 중인지 체크 $usageCount = $this->checkProductUsageInBom($tenantId, $id); if ($usageCount > 0) { throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount])); } $product->delete(); } /** * Material 삭제 (SM, RM, CS) */ private function deleteMaterial(int $id): void { $tenantId = $this->tenantId(); $material = Material::query() ->where('tenant_id', $tenantId) ->find($id); if (! $material) { throw new NotFoundHttpException(__('error.item.not_found')); } // BOM 구성품으로 사용 중인지 체크 $usageCount = $this->checkMaterialUsageInBom($tenantId, $id); if ($usageCount > 0) { throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount])); } $material->delete(); } /** * Product가 다른 BOM의 구성품으로 사용 중인지 체크 * * @param int $tenantId 테넌트 ID * @param int $productId 제품 ID * @return int 사용 건수 */ private function checkProductUsageInBom(int $tenantId, int $productId): int { return \App\Models\Products\ProductComponent::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'PRODUCT') ->where('ref_id', $productId) ->count(); } /** * Material이 다른 BOM의 구성품으로 사용 중인지 체크 * * @param int $tenantId 테넌트 ID * @param int $materialId 자재 ID * @return int 사용 건수 */ private function checkMaterialUsageInBom(int $tenantId, int $materialId): int { return \App\Models\Products\ProductComponent::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'MATERIAL') ->where('ref_id', $materialId) ->count(); } /** * 품목 일괄 삭제 (Product/Material 통합, Soft Delete) * * - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가 * * @param array $ids 품목 ID 배열 * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) */ public function batchDeleteItems(array $ids, string $itemType = 'FG'): void { $tenantId = $this->tenantId(); $itemType = strtoupper($itemType); if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { $this->batchDeleteMaterials($ids); return; } $this->batchDeleteProducts($ids); } /** * Product 일괄 삭제 (FG, PT) */ private function batchDeleteProducts(array $ids): void { $tenantId = $this->tenantId(); $products = Product::query() ->where('tenant_id', $tenantId) ->whereIn('id', $ids) ->get(); if ($products->isEmpty()) { throw new NotFoundHttpException(__('error.item.not_found')); } // BOM 구성품으로 사용 중인 품목 체크 $inUseIds = []; foreach ($products as $product) { $usageCount = $this->checkProductUsageInBom($tenantId, $product->id); if ($usageCount > 0) { $inUseIds[] = $product->id; } } if (! empty($inUseIds)) { throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)])); } foreach ($products as $product) { $product->delete(); } } /** * Material 일괄 삭제 (SM, RM, CS) */ private function batchDeleteMaterials(array $ids): void { $tenantId = $this->tenantId(); $materials = Material::query() ->where('tenant_id', $tenantId) ->whereIn('id', $ids) ->get(); if ($materials->isEmpty()) { throw new NotFoundHttpException(__('error.item.not_found')); } // BOM 구성품으로 사용 중인 자재 체크 $inUseIds = []; foreach ($materials as $material) { $usageCount = $this->checkMaterialUsageInBom($tenantId, $material->id); if ($usageCount > 0) { $inUseIds[] = $material->id; } } if (! empty($inUseIds)) { throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)])); } foreach ($materials as $material) { $material->delete(); } } /** * 품목 상세 조회 (code 기반, BOM 포함 옵션) * * Product 먼저 조회 → 없으면 Material 조회 * * @param string $code 품목 코드 * @param bool $includeBom BOM 포함 여부 * @return array 품목 데이터 (attributes 플랫 전개) */ public function getItemByCode(string $code, bool $includeBom = false): array { $tenantId = $this->tenantId(); // 1. Product에서 먼저 조회 $query = Product::query() ->with('category:id,name') ->where('tenant_id', $tenantId) ->where('code', $code); if ($includeBom) { $query->with('componentLines.childProduct:id,code,name,unit'); } $product = $query->first(); if ($product) { $data = $this->flattenAttributes($product->toArray()); $data['item_type'] = $product->product_type; $data['type_code'] = $product->product_type; return $data; } // 2. Material에서 조회 $material = Material::query() ->with('category:id,name') ->where('tenant_id', $tenantId) ->where('material_code', $code) ->first(); if ($material) { $data = $this->flattenAttributes($material->toArray()); $data['item_type'] = $material->material_type; $data['code'] = $material->material_code; $data['type_code'] = $material->material_type; return $data; } throw new NotFoundHttpException(__('error.not_found')); } }