From 20ad6da16447908ab8b4091f0127751d8129f51f Mon Sep 17 00:00:00 2001 From: kent Date: Sun, 14 Dec 2025 01:10:25 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20P0=20Critical=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=82=AD=EC=A0=9C=EB=90=9C=20Product/?= =?UTF-8?q?Material=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemsBomController: ProductBomService 제거, Item.bom JSON 기반으로 재구현 - ItemsFileController: Product/Material → Item 모델로 통합 - ItemPage: products/materials 클래스 매핑 제거 - ItemsService 삭제 (1,210줄) - ItemService로 대체 완료 BOM 기능 변경: - 기존: ProductBomService (삭제됨) - 변경: Item 모델의 bom JSON 필드 직접 조작 - 모든 BOM API 엔드포인트 정상 동작 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Controllers/Api/V1/ItemsBomController.php | 312 ++++- .../Api/V1/ItemsFileController.php | 31 +- app/Models/ItemMaster/ItemPage.php | 3 - app/Services/ItemsService.php | 1210 ----------------- 4 files changed, 287 insertions(+), 1269 deletions(-) delete mode 100644 app/Services/ItemsService.php diff --git a/app/Http/Controllers/Api/V1/ItemsBomController.php b/app/Http/Controllers/Api/V1/ItemsBomController.php index f67462d..2c64c7b 100644 --- a/app/Http/Controllers/Api/V1/ItemsBomController.php +++ b/app/Http/Controllers/Api/V1/ItemsBomController.php @@ -4,31 +4,30 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; -use App\Models\Products\Product; -use App\Services\ProductBomService; +use App\Models\Items\Item; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Items BOM Controller (ID-based) * - * ID 기반으로 BOM을 관리하는 컨트롤러 - * 내부적으로 기존 ProductBomService 재사용 + * items.bom JSON 필드 기반 BOM 관리 컨트롤러 + * BOM 구조: [{child_item_id, quantity}, ...] */ class ItemsBomController extends Controller { - public function __construct(private ProductBomService $service) {} - /** * GET /api/v1/items/{id}/bom * BOM 라인 목록 조회 (flat list) */ public function index(int $id, Request $request) { - return ApiResponse::handle(function () use ($id, $request) { - $this->validateProductExists($id); + return ApiResponse::handle(function () use ($id) { + $item = $this->getItem($id); + $bom = $item->bom ?? []; - return $this->service->index($id, $request->all()); + // child_item 정보 확장 + return $this->expandBomItems($bom); }, __('message.bom.fetch')); } @@ -39,9 +38,10 @@ public function index(int $id, Request $request) public function tree(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $maxDepth = (int) ($request->input('depth', 3)); - return $this->service->tree($request, $id); + return $this->buildBomTree($item, $maxDepth, 1); }, __('message.bom.fetch')); } @@ -52,35 +52,94 @@ public function tree(int $id, Request $request) public function store(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $inputItems = $request->input('items', []); - return $this->service->bulkUpsert($id, $request->input('items', [])); + $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 라인 수정 + * BOM 라인 수정 (lineId = child_item_id) */ public function update(int $id, int $lineId, Request $request) { return ApiResponse::handle(function () use ($id, $lineId, $request) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $bom = $item->bom ?? []; - return $this->service->update($id, $lineId, $request->all()); + $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 라인 삭제 + * BOM 라인 삭제 (lineId = child_item_id) */ public function destroy(int $id, int $lineId) { return ApiResponse::handle(function () use ($id, $lineId) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $bom = $item->bom ?? []; - $this->service->destroy($id, $lineId); + $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')); @@ -93,9 +152,19 @@ public function destroy(int $id, int $lineId) public function summary(int $id) { return ApiResponse::handle(function () use ($id) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $bom = $item->bom ?? []; - return $this->service->summary($id); + $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')); } @@ -106,9 +175,48 @@ public function summary(int $id) public function validate(int $id) { return ApiResponse::handle(function () use ($id) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $bom = $item->bom ?? []; + $tenantId = app('tenant_id'); - return $this->service->validateBom($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')); } @@ -119,9 +227,27 @@ public function validate(int $id) public function replace(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $inputItems = $request->input('items', []); - return $this->service->replaceBom($id, $request->all()); + $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')); } @@ -132,9 +258,33 @@ public function replace(int $id, Request $request) public function reorder(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $orderedIds = $request->input('items', []); - $this->service->reorder($id, $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')); @@ -147,30 +297,122 @@ public function reorder(int $id, Request $request) public function listCategories(int $id) { return ApiResponse::handle(function () use ($id) { - $this->validateProductExists($id); + $item = $this->getItem($id); + $bom = $item->bom ?? []; + $tenantId = app('tenant_id'); - return $this->service->listCategoriesForProduct($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 ==================== /** - * 품목 ID로 tenant 소유권 검증 + * 품목 조회 및 tenant 소유권 검증 * * @throws NotFoundHttpException */ - private function validateProductExists(int $id): void + private function getItem(int $id): Item { $tenantId = app('tenant_id'); - $exists = Product::query() + $item = Item::query() ->where('tenant_id', $tenantId) - ->where('id', $id) - ->exists(); + ->find($id); - if (! $exists) { + 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; } } diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index a9b3f62..1801c04 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -3,12 +3,10 @@ namespace App\Http\Controllers\Api\V1; use App\Helpers\ApiResponse; -use App\Helpers\ItemTypeHelper; use App\Http\Controllers\Controller; use App\Http\Requests\Item\ItemFileUploadRequest; use App\Models\Commons\File; -use App\Models\Materials\Material; -use App\Models\Products\Product; +use App\Models\Items\Item; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -37,11 +35,10 @@ public function index(int $id, Request $request) { return ApiResponse::handle(function () use ($id, $request) { $tenantId = app('tenant_id'); - $itemType = strtoupper($request->input('item_type', 'FG')); $fieldKey = $request->input('field_key'); // 품목 존재 확인 - $this->getItemById($id, $itemType, $tenantId); + $this->getItemById($id, $tenantId); // 파일 조회 $query = File::query() @@ -76,13 +73,12 @@ public function upload(int $id, ItemFileUploadRequest $request) $tenantId = app('tenant_id'); $userId = auth()->id(); $validated = $request->validated(); - $itemType = strtoupper($validated['item_type'] ?? 'FG'); $fieldKey = $validated['field_key']; $uploadedFile = $validated['file']; $existingFileId = $validated['file_id'] ?? null; // 품목 존재 확인 - $this->getItemById($id, $itemType, $tenantId); + $this->getItemById($id, $tenantId); $replaced = false; @@ -156,13 +152,12 @@ public function upload(int $id, ItemFileUploadRequest $request) */ public function delete(int $id, int $fileId, Request $request) { - return ApiResponse::handle(function () use ($id, $fileId, $request) { + return ApiResponse::handle(function () use ($id, $fileId) { $tenantId = app('tenant_id'); $userId = auth()->id(); - $itemType = strtoupper($request->input('item_type', 'FG')); // 품목 존재 확인 - $this->getItemById($id, $itemType, $tenantId); + $this->getItemById($id, $tenantId); // 파일 조회 $file = File::query() @@ -187,19 +182,13 @@ public function delete(int $id, int $fileId, Request $request) } /** - * ID로 품목 조회 (Product 또는 Material) + * ID로 품목 조회 (통합 items 테이블) */ - private function getItemById(int $id, string $itemType, int $tenantId): Product|Material + private function getItemById(int $id, int $tenantId): Item { - if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { - $item = Material::query() - ->where('tenant_id', $tenantId) - ->find($id); - } else { - $item = Product::query() - ->where('tenant_id', $tenantId) - ->find($id); - } + $item = Item::query() + ->where('tenant_id', $tenantId) + ->find($id); if (! $item) { throw new NotFoundHttpException(__('error.not_found')); diff --git a/app/Models/ItemMaster/ItemPage.php b/app/Models/ItemMaster/ItemPage.php index 746f43a..aeaf7e0 100644 --- a/app/Models/ItemMaster/ItemPage.php +++ b/app/Models/ItemMaster/ItemPage.php @@ -104,9 +104,6 @@ public function getTargetModelClass(): ?string { $mapping = [ 'items' => \App\Models\Items\Item::class, - // 하위 호환성 (마이그레이션 완료 전까지) - 'products' => \App\Models\Products\Product::class, - 'materials' => \App\Models\Materials\Material::class, ]; return $mapping[$this->source_table] ?? null; diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php deleted file mode 100644 index e5305d1..0000000 --- a/app/Services/ItemsService.php +++ /dev/null @@ -1,1210 +0,0 @@ -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')); - } -}