From fbaf2720d870d8b11cd554e7f0bd21323dfd1029 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 3 Dec 2025 22:35:38 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=92=88=EB=AA=A9=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20BOM=20=EC=B0=B8=EC=A1=B0=20=EB=AC=B4=EA=B2=B0?= =?UTF-8?q?=EC=84=B1=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 전 product_components 테이블에서 사용 여부 확인 - BOM 구성품으로 사용 중인 품목 삭제 차단 (400 에러) - 일괄 삭제에도 동일한 참조 체크 적용 - 품목 관련 에러 메시지 추가 (error.item.*) - 품목 삭제 API 테스트 플로우 JSON 추가 --- app/Services/ItemsService.php | 44 +++- claudedocs/flow-tester-item-delete.json | 287 ++++++++++++++++++++++++ lang/ko/error.php | 8 + 3 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 claudedocs/flow-tester-item-delete.json diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index 17e1ce0..fb59f91 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -446,6 +446,9 @@ public function updateItem(int $id, array $data): Product /** * 품목 삭제 (Product 전용, Soft Delete) * + * - 이미 삭제된 품목은 404 반환 + * - 다른 BOM의 구성품으로 사용 중이면 삭제 불가 + * * @param int $id 품목 ID */ public function deleteItem(int $id): void @@ -457,15 +460,39 @@ public function deleteItem(int $id): void ->find($id); if (! $product) { - throw new NotFoundHttpException(__('error.not_found')); + throw new NotFoundHttpException(__('error.item.not_found')); + } + + // BOM 구성품으로 사용 중인지 체크 + $usageCount = $this->checkProductUsageInBom($tenantId, $id); + if ($usageCount > 0) { + throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount])); } $product->delete(); } + /** + * Product가 다른 BOM의 구성품으로 사용 중인지 체크 + * + * @param int $tenantId 테넌트 ID + * @param int $productId 제품 ID + * @return int 사용 건수 + */ + private function checkProductUsageInBom(int $tenantId, int $productId): int + { + return \App\Models\Products\ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('ref_type', 'PRODUCT') + ->where('ref_id', $productId) + ->count(); + } + /** * 품목 일괄 삭제 (Product 전용, Soft Delete) * + * - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가 + * * @param array $ids 품목 ID 배열 */ public function batchDeleteItems(array $ids): void @@ -478,7 +505,20 @@ public function batchDeleteItems(array $ids): void ->get(); if ($products->isEmpty()) { - throw new NotFoundHttpException(__('error.not_found')); + throw new NotFoundHttpException(__('error.item.not_found')); + } + + // BOM 구성품으로 사용 중인 품목 체크 + $inUseIds = []; + foreach ($products as $product) { + $usageCount = $this->checkProductUsageInBom($tenantId, $product->id); + if ($usageCount > 0) { + $inUseIds[] = $product->id; + } + } + + if (! empty($inUseIds)) { + throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)])); } foreach ($products as $product) { diff --git a/claudedocs/flow-tester-item-delete.json b/claudedocs/flow-tester-item-delete.json new file mode 100644 index 0000000..e03e817 --- /dev/null +++ b/claudedocs/flow-tester-item-delete.json @@ -0,0 +1,287 @@ +{ + "name": "품목 삭제 API 테스트", + "description": "품목 삭제 시 참조 무결성 체크 및 soft delete 동작을 테스트합니다.", + "version": "1.0", + "config": { + "apiKey": "42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a", + "baseUrl": "https://api.sam.kr/api/v1", + "timeout": 30000, + "stopOnFailure": false + }, + "variables": { + "user_id": "codebridgex", + "user_pwd": "code1234", + "testProductCode": "TEST-DEL-001", + "testProductName": "삭제 테스트용 품목", + "testBomParentCode": "TEST-BOM-PARENT-001", + "testBomParentName": "BOM 부모 품목" + }, + "steps": [ + { + "id": "login", + "name": "로그인", + "description": "API 테스트를 위한 로그인", + "method": "POST", + "endpoint": "/login", + "body": { + "user_id": "{{variables.user_id}}", + "user_pwd": "{{variables.user_pwd}}" + }, + "extract": { + "accessToken": "$.access_token", + "refreshToken": "$.refresh_token" + }, + "expect": { + "status": [200], + "jsonPath": { + "$.access_token": "@isString" + } + } + }, + { + "id": "create_product_for_delete", + "name": "삭제 테스트용 품목 생성", + "description": "단순 삭제 테스트용 품목 생성 (BOM에 미사용)", + "method": "POST", + "endpoint": "/items", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "body": { + "code": "{{variables.testProductCode}}", + "name": "{{variables.testProductName}}", + "unit": "EA", + "product_type": "FG", + "is_active": true + }, + "dependsOn": ["login"], + "extract": { + "createdProductId": "$.data.id", + "createdProductCode": "$.data.code" + }, + "expect": { + "status": [200, 201], + "jsonPath": { + "$.success": true, + "$.data.id": "@isNumber" + } + } + }, + { + "id": "get_items_list", + "name": "품목 목록 조회", + "description": "생성된 품목이 목록에 있는지 확인", + "method": "GET", + "endpoint": "/items?type=FG&search={{create_product_for_delete.createdProductCode}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["create_product_for_delete"], + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "delete_product_success", + "name": "품목 삭제 (정상)", + "description": "BOM에서 사용되지 않는 품목 삭제 - 성공해야 함", + "method": "DELETE", + "endpoint": "/items/{{create_product_for_delete.createdProductId}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["get_items_list"], + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "verify_deleted_not_in_list", + "name": "삭제된 품목 목록 미포함 확인", + "description": "삭제된 품목이 기본 목록에서 제외되는지 확인", + "method": "GET", + "endpoint": "/items?type=FG&search={{create_product_for_delete.createdProductCode}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["delete_product_success"], + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + }, + "note": "data.total이 0이어야 함 (삭제된 품목 미포함)" + }, + { + "id": "delete_already_deleted_item", + "name": "이미 삭제된 품목 재삭제 시도", + "description": "soft delete된 품목을 다시 삭제하면 404 반환해야 함", + "method": "DELETE", + "endpoint": "/items/{{create_product_for_delete.createdProductId}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["verify_deleted_not_in_list"], + "expect": { + "status": [404], + "jsonPath": { + "$.success": false + } + } + }, + { + "id": "create_bom_parent", + "name": "BOM 부모 품목 생성", + "description": "BOM 테스트를 위한 부모 품목 생성", + "method": "POST", + "endpoint": "/items", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "body": { + "code": "{{variables.testBomParentCode}}", + "name": "{{variables.testBomParentName}}", + "unit": "EA", + "product_type": "FG", + "is_active": true + }, + "dependsOn": ["login"], + "extract": { + "bomParentId": "$.data.id" + }, + "expect": { + "status": [200, 201], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "create_bom_child", + "name": "BOM 자식 품목 생성", + "description": "BOM 구성품으로 사용될 품목 생성", + "method": "POST", + "endpoint": "/items", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "body": { + "code": "TEST-BOM-CHILD-001", + "name": "BOM 자식 품목", + "unit": "EA", + "product_type": "PT", + "is_active": true + }, + "dependsOn": ["create_bom_parent"], + "extract": { + "bomChildId": "$.data.id" + }, + "expect": { + "status": [200, 201], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "add_bom_component", + "name": "BOM 구성품 추가", + "description": "부모 품목에 자식 품목을 BOM으로 등록", + "method": "POST", + "endpoint": "/items/{{create_bom_parent.bomParentId}}/bom", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "body": { + "items": [ + { + "ref_type": "PRODUCT", + "ref_id": "{{create_bom_child.bomChildId}}", + "quantity": 2, + "sort_order": 1 + } + ] + }, + "dependsOn": ["create_bom_child"], + "expect": { + "status": [200, 201], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "delete_bom_used_item_fail", + "name": "BOM 사용 중인 품목 삭제 시도", + "description": "다른 BOM에서 구성품으로 사용 중인 품목 삭제 - 400 에러 반환해야 함", + "method": "DELETE", + "endpoint": "/items/{{create_bom_child.bomChildId}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["add_bom_component"], + "expect": { + "status": [400], + "jsonPath": { + "$.success": false, + "$.message": "@contains:BOM" + } + } + }, + { + "id": "cleanup_bom", + "name": "BOM 구성품 제거", + "description": "테스트 정리 - BOM 구성품 제거", + "method": "DELETE", + "endpoint": "/items/{{create_bom_parent.bomParentId}}/bom", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["delete_bom_used_item_fail"], + "expect": { + "status": [200, 204] + } + }, + { + "id": "delete_bom_child_after_cleanup", + "name": "BOM 해제 후 자식 품목 삭제", + "description": "BOM에서 제거된 품목은 삭제 가능해야 함", + "method": "DELETE", + "endpoint": "/items/{{create_bom_child.bomChildId}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["cleanup_bom"], + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + } + }, + { + "id": "cleanup_bom_parent", + "name": "BOM 부모 품목 삭제", + "description": "테스트 정리 - 부모 품목 삭제", + "method": "DELETE", + "endpoint": "/items/{{create_bom_parent.bomParentId}}", + "headers": { + "Authorization": "Bearer {{login.accessToken}}" + }, + "dependsOn": ["delete_bom_child_after_cleanup"], + "expect": { + "status": [200], + "jsonPath": { + "$.success": true + } + } + } + ] +} \ No newline at end of file diff --git a/lang/ko/error.php b/lang/ko/error.php index a837851..ab0dd73 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -111,6 +111,14 @@ 'field_not_found' => '필드를 찾을 수 없습니다.', 'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.', + // 품목 관리 관련 + 'item' => [ + 'not_found' => '품목 정보를 찾을 수 없습니다.', + 'already_deleted' => '이미 삭제된 품목입니다.', + 'in_use_as_bom_component' => '다른 제품의 BOM 구성품으로 사용 중이어서 삭제할 수 없습니다. (사용처: :count건)', + 'invalid_item_type' => '유효하지 않은 품목 유형입니다.', + ], + // 잠금 관련 'relationship_locked' => '잠금된 연결은 해제할 수 없습니다.', 'has_locked_relationships' => '잠금된 연결이 포함되어 있어 처리할 수 없습니다.',