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']); } } /** * BOM 데이터에서 child_item_id, child_item_type, quantity만 추출 * * @param array|null $bomData BOM 데이터 배열 * @return array|null [{child_item_id, child_item_type, quantity}, ...] */ private function extractBomData(?array $bomData): ?array { if (empty($bomData)) { return null; } $extracted = []; foreach ($bomData as $item) { if (! is_array($item)) { continue; } $childItemId = $item['child_item_id'] ?? null; $childItemType = $item['child_item_type'] ?? $item['ref_type'] ?? 'PRODUCT'; $quantity = $item['quantity'] ?? null; if ($childItemId === null) { continue; } // child_item_type 정규화 (PRODUCT/MATERIAL) $childItemType = strtoupper($childItemType); if (! in_array($childItemType, ['PRODUCT', 'MATERIAL'])) { $childItemType = 'PRODUCT'; } $extracted[] = [ 'child_item_id' => (int) $childItemId, 'child_item_type' => $childItemType, 'quantity' => $quantity !== null ? (float) $quantity : 1, ]; } return empty($extracted) ? null : $extracted; } /** * BOM 데이터 확장 (child_item 상세 정보 포함) * * DB에 저장된 [{child_item_id, child_item_type, quantity}] 형태를 * [{child_item_id, child_item_type, child_item_code, child_item_name, quantity, unit, specification?}] * 형태로 확장하여 반환 * * @param array $bomData BOM 데이터 배열 [{child_item_id, child_item_type, quantity}, ...] * @param int $tenantId 테넌트 ID * @return array 확장된 BOM 데이터 */ private function expandBomData(array $bomData, int $tenantId): array { if (empty($bomData)) { return []; } // child_item_type별로 ID 분리 $productIds = []; $materialIds = []; foreach ($bomData as $item) { $childId = $item['child_item_id'] ?? null; $childType = strtoupper($item['child_item_type'] ?? 'PRODUCT'); if ($childId === null) { continue; } if ($childType === 'MATERIAL') { $materialIds[] = $childId; } else { $productIds[] = $childId; } } // Products에서 조회 (FG, PT) $products = collect([]); if (! empty($productIds)) { $products = Product::query() ->where('tenant_id', $tenantId) ->whereIn('id', $productIds) ->get(['id', 'code', 'name', 'unit']) ->keyBy('id'); } // Materials에서 조회 (SM, RM, CS) $materials = collect([]); if (! empty($materialIds)) { $materials = Material::query() ->where('tenant_id', $tenantId) ->whereIn('id', $materialIds) ->get(['id', 'material_code', 'name', 'unit', 'specification']) ->keyBy('id'); } // BOM 데이터 확장 $expanded = []; foreach ($bomData as $item) { $childId = $item['child_item_id'] ?? null; $childType = strtoupper($item['child_item_type'] ?? 'PRODUCT'); if ($childId === null) { continue; } $result = [ 'child_item_id' => (int) $childId, 'child_item_type' => $childType, 'quantity' => $item['quantity'] ?? 1, ]; // child_item_type에 따라 조회 if ($childType === 'MATERIAL' && isset($materials[$childId])) { $material = $materials[$childId]; $result['child_item_code'] = $material->material_code; $result['child_item_name'] = $material->name; $result['unit'] = $material->unit; if ($material->specification) { $result['specification'] = $material->specification; } } elseif ($childType === 'PRODUCT' && isset($products[$childId])) { $product = $products[$childId]; $result['child_item_code'] = $product->code; $result['child_item_name'] = $product->name; $result['unit'] = $product->unit; } else { // 해당하는 품목이 없으면 null 처리 $result['child_item_code'] = null; $result['child_item_name'] = null; $result['unit'] = null; } $expanded[] = $result; } return $expanded; } /** * 통합 품목 조회 (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); } // 파일 정보 추가 $data['files'] = $this->getItemFiles($id, $tenantId); 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; // BOM 데이터 확장 (child_item 상세 정보 포함) if (! empty($data['bom'])) { $data['bom'] = $this->expandBomData($data['bom'], $tenantId); } // 가격 정보 추가 if ($includePrice) { $data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate); } // 파일 정보 추가 $data['files'] = $this->getItemFiles($id, $tenantId); 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'); // BOM 데이터 처리 (child_item_id, quantity만 추출) $bomData = $this->extractBomData($data['bom'] ?? null); $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; $payload['bom'] = $bomData; 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'); // BOM 데이터 처리 (bom 키가 있을 때만) if (array_key_exists('bom', $data)) { $data['bom'] = $this->extractBomData($data['bom']); } // 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 통합, Force Delete) * * - 이미 삭제된 품목은 404 반환 * - 다른 테이블에서 사용 중이면 삭제 불가 * - 사용 안함 → Force Delete (영구 삭제) * * @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) - Force Delete * * - 사용 중이면 삭제 불가 (에러) * - 사용 안함 → 영구 삭제 */ 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')); } // 사용 여부 종합 체크 $usageResult = $this->checkProductUsage($tenantId, $id); if ($usageResult['is_used']) { $usageMessage = $this->formatUsageMessage($usageResult['usage']); throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage])); } // 사용 안함 → Force Delete $product->forceDelete(); } /** * Material 삭제 (SM, RM, CS) - Force Delete * * - 사용 중이면 삭제 불가 (에러) * - 사용 안함 → 영구 삭제 */ 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')); } // 사용 여부 종합 체크 $usageResult = $this->checkMaterialUsage($tenantId, $id); if ($usageResult['is_used']) { $usageMessage = $this->formatUsageMessage($usageResult['usage']); throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage])); } // 사용 안함 → Force Delete $material->forceDelete(); } /** * Product 사용 여부 종합 체크 (모든 참조 테이블) * * @param int $tenantId 테넌트 ID * @param int $productId 제품 ID * @return array ['is_used' => bool, 'usage' => ['table' => count, ...]] */ private function checkProductUsage(int $tenantId, int $productId): array { $usage = []; // 1. BOM 구성품으로 사용 (product_components.ref_type=PRODUCT) $bomComponentCount = \App\Models\Products\ProductComponent::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'PRODUCT') ->where('ref_id', $productId) ->count(); if ($bomComponentCount > 0) { $usage['bom_components'] = $bomComponentCount; } // 2. BOM 상위품목으로 사용 (product_components.parent_product_id) $bomParentCount = \App\Models\Products\ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $productId) ->count(); if ($bomParentCount > 0) { $usage['bom_parent'] = $bomParentCount; } // 3. BOM 템플릿 항목 (bom_template_items.ref_type=PRODUCT) $bomTemplateCount = \App\Models\Design\BomTemplateItem::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'PRODUCT') ->where('ref_id', $productId) ->count(); if ($bomTemplateCount > 0) { $usage['bom_templates'] = $bomTemplateCount; } // 4. 주문 (orders.product_id) $orderCount = \App\Models\Orders\Order::query() ->where('tenant_id', $tenantId) ->where('product_id', $productId) ->count(); if ($orderCount > 0) { $usage['orders'] = $orderCount; } // 5. 주문 항목 (order_items.product_id) $orderItemCount = \App\Models\Orders\OrderItem::query() ->where('tenant_id', $tenantId) ->where('product_id', $productId) ->count(); if ($orderItemCount > 0) { $usage['order_items'] = $orderItemCount; } // 6. 견적 (quotes.product_id) $quoteCount = \App\Models\Quote\Quote::query() ->where('tenant_id', $tenantId) ->where('product_id', $productId) ->count(); if ($quoteCount > 0) { $usage['quotes'] = $quoteCount; } return [ 'is_used' => ! empty($usage), 'usage' => $usage, ]; } /** * Material 사용 여부 종합 체크 (모든 참조 테이블) * * @param int $tenantId 테넌트 ID * @param int $materialId 자재 ID * @return array ['is_used' => bool, 'usage' => ['table' => count, ...]] */ private function checkMaterialUsage(int $tenantId, int $materialId): array { $usage = []; // 1. BOM 구성품으로 사용 (product_components.ref_type=MATERIAL) $bomComponentCount = \App\Models\Products\ProductComponent::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'MATERIAL') ->where('ref_id', $materialId) ->count(); if ($bomComponentCount > 0) { $usage['bom_components'] = $bomComponentCount; } // 2. BOM 템플릿 항목 (bom_template_items.ref_type=MATERIAL) $bomTemplateCount = \App\Models\Design\BomTemplateItem::query() ->where('tenant_id', $tenantId) ->where('ref_type', 'MATERIAL') ->where('ref_id', $materialId) ->count(); if ($bomTemplateCount > 0) { $usage['bom_templates'] = $bomTemplateCount; } // 3. 자재 입고 (material_receipts.material_id) $receiptCount = \App\Models\Materials\MaterialReceipt::query() ->where('tenant_id', $tenantId) ->where('material_id', $materialId) ->count(); if ($receiptCount > 0) { $usage['receipts'] = $receiptCount; } // 4. LOT (lots.material_id) $lotCount = \App\Models\Qualitys\Lot::query() ->where('tenant_id', $tenantId) ->where('material_id', $materialId) ->count(); if ($lotCount > 0) { $usage['lots'] = $lotCount; } return [ 'is_used' => ! empty($usage), 'usage' => $usage, ]; } /** * 사용처 정보를 한글 메시지로 변환 */ private function formatUsageMessage(array $usage): string { $labels = [ 'bom_components' => 'BOM 구성품', 'bom_parent' => 'BOM 상위품목', 'bom_templates' => 'BOM 템플릿', 'orders' => '주문', 'order_items' => '주문 항목', 'quotes' => '견적', 'receipts' => '입고', 'lots' => 'LOT', ]; $parts = []; foreach ($usage as $key => $count) { $label = $labels[$key] ?? $key; $parts[] = "{$label} {$count}건"; } return implode(', ', $parts); } /** * 품목 일괄 삭제 (Product/Material 통합, Force Delete) * * - 다른 테이블에서 사용 중인 품목은 삭제 불가 * - 사용 안함 → Force Delete (영구 삭제) * * @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) - Force Delete * * - 사용 중인 품목이 있으면 전체 삭제 실패 * - 모두 사용 안함 → 영구 삭제 */ 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')); } // 사용 중인 품목 체크 (하나라도 사용 중이면 전체 실패) $inUseItems = []; foreach ($products as $product) { $usageResult = $this->checkProductUsage($tenantId, $product->id); if ($usageResult['is_used']) { $inUseItems[] = [ 'id' => $product->id, 'code' => $product->code, 'usage' => $usageResult['usage'], ]; } } if (! empty($inUseItems)) { $codes = array_column($inUseItems, 'code'); throw new BadRequestHttpException(__('error.item.batch_in_use', [ 'codes' => implode(', ', $codes), 'count' => count($inUseItems), ])); } // 모두 사용 안함 → Force Delete foreach ($products as $product) { $product->forceDelete(); } } /** * Material 일괄 삭제 (SM, RM, CS) - Force Delete * * - 사용 중인 자재가 있으면 전체 삭제 실패 * - 모두 사용 안함 → 영구 삭제 */ 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')); } // 사용 중인 자재 체크 (하나라도 사용 중이면 전체 실패) $inUseItems = []; foreach ($materials as $material) { $usageResult = $this->checkMaterialUsage($tenantId, $material->id); if ($usageResult['is_used']) { $inUseItems[] = [ 'id' => $material->id, 'code' => $material->material_code, 'usage' => $usageResult['usage'], ]; } } if (! empty($inUseItems)) { $codes = array_column($inUseItems, 'code'); throw new BadRequestHttpException(__('error.item.batch_in_use', [ 'codes' => implode(', ', $codes), 'count' => count($inUseItems), ])); } // 모두 사용 안함 → Force Delete foreach ($materials as $material) { $material->forceDelete(); } } /** * 품목의 파일 목록 조회 (field_key별 그룹핑) * * @param int $itemId 품목 ID * @param int $tenantId 테넌트 ID * @return array field_key별로 그룹핑된 파일 목록 */ private function getItemFiles(int $itemId, int $tenantId): array { $files = File::query() ->where('tenant_id', $tenantId) ->where('document_type', '1') // ITEM_GROUP_ID ->where('document_id', $itemId) ->whereNull('deleted_at') ->orderBy('created_at', 'desc') ->get(); if ($files->isEmpty()) { return []; } return $files->groupBy('field_key')->map(function ($group) { return $group->map(fn ($file) => [ 'id' => $file->id, 'file_name' => $file->display_name ?? $file->file_name, 'file_path' => $file->file_path, ])->values()->toArray(); })->toArray(); } /** * 품목 상세 조회 (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')); } }