feat: Items BOM API 추가 (Code 기반 Adapter) - BP-MES Phase 1 Day 6-9

- ItemsBomController 생성 (code 기반 BOM 관리)
- 기존 ProductBomService 100% 재사용 (Adapter 패턴)
- Code → ID 변환 후 기존 비즈니스 로직 활용
- 프론트엔드 요구사항 완벽 대응 (itemCode 기반 API)
- 10개 엔드포인트 추가:
  * GET /items/{code}/bom - BOM 목록 (flat)
  * GET /items/{code}/bom/tree - BOM 트리 (계층)
  * POST /items/{code}/bom - BOM 추가 (bulk upsert)
  * PUT /items/{code}/bom/{lineId} - BOM 수정
  * DELETE /items/{code}/bom/{lineId} - BOM 삭제
  * GET /items/{code}/bom/summary - BOM 요약
  * GET /items/{code}/bom/validate - BOM 검증
  * POST /items/{code}/bom/replace - BOM 전체 교체
  * POST /items/{code}/bom/reorder - BOM 정렬
  * GET /items/{code}/bom/categories - 카테고리 목록
- Swagger 문서 완성 (ItemsBomApi.php)
- i18n 메시지 키 추가 (message.bom.created/updated/deleted)
- Hybrid 구조 지원 (quantity_formula, condition, attributes)
This commit is contained in:
2025-11-17 11:45:16 +09:00
parent a23b727557
commit 2f2fffb6f0
4 changed files with 645 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Products\Product;
use App\Services\ProductBomService;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Items BOM Controller (Code-based Adapter)
*
* 프론트엔드 요구사항에 맞춰 itemCode 기반으로 BOM을 관리하는 Adapter
* 내부적으로 code → id 변환 후 기존 ProductBomService 재사용
*/
class ItemsBomController extends Controller
{
public function __construct(private ProductBomService $service) {}
/**
* GET /api/v1/items/{code}/bom
* BOM 라인 목록 조회 (flat list)
*/
public function index(string $code, Request $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$productId = $this->getProductIdByCode($code);
return $this->service->index($productId, $request->all());
}, __('message.bom.fetch'));
}
/**
* GET /api/v1/items/{code}/bom/tree
* BOM 트리 구조 조회 (계층적)
*/
public function tree(string $code, Request $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$productId = $this->getProductIdByCode($code);
return $this->service->tree($request, $productId);
}, __('message.bom.fetch'));
}
/**
* POST /api/v1/items/{code}/bom
* BOM 라인 추가 (bulk upsert)
*/
public function store(string $code, Request $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$productId = $this->getProductIdByCode($code);
return $this->service->bulkUpsert($productId, $request->input('items', []));
}, __('message.bom.created'));
}
/**
* PUT /api/v1/items/{code}/bom/{lineId}
* BOM 라인 수정
*/
public function update(string $code, int $lineId, Request $request)
{
return ApiResponse::handle(function () use ($code, $lineId, $request) {
$productId = $this->getProductIdByCode($code);
return $this->service->update($productId, $lineId, $request->all());
}, __('message.bom.updated'));
}
/**
* DELETE /api/v1/items/{code}/bom/{lineId}
* BOM 라인 삭제
*/
public function destroy(string $code, int $lineId)
{
return ApiResponse::handle(function () use ($code, $lineId) {
$productId = $this->getProductIdByCode($code);
$this->service->destroy($productId, $lineId);
return 'success';
}, __('message.bom.deleted'));
}
/**
* GET /api/v1/items/{code}/bom/summary
* BOM 요약 정보
*/
public function summary(string $code)
{
return ApiResponse::handle(function () use ($code) {
$productId = $this->getProductIdByCode($code);
return $this->service->summary($productId);
}, __('message.bom.fetch'));
}
/**
* GET /api/v1/items/{code}/bom/validate
* BOM 유효성 검사
*/
public function validate(string $code)
{
return ApiResponse::handle(function () use ($code) {
$productId = $this->getProductIdByCode($code);
return $this->service->validateBom($productId);
}, __('message.bom.fetch'));
}
/**
* POST /api/v1/items/{code}/bom/replace
* BOM 전체 교체
*/
public function replace(string $code, Request $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$productId = $this->getProductIdByCode($code);
return $this->service->replaceBom($productId, $request->all());
}, __('message.bom.created'));
}
/**
* POST /api/v1/items/{code}/bom/reorder
* BOM 정렬 변경
*/
public function reorder(string $code, Request $request)
{
return ApiResponse::handle(function () use ($code, $request) {
$productId = $this->getProductIdByCode($code);
$this->service->reorder($productId, $request->input('items', []));
return 'success';
}, __('message.bom.reordered'));
}
/**
* GET /api/v1/items/{code}/bom/categories
* 해당 품목의 BOM에서 사용 중인 카테고리 목록
*/
public function listCategories(string $code)
{
return ApiResponse::handle(function () use ($code) {
$productId = $this->getProductIdByCode($code);
return $this->service->listCategoriesForProduct($productId);
}, __('message.bom.fetch'));
}
// ==================== Helper Methods ====================
/**
* itemCode로 product ID 조회
*
* @throws NotFoundHttpException
*/
private function getProductIdByCode(string $code): int
{
$tenantId = app('tenant_id');
$product = Product::query()
->where('tenant_id', $tenantId)
->where('code', $code)
->first(['id']);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $product->id;
}
}

