fix : BOM구성 API, DB 작업

- product_components 컬럼 변경
- BOM 구성, 카테고리리스트, BOM트리(재귀)호출 API 개발
This commit is contained in:
2025-08-29 16:22:05 +09:00
parent 028af8fbfa
commit 622c4905fa
10 changed files with 973 additions and 37 deletions

View File

@@ -68,4 +68,47 @@ public function validateBom(int $id)
return $this->service->validateBom($id); return $this->service->validateBom($id);
}, 'BOM 유효성 검사'); }, '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')
);
}
} }

View File

@@ -38,8 +38,16 @@ class Product extends Model
public function category() { return $this->belongsTo(Category::class, 'category_id'); } public function category() { return $this->belongsTo(Category::class, 'category_id'); }
// BOM (자기참조) — 라인 모델 경유 // BOM (자기참조) — 라인 모델 경유
public function componentLines() { return $this->hasMany(ProductComponent::class, 'parent_product_id'); } // 라인들 public function componentLines()
public function parentLines() { return $this->hasMany(ProductComponent::class, 'child_product_id'); } // 나를 쓰는 상위 라인들 {
return $this->hasMany(ProductComponent::class, 'parent_product_id')->orderBy('sort_order');
}
// 라인들
public function parentLines()
{
return $this->hasMany(ProductComponent::class, 'child_product_id');
} // 나를 쓰는 상위 라인들
// 편의: 직접 children/parents 제품에 접근 // 편의: 직접 children/parents 제품에 접근
public function children() public function children()

View File

@@ -2,6 +2,7 @@
namespace App\Models\Products; namespace App\Models\Products;
use App\Models\Materials\Material;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\ModelTrait; use App\Traits\ModelTrait;
@@ -16,12 +17,12 @@ class ProductComponent extends Model
protected $fillable = [ protected $fillable = [
'tenant_id', 'tenant_id',
'parent_product_id', 'parent_product_id',
'category_id',
'category_name',
'ref_type', 'ref_type',
'child_product_id', 'ref_id',
'material_id',
'quantity', 'quantity',
'sort_order', 'sort_order',
'is_default',
'created_by', 'created_by',
'updated_by', 'updated_by',
]; ];
@@ -60,7 +61,7 @@ public function childProduct()
*/ */
public function material() public function material()
{ {
return $this->belongsTo(\App\Models\Materials\Material::class, 'material_id'); return $this->belongsTo(Material::class, 'material_id');
} }
// --------------------------------------------------- // ---------------------------------------------------

View File

@@ -5,8 +5,11 @@
use App\Models\Materials\Material; use App\Models\Materials\Material;
use App\Models\Products\Product; use App\Models\Products\Product;
use App\Models\Products\ProductComponent; use App\Models\Products\ProductComponent;
use App\Services\Products\ProductComponentResolver;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 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')); 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);
}
} }

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Services\Products;
use App\Models\Products\Product;
use App\Models\Products\ProductComponent;
use App\Services\Service;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ProductComponentResolver extends Service
{
public function __construct(
protected ?int $tenantId = null
) {
// 주입값 없으면 Service::tenantId() 사용 (app('tenant_id')에서 끌어옴)
$this->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;
}
}

View File

