From f09fa3791cc5549d5d489adea86c05c822791dbc Mon Sep 17 00:00:00 2001 From: hskwon Date: Sun, 30 Nov 2025 21:05:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[items]=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?=20API=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemsController, ItemsBomController, ItemsFileController 수정 - ItemBatchDeleteRequest 추가 - ItemsService 개선 - ItemsApi Swagger 문서 업데이트 --- .../Controllers/Api/V1/ItemsBomController.php | 120 +++++++++--------- .../Controllers/Api/V1/ItemsController.php | 31 +++-- .../Api/V1/ItemsFileController.php | 29 ++--- .../Requests/Item/ItemBatchDeleteRequest.php | 33 +++++ app/Services/ItemsService.php | 39 ++++-- app/Swagger/v1/ItemsApi.php | 53 +++++++- 6 files changed, 208 insertions(+), 97 deletions(-) create mode 100644 app/Http/Requests/Item/ItemBatchDeleteRequest.php diff --git a/app/Http/Controllers/Api/V1/ItemsBomController.php b/app/Http/Controllers/Api/V1/ItemsBomController.php index 9264c8b..f67462d 100644 --- a/app/Http/Controllers/Api/V1/ItemsBomController.php +++ b/app/Http/Controllers/Api/V1/ItemsBomController.php @@ -10,169 +10,167 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** - * Items BOM Controller (Code-based Adapter) + * Items BOM Controller (ID-based) * - * 프론트엔드 요구사항에 맞춰 itemCode 기반으로 BOM을 관리하는 Adapter - * 내부적으로 code → id 변환 후 기존 ProductBomService 재사용 + * ID 기반으로 BOM을 관리하는 컨트롤러 + * 내부적으로 기존 ProductBomService 재사용 */ class ItemsBomController extends Controller { public function __construct(private ProductBomService $service) {} /** - * GET /api/v1/items/{code}/bom + * GET /api/v1/items/{id}/bom * BOM 라인 목록 조회 (flat list) */ - public function index(string $code, Request $request) + public function index(int $id, Request $request) { - return ApiResponse::handle(function () use ($code, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $this->validateProductExists($id); - return $this->service->index($productId, $request->all()); + return $this->service->index($id, $request->all()); }, __('message.bom.fetch')); } /** - * GET /api/v1/items/{code}/bom/tree + * GET /api/v1/items/{id}/bom/tree * BOM 트리 구조 조회 (계층적) */ - public function tree(string $code, Request $request) + public function tree(int $id, Request $request) { - return ApiResponse::handle(function () use ($code, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $this->validateProductExists($id); - return $this->service->tree($request, $productId); + return $this->service->tree($request, $id); }, __('message.bom.fetch')); } /** - * POST /api/v1/items/{code}/bom + * POST /api/v1/items/{id}/bom * BOM 라인 추가 (bulk upsert) */ - public function store(string $code, Request $request) + public function store(int $id, Request $request) { - return ApiResponse::handle(function () use ($code, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $this->validateProductExists($id); - return $this->service->bulkUpsert($productId, $request->input('items', [])); + return $this->service->bulkUpsert($id, $request->input('items', [])); }, __('message.bom.created')); } /** - * PUT /api/v1/items/{code}/bom/{lineId} + * PUT /api/v1/items/{id}/bom/{lineId} * BOM 라인 수정 */ - public function update(string $code, int $lineId, Request $request) + public function update(int $id, int $lineId, Request $request) { - return ApiResponse::handle(function () use ($code, $lineId, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $lineId, $request) { + $this->validateProductExists($id); - return $this->service->update($productId, $lineId, $request->all()); + return $this->service->update($id, $lineId, $request->all()); }, __('message.bom.updated')); } /** - * DELETE /api/v1/items/{code}/bom/{lineId} + * DELETE /api/v1/items/{id}/bom/{lineId} * BOM 라인 삭제 */ - public function destroy(string $code, int $lineId) + public function destroy(int $id, int $lineId) { - return ApiResponse::handle(function () use ($code, $lineId) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $lineId) { + $this->validateProductExists($id); - $this->service->destroy($productId, $lineId); + $this->service->destroy($id, $lineId); return 'success'; }, __('message.bom.deleted')); } /** - * GET /api/v1/items/{code}/bom/summary + * GET /api/v1/items/{id}/bom/summary * BOM 요약 정보 */ - public function summary(string $code) + public function summary(int $id) { - return ApiResponse::handle(function () use ($code) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id) { + $this->validateProductExists($id); - return $this->service->summary($productId); + return $this->service->summary($id); }, __('message.bom.fetch')); } /** - * GET /api/v1/items/{code}/bom/validate + * GET /api/v1/items/{id}/bom/validate * BOM 유효성 검사 */ - public function validate(string $code) + public function validate(int $id) { - return ApiResponse::handle(function () use ($code) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id) { + $this->validateProductExists($id); - return $this->service->validateBom($productId); + return $this->service->validateBom($id); }, __('message.bom.fetch')); } /** - * POST /api/v1/items/{code}/bom/replace + * POST /api/v1/items/{id}/bom/replace * BOM 전체 교체 */ - public function replace(string $code, Request $request) + public function replace(int $id, Request $request) { - return ApiResponse::handle(function () use ($code, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $this->validateProductExists($id); - return $this->service->replaceBom($productId, $request->all()); + return $this->service->replaceBom($id, $request->all()); }, __('message.bom.created')); } /** - * POST /api/v1/items/{code}/bom/reorder + * POST /api/v1/items/{id}/bom/reorder * BOM 정렬 변경 */ - public function reorder(string $code, Request $request) + public function reorder(int $id, Request $request) { - return ApiResponse::handle(function () use ($code, $request) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $this->validateProductExists($id); - $this->service->reorder($productId, $request->input('items', [])); + $this->service->reorder($id, $request->input('items', [])); return 'success'; }, __('message.bom.reordered')); } /** - * GET /api/v1/items/{code}/bom/categories + * GET /api/v1/items/{id}/bom/categories * 해당 품목의 BOM에서 사용 중인 카테고리 목록 */ - public function listCategories(string $code) + public function listCategories(int $id) { - return ApiResponse::handle(function () use ($code) { - $productId = $this->getProductIdByCode($code); + return ApiResponse::handle(function () use ($id) { + $this->validateProductExists($id); - return $this->service->listCategoriesForProduct($productId); + return $this->service->listCategoriesForProduct($id); }, __('message.bom.fetch')); } // ==================== Helper Methods ==================== /** - * itemCode로 product ID 조회 + * 품목 ID로 tenant 소유권 검증 * * @throws NotFoundHttpException */ - private function getProductIdByCode(string $code): int + private function validateProductExists(int $id): void { $tenantId = app('tenant_id'); - $product = Product::query() + $exists = Product::query() ->where('tenant_id', $tenantId) - ->where('code', $code) - ->first(['id']); + ->where('id', $id) + ->exists(); - if (! $product) { + if (! $exists) { throw new NotFoundHttpException(__('error.not_found')); } - - return $product->id; } } diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 3b9ba7c..327be70 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Item\ItemBatchDeleteRequest; use App\Http\Requests\Item\ItemStoreRequest; use App\Http\Requests\Item\ItemUpdateRequest; use App\Services\ItemsService; @@ -74,26 +75,40 @@ public function store(ItemStoreRequest $request) /** * 품목 수정 * - * PUT /api/v1/items/{code} + * PUT /api/v1/items/{id} */ - public function update(string $code, ItemUpdateRequest $request) + public function update(int $id, ItemUpdateRequest $request) { - return ApiResponse::handle(function () use ($code, $request) { - return $this->service->updateItem($code, $request->validated()); + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->updateItem($id, $request->validated()); }, __('message.item.updated')); } /** * 품목 삭제 (Soft Delete) * - * DELETE /api/v1/items/{code} + * DELETE /api/v1/items/{id} */ - public function destroy(string $code) + public function destroy(int $id) { - return ApiResponse::handle(function () use ($code) { - $this->service->deleteItem($code); + return ApiResponse::handle(function () use ($id) { + $this->service->deleteItem($id); return 'success'; }, __('message.item.deleted')); } + + /** + * 품목 일괄 삭제 (Soft Delete) + * + * DELETE /api/v1/items/batch + */ + public function batchDestroy(ItemBatchDeleteRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $this->service->batchDeleteItems($request->validated()['ids']); + + return 'success'; + }, __('message.item.batch_deleted')); + } } diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index 747ea01..d9f38de 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -14,7 +14,7 @@ /** * 품목 파일 관리 컨트롤러 * - * Code-based 파일 업로드/삭제 API + * ID-based 파일 업로드/삭제 API * - 절곡도 (bending_diagram) * - 시방서 (specification) * - 인정서 (certification) @@ -24,18 +24,18 @@ class ItemsFileController extends Controller /** * 파일 업로드 * - * POST /api/v1/items/{code}/files + * POST /api/v1/items/{id}/files */ - public function upload(string $code, ItemsFileUploadRequest $request) + public function upload(int $id, ItemsFileUploadRequest $request) { - return ApiResponse::handle(function () use ($code, $request) { - $product = $this->getProductByCode($code); + return ApiResponse::handle(function () use ($id, $request) { + $product = $this->getProductById($id); $validated = $request->validated(); $fileType = $request->route('type') ?? $validated['type']; $file = $validated['file']; - // 파일 저장 경로: items/{code}/{type}/{filename} - $directory = sprintf('items/%s/%s', $code, $fileType); + // 파일 저장 경로: items/{id}/{type}/{filename} + $directory = sprintf('items/%d/%s', $id, $fileType); $filePath = Storage::disk('public')->putFile($directory, $file); $fileUrl = Storage::disk('public')->url($filePath); $originalName = $file->getClientOriginalName(); @@ -57,12 +57,12 @@ public function upload(string $code, ItemsFileUploadRequest $request) /** * 파일 삭제 * - * DELETE /api/v1/items/{code}/files/{type} + * DELETE /api/v1/items/{id}/files/{type} */ - public function delete(string $code, string $type, Request $request) + public function delete(int $id, string $type, Request $request) { - return ApiResponse::handle(function () use ($code, $type) { - $product = $this->getProductByCode($code); + return ApiResponse::handle(function () use ($id, $type) { + $product = $this->getProductById($id); // 파일 타입 검증 if (! in_array($type, ['bending_diagram', 'specification', 'certification'])) { @@ -90,15 +90,14 @@ public function delete(string $code, string $type, Request $request) } /** - * 코드로 Product 조회 + * ID로 Product 조회 */ - private function getProductByCode(string $code): Product + private function getProductById(int $id): Product { $tenantId = app('tenant_id'); $product = Product::query() ->where('tenant_id', $tenantId) - ->where('code', $code) - ->first(); + ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.not_found')); diff --git a/app/Http/Requests/Item/ItemBatchDeleteRequest.php b/app/Http/Requests/Item/ItemBatchDeleteRequest.php new file mode 100644 index 0000000..b76316d --- /dev/null +++ b/app/Http/Requests/Item/ItemBatchDeleteRequest.php @@ -0,0 +1,33 @@ + 'required|array|min:1', + 'ids.*' => 'required|integer|min:1', + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => '삭제할 품목 ID 목록은 필수입니다.', + 'ids.array' => '품목 ID 목록은 배열이어야 합니다.', + 'ids.min' => '삭제할 품목을 하나 이상 선택하세요.', + 'ids.*.required' => '품목 ID는 필수입니다.', + 'ids.*.integer' => '품목 ID는 정수여야 합니다.', + 'ids.*.min' => '품목 ID는 1 이상이어야 합니다.', + ]; + } +} diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index c4494b2..2d5032e 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -255,25 +255,24 @@ public function createItem(array $data): Product /** * 품목 수정 (Product 전용) * - * @param string $code 품목 코드 + * @param int $id 품목 ID * @param array $data 검증된 데이터 */ - public function updateItem(string $code, array $data): Product + public function updateItem(int $id, array $data): Product { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $product = Product::query() ->where('tenant_id', $tenantId) - ->where('code', $code) - ->first(); + ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.not_found')); } // 코드 변경 시 중복 체크 - if (isset($data['code']) && $data['code'] !== $code) { + if (isset($data['code']) && $data['code'] !== $product->code) { $exists = Product::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) @@ -294,16 +293,15 @@ public function updateItem(string $code, array $data): Product /** * 품목 삭제 (Product 전용, Soft Delete) * - * @param string $code 품목 코드 + * @param int $id 품목 ID */ - public function deleteItem(string $code): void + public function deleteItem(int $id): void { $tenantId = $this->tenantId(); $product = Product::query() ->where('tenant_id', $tenantId) - ->where('code', $code) - ->first(); + ->find($id); if (! $product) { throw new NotFoundHttpException(__('error.not_found')); @@ -312,6 +310,29 @@ public function deleteItem(string $code): void $product->delete(); } + /** + * 품목 일괄 삭제 (Product 전용, Soft Delete) + * + * @param array $ids 품목 ID 배열 + */ + public function batchDeleteItems(array $ids): void + { + $tenantId = $this->tenantId(); + + $products = Product::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + if ($products->isEmpty()) { + throw new NotFoundHttpException(__('error.not_found')); + } + + foreach ($products as $product) { + $product->delete(); + } + } + /** * 품목 상세 조회 (code 기반, BOM 포함 옵션) * diff --git a/app/Swagger/v1/ItemsApi.php b/app/Swagger/v1/ItemsApi.php index c6d4464..6358f49 100644 --- a/app/Swagger/v1/ItemsApi.php +++ b/app/Swagger/v1/ItemsApi.php @@ -88,6 +88,21 @@ * description="동적 속성 (JSON)" * ) * ) + * + * @OA\Schema( + * schema="ItemBatchDeleteRequest", + * type="object", + * required={"ids"}, + * + * @OA\Property( + * property="ids", + * type="array", + * description="삭제할 품목 ID 목록", + * + * @OA\Items(type="integer"), + * example={1, 2, 3} + * ) + * ) */ class ItemsApi { @@ -183,12 +198,12 @@ public function showByCode() {} /** * @OA\Put( - * path="/api/v1/items/{code}", + * path="/api/v1/items/{id}", * tags={"Items"}, * summary="품목 수정", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * - * @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"), + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1, description="품목 ID"), * * @OA\RequestBody( * required=true, @@ -214,12 +229,12 @@ public function update() {} /** * @OA\Delete( - * path="/api/v1/items/{code}", + * path="/api/v1/items/{id}", * tags={"Items"}, * summary="품목 삭제", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * - * @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"), + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1, description="품목 ID"), * * @OA\Response( * response=200, @@ -236,4 +251,34 @@ public function update() {} * ) */ public function destroy() {} + + /** + * @OA\Delete( + * path="/api/v1/items/batch", + * tags={"Items"}, + * summary="품목 일괄 삭제", + * description="여러 품목을 한 번에 삭제합니다.", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/ItemBatchDeleteRequest") + * ), + * + * @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 batchDestroy() {} }