fix: 품목 삭제 시 BOM 참조 무결성 체크 추가
- 삭제 전 product_components 테이블에서 사용 여부 확인 - BOM 구성품으로 사용 중인 품목 삭제 차단 (400 에러) - 일괄 삭제에도 동일한 참조 체크 적용 - 품목 관련 에러 메시지 추가 (error.item.*) - 품목 삭제 API 테스트 플로우 JSON 추가
This commit is contained in:
@@ -446,6 +446,9 @@ public function updateItem(int $id, array $data): Product
|
|||||||
/**
|
/**
|
||||||
* 품목 삭제 (Product 전용, Soft Delete)
|
* 품목 삭제 (Product 전용, Soft Delete)
|
||||||
*
|
*
|
||||||
|
* - 이미 삭제된 품목은 404 반환
|
||||||
|
* - 다른 BOM의 구성품으로 사용 중이면 삭제 불가
|
||||||
|
*
|
||||||
* @param int $id 품목 ID
|
* @param int $id 품목 ID
|
||||||
*/
|
*/
|
||||||
public function deleteItem(int $id): void
|
public function deleteItem(int $id): void
|
||||||
@@ -457,15 +460,39 @@ public function deleteItem(int $id): void
|
|||||||
->find($id);
|
->find($id);
|
||||||
|
|
||||||
if (! $product) {
|
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->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)
|
* 품목 일괄 삭제 (Product 전용, Soft Delete)
|
||||||
*
|
*
|
||||||
|
* - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가
|
||||||
|
*
|
||||||
* @param array $ids 품목 ID 배열
|
* @param array $ids 품목 ID 배열
|
||||||
*/
|
*/
|
||||||
public function batchDeleteItems(array $ids): void
|
public function batchDeleteItems(array $ids): void
|
||||||
@@ -478,7 +505,20 @@ public function batchDeleteItems(array $ids): void
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
if ($products->isEmpty()) {
|
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) {
|
foreach ($products as $product) {
|
||||||
|
|||||||
287
claudedocs/flow-tester-item-delete.json
Normal file
287
claudedocs/flow-tester-item-delete.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -111,6 +111,14 @@
|
|||||||
'field_not_found' => '필드를 찾을 수 없습니다.',
|
'field_not_found' => '필드를 찾을 수 없습니다.',
|
||||||
'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.',
|
'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.',
|
||||||
|
|
||||||
|
// 품목 관리 관련
|
||||||
|
'item' => [
|
||||||
|
'not_found' => '품목 정보를 찾을 수 없습니다.',
|
||||||
|
'already_deleted' => '이미 삭제된 품목입니다.',
|
||||||
|
'in_use_as_bom_component' => '다른 제품의 BOM 구성품으로 사용 중이어서 삭제할 수 없습니다. (사용처: :count건)',
|
||||||
|
'invalid_item_type' => '유효하지 않은 품목 유형입니다.',
|
||||||
|
],
|
||||||
|
|
||||||
// 잠금 관련
|
// 잠금 관련
|
||||||
'relationship_locked' => '잠금된 연결은 해제할 수 없습니다.',
|
'relationship_locked' => '잠금된 연결은 해제할 수 없습니다.',
|
||||||
'has_locked_relationships' => '잠금된 연결이 포함되어 있어 처리할 수 없습니다.',
|
'has_locked_relationships' => '잠금된 연결이 포함되어 있어 처리할 수 없습니다.',
|
||||||
|
|||||||
Reference in New Issue
Block a user