input('per_page', $request->input('size', 20))); $itemType = $request->input('item_type'); $query = Item::query() ->where('tenant_id', $tenantId) ->whereNotNull('bom') ->where('bom', '!=', '[]') ->where('bom', '!=', 'null'); // item_type 필터 (선택) if ($itemType) { $query->where('item_type', strtoupper($itemType)); } $items = $query->orderBy('id') ->paginate($perPage, ['id', 'code', 'name', 'item_type', 'unit', 'bom']); // BOM 개수 추가 $items->getCollection()->transform(function ($item) { $bom = $item->bom ?? []; return [ 'id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'item_type' => $item->item_type, 'unit' => $item->unit, 'bom_count' => count($bom), 'bom' => $this->expandBomItems($bom), ]; }); return $items; }, __('message.bom.fetch')); } /** * GET /api/v1/items/{id}/bom * BOM 라인 목록 조회 (flat list) */ public function index(int $id, Request $request) { return ApiResponse::handle(function () use ($id) { $item = $this->getItem($id); $bom = $item->bom ?? []; // child_item 정보 확장 return $this->expandBomItems($bom); }, __('message.bom.fetch')); } /** * GET /api/v1/items/{id}/bom/tree * BOM 트리 구조 조회 (계층적) */ public function tree(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { $item = $this->getItem($id); $maxDepth = (int) ($request->input('depth', 3)); return $this->buildBomTree($item, $maxDepth, 1); }, __('message.bom.fetch')); } /** * POST /api/v1/items/{id}/bom * BOM 라인 추가 (bulk upsert) */ public function store(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { $item = $this->getItem($id); $inputItems = $request->input('items', []); $tenantId = app('tenant_id'); // child_item_id 존재 검증 $childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray(); if (! empty($childIds)) { $validIds = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->pluck('id') ->toArray(); $invalidIds = array_diff($childIds, $validIds); if (! empty($invalidIds)) { throw new \InvalidArgumentException( __('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)]) ); } } $existingBom = $item->bom ?? []; $existingMap = collect($existingBom)->keyBy('child_item_id')->toArray(); // 입력된 items upsert foreach ($inputItems as $inputItem) { $childItemId = $inputItem['child_item_id'] ?? null; if (! $childItemId) { continue; } // 자기 자신 참조 방지 if ($childItemId == $id) { continue; } $existingMap[$childItemId] = [ 'child_item_id' => (int) $childItemId, 'quantity' => (float) ($inputItem['quantity'] ?? 1), ]; } // 저장 $item->bom = array_values($existingMap); $item->updated_by = auth()->id(); $item->save(); return $this->expandBomItems($item->bom); }, __('message.bom.created')); } /** * PUT /api/v1/items/{id}/bom/{lineId} * BOM 라인 수정 (lineId = child_item_id) */ public function update(int $id, int $lineId, Request $request) { return ApiResponse::handle(function () use ($id, $lineId, $request) { $item = $this->getItem($id); $bom = $item->bom ?? []; $updated = false; foreach ($bom as &$entry) { if (($entry['child_item_id'] ?? null) == $lineId) { if ($request->has('quantity')) { $entry['quantity'] = (float) $request->input('quantity'); } $updated = true; break; } } if (! $updated) { throw new NotFoundHttpException(__('error.bom.line_not_found')); } $item->bom = $bom; $item->updated_by = auth()->id(); $item->save(); return $this->expandBomItems($item->bom); }, __('message.bom.updated')); } /** * DELETE /api/v1/items/{id}/bom/{lineId} * BOM 라인 삭제 (lineId = child_item_id) */ public function destroy(int $id, int $lineId) { return ApiResponse::handle(function () use ($id, $lineId) { $item = $this->getItem($id); $bom = $item->bom ?? []; $originalCount = count($bom); $bom = array_values(array_filter($bom, function ($entry) use ($lineId) { return ($entry['child_item_id'] ?? null) != $lineId; })); if (count($bom) === $originalCount) { throw new NotFoundHttpException(__('error.bom.line_not_found')); } $item->bom = $bom; $item->updated_by = auth()->id(); $item->save(); return 'success'; }, __('message.bom.deleted')); } /** * GET /api/v1/items/{id}/bom/summary * BOM 요약 정보 */ public function summary(int $id) { return ApiResponse::handle(function () use ($id) { $item = $this->getItem($id); $bom = $item->bom ?? []; $expandedBom = $this->expandBomItems($bom); return [ 'item_id' => $item->id, 'item_code' => $item->code, 'item_name' => $item->name, 'total_lines' => count($bom), 'total_quantity' => collect($bom)->sum('quantity'), 'child_items' => collect($expandedBom)->pluck('child_item_name')->filter()->unique()->values(), ]; }, __('message.bom.fetch')); } /** * GET /api/v1/items/{id}/bom/validate * BOM 유효성 검사 */ public function validate(int $id) { return ApiResponse::handle(function () use ($id) { $item = $this->getItem($id); $bom = $item->bom ?? []; $tenantId = app('tenant_id'); $issues = []; // 1. 자기 자신 참조 체크 foreach ($bom as $entry) { if (($entry['child_item_id'] ?? null) == $id) { $issues[] = ['type' => 'self_reference', 'message' => '자기 자신을 BOM에 포함할 수 없습니다.']; } } // 2. 존재하지 않는 품목 체크 $childIds = collect($bom)->pluck('child_item_id')->filter()->toArray(); if (! empty($childIds)) { $existingIds = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->pluck('id') ->toArray(); $missingIds = array_diff($childIds, $existingIds); foreach ($missingIds as $missingId) { $issues[] = ['type' => 'missing_item', 'message' => "품목 ID {$missingId}가 존재하지 않습니다.", 'child_item_id' => $missingId]; } } // 3. 순환 참조 체크 (1단계만) foreach ($childIds as $childId) { $childItem = Item::where('tenant_id', $tenantId)->find($childId); if ($childItem && ! empty($childItem->bom)) { $grandchildIds = collect($childItem->bom)->pluck('child_item_id')->filter()->toArray(); if (in_array($id, $grandchildIds)) { $issues[] = ['type' => 'circular_reference', 'message' => "순환 참조가 감지되었습니다: {$childItem->code}", 'child_item_id' => $childId]; } } } return [ 'is_valid' => empty($issues), 'issues' => $issues, ]; }, __('message.bom.fetch')); } /** * POST /api/v1/items/{id}/bom/replace * BOM 전체 교체 */ public function replace(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { $item = $this->getItem($id); $inputItems = $request->input('items', []); $tenantId = app('tenant_id'); // child_item_id 존재 검증 $childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray(); if (! empty($childIds)) { $validIds = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->pluck('id') ->toArray(); $invalidIds = array_diff($childIds, $validIds); if (! empty($invalidIds)) { throw new \InvalidArgumentException( __('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)]) ); } } $newBom = []; foreach ($inputItems as $inputItem) { $childItemId = $inputItem['child_item_id'] ?? null; if (! $childItemId || $childItemId == $id) { continue; } $newBom[] = [ 'child_item_id' => (int) $childItemId, 'quantity' => (float) ($inputItem['quantity'] ?? 1), ]; } $item->bom = $newBom; $item->updated_by = auth()->id(); $item->save(); return $this->expandBomItems($item->bom); }, __('message.bom.created')); } /** * POST /api/v1/items/{id}/bom/reorder * BOM 정렬 변경 */ public function reorder(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { $item = $this->getItem($id); $orderedIds = $request->input('items', []); if (empty($orderedIds)) { return 'success'; } $existingBom = $item->bom ?? []; $bomMap = collect($existingBom)->keyBy('child_item_id')->toArray(); // 순서대로 재정렬 $reorderedBom = []; foreach ($orderedIds as $childItemId) { if (isset($bomMap[$childItemId])) { $reorderedBom[] = $bomMap[$childItemId]; unset($bomMap[$childItemId]); } } // 남은 항목 추가 foreach ($bomMap as $entry) { $reorderedBom[] = $entry; } $item->bom = $reorderedBom; $item->updated_by = auth()->id(); $item->save(); return 'success'; }, __('message.bom.reordered')); } /** * GET /api/v1/items/{id}/bom/categories * 해당 품목의 BOM에서 사용 중인 카테고리 목록 */ public function listCategories(int $id) { return ApiResponse::handle(function () use ($id) { $item = $this->getItem($id); $bom = $item->bom ?? []; $tenantId = app('tenant_id'); $childIds = collect($bom)->pluck('child_item_id')->filter()->toArray(); if (empty($childIds)) { return []; } $categories = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->whereNotNull('category_id') ->with('category:id,name') ->get() ->pluck('category') ->filter() ->unique('id') ->values(); return $categories; }, __('message.bom.fetch')); } // ==================== Helper Methods ==================== /** * 품목 조회 및 tenant 소유권 검증 * * @throws NotFoundHttpException */ private function getItem(int $id): Item { $tenantId = app('tenant_id'); $item = Item::query() ->where('tenant_id', $tenantId) ->find($id); if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } return $item; } /** * BOM 항목에 child_item 상세 정보 확장 */ private function expandBomItems(array $bom): array { if (empty($bom)) { return []; } $tenantId = app('tenant_id'); $childIds = collect($bom)->pluck('child_item_id')->filter()->toArray(); $childItems = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->get(['id', 'code', 'name', 'unit', 'item_type', 'category_id']) ->keyBy('id'); return collect($bom)->map(function ($entry) use ($childItems) { $childItemId = $entry['child_item_id'] ?? null; $childItem = $childItems[$childItemId] ?? null; return [ 'child_item_id' => $childItemId, 'child_item_code' => $childItem?->code, 'child_item_name' => $childItem?->name, 'child_item_type' => $childItem?->item_type, 'unit' => $childItem?->unit, 'quantity' => $entry['quantity'] ?? 1, ]; })->toArray(); } /** * BOM 트리 구조 빌드 (재귀) */ private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array { $tenantId = app('tenant_id'); $bom = $item->bom ?? []; $result = [ 'id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'item_type' => $item->item_type, 'unit' => $item->unit, 'depth' => $currentDepth, 'children' => [], ]; if (empty($bom) || $currentDepth >= $maxDepth) { return $result; } $childIds = collect($bom)->pluck('child_item_id')->filter()->toArray(); $childItems = Item::where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->get() ->keyBy('id'); foreach ($bom as $entry) { $childItemId = $entry['child_item_id'] ?? null; $childItem = $childItems[$childItemId] ?? null; if ($childItem) { $childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 1); $childTree['quantity'] = $entry['quantity'] ?? 1; $result['children'][] = $childTree; } } return $result; } }