tenantId(); // 부모 제품 유효성 $this->assertProduct($tenantId, $parentProductId); $items = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->orderBy('sort_order') ->get(); // 리졸브(제품/자재) $productIds = $items->where('ref_type', 'PRODUCT')->pluck('child_product_id')->filter()->unique()->values(); $materialIds = $items->where('ref_type', 'MATERIAL')->pluck('material_id')->filter()->unique()->values(); $products = $productIds->isNotEmpty() ? Product::query()->where('tenant_id', $tenantId)->whereIn('id', $productIds)->get(['id', 'code', 'name', 'product_type', 'category_id'])->keyBy('id') : collect(); $materials = $materialIds->isNotEmpty() ? Material::query()->where('tenant_id', $tenantId)->whereIn('id', $materialIds)->get(['id', 'material_code as code', 'name', 'unit', 'category_id'])->keyBy('id') : collect(); return $items->map(function ($row) use ($products, $materials) { $base = [ 'id' => (int) $row->id, 'ref_type' => $row->ref_type, 'quantity' => $row->quantity, 'sort_order' => (int) $row->sort_order, 'is_default' => (int) $row->is_default, ]; if ($row->ref_type === 'PRODUCT') { $p = $products->get($row->child_product_id); return $base + [ 'ref_id' => (int) $row->child_product_id, 'code' => $p?->code, 'name' => $p?->name, 'product_type' => $p?->product_type, 'category_id' => $p?->category_id, ]; } else { // MATERIAL $m = $materials->get($row->material_id); return $base + [ 'ref_id' => (int) $row->material_id, 'code' => $m?->code, 'name' => $m?->name, 'unit' => $m?->unit, 'category_id' => $m?->category_id, ]; } })->values(); } /** * 일괄 업서트 * items[]: { id?, ref_type: PRODUCT|MATERIAL, ref_id: int, quantity: number, sort_order?: int, is_default?: 0|1 } */ public function bulkUpsert(int $parentProductId, array $items): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $this->assertProduct($tenantId, $parentProductId); if (! is_array($items) || empty($items)) { throw new BadRequestHttpException(__('error.empty_items')); } $created = 0; $updated = 0; DB::transaction(function () use ($tenantId, $userId, $parentProductId, $items, &$created, &$updated) { foreach ($items as $it) { $payload = $this->validateItem($it); // ref 확인 & 자기참조 방지 $this->assertReference($tenantId, $parentProductId, $payload['ref_type'], (int) $payload['ref_id']); if (! empty($it['id'])) { $pc = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->find((int) $it['id']); if (! $pc) { throw new BadRequestHttpException(__('error.not_found')); } // ref 변경 허용 시: 충돌 검사 [$childProductId, $materialId] = $this->splitRef($payload); $pc->update([ 'ref_type' => $payload['ref_type'], 'child_product_id' => $childProductId, 'material_id' => $materialId, 'quantity' => $payload['quantity'], 'sort_order' => $payload['sort_order'] ?? $pc->sort_order, 'is_default' => $payload['is_default'] ?? $pc->is_default, 'updated_by' => $userId, ]); $updated++; } else { // 신규 [$childProductId, $materialId] = $this->splitRef($payload); ProductComponent::create([ 'tenant_id' => $tenantId, 'parent_product_id' => $parentProductId, 'ref_type' => $payload['ref_type'], 'child_product_id' => $childProductId, 'material_id' => $materialId, 'quantity' => $payload['quantity'], 'sort_order' => $payload['sort_order'] ?? 0, 'is_default' => $payload['is_default'] ?? 0, 'created_by' => $userId, ]); $created++; } } }); return compact('created', 'updated'); } // 단건 수정 public function update(int $parentProductId, int $itemId, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $this->assertProduct($tenantId, $parentProductId); $pc = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->find($itemId); if (! $pc) { throw new BadRequestHttpException(__('error.not_found')); } $v = Validator::make($data, [ 'ref_type' => 'sometimes|in:PRODUCT,MATERIAL', 'ref_id' => 'sometimes|integer', 'quantity' => 'sometimes|numeric|min:0.0001', 'sort_order' => 'sometimes|integer|min:0', 'is_default' => 'sometimes|in:0,1', ]); $payload = $v->validate(); if (isset($payload['ref_type']) || isset($payload['ref_id'])) { $refType = $payload['ref_type'] ?? $pc->ref_type; $refId = isset($payload['ref_id']) ? (int) $payload['ref_id'] : ($pc->ref_type === 'PRODUCT' ? (int) $pc->child_product_id : (int) $pc->material_id); $this->assertReference($tenantId, $parentProductId, $refType, $refId); [$childProductId, $materialId] = $this->splitRef(['ref_type' => $refType, 'ref_id' => $refId]); $pc->ref_type = $refType; $pc->child_product_id = $childProductId; $pc->material_id = $materialId; } if (isset($payload['quantity'])) { $pc->quantity = $payload['quantity']; } if (isset($payload['sort_order'])) { $pc->sort_order = $payload['sort_order']; } if (isset($payload['is_default'])) { $pc->is_default = $payload['is_default']; } $pc->updated_by = $userId; $pc->save(); return $pc->refresh(); } // 삭제 public function destroy(int $parentProductId, int $itemId): void { $tenantId = $this->tenantId(); $this->assertProduct($tenantId, $parentProductId); $pc = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->find($itemId); if (! $pc) { throw new BadRequestHttpException(__('error.not_found')); } $pc->delete(); } // 정렬 변경 public function reorder(int $parentProductId, array $items): void { $tenantId = $this->tenantId(); $this->assertProduct($tenantId, $parentProductId); if (! is_array($items)) { throw new BadRequestHttpException(__('error.invalid_payload')); } DB::transaction(function () use ($tenantId, $parentProductId, $items) { foreach ($items as $row) { if (! isset($row['id'], $row['sort_order'])) { continue; } ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->where('id', (int) $row['id']) ->update(['sort_order' => (int) $row['sort_order']]); } }); } // 요약(간단 합계/건수) public function summary(int $parentProductId): array { $tenantId = $this->tenantId(); $this->assertProduct($tenantId, $parentProductId); $items = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->get(); $cnt = $items->count(); $cntP = $items->where('ref_type', 'PRODUCT')->count(); $cntM = $items->where('ref_type', 'MATERIAL')->count(); $qtySum = (string) $items->sum('quantity'); return [ 'count' => $cnt, 'count_product' => $cntP, 'count_material' => $cntM, 'quantity_sum' => $qtySum, ]; } // 유효성 검사(중복/자기참조/음수 등) public function validateBom(int $parentProductId): array { $tenantId = $this->tenantId(); $this->assertProduct($tenantId, $parentProductId); $items = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $parentProductId) ->orderBy('sort_order') ->get(); $errors = []; $seen = []; foreach ($items as $row) { if ($row->quantity <= 0) { $errors[] = ['id' => $row->id, 'error' => 'INVALID_QUANTITY']; } $key = $row->ref_type.':'.($row->ref_type === 'PRODUCT' ? $row->child_product_id : $row->material_id); if (isset($seen[$key])) { $errors[] = ['id' => $row->id, 'error' => 'DUPLICATE_ITEM']; } else { $seen[$key] = true; } // 자기참조 if ($row->ref_type === 'PRODUCT' && (int) $row->child_product_id === (int) $parentProductId) { $errors[] = ['id' => $row->id, 'error' => 'SELF_REFERENCE']; } } return [ 'valid' => count($errors) === 0, 'errors' => $errors, ]; } // ---------- helpers ---------- private function validateItem(array $it): array { $v = Validator::make($it, [ 'id' => 'nullable|integer', 'ref_type' => 'required|in:PRODUCT,MATERIAL', 'ref_id' => 'required|integer', 'quantity' => 'required|numeric|min:0.0001', 'sort_order' => 'nullable|integer|min:0', 'is_default' => 'nullable|in:0,1', ]); return $v->validate(); } private function splitRef(array $payload): array { // returns [child_product_id, material_id] if ($payload['ref_type'] === 'PRODUCT') { return [(int) $payload['ref_id'], null]; } return [null, (int) $payload['ref_id']]; } private function assertProduct(int $tenantId, int $productId): void { $exists = Product::query()->where('tenant_id', $tenantId)->where('id', $productId)->exists(); if (! $exists) { // ko: 제품 정보를 찾을 수 없습니다. throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '제품'])); } } private function assertReference(int $tenantId, int $parentProductId, string $refType, int $refId): void { if ($refType === 'PRODUCT') { if ($refId === $parentProductId) { throw new BadRequestHttpException(__('error.invalid_payload')); // 자기참조 방지 } $ok = Product::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists(); if (! $ok) { throw new BadRequestHttpException(__('error.not_found')); } } else { $ok = Material::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists(); if (! $ok) { throw new BadRequestHttpException(__('error.not_found')); } } } /** * 특정 제품의 BOM을 전체 교체(기존 삭제 → 새 데이터 일괄 삽입) * - $productId: products.id * - $payload: ['categories' => [ {id?, name?, items: [{ref_type, ref_id, quantity, sort_order?}, ...]}, ... ]] * 반환: ['deleted_count' => int, 'inserted_count' => int] */ public function replaceBom(int $productId, array $payload): array { if ($productId <= 0) { throw new BadRequestHttpException(__('error.bad_request')); // 400 } $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 0) ====== 빈 카테고리 제거 ====== $rawCats = Arr::get($payload, 'categories', []); $normalized = []; foreach ((array) $rawCats as $cat) { $catId = Arr::get($cat, 'id'); $catName = Arr::get($cat, 'name'); $items = array_values(array_filter((array) Arr::get($cat, 'items', []), function ($it) { $type = Arr::get($it, 'ref_type'); $id = (int) Arr::get($it, 'ref_id'); $qty = Arr::get($it, 'quantity'); return in_array($type, ['MATERIAL', 'PRODUCT'], true) && $id > 0 && is_numeric($qty); })); if (count($items) === 0) { continue; // 아이템 없으면 skip } $normalized[] = [ 'id' => $catId, 'name' => $catName, 'items' => $items, ]; } // 🔕 전부 비었으면: 기존 BOM 전체 삭제 후 성공 if (count($normalized) === 0) { $deleted = ProductComponent::where('tenant_id', $tenantId) ->where('parent_product_id', $productId) ->delete(); return [ 'deleted_count' => $deleted, 'inserted_count' => 0, 'message' => '모든 BOM 항목이 비어 기존 데이터를 삭제했습니다.', ]; } // 1) ====== 검증 ====== $v = Validator::make( ['categories' => $normalized], [ 'categories' => ['required', 'array', 'min:1'], 'categories.*.id' => ['nullable', 'integer'], 'categories.*.name' => ['nullable', 'string', 'max:100'], 'categories.*.items' => ['required', 'array', 'min:1'], 'categories.*.items.*.ref_type' => ['required', 'in:MATERIAL,PRODUCT'], 'categories.*.items.*.ref_id' => ['required', 'integer', 'min:1'], 'categories.*.items.*.quantity' => ['required', 'numeric', 'min:0'], 'categories.*.items.*.sort_order' => ['nullable', 'integer', 'min:0'], ] ); if ($v->fails()) { throw new ValidationException($v, null, __('error.validation_failed')); } // 2) ====== 플랫 레코드 생성 (note 제거) ====== $rows = []; $now = now(); foreach ($normalized as $cat) { $catId = Arr::get($cat, 'id'); $catName = Arr::get($cat, 'name'); foreach ($cat['items'] as $idx => $item) { $rows[] = [ 'tenant_id' => $tenantId, 'parent_product_id' => $productId, 'category_id' => $catId, 'category_name' => $catName, 'ref_type' => $item['ref_type'], 'ref_id' => (int) $item['ref_id'], 'quantity' => (string) $item['quantity'], 'sort_order' => isset($item['sort_order']) ? (int) $item['sort_order'] : $idx, 'created_by' => $userId, 'updated_by' => $userId, 'created_at' => $now, 'updated_at' => $now, ]; } } // 3) ====== 트랜잭션: 기존 삭제 후 신규 삽입 ====== return DB::transaction(function () use ($tenantId, $productId, $rows) { $deleted = ProductComponent::where('tenant_id', $tenantId) ->where('parent_product_id', $productId) ->delete(); $inserted = 0; foreach (array_chunk($rows, 500) as $chunk) { $ok = ProductComponent::insert($chunk); $inserted += $ok ? count($chunk) : 0; } return [ 'deleted_count' => $deleted, 'inserted_count' => $inserted, 'message' => 'BOM 저장 성공', ]; }); } /** 제품별: 현재 BOM에 쓰인 카테고리 */ public function listCategoriesForProduct(int $productId): array { if ($productId <= 0) { throw new BadRequestHttpException(__('error.bad_request')); } $tenantId = $this->tenantId(); $rows = ProductComponent::query() ->where('tenant_id', $tenantId) ->where('parent_product_id', $productId) ->whereNotNull('category_name') ->select([ DB::raw('category_id'), DB::raw('category_name'), DB::raw('COUNT(*) as count'), ]) ->groupBy('category_id', 'category_name') ->orderByDesc('count') ->orderBy('category_name') ->get() ->toArray(); return $rows; } /** 테넌트 전역: 자주 쓰인 카테고리 추천(+검색) */ public function listCategoriesForTenant(?string $q, int $limit = 20): array { $tenantId = $this->tenantId(); $query = ProductComponent::query() ->where('tenant_id', $tenantId) ->whereNotNull('category_name') ->select([ DB::raw('category_id'), DB::raw('category_name'), DB::raw('COUNT(*) as count'), ]) ->groupBy('category_id', 'category_name'); if ($q) { $query->havingRaw('category_name LIKE ?', ["%{$q}%"]); } $rows = $query ->orderByDesc('count') ->orderBy('category_name') ->limit($limit > 0 ? $limit : 20) ->get() ->toArray(); return $rows; } public function tree($request, int $productId): array { $depth = (int) $request->query('depth', config('products.default_tree_depth', 10)); $resolver = app(ProductComponentResolver::class); // 트리 배열만 반환 (ApiResponse가 바깥에서 래핑) return $resolver->resolveTree($productId, $depth); } }