diff --git a/app/Http/Controllers/Api/V1/ProductBomItemController.php b/app/Http/Controllers/Api/V1/ProductBomItemController.php index 33308d3..ddb1107 100644 --- a/app/Http/Controllers/Api/V1/ProductBomItemController.php +++ b/app/Http/Controllers/Api/V1/ProductBomItemController.php @@ -68,4 +68,47 @@ public function validateBom(int $id) return $this->service->validateBom($id); }, 'BOM 유효성 검사'); } + + /** + * POST /api/v1/products/{id}/bom + * BOM 구성 저장 (기존 전체 삭제 후 재등록) + */ + public function replace(Request $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + // 서비스에서 트랜잭션 처리 + 예외는 글로벌 핸들러로 + return $this->service->replaceBom($id, $request->all()); + }, __('message.bom.creat')); + } + + + /** 특정 제품 BOM에서 사용 중인 카테고리 목록 */ + public function listCategories(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->listCategoriesForProduct($id); + }, __('message.bom.fetch')); + } + + /** 테넌트 전역 카테고리 추천(히스토리) */ + public function suggestCategories(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $q = $request->query('q'); + $limit = (int)($request->query('limit', 20)); + return $this->service->listCategoriesForTenant($q, $limit); + }, __('message.bom.fetch')); + } + + + + /** Bom Tree */ + public function tree(Request $request, int $id) + { + return ApiResponse::handle( + function () use ($request, $id) { + return $this->service->tree($request, $id); + }, __('message.bom.fetch') + ); + } } diff --git a/app/Models/Products/Product.php b/app/Models/Products/Product.php index df10f73..daafe17 100644 --- a/app/Models/Products/Product.php +++ b/app/Models/Products/Product.php @@ -38,8 +38,16 @@ class Product extends Model public function category() { return $this->belongsTo(Category::class, 'category_id'); } // BOM (자기참조) — 라인 모델 경유 - public function componentLines() { return $this->hasMany(ProductComponent::class, 'parent_product_id'); } // 라인들 - public function parentLines() { return $this->hasMany(ProductComponent::class, 'child_product_id'); } // 나를 쓰는 상위 라인들 + public function componentLines() + { + return $this->hasMany(ProductComponent::class, 'parent_product_id')->orderBy('sort_order'); + } + + // 라인들 + public function parentLines() + { + return $this->hasMany(ProductComponent::class, 'child_product_id'); + } // 나를 쓰는 상위 라인들 // 편의: 직접 children/parents 제품에 접근 public function children() diff --git a/app/Models/Products/ProductComponent.php b/app/Models/Products/ProductComponent.php index 9fab41b..00ccbc2 100644 --- a/app/Models/Products/ProductComponent.php +++ b/app/Models/Products/ProductComponent.php @@ -2,6 +2,7 @@ namespace App\Models\Products; +use App\Models\Materials\Material; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use App\Traits\ModelTrait; @@ -16,12 +17,12 @@ class ProductComponent extends Model protected $fillable = [ 'tenant_id', 'parent_product_id', + 'category_id', + 'category_name', 'ref_type', - 'child_product_id', - 'material_id', + 'ref_id', 'quantity', 'sort_order', - 'is_default', 'created_by', 'updated_by', ]; @@ -60,7 +61,7 @@ public function childProduct() */ public function material() { - return $this->belongsTo(\App\Models\Materials\Material::class, 'material_id'); + return $this->belongsTo(Material::class, 'material_id'); } // --------------------------------------------------- diff --git a/app/Services/ProductBomService.php b/app/Services/ProductBomService.php index 8dfdafd..4b7c0a0 100644 --- a/app/Services/ProductBomService.php +++ b/app/Services/ProductBomService.php @@ -5,8 +5,11 @@ use App\Models\Materials\Material; use App\Models\Products\Product; use App\Models\Products\ProductComponent; +use App\Services\Products\ProductComponentResolver; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Arr; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -332,4 +335,188 @@ private function assertReference(int $tenantId, int $parentProductId, string $re 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); + } } diff --git a/app/Services/Products/ProductComponentResolver.php b/app/Services/Products/ProductComponentResolver.php new file mode 100644 index 0000000..e55b0aa --- /dev/null +++ b/app/Services/Products/ProductComponentResolver.php @@ -0,0 +1,187 @@ +tenantId = $this->tenantId ?? $this->tenantId(); + } + + /** 테넌트별로 "제품처럼 자식이 있을 수 있는" ref_type 목록 */ + protected function productLikeTypes(): array + { + $all = config('products.product_like_types', []); + $byTenant = Arr::get($all, (string)$this->tenantId, null); + + if (is_array($byTenant) && $byTenant) return $byTenant; + return Arr::get($all, '*', ['PRODUCT']); + } + + /** 한 부모 ID에 달린 컴포넌트 라인들 로드 (메모이즈) */ + protected function getLinesForParent(int $parentId): array + { + // 간단 메모이즈(요청 범위); 대량 호출 방지 + static $memo = []; + if (array_key_exists($parentId, $memo)) { + return $memo[$parentId]; + } + + $rows = ProductComponent::where('tenant_id', $this->tenantId) + ->where('parent_product_id', $parentId) + ->orderBy('sort_order')->orderBy('id') + ->get([ + 'id','tenant_id','parent_product_id', + 'category_id','category_name', + 'ref_type','ref_id','quantity','sort_order', + ]) + ->map(fn($r) => $r->getAttributes()) // ✅ 핵심 수정 + ->all(); + + return $memo[$parentId] = $rows; + } + + /** ref_type/ref_id 에 해당하는 노드의 "표시용 정보"를 로드 */ + protected function resolveNodeInfo(string $refType, int $refId): array + { + if ($refType === 'PRODUCT') { + $p = Product::query() + ->where('tenant_id', $this->tenantId) + ->find($refId, ['id','code','name','product_type','category_id']); + if (!$p) return ['id'=>$refId, 'code'=>null, 'name'=>null, 'product_type'=>null, 'category_id'=>null]; + return [ + 'id' => $p->id, + 'code' => $p->code, + 'name' => $p->name, + 'product_type' => $p->product_type, + 'category_id' => $p->category_id, + ]; + } + + // MATERIAL 등 다른 타입은 여기서 분기 추가 + // if ($refType === 'MATERIAL') { + // $m = DB::table('materials') + // ->where('tenant_id', $this->tenantId) + // ->where('id', $refId) + // ->first(['id','code','name','unit']); + // return $m ? ['id'=>$m->id,'code'=>$m->code,'name'=>$m->name,'unit'=>$m->unit] : ['id'=>$refId,'code'=>null,'name'=>null]; + // } + + return ['id'=>$refId, 'code'=>null, 'name'=>null]; + } + + /** + * 단일 제품을 루트로 트리를 생성 (재귀 / 사이클 방지 / 깊이 제한) + * + * @param int $productId 루트 제품 ID + * @param int|null $maxDepth 최대 깊이(루트=0). null 이면 config default + * @return array 트리 구조 + */ + public function resolveTree(int $productId, ?int $maxDepth = null): array + { + $maxDepth = $maxDepth ?? (int)config('products.default_tree_depth', 10); + + $root = Product::query() + ->where('tenant_id', $this->tenantId) + ->findOrFail($productId, ['id','code','name','product_type','category_id']); + + $visited = []; // 사이클 방지용 (product id 기준) + + $node = [ + 'type' => 'PRODUCT', + 'id' => $root->id, + 'code' => $root->code, + 'name' => $root->name, + 'product_type' => $root->product_type, + 'category_id' => $root->category_id, + 'quantity' => 1, // 루트는 수량 1로 간주 + 'category' => null, // 루트는 임의 + 'children' => [], + 'depth' => 0, + ]; + + $node['children'] = $this->resolveChildren($root->id, 0, $maxDepth, $visited); + + return $node; + } + + /** + * 하위 노드(들) 재귀 확장 + * @param int $parentId + * @param int $depth + * @param int $maxDepth + * @param array $visited product-id 기준 사이클 방지 + * @return array + */ + protected function resolveChildren(int $parentId, int $depth, int $maxDepth, array &$visited): array + { + // 깊이 제한 + if ($depth >= $maxDepth) return []; + + $lines = $this->getLinesForParent($parentId); + if (!$lines) return []; + + $productLike = $this->productLikeTypes(); + $children = []; + + foreach ($lines as $line) { + $refType = (string)$line['ref_type']; + $refId = (int)$line['ref_id']; + $qty = (float)$line['quantity']; + + if (!$refType || $refId <= 0) { + // 로그 남기고 스킵 + // logger()->warning('Invalid component line', ['line' => $line]); + continue; + } + + $info = $this->resolveNodeInfo($refType, $refId); + + $child = [ + 'type' => $refType, + 'id' => $info['id'] ?? $refId, + 'code' => $info['code'] ?? null, + 'name' => $info['name'] ?? null, + 'product_type'=> $info['product_type'] ?? null, + 'category_id' => $info['category_id'] ?? null, + 'quantity' => $qty, + 'category' => [ + 'id' => $line['category_id'], + 'name' => $line['category_name'], + ], + 'sort_order' => (int)$line['sort_order'], + 'children' => [], + 'depth' => $depth + 1, + ]; + + // 제품처럼 자식이 달릴 수 있는 타입이면 재귀 + if (in_array($refType, $productLike, true)) { + // 사이클 방지: 같은 product id 재방문 금지 + $pid = (int)$child['id']; + if ($pid > 0) { + if (isset($visited[$pid])) { + $child['cycle'] = true; // 표식만 남기고 children 안탐 + } else { + $visited[$pid] = true; + $child['children'] = $this->resolveChildren($pid, $depth + 1, $maxDepth, $visited); + unset($visited[$pid]); // 백트래킹 + } + } + } + + $children[] = $child; + } + + return $children; + } +} diff --git a/app/Swagger/v1/ProductApi.php b/app/Swagger/v1/ProductApi.php index 33f2ab2..7f2bded 100644 --- a/app/Swagger/v1/ProductApi.php +++ b/app/Swagger/v1/ProductApi.php @@ -2,43 +2,144 @@ namespace App\Swagger\v1; +use OpenApi\Annotations as OA; + /** * @OA\Tag(name="Product", description="제품 카테고리/검색(간단)") * @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD") * @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리") - */ - - -/** - * 카테고리 목록 조회 (기존) - * @OA\Get( - * path="/api/v1/product/category", - * summary="제품 카테고리 목록 조회", - * description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.", - * tags={"Products"}, - * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, - * @OA\Response( - * response=200, - * description="카테고리 목록 조회 성공", - * @OA\JsonContent( - * allOf={ - * @OA\Schema(ref="#/components/schemas/ApiResponse"), - * @OA\Schema( - * @OA\Property( - * property="data", - * type="array", - * @OA\Items(ref="#/components/schemas/ProductCategory") - * ) - * ) - * } - * ) - * ), - * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), - * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * + * ========= 공용 스키마(이 파일에서 사용하는 것만 정의) ========= + * + * 트리 노드 스키마 (재귀) + * @OA\Schema( + * schema="BomTreeNode", + * type="object", + * description="BOM 트리 한 노드(제품/자재 공통)", + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="type", type="string", example="PRODUCT", description="PRODUCT / MATERIAL 등"), + * @OA\Property(property="code", type="string", example="PRD-001"), + * @OA\Property(property="name", type="string", example="스크린 모듈 KS001"), + * @OA\Property(property="unit", type="string", nullable=true, example="SET"), + * @OA\Property(property="quantity", type="number", format="float", example=1), + * @OA\Property(property="sort_order", type="integer", example=0), + * @OA\Property( + * property="category", + * type="object", + * nullable=true, + * @OA\Property(property="id", type="integer", nullable=true, example=2), + * @OA\Property(property="name", type="string", nullable=true, example="본체") + * ), + * @OA\Property( + * property="children", + * type="array", + * description="자식 노드 목록(없으면 빈 배열)", + * @OA\Items(ref="#/components/schemas/BomTreeNode") + * ) + * ) + * + * BOM 카테고리 사용/추천 항목 + * @OA\Schema( + * schema="BomCategoryStat", + * type="object", + * @OA\Property(property="category_id", type="integer", nullable=true, example=1), + * @OA\Property(property="category_name", type="string", example="기본"), + * @OA\Property(property="count", type="integer", example=5, description="해당 카테고리를 사용하는 BOM 항목 수(빈도)") + * ) + * + * BOM 전체 교체 저장용 스키마 + * @OA\Schema( + * schema="BomReplaceItem", + * type="object", + * required={"ref_type","ref_id","quantity"}, + * @OA\Property(property="ref_type", type="string", enum={"MATERIAL","PRODUCT"}, example="MATERIAL", description="참조 타입"), + * @OA\Property(property="ref_id", type="integer", example=201, description="참조 ID (materials.id 또는 products.id)"), + * @OA\Property(property="quantity", type="number", format="float", example=2, description="수량(0 이상, 소수 허용)"), + * @OA\Property(property="sort_order", type="integer", nullable=true, example=0, description="정렬 순서(선택)") + * ) + * + * @OA\Schema( + * schema="BomReplaceCategory", + * type="object", + * required={"items"}, + * @OA\Property(property="id", type="integer", nullable=true, example=1, description="프론트 임시 카테고리 ID(선택)"), + * @OA\Property(property="name", type="string", nullable=true, example="기본", description="프론트 카테고리명(선택)"), + * @OA\Property( + * property="items", + * type="array", + * @OA\Items(ref="#/components/schemas/BomReplaceItem") + * ) + * ) + * + * @OA\Schema( + * schema="BomReplaceRequest", + * type="object", + * required={"categories"}, + * @OA\Property( + * property="categories", + * type="array", + * @OA\Items(ref="#/components/schemas/BomReplaceCategory") + * ), + * example={ + * "categories": { + * { + * "id": 1, + * "name": "기본", + * "items": { + * { "ref_type": "MATERIAL", "ref_id": 201, "quantity": 2, "sort_order": 0 }, + * { "ref_type": "PRODUCT", "ref_id": 301, "quantity": 1 } + * } + * }, + * { + * "id": 2, + * "name": "옵션", + * "items": { + * { "ref_type": "MATERIAL", "ref_id": 202, "quantity": 5 } + * } + * } + * } + * } + * ) + * + * @OA\Schema( + * schema="BomReplaceResult", + * type="object", + * @OA\Property(property="deleted_count", type="integer", example=5, description="삭제된 기존 항목 수"), + * @OA\Property(property="inserted_count", type="integer", example=7, description="신규로 삽입된 항목 수") * ) */ class ProductApi { + /** + * 카테고리 목록 조회 (기존) + * @OA\Get( + * path="/api/v1/product/category", + * summary="제품 카테고리 목록 조회", + * description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.", + * tags={"Products"}, + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * @OA\Response( + * response=200, + * description="카테고리 목록 조회 성공", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/ProductCategory") + * ) + * ) + * } + * ) + * ), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function productCategoryIndex() {} + /** * 제품 목록/검색 * @OA\Get( @@ -64,7 +165,6 @@ class ProductApi */ public function productsIndex() {} - /** * 제품 생성 * @OA\Post( @@ -85,7 +185,6 @@ public function productsIndex() {} */ public function productsStore() {} - /** * 제품 단건 * @OA\Get( @@ -355,4 +454,192 @@ public function bomSummary() {} * ) */ public function bomValidate() {} + + /** + * BOM 전체 교체 저장 + * 프론트에서 보낸 현재 BOM 상태로 기존 구성을 모두 삭제 후 재등록합니다. + * @OA\Post( + * path="/api/v1/products/{id}/bom", + * tags={"Products-BOM"}, + * summary="BOM 구성 저장(구성 전체 교체)", + * description="기존 BOM을 모두 삭제하고, 전달된 categories/items 기준으로 다시 저장합니다.", + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="상위(모델) 제품 ID", + * @OA\Schema(type="integer", example=123) + * ), + * @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomReplaceRequest")), + * @OA\Response( + * response=200, + * description="저장 성공", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property(property="message", type="string", example="BOM 항목이 저장되었습니다."), + * @OA\Property(property="data", ref="#/components/schemas/BomReplaceResult") + * ) + * } + * ) + * ), + * @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function bomReplace() {} + + /** + * 제품별 사용 중인 BOM 카테고리 목록 + * @OA\Get( + * path="/api/v1/products/{id}/bom/categories", + * tags={"Products-BOM"}, + * summary="해당 제품에서 사용 중인 BOM 카테고리 목록", + * description="product_components 테이블에서 해당 제품(parent_product_id)의 카테고리(id/name)를 집계하여 반환합니다.", + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="상위(모델) 제품 ID", + * @OA\Schema(type="integer", example=123) + * ), + * @OA\Response( + * response=200, + * description="조회 성공", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/BomCategoryStat") + * ) + * ) + * }, + * example={ + * "success": true, + * "message": "조회 성공", + * "data": { + * {"category_id":1, "category_name":"기본", "count":5}, + * {"category_id":2, "category_name":"옵션", "count":3} + * } + * } + * ) + * ), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function bomCategoriesForProduct() {} + + /** + * 테넌트 전역 카테고리 추천(히스토리 기반) + * @OA\Get( + * path="/api/v1/products/bom/categories", + * tags={"Products-BOM"}, + * summary="자주 사용된 BOM 카테고리 추천", + * description="테넌트 전체 product_components 데이터를 집계해 카테고리 사용 빈도가 높은 순으로 반환합니다. q로 부분 검색 가능합니다.", + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * @OA\Parameter( + * name="q", + * in="query", + * required=false, + * description="카테고리명 부분 검색", + * @OA\Schema(type="string", example="기") + * ), + * @OA\Parameter( + * name="limit", + * in="query", + * required=false, + * description="최대 항목 수(기본 20)", + * @OA\Schema(type="integer", example=20) + * ), + * @OA\Response( + * response=200, + * description="조회 성공", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/BomCategoryStat") + * ) + * ) + * }, + * example={ + * "success": true, + * "message": "조회 성공", + * "data": { + * {"category_id":1, "category_name":"기본", "count":127}, + * {"category_id":2, "category_name":"옵션", "count":88}, + * {"category_id":null, "category_name":"패키지", "count":12} + * } + * } + * ) + * ), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function bomCategoriesSuggest() {} + + /** + * 제품 BOM 트리 조회 + * @OA\Get( + * path="/api/v1/products/{id}/bom/tree", + * tags={"Products-BOM"}, + * summary="제품 BOM 트리 조회(재귀)", + * description="특정 제품의 하위 구성(제품/자재)을 재귀적으로 트리 형태로 반환합니다. depth로 최대 깊이를 제한합니다(기본 10).", + * security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", minimum=1), description="제품 ID"), + * @OA\Parameter( + * name="depth", + * in="query", + * required=false, + * description="재귀 깊이(루트=0). 기본 10", + * @OA\Schema(type="integer", minimum=0, maximum=50, default=10, example=5) + * ), + * @OA\Response( + * response=200, + * description="조회 성공", + * @OA\JsonContent( + * allOf={ + * @OA\Schema(ref="#/components/schemas/ApiResponse"), + * @OA\Schema( + * @OA\Property( + * property="data", + * type="object", + * @OA\Property( + * property="product", + * type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="code", type="string"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="unit", type="string", nullable=true), + * @OA\Property(property="category_id", type="integer", nullable=true), + * @OA\Property(property="product_type", type="string") + * ), + * @OA\Property(property="tree", ref="#/components/schemas/BomTreeNode") + * ) + * ) + * } + * ) + * ), + * @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="대상 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) + * ) + */ + public function bomTree() {} } diff --git a/config/products.php b/config/products.php new file mode 100644 index 0000000..e16428a --- /dev/null +++ b/config/products.php @@ -0,0 +1,14 @@ + [ + // 전체 기본 + '*' => ['PRODUCT'], + + // 테넌트 별 오버라이드 예시 + // '1' => ['PRODUCT', 'MODULE'], + // '42' => ['PRODUCT', 'ASSEMBLY', 'KIT'], + ], + // 재귀 기본 최대 깊이 + 'default_tree_depth' => 10, +]; diff --git a/database/migrations/2025_08_28_000100_alter_product_components_unify_ref_columns.php b/database/migrations/2025_08_28_000100_alter_product_components_unify_ref_columns.php new file mode 100644 index 0000000..7acd5e5 --- /dev/null +++ b/database/migrations/2025_08_28_000100_alter_product_components_unify_ref_columns.php @@ -0,0 +1,196 @@ +unsignedBigInteger('category_id')->nullable()->after('parent_product_id') + ->comment('프론트 카테고리 ID(선택)'); + } + if (!Schema::hasColumn('product_components', 'category_name')) { + $table->string('category_name', 100)->nullable()->after('category_id') + ->comment('프론트 카테고리명(선택)'); + } + + // 통합 참조키 ref_id 추가 + if (!Schema::hasColumn('product_components', 'ref_id')) { + $table->unsignedBigInteger('ref_id')->nullable()->after('ref_type') + ->comment('참조 ID (materials.id 또는 products.id)'); + } + }); + + // ref_type: ENUM → VARCHAR(20) + DB::statement(" + ALTER TABLE `product_components` + MODIFY COLUMN `ref_type` VARCHAR(20) NOT NULL + COMMENT '참조 타입: MATERIAL | PRODUCT' + "); + + // quantity 정밀도 확장 + DB::statement(" + ALTER TABLE `product_components` + MODIFY COLUMN `quantity` DECIMAL(18,6) NOT NULL DEFAULT 0 + COMMENT '수량(소수 허용, 0 이상)' + "); + + // ref_id NOT NULL 전환 (TRUNCATE 했으므로 바로 가능) + DB::statement(" + ALTER TABLE `product_components` + MODIFY COLUMN `ref_id` BIGINT UNSIGNED NOT NULL + COMMENT '참조 ID (materials.id 또는 products.id)' + "); + + // 불필요 컬럼 제거 + Schema::table('product_components', function (Blueprint $table) { + if (Schema::hasColumn('product_components', 'child_product_id')) { + $table->dropColumn('child_product_id'); + } + if (Schema::hasColumn('product_components', 'material_id')) { + $table->dropColumn('material_id'); + } + if (Schema::hasColumn('product_components', 'is_default')) { + $table->dropColumn('is_default'); + } + }); + + // 4) 인덱스 재구성 (FK 최소화 정책, 조회 성능 중심) + Schema::table('product_components', function (Blueprint $table) { + $table->index(['tenant_id', 'parent_product_id'], 'idx_tenant_parent'); + $table->index(['tenant_id', 'ref_type', 'ref_id'], 'idx_tenant_ref'); + $table->index(['tenant_id', 'category_id'], 'idx_tenant_category'); + $table->index(['tenant_id', 'sort_order'], 'idx_tenant_sort'); + }); + } + + public function down(): void + { + // 인덱스 제거 + foreach (['idx_tenant_parent','idx_tenant_ref','idx_tenant_category','idx_tenant_sort'] as $idx) { + try { DB::statement("ALTER TABLE `product_components` DROP INDEX `$idx`"); } catch (\Throwable $e) {} + } + + // 데이터 손실 허용: TRUNCATE 후 원형에 가깝게 복원 + try { DB::statement("TRUNCATE TABLE `product_components`"); } catch (\Throwable $e) {} + + // 컬럼 복원 + Schema::table('product_components', function (Blueprint $table) { + // child_product_id, material_id, is_default 복원 + if (!Schema::hasColumn('product_components', 'child_product_id')) { + $table->unsignedBigInteger('child_product_id')->nullable()->after('ref_type')->comment('하위 제품/부품 ID'); + } + if (!Schema::hasColumn('product_components', 'material_id')) { + $table->unsignedBigInteger('material_id')->nullable()->after('child_product_id')->comment('자재 ID'); + } + if (!Schema::hasColumn('product_components', 'is_default')) { + $table->tinyInteger('is_default')->default(0)->after('sort_order')->comment('기본 BOM 여부(1/0)'); + } + }); + + // ref_type: VARCHAR → ENUM + DB::statement(" + ALTER TABLE `product_components` + MODIFY COLUMN `ref_type` ENUM('PRODUCT','MATERIAL') NOT NULL DEFAULT 'PRODUCT' + COMMENT '참조 대상 타입(PRODUCT=제품, MATERIAL=자재)' + "); + + // quantity 정밀도 원복 + DB::statement(" + ALTER TABLE `product_components` + MODIFY COLUMN `quantity` DECIMAL(18,4) NOT NULL DEFAULT 1.0000 + "); + + // ref_id 제거 + if (Schema::hasColumn('product_components', 'ref_id')) { + Schema::table('product_components', function (Blueprint $table) { + $table->dropColumn('ref_id'); + }); + } + + // category 메타 제거 + Schema::table('product_components', function (Blueprint $table) { + if (Schema::hasColumn('product_components', 'category_name')) { + $table->dropColumn('category_name'); + } + if (Schema::hasColumn('product_components', 'category_id')) { + $table->dropColumn('category_id'); + } + }); + + // 원래 인덱스/제약 복원 (FK 최소화 정책이지만 down에서는 원형 회귀) + try { + DB::statement(" + ALTER TABLE `product_components` + ADD CONSTRAINT `uq_component_row` + UNIQUE (`tenant_id`,`parent_product_id`,`ref_type`,`child_product_id`,`material_id`,`sort_order`) + "); + } catch (\Throwable $e) {} + + try { + DB::statement(" + ALTER TABLE `product_components` + ADD CONSTRAINT `product_components_child_product_id_foreign` + FOREIGN KEY (`child_product_id`) REFERENCES `products`(`id`) + "); + } catch (\Throwable $e) {} + + try { + DB::statement(" + ALTER TABLE `product_components` + ADD CONSTRAINT `product_components_material_id_foreign` + FOREIGN KEY (`material_id`) REFERENCES `materials`(`id`) ON DELETE SET NULL + "); + } catch (\Throwable $e) {} + + try { + DB::statement(" + ALTER TABLE `product_components` + ADD CONSTRAINT `product_components_parent_product_id_foreign` + FOREIGN KEY (`parent_product_id`) REFERENCES `products`(`id`) ON DELETE CASCADE + "); + } catch (\Throwable $e) {} + + try { + DB::statement(" + CREATE INDEX `product_components_tenant_id_child_product_id_index` + ON `product_components`(`tenant_id`,`child_product_id`) + "); + } catch (\Throwable $e) {} + + try { + DB::statement(" + CREATE INDEX `product_components_tenant_id_parent_product_id_index` + ON `product_components`(`tenant_id`,`parent_product_id`) + "); + } catch (\Throwable $e) {} + } +}; diff --git a/lang/ko/message.php b/lang/ko/message.php index 4718375..fe56e7a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -38,6 +38,11 @@ 'fetched' => 'BOM 항목을 조회했습니다.', 'bulk_upsert' => 'BOM 항목이 저장되었습니다.', 'reordered' => 'BOM 정렬이 변경되었습니다.', + 'fetch' => 'BOM 항목 조회', + 'creat' => 'BOM 항목 등록', + 'update' => 'BOM 항목 수정', + 'delete' => 'BOM 항목 삭제', + 'restore' => 'BOM 항목 복구', ], 'category' => [ diff --git a/routes/api.php b/routes/api.php index 1a49971..cd8e2f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -307,10 +307,16 @@ // (선택) 드롭다운/모달용 간편 검색 & 활성 토글 Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search'); Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); + + // BOM 카테고리 + Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천 + Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중 }); // BOM (product_components: ref_type=PRODUCT|MATERIAL) Route::prefix('products/{id}/bom')->group(function () { + Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace'); + Route::get ('/items', [ProductBomItemController::class, 'index'])->name('v1.products.bom.items.index'); // 조회(제품+자재 병합) Route::post ('/items/bulk', [ProductBomItemController::class, 'bulkUpsert'])->name('v1.products.bom.items.bulk'); // 대량 업서트 Route::patch ('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정 @@ -320,6 +326,8 @@ // (선택) 합계/검증 Route::get ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary'); Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); + + Route::get('/tree', [ProductBomItemController::class, 'tree']); }); });