@@ -2,43 +2,144 @@
namespace App\Swagger\v1; namespace App\Swagger\v1;
use OpenApi\Annotations as OA;
/** /**
* @OA\Tag(name="Product", description="제품 카테고리/검색(간단)") * @OA\Tag(name="Product", description="제품 카테고리/검색(간단)")
* @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD") * @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD")
* @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리") * @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리")
*/ *
* ========= 공용 스키마(이 파일에서 사용하는 것만 정의) =========
*
/** * 트리 노드 스키마 (재귀)
* 카테고리 목록 조회 (기존) * @OA\Schema(
* @OA\Get( * schema="BomTreeNode",
* path="/api/v1/product/category", * type="object",
* summary="제품 카테고리 목록 조회", * description="BOM 트리 한 노드(제품/자재 공통)",
* description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.", * @OA\Property(property="id", type="integer", example=1),
* tags={"Products"}, * @OA\Property(property="type", type="string", example="PRODUCT", description="PRODUCT / MATERIAL 등"),
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}}, * @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Response( * @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* response=200, * @OA\Property(property="unit", type="string", nullable=true, example="SET"),
* description="카테고리 목록 조회 성공", * @OA\Property(property="quantity", type="number", format="float", example=1),
* @OA\JsonContent( * @OA\Property(property="sort_order", type="integer", example=0),
* allOf={ * @OA\Property(
* @OA\Schema(ref="#/components/schemas/ApiResponse"), * property="category",
* @OA\Schema( * type="object",
* @OA\Property( * nullable=true,
* property="data", * @OA\Property(property="id", type="integer", nullable=true, example=2),
* type="array", * @OA\Property(property="name", type="string", nullable=true, example="본체")
* @OA\Items(ref="#/components/schemas/ProductCategory") * ),
* ) * @OA\Property(
* ) * property="children",
* } * type="array",
* ) * description="자식 노드 목록(없으면 빈 배열)",
* ), * @OA\Items(ref="#/components/schemas/BomTreeNode")
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), * )
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * )
*
* 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 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( * @OA\Get(
@@ -64,7 +165,6 @@ class ProductApi
*/ */
public function productsIndex() {} public function productsIndex() {}
/** /**
* 제품 생성 * 제품 생성
* @OA\Post( * @OA\Post(
@@ -85,7 +185,6 @@ public function productsIndex() {}
*/ */
public function productsStore() {} public function productsStore() {}
/** /**
* 제품 단건 * 제품 단건
* @OA\Get( * @OA\Get(
@@ -355,4 +454,192 @@ public function bomSummary() {}
* ) * )
*/ */
public function bomValidate() {} 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() {}
} }

14
config/products.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
return [
// 기본값: PRODUCT 만 "더 내려갈 수 있는" 타입으로 간주
'product_like_types' => [
// 전체 기본
'*' => ['PRODUCT'],
// 테넌트 별 오버라이드 예시
// '1' => ['PRODUCT', 'MODULE'],
// '42' => ['PRODUCT', 'ASSEMBLY', 'KIT'],
],
// 재귀 기본 최대 깊이
'default_tree_depth' => 10,
];

View File

@@ -0,0 +1,196 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
// 1) 기존 제약/인덱스 해제 (있으면 제거)
$dropIndexes = [
'uq_component_row',
'product_components_tenant_id_child_product_id_index',
'product_components_tenant_id_parent_product_id_index',
];
foreach ($dropIndexes as $idx) {
try { DB::statement("ALTER TABLE `product_components` DROP INDEX `$idx`"); } catch (\Throwable $e) {}
}
$dropFks = [
'product_components_child_product_id_foreign',
'product_components_material_id_foreign',
'product_components_parent_product_id_foreign',
];
foreach ($dropFks as $fk) {
try { DB::statement("ALTER TABLE `product_components` DROP FOREIGN KEY `$fk`"); } catch (\Throwable $e) {}
}
// 2) 데이터 손실 허용 → TRUNCATE 로 비우고 진행
try { DB::statement("TRUNCATE TABLE `product_components`"); } catch (\Throwable $e) {}
// 3) 컬럼 추가/수정
Schema::table('product_components', function (Blueprint $table) {
// 프론트 카테고리 메타(선택 저장)
if (!Schema::hasColumn('product_components', 'category_id')) {
$table->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) {}
}
};

View File

@@ -38,6 +38,11 @@
'fetched' => 'BOM 항목을 조회했습니다.', 'fetched' => 'BOM 항목을 조회했습니다.',
'bulk_upsert' => 'BOM 항목이 저장되었습니다.', 'bulk_upsert' => 'BOM 항목이 저장되었습니다.',
'reordered' => 'BOM 정렬이 변경되었습니다.', 'reordered' => 'BOM 정렬이 변경되었습니다.',
'fetch' => 'BOM 항목 조회',
'creat' => 'BOM 항목 등록',
'update' => 'BOM 항목 수정',
'delete' => 'BOM 항목 삭제',
'restore' => 'BOM 항목 복구',
], ],
'category' => [ 'category' => [

View File

@@ -307,10 +307,16 @@
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글 // (선택) 드롭다운/모달용 간편 검색 & 활성 토글
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search'); Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle'); 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) // BOM (product_components: ref_type=PRODUCT|MATERIAL)
Route::prefix('products/{id}/bom')->group(function () { 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::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::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'); // 단건 수정 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 ('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary');
Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate'); Route::get ('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate');
Route::get('/tree', [ProductBomItemController::class, 'tree']);
}); });
}); });