tenantId(); // 필터 파라미터 추출 $types = $filters['type'] ?? ['FG', 'PT', 'SM', 'RM', 'CS']; $search = trim($filters['search'] ?? $filters['q'] ?? ''); $categoryId = $filters['category_id'] ?? null; // 타입을 배열로 변환 (문자열인 경우 쉼표로 분리) 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) ->where('is_active', 1) ->select([ 'id', 'product_type as item_type', 'code', 'name', DB::raw('NULL as specification'), 'unit', 'category_id', 'product_type as type_code', 'attributes', '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) ->select([ 'id', 'material_type as item_type', 'material_code as code', 'name', 'specification', 'unit', 'category_id', 'material_type as type_code', 'attributes', '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 JSON 필드를 최상위로 플랫 전개 */ private function flattenAttributes(array $data): array { $attributes = $data['attributes'] ?? []; if (is_string($attributes)) { $attributes = json_decode($attributes, true) ?? []; } unset($data['attributes']); return array_merge($data, $attributes); } /** * 품목의 판매가/매입가 조회 * * @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 { // 품목 코드 중복 시 자동 증가 $data['code'] = $this->resolveUniqueCode($data['code'], $tenantId); $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 { // 품목 코드 중복 시 자동 증가 (Materials용) $code = $data['code'] ?? $data['material_code'] ?? ''; $data['material_code'] = $this->resolveUniqueMaterialCode($code, $tenantId); $payload = [ 'tenant_id' => $tenantId, 'created_by' => $userId, 'material_type' => $materialType, 'material_code' => $data['material_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); } /** * Material 코드 중복 체크 및 고유 코드 생성 */ private function resolveUniqueMaterialCode(string $code, int $tenantId): string { $exists = Material::withTrashed() ->where('tenant_id', $tenantId) ->where('material_code', $code) ->exists(); if (! $exists) { return $code; } // 마지막이 숫자인지 확인 if (preg_match('/^(.+?)(\d+)$/', $code, $matches)) { $prefix = $matches[1]; $number = (int) $matches[2]; $digits = strlen($matches[2]); do { $number++; $newCode = $prefix.str_pad($number, $digits, '0', STR_PAD_LEFT); $exists = Material::withTrashed() ->where('tenant_id', $tenantId) ->where('material_code', $newCode) ->exists(); } while ($exists); return $newCode; } // 마지막이 문자면 -001 추가 $suffix = 1; do { $newCode = $code.'-'.str_pad($suffix, 3, '0', STR_PAD_LEFT); $exists = Material::withTrashed() ->where('tenant_id', $tenantId) ->where('material_code', $newCode) ->exists(); $suffix++; } while ($exists); return $newCode; } /** * 중복되지 않는 고유 코드 생성 * * - 중복 없으면 원본 반환 * - 마지막이 숫자면 숫자 증가 (P-001 → P-002) * - 마지막이 문자면 -001 추가 (ABC → ABC-001) */ private function resolveUniqueCode(string $code, int $tenantId): string { // 삭제된 항목 포함해서 중복 체크 $exists = Product::withTrashed() ->where('tenant_id', $tenantId) ->where('code', $code) ->exists(); if (! $exists) { return $code; } // 마지막이 숫자인지 확인 (예: P-001, ITEM123) if (preg_match('/^(.+?)(\d+)$/', $code, $matches)) { $prefix = $matches[1]; $number = (int) $matches[2]; $digits = strlen($matches[2]); // 숫자 증가하며 고유 코드 찾기 do { $number++; $newCode = $prefix.str_pad($number, $digits, '0', STR_PAD_LEFT); $exists = Product::withTrashed() ->where('tenant_id', $tenantId) ->where('code', $newCode) ->exists(); } while ($exists); return $newCode; } // 마지막이 문자면 -001 추가 $suffix = 1; do { $newCode = $code.'-'.str_pad($suffix, 3, '0', STR_PAD_LEFT); $exists = Product::withTrashed() ->where('tenant_id', $tenantId) ->where('code', $newCode) ->exists(); $suffix++; } while ($exists); return $newCode; } /** * 품목 수정 (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) { $exists = Product::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->where('id', '!=', $product->id) ->exists(); if ($exists) { throw new BadRequestHttpException(__('error.duplicate_code')); } } // 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) { $exists = Material::query() ->where('tenant_id', $tenantId) ->where('material_code', $newCode) ->where('id', '!=', $material->id) ->exists(); if ($exists) { throw new BadRequestHttpException(__('error.duplicate_code')); } $data['material_code'] = $newCode; } // 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')); } }