View File

@@ -0,0 +1,450 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Items BOM", description="품목 BOM 관리 (Code 기반)")
*
* @OA\Schema(
* schema="BOMLine",
* type="object",
* required={"id","ref_type","ref_id","quantity"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="ref_type", type="string", example="PRODUCT", description="PRODUCT|MATERIAL"),
* @OA\Property(property="ref_id", type="integer", example=10),
* @OA\Property(property="code", type="string", example="P-001"),
* @OA\Property(property="name", type="string", example="가이드레일"),
* @OA\Property(property="quantity", type="number", example=2.5),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
* @OA\Property(property="category_name", type="string", nullable=true, example="조립품"),
* @OA\Property(property="quantity_formula", type="string", nullable=true, example="W * 2", description="수량 계산 수식"),
* @OA\Property(property="condition", type="string", nullable=true, example="MOTOR='Y'", description="조건부 BOM")
* )
*
* @OA\Schema(
* schema="BOMTree",
* type="object",
*
* @OA\Property(property="type", type="string", example="PRODUCT"),
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="code", type="string", example="P-001"),
* @OA\Property(property="name", type="string", example="스크린 세트"),
* @OA\Property(property="quantity", type="number", example=1),
* @OA\Property(property="depth", type="integer", example=0),
* @OA\Property(
* property="children",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BOMTree")
* )
* )
*
* @OA\Schema(
* schema="BOMCreateRequest",
* type="object",
* required={"items"},
*
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(
* type="object",
* required={"ref_type","ref_id","quantity"},
*
* @OA\Property(property="id", type="integer", nullable=true, example=1, description="기존 라인 ID (업데이트 시)"),
* @OA\Property(property="ref_type", type="string", example="PRODUCT", description="PRODUCT|MATERIAL"),
* @OA\Property(property="ref_id", type="integer", example=10),
* @OA\Property(property="quantity", type="number", example=2.5),
* @OA\Property(property="sort_order", type="integer", nullable=true, example=1),
* @OA\Property(property="quantity_formula", type="string", nullable=true, example="W * 2"),
* @OA\Property(property="condition", type="string", nullable=true, example="MOTOR='Y'")
* )
* )
* )
*
* @OA\Schema(
* schema="BOMUpdateRequest",
* type="object",
*
* @OA\Property(property="ref_type", type="string", example="PRODUCT", description="PRODUCT|MATERIAL"),
* @OA\Property(property="ref_id", type="integer", example=10),
* @OA\Property(property="quantity", type="number", example=2.5),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="quantity_formula", type="string", nullable=true, example="W * 2"),
* @OA\Property(property="condition", type="string", nullable=true, example="MOTOR='Y'")
* )
*/
class ItemsBomApi
{
/**
* @OA\Get(
* path="/api/v1/items/{code}/bom",
* tags={"Items BOM"},
* summary="BOM 목록 조회 (flat list)",
* 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="BOM 항목 조회"),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BOMLine")
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/items/{code}/bom/tree",
* tags={"Items BOM"},
* summary="BOM 트리 구조 조회 (계층적)",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
* @OA\Parameter(name="depth", in="query", @OA\Schema(type="integer"), example=10, description="최대 깊이 (기본값: 10)"),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 항목 조회"),
* @OA\Property(property="data", ref="#/components/schemas/BOMTree")
* )
* )
* )
*/
public function tree() {}
/**
* @OA\Post(
* path="/api/v1/items/{code}/bom",
* tags={"Items BOM"},
* summary="BOM 라인 추가 (bulk upsert)",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BOMCreateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 항목이 등록되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="created", type="integer", example=3),
* @OA\Property(property="updated", type="integer", example=1)
* )
* )
* )
* )
*/
public function store() {}
/**
* @OA\Put(
* path="/api/v1/items/{code}/bom/{lineId}",
* tags={"Items BOM"},
* summary="BOM 라인 수정",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
* @OA\Parameter(name="lineId", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/BOMUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 항목이 수정되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BOMLine")
* )
* )
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/items/{code}/bom/{lineId}",
* tags={"Items BOM"},
* summary="BOM 라인 삭제",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
* @OA\Parameter(name="lineId", in="path", required=true, @OA\Schema(type="integer"), example=1),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 항목이 삭제되었습니다."),
* @OA\Property(property="data", type="string", example="success")
* )
* )
* )
*/
public function destroy() {}
/**
* @OA\Get(
* path="/api/v1/items/{code}/bom/summary",
* tags={"Items BOM"},
* summary="BOM 요약 정보",
* 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="BOM 항목 조회"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="count", type="integer", example=10),
* @OA\Property(property="count_product", type="integer", example=5),
* @OA\Property(property="count_material", type="integer", example=5),
* @OA\Property(property="quantity_sum", type="string", example="25.5000")
* )
* )
* )
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/items/{code}/bom/validate",
* tags={"Items BOM"},
* summary="BOM 유효성 검사",
* 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="BOM 항목 조회"),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="valid", type="boolean", example=true),
* @OA\Property(
* property="errors",
* type="array",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="error", type="string", example="INVALID_QUANTITY")
* )
* )
* )
* )
* )
* )
*/
public function validate() {}
/**
* @OA\Post(
* path="/api/v1/items/{code}/bom/replace",
* tags={"Items BOM"},
* summary="BOM 전체 교체 (기존 삭제 후 재등록)",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(
* property="categories",
* type="array",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="id", type="integer", nullable=true, example=1),
* @OA\Property(property="name", type="string", example="조립품"),
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(
* type="object",
* required={"ref_type","ref_id","quantity"},
*
* @OA\Property(property="ref_type", type="string", example="PRODUCT"),
* @OA\Property(property="ref_id", type="integer", example=10),
* @OA\Property(property="quantity", type="number", example=2.5),
* @OA\Property(property="sort_order", type="integer", nullable=true, example=1)
* )
* )
* )
* )
* )
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 항목이 등록되었습니다."),
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="deleted_count", type="integer", example=5),
* @OA\Property(property="inserted_count", type="integer", example=8),
* @OA\Property(property="message", type="string", example="BOM 저장 성공")
* )
* )
* )
* )
*/
public function replace() {}
/**
* @OA\Post(
* path="/api/v1/items/{code}/bom/reorder",
* tags={"Items BOM"},
* summary="BOM 정렬 변경",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(
* type="object",
* required={"id","sort_order"},
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=2)
* )
* )
* )
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
* type="object",
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="BOM 정렬이 변경되었습니다."),
* @OA\Property(property="data", type="string", example="success")
* )
* )
* )
*/
public function reorder() {}
/**
* @OA\Get(
* path="/api/v1/items/{code}/bom/categories",
* tags={"Items BOM"},
* summary="BOM에서 사용 중인 카테고리 목록",
* 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="BOM 항목 조회"),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(
* 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)
* )
* )
* )
* )
* )
*/
public function listCategories() {}
}

