feat: Items API CRUD 기능 추가 (BP-MES Phase 1 Day 3-5)

- ItemsController 및 ItemsService CRUD 메서드 구현
- FormRequest 검증 클래스 추가 (ItemStoreRequest, ItemUpdateRequest)
- Swagger 문서 완성 (ItemsApi.php)
- 품목 생성/조회/수정/삭제 엔드포인트 추가
- i18n 메시지 키 추가 (message.item)
- Code 기반 라우팅 적용
- Hybrid 구조 지원 (고정 필드 + attributes JSON)
This commit is contained in:
2025-11-17 11:22:49 +09:00
parent 63ab79b910
commit a23b727557
7 changed files with 454 additions and 206 deletions

View File

@@ -4,6 +4,8 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Item\ItemStoreRequest;
use App\Http\Requests\Item\ItemUpdateRequest;
use App\Services\ItemsService;
use Illuminate\Http\Request;
@@ -42,4 +44,56 @@ public function show(Request $request, int $id)
return $this->service->getItem($itemType, $id, $includePrice, $clientId, $priceDate);
}, __('message.fetched'));
}
}
/**
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
*
* GET /api/v1/items/code/{code}?include_bom=true
*/
public function showByCode(Request $request, string $code)
{
return ApiResponse::handle(function () use ($request, $code) {
$includeBom = filter_var($request->input('include_bom', false), FILTER_VALIDATE_BOOLEAN);
return $this->service->getItemByCode($code, $includeBom);
}, __('message.item.fetched'));
}
/**
* 품목 생성
*
* POST /api/v1/items
*/
public function store(ItemStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->createItem($request->validated());
}, __('message.item.created'));
}
/**
* 품목 수정
*
* PUT /api/v1/items/{code}
*/
public function update(string $code, ItemUpdateRequest $request)
{
return ApiResponse::handle(function () use ($code, $request) {
return $this->service->updateItem($code, $request->validated());
}, __('message.item.updated'));
}
/**
* 품목 삭제 (Soft Delete)
*
* DELETE /api/v1/items/{code}
*/
public function destroy(string $code)
{
return ApiResponse::handle(function () use ($code) {
$this->service->deleteItem($code);
return 'success';
}, __('message.item.deleted'));
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests\Item;
use Illuminate\Foundation\Http\FormRequest;
class ItemStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 필수 필드
'code' => 'required|string|max:50',
'name' => 'required|string|max:255',
'product_type' => 'required|string|in:FG,PT,SM,RM,CS',
'unit' => 'required|string|max:20',
// 선택 필드
'category_id' => 'nullable|integer|exists:categories,id',
'description' => 'nullable|string',
// 플래그
'is_sellable' => 'nullable|boolean',
'is_purchasable' => 'nullable|boolean',
'is_producible' => 'nullable|boolean',
// 하이브리드 고정 필드
'safety_stock' => 'nullable|integer|min:0',
'lead_time' => 'nullable|integer|min:0',
'is_variable_size' => 'nullable|boolean',
'product_category' => 'nullable|string|max:20',
'part_type' => 'nullable|string|max:20',
// 동적 필드 (JSON)
'attributes' => 'nullable|array',
];
}
public function messages(): array
{
return [
'code.required' => '품목코드는 필수입니다.',
'code.max' => '품목코드는 50자 이내로 입력하세요.',
'name.required' => '품목명은 필수입니다.',
'name.max' => '품목명은 255자 이내로 입력하세요.',
'product_type.required' => '품목 유형은 필수입니다.',
'product_type.in' => '품목 유형은 FG, PT, SM, RM, CS 중 하나여야 합니다.',
'unit.required' => '단위는 필수입니다.',
'unit.max' => '단위는 20자 이내로 입력하세요.',
'category_id.exists' => '존재하지 않는 카테고리입니다.',
'safety_stock.min' => '안전재고는 0 이상이어야 합니다.',
'lead_time.min' => '리드타임은 0 이상이어야 합니다.',
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Item;
use Illuminate\Foundation\Http\FormRequest;
class ItemUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 선택 필드 (모두 sometimes)
'code' => 'sometimes|string|max:50',
'name' => 'sometimes|string|max:255',
'product_type' => 'sometimes|string|in:FG,PT,SM,RM,CS',
'unit' => 'sometimes|string|max:20',
'category_id' => 'nullable|integer|exists:categories,id',
'description' => 'nullable|string',
// 플래그
'is_sellable' => 'sometimes|boolean',
'is_purchasable' => 'sometimes|boolean',
'is_producible' => 'sometimes|boolean',
// 하이브리드 고정 필드
'safety_stock' => 'sometimes|integer|min:0',
'lead_time' => 'sometimes|integer|min:0',
'is_variable_size' => 'sometimes|boolean',
'product_category' => 'sometimes|string|max:20',
'part_type' => 'sometimes|string|max:20',
// 동적 필드 (JSON)
'attributes' => 'sometimes|array',
];
}
public function messages(): array
{
return [
'code.max' => '품목코드는 50자 이내로 입력하세요.',
'name.max' => '품목명은 255자 이내로 입력하세요.',
'product_type.in' => '품목 유형은 FG, PT, SM, RM, CS 중 하나여야 합니다.',
'unit.max' => '단위는 20자 이내로 입력하세요.',
'category_id.exists' => '존재하지 않는 카테고리입니다.',
'safety_stock.min' => '안전재고는 0 이상이어야 합니다.',
'lead_time.min' => '리드타임은 0 이상이어야 합니다.',
];
}
}