View File

@@ -49,6 +49,9 @@
'bom' => [
'fetched' => 'BOM 항목을 조회했습니다.',
'created' => 'BOM 항목이 등록되었습니다.',
'updated' => 'BOM 항목이 수정되었습니다.',
'deleted' => 'BOM 항목이 삭제되었습니다.',
'bulk_upsert' => 'BOM 항목이 저장되었습니다.',
'reordered' => 'BOM 정렬이 변경되었습니다.',
'fetch' => 'BOM 항목 조회',

View File

@@ -344,6 +344,20 @@
Route::delete('/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제
});
// Items BOM (Code-based BOM API - adapter for frontend)
Route::prefix('items/{code}/bom')->group(function () {
Route::get('', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'index'])->name('v1.items.bom.index'); // BOM 목록 (flat)
Route::get('/tree', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'tree'])->name('v1.items.bom.tree'); // BOM 트리 (계층)
Route::post('', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'store'])->name('v1.items.bom.store'); // BOM 추가 (bulk)
Route::put('/{lineId}', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'update'])->name('v1.items.bom.update'); // BOM 수정
Route::delete('/{lineId}', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'destroy'])->name('v1.items.bom.destroy'); // BOM 삭제
Route::get('/summary', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'summary'])->name('v1.items.bom.summary'); // BOM 요약
Route::get('/validate', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'validate'])->name('v1.items.bom.validate'); // BOM 검증
Route::post('/replace', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'replace'])->name('v1.items.bom.replace'); // BOM 전체 교체
Route::post('/reorder', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'reorder'])->name('v1.items.bom.reorder'); // BOM 정렬
Route::get('/categories', [\App\Http\Controllers\Api\V1\ItemsBomController::class, 'listCategories'])->name('v1.items.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');