View File

@@ -5,6 +5,7 @@
use App\Models\Materials\Material;
use App\Models\Products\Product;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemsService extends Service
@@ -220,4 +221,122 @@ private function fetchPrices(string $itemType, int $itemId, ?int $clientId, ?str
'purchase' => $purchasePrice,
];
}
}
/**
* 품목 생성 (Product 전용)
*
* @param array $data 검증된 데이터
*/
public function createItem(array $data): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 품목 코드 중복 체크
$exists = Product::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.duplicate_code'));
}
$payload = $data;
$payload['tenant_id'] = $tenantId;
$payload['created_by'] = $userId;
$payload['is_sellable'] = $payload['is_sellable'] ?? true;
$payload['is_purchasable'] = $payload['is_purchasable'] ?? false;
$payload['is_producible'] = $payload['is_producible'] ?? false;
return Product::create($payload);
}
/**
* 품목 수정 (Product 전용)
*
* @param string $code 품목 코드
* @param array $data 검증된 데이터
*/
public function updateItem(string $code, array $data): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$product = Product::query()
->where('tenant_id', $tenantId)
->where('code', $code)
->first();
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 코드 변경 시 중복 체크
if (isset($data['code']) && $data['code'] !== $code) {
$exists = Product::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->where('id', '!=', $product->id)
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.duplicate_code'));
}
}
$data['updated_by'] = $userId;
$product->update($data);
return $product->refresh();
}
/**
* 품목 삭제 (Product 전용, Soft Delete)
*
* @param string $code 품목 코드
*/
public function deleteItem(string $code): void
{
$tenantId = $this->tenantId();
$product = Product::query()
->where('tenant_id', $tenantId)
->where('code', $code)
->first();
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
$product->delete();
}
/**
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
*
* @param string $code 품목 코드
* @param bool $includeBom BOM 포함 여부
*/
public function getItemByCode(string $code, bool $includeBom = false): Product
{
$tenantId = $this->tenantId();
$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) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $product;
}
}

View File

@@ -2,253 +2,203 @@
namespace App\Swagger\v1;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(name="Items", description="통합 품목 조회 (materials + products)")
*
* ========= 공용 스키마 =========
*
* 통합 품목 스키마
* @OA\Tag(name="Items", description="품목 관리 (Product CRUD)")
*
* @OA\Schema(
* schema="Item",
* type="object",
* description="통합 품목 (PRODUCT 또는 MATERIAL)",
* required={"id","code","name","product_type","unit"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="item_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT", description="품목 유형"),
* @OA\Property(property="code", type="string", example="PRD-001", description="제품 코드 또는 자재 코드"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="unit", type="string", nullable=true, example="SET"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=2),
* @OA\Property(property="type_code", type="string", example="FG", description="제품: FG/PT, 자재: SM/RM/CS"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-10T10:00:00Z")
* )
*
* 가격 정보 스키마
*
* @OA\Schema(
* schema="PriceInfo",
* type="object",
* description="가격 정보 (우선순위: 고객그룹가격 → 기본가격)",
*
* @OA\Property(property="price", type="number", format="float", nullable=true, example=15000.5000, description="단가"),
* @OA\Property(property="price_history_id", type="integer", nullable=true, example=123, description="가격 이력 ID"),
* @OA\Property(property="client_group_id", type="integer", nullable=true, example=5, description="고객 그룹 ID"),
* @OA\Property(property="warning", type="string", nullable=true, example="가격 정보를 찾을 수 없습니다.", description="경고 메시지")
* )
*
* 가격 통합 품목 스키마
*
* @OA\Schema(
* schema="ItemWithPrice",
* allOf={
*
* @OA\Schema(ref="#/components/schemas/Item"),
* @OA\Schema(
* type="object",
*
* @OA\Property(
* property="prices",
* type="object",
* description="판매가/매입가 정보",
*
* @OA\Property(property="sale", ref="#/components/schemas/PriceInfo"),
* @OA\Property(property="purchase", ref="#/components/schemas/PriceInfo")
* )
* )
* }
* )
*
* 통합 품목 페이지네이션
*
* @OA\Schema(
* schema="ItemPagination",
* type="object",
*
* @OA\Property(property="code", type="string", example="P-001"),
* @OA\Property(property="name", type="string", example="스크린 제품 A"),
* @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"),
* @OA\Property(property="unit", type="string", example="EA"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
* @OA\Property(property="description", type="string", nullable=true, example="제품 설명"),
* @OA\Property(property="is_sellable", type="boolean", example=true),
* @OA\Property(property="is_purchasable", type="boolean", example=false),
* @OA\Property(property="is_producible", type="boolean", example=false),
* @OA\Property(property="safety_stock", type="integer", nullable=true, example=10),
* @OA\Property(property="lead_time", type="integer", nullable=true, example=7),
* @OA\Property(property="is_variable_size", type="boolean", example=false),
* @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"),
* @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/Item")
* property="attributes",
* type="object",
* nullable=true,
* description="동적 속성 (JSON)",
* example={"color": "black", "weight": 5.5}
* ),
* @OA\Property(property="created_at", type="string", example="2025-11-14 10:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-11-14 10:10:00")
* )
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=100),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="to", type="integer", example=20)
* @OA\Schema(
* schema="ItemCreateRequest",
* type="object",
* required={"code","name","product_type","unit"},
*
* @OA\Property(property="code", type="string", maxLength=50, example="P-001"),
* @OA\Property(property="name", type="string", maxLength=255, example="스크린 제품 A"),
* @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"),
* @OA\Property(property="unit", type="string", maxLength=20, example="EA"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
* @OA\Property(property="description", type="string", nullable=true, example="제품 설명"),
* @OA\Property(property="is_sellable", type="boolean", nullable=true, example=true),
* @OA\Property(property="is_purchasable", type="boolean", nullable=true, example=false),
* @OA\Property(property="is_producible", type="boolean", nullable=true, example=false),
* @OA\Property(property="safety_stock", type="integer", nullable=true, example=10),
* @OA\Property(property="lead_time", type="integer", nullable=true, example=7),
* @OA\Property(property="is_variable_size", type="boolean", nullable=true, example=false),
* @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"),
* @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"),
* @OA\Property(
* property="attributes",
* type="object",
* nullable=true,
* description="동적 속성 (JSON)"
* )
* )
*
* @OA\Schema(
* schema="ItemUpdateRequest",
* type="object",
*
* @OA\Property(property="code", type="string", maxLength=50, example="P-001"),
* @OA\Property(property="name", type="string", maxLength=255, example="스크린 제품 A"),
* @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"),
* @OA\Property(property="unit", type="string", maxLength=20, example="EA"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
* @OA\Property(property="description", type="string", nullable=true, example="제품 설명"),
* @OA\Property(property="is_sellable", type="boolean", nullable=true, example=true),
* @OA\Property(property="is_purchasable", type="boolean", nullable=true, example=false),
* @OA\Property(property="is_producible", type="boolean", nullable=true, example=false),
* @OA\Property(property="safety_stock", type="integer", nullable=true, example=10),
* @OA\Property(property="lead_time", type="integer", nullable=true, example=7),
* @OA\Property(property="is_variable_size", type="boolean", nullable=true, example=false),
* @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"),
* @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"),
* @OA\Property(
* property="attributes",
* type="object",
* nullable=true,
* description="동적 속성 (JSON)"
* )
* )
*/
class ItemsApi
{
/**
* 통합 품목 목록 조회
*
* @OA\Get(
* @OA\Post(
* path="/api/v1/items",
* tags={"Items"},
* summary="통합 품목 목록 조회 (materials + products UNION)",
* description="제품과 자재를 UNION으로 통합하여 조회합니다. 타입 필터링, 검색, 카테고리 필터 지원.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* summary="품목 생성",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(
* name="type",
* in="query",
* description="품목 타입 (쉼표 구분 또는 배열). FG=완제품, PT=부품/서브조립, SM=부자재, RM=원자재, CS=소모품",
* required=false,
* @OA\RequestBody(
* required=true,
*
* @OA\Schema(
* type="string",
* example="FG,PT,SM"
* )
* ),
*
* @OA\Parameter(
* name="search",
* in="query",
* description="검색어 (name, code 필드 LIKE 검색)",
* required=false,
*
* @OA\Schema(type="string", example="스크린")
* ),
*
* @OA\Parameter(
* name="category_id",
* in="query",
* description="카테고리 ID 필터",
* required=false,
*
* @OA\Schema(type="integer", example=2)
* ),
*
* @OA\Parameter(
* name="page",
* in="query",
* description="페이지 번호 (기본: 1)",
* required=false,
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @OA\Parameter(
* name="size",
* in="query",
* description="페이지당 항목 수 (기본: 20)",
* required=false,
*
* @OA\Schema(type="integer", example=20)
* @OA\JsonContent(ref="#/components/schemas/ItemCreateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
* description="성공",
*
* @OA\JsonContent(
* allOf={
* type="object",
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/ItemPagination")
* )
* }
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="품목이 등록되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* ),
*
* @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"))
* )
* )
*/
public function index() {}
public function store() {}
/**
* 단일 품목 조회 (가격 정보 포함 가능)
*
* @OA\Get(
* path="/api/v1/items/{id}",
* path="/api/v1/items/code/{code}",
* tags={"Items"},
* summary="단일 품목 조회 (가격 정보 포함 가능)",
* description="PRODUCT 또는 MATERIAL 단일 품목 상세 조회. include_price=true로 판매가/매입가 포함 가능.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* summary="품목 코드로 상세 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
* @OA\Parameter(name="include_bom", in="query", @OA\Schema(type="boolean"), example=false),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="품목을 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* )
* )
*/
public function showByCode() {}
/**
* @OA\Put(
* path="/api/v1/items/{code}",
* tags={"Items"},
* summary="품목 수정",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
*
* @OA\RequestBody(
* required=true,
* description="품목 ID",
*
* @OA\Schema(type="integer", example=10)
* ),
*
* @OA\Parameter(
* name="item_type",
* in="query",
* required=true,
* description="품목 유형 (PRODUCT 또는 MATERIAL)",
*
* @OA\Schema(type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT")
* ),
*
* @OA\Parameter(
* name="include_price",
* in="query",
* required=false,
* description="가격 정보 포함 여부 (기본: false)",
*
* @OA\Schema(type="boolean", example=true)
* ),
*
* @OA\Parameter(
* name="client_id",
* in="query",
* required=false,
* description="고객 ID (가격 조회 시 고객그룹 가격 우선 적용)",
*
* @OA\Schema(type="integer", example=5)
* ),
*
* @OA\Parameter(
* name="price_date",
* in="query",
* required=false,
* description="가격 기준일 (기본: 오늘)",
*
* @OA\Schema(type="string", format="date", example="2025-01-10")
* @OA\JsonContent(ref="#/components/schemas/ItemUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
* description="성공",
*
* @OA\JsonContent(
* allOf={
* type="object",
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* type="object",
*
* @OA\Property(
* property="data",
* oneOf={
*
* @OA\Schema(ref="#/components/schemas/Item"),
* @OA\Schema(ref="#/components/schemas/ItemWithPrice")
* },
* description="include_price=false: Item, include_price=true: ItemWithPrice"
* )
* )
* }
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="품목이 수정되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/Item")
* )
* ),
*
* @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
* )
*/
public function show() {}
}
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/items/{code}",
* tags={"Items"},
* summary="품목 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="품목이 삭제되었습니다."),
* @OA\Property(property="data", type="string", example="success")
* )
* )
* )
*/
public function destroy() {}
}

View File

@@ -30,6 +30,13 @@
'tenant_switched' => '활성 테넌트가 전환되었습니다.',
// 리소스별 세부 (필요 시)
'item' => [
'fetched' => '품목을 조회했습니다.',
'created' => '품목이 등록되었습니다.',
'updated' => '품목이 수정되었습니다.',
'deleted' => '품목이 삭제되었습니다.',
],
'product' => [
'fetched' => '제품을 조회했습니다.',
'category_fetched' => '제품 카테고리를 조회했습니다.',

View File

@@ -337,7 +337,11 @@
// Items (통합 품목 조회 - materials + products UNION)
Route::prefix('items')->group(function () {
Route::get('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록
Route::post('', [\App\Http\Controllers\Api\V1\ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성
Route::get('/code/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'showByCode'])->name('v1.items.show_by_code'); // code 기반 조회
Route::get('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수)
Route::put('/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'update'])->name('v1.items.update'); // 품목 수정
Route::delete('/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제
});
// BOM (product_components: ref_type=PRODUCT|MATERIAL)