feat: 품목 삭제 로직 개선 및 중복 코드 예외 처리 보완
- 품목 삭제 시 모든 참조 테이블 사용 여부 체크 (Force Delete) - Product: BOM 구성품/상위품목, BOM 템플릿, 주문, 견적 - Material: BOM 구성품, BOM 템플릿, 입고, LOT - 사용 중인 품목 삭제 불가, 미사용 품목만 영구 삭제 - 일괄 삭제도 동일 로직 적용 - DuplicateCodeException 예외 처리 추가 - ApiResponse.handle()에서 정상 처리되도록 수정 - Handler.php에도 fallback 처리 추가 - i18n 에러 메시지 추가 (in_use, batch_in_use)
This commit is contained in:
@@ -132,6 +132,19 @@ public function render($request, Throwable $exception)
|
||||
], 405);
|
||||
}
|
||||
|
||||
// 400 Bad Request - 품목 코드 중복
|
||||
if ($exception instanceof DuplicateCodeException) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
'error' => [
|
||||
'code' => 400,
|
||||
],
|
||||
'duplicate_id' => $exception->getDuplicateId(),
|
||||
'duplicate_code' => $exception->getDuplicateCode(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
// 500 Internal Server Error (기타 모든 에러)
|
||||
if (
|
||||
$exception instanceof HttpException &&
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Exceptions\DuplicateCodeException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
@@ -168,6 +169,17 @@ public static function handle(
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
// 품목 코드 중복 예외 - duplicate_id, duplicate_code 포함
|
||||
if ($e instanceof DuplicateCodeException) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'error' => ['code' => 400],
|
||||
'duplicate_id' => $e->getDuplicateId(),
|
||||
'duplicate_code' => $e->getDuplicateCode(),
|
||||
], 400);
|
||||
}
|
||||
|
||||
// HttpException 계열은 상태코드/메시지를 그대로 반영
|
||||
if ($e instanceof HttpException) {
|
||||
return self::error(
|
||||
|
||||
@@ -615,10 +615,11 @@ private function updateMaterial(int $id, array $data): Material
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 삭제 (Product/Material 통합, Soft Delete)
|
||||
* 품목 삭제 (Product/Material 통합, Force Delete)
|
||||
*
|
||||
* - 이미 삭제된 품목은 404 반환
|
||||
* - 다른 BOM의 구성품으로 사용 중이면 삭제 불가
|
||||
* - 다른 테이블에서 사용 중이면 삭제 불가
|
||||
* - 사용 안함 → Force Delete (영구 삭제)
|
||||
*
|
||||
* @param int $id 품목 ID
|
||||
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
|
||||
@@ -638,7 +639,10 @@ public function deleteItem(int $id, string $itemType = 'FG'): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Product 삭제 (FG, PT)
|
||||
* Product 삭제 (FG, PT) - Force Delete
|
||||
*
|
||||
* - 사용 중이면 삭제 불가 (에러)
|
||||
* - 사용 안함 → 영구 삭제
|
||||
*/
|
||||
private function deleteProduct(int $id): void
|
||||
{
|
||||
@@ -652,17 +656,22 @@ private function deleteProduct(int $id): void
|
||||
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]));
|
||||
// 사용 여부 종합 체크
|
||||
$usageResult = $this->checkProductUsage($tenantId, $id);
|
||||
if ($usageResult['is_used']) {
|
||||
$usageMessage = $this->formatUsageMessage($usageResult['usage']);
|
||||
throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage]));
|
||||
}
|
||||
|
||||
$product->delete();
|
||||
// 사용 안함 → Force Delete
|
||||
$product->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Material 삭제 (SM, RM, CS)
|
||||
* Material 삭제 (SM, RM, CS) - Force Delete
|
||||
*
|
||||
* - 사용 중이면 삭제 불가 (에러)
|
||||
* - 사용 안함 → 영구 삭제
|
||||
*/
|
||||
private function deleteMaterial(int $id): void
|
||||
{
|
||||
@@ -676,51 +685,175 @@ private function deleteMaterial(int $id): void
|
||||
throw new NotFoundHttpException(__('error.item.not_found'));
|
||||
}
|
||||
|
||||
// BOM 구성품으로 사용 중인지 체크
|
||||
$usageCount = $this->checkMaterialUsageInBom($tenantId, $id);
|
||||
if ($usageCount > 0) {
|
||||
throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount]));
|
||||
// 사용 여부 종합 체크
|
||||
$usageResult = $this->checkMaterialUsage($tenantId, $id);
|
||||
if ($usageResult['is_used']) {
|
||||
$usageMessage = $this->formatUsageMessage($usageResult['usage']);
|
||||
throw new BadRequestHttpException(__('error.item.in_use', ['usage' => $usageMessage]));
|
||||
}
|
||||
|
||||
$material->delete();
|
||||
// 사용 안함 → Force Delete
|
||||
$material->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Product가 다른 BOM의 구성품으로 사용 중인지 체크
|
||||
* Product 사용 여부 종합 체크 (모든 참조 테이블)
|
||||
*
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @param int $productId 제품 ID
|
||||
* @return int 사용 건수
|
||||
* @return array ['is_used' => bool, 'usage' => ['table' => count, ...]]
|
||||
*/
|
||||
private function checkProductUsageInBom(int $tenantId, int $productId): int
|
||||
private function checkProductUsage(int $tenantId, int $productId): array
|
||||
{
|
||||
return \App\Models\Products\ProductComponent::query()
|
||||
$usage = [];
|
||||
|
||||
// 1. BOM 구성품으로 사용 (product_components.ref_type=PRODUCT)
|
||||
$bomComponentCount = \App\Models\Products\ProductComponent::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('ref_type', 'PRODUCT')
|
||||
->where('ref_id', $productId)
|
||||
->count();
|
||||
if ($bomComponentCount > 0) {
|
||||
$usage['bom_components'] = $bomComponentCount;
|
||||
}
|
||||
|
||||
// 2. BOM 상위품목으로 사용 (product_components.parent_product_id)
|
||||
$bomParentCount = \App\Models\Products\ProductComponent::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('parent_product_id', $productId)
|
||||
->count();
|
||||
if ($bomParentCount > 0) {
|
||||
$usage['bom_parent'] = $bomParentCount;
|
||||
}
|
||||
|
||||
// 3. BOM 템플릿 항목 (bom_template_items.ref_type=PRODUCT)
|
||||
$bomTemplateCount = \App\Models\Design\BomTemplateItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('ref_type', 'PRODUCT')
|
||||
->where('ref_id', $productId)
|
||||
->count();
|
||||
if ($bomTemplateCount > 0) {
|
||||
$usage['bom_templates'] = $bomTemplateCount;
|
||||
}
|
||||
|
||||
// 4. 주문 (orders.product_id)
|
||||
$orderCount = \App\Models\Orders\Order::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('product_id', $productId)
|
||||
->count();
|
||||
if ($orderCount > 0) {
|
||||
$usage['orders'] = $orderCount;
|
||||
}
|
||||
|
||||
// 5. 주문 항목 (order_items.product_id)
|
||||
$orderItemCount = \App\Models\Orders\OrderItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('product_id', $productId)
|
||||
->count();
|
||||
if ($orderItemCount > 0) {
|
||||
$usage['order_items'] = $orderItemCount;
|
||||
}
|
||||
|
||||
// 6. 견적 (quotes.product_id)
|
||||
$quoteCount = \App\Models\Quote\Quote::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('product_id', $productId)
|
||||
->count();
|
||||
if ($quoteCount > 0) {
|
||||
$usage['quotes'] = $quoteCount;
|
||||
}
|
||||
|
||||
return [
|
||||
'is_used' => ! empty($usage),
|
||||
'usage' => $usage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Material이 다른 BOM의 구성품으로 사용 중인지 체크
|
||||
* Material 사용 여부 종합 체크 (모든 참조 테이블)
|
||||
*
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @param int $materialId 자재 ID
|
||||
* @return int 사용 건수
|
||||
* @return array ['is_used' => bool, 'usage' => ['table' => count, ...]]
|
||||
*/
|
||||
private function checkMaterialUsageInBom(int $tenantId, int $materialId): int
|
||||
private function checkMaterialUsage(int $tenantId, int $materialId): array
|
||||
{
|
||||
return \App\Models\Products\ProductComponent::query()
|
||||
$usage = [];
|
||||
|
||||
// 1. BOM 구성품으로 사용 (product_components.ref_type=MATERIAL)
|
||||
$bomComponentCount = \App\Models\Products\ProductComponent::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('ref_type', 'MATERIAL')
|
||||
->where('ref_id', $materialId)
|
||||
->count();
|
||||
if ($bomComponentCount > 0) {
|
||||
$usage['bom_components'] = $bomComponentCount;
|
||||
}
|
||||
|
||||
// 2. BOM 템플릿 항목 (bom_template_items.ref_type=MATERIAL)
|
||||
$bomTemplateCount = \App\Models\Design\BomTemplateItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('ref_type', 'MATERIAL')
|
||||
->where('ref_id', $materialId)
|
||||
->count();
|
||||
if ($bomTemplateCount > 0) {
|
||||
$usage['bom_templates'] = $bomTemplateCount;
|
||||
}
|
||||
|
||||
// 3. 자재 입고 (material_receipts.material_id)
|
||||
$receiptCount = \App\Models\Materials\MaterialReceipt::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('material_id', $materialId)
|
||||
->count();
|
||||
if ($receiptCount > 0) {
|
||||
$usage['receipts'] = $receiptCount;
|
||||
}
|
||||
|
||||
// 4. LOT (lots.material_id)
|
||||
$lotCount = \App\Models\Qualitys\Lot::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('material_id', $materialId)
|
||||
->count();
|
||||
if ($lotCount > 0) {
|
||||
$usage['lots'] = $lotCount;
|
||||
}
|
||||
|
||||
return [
|
||||
'is_used' => ! empty($usage),
|
||||
'usage' => $usage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 일괄 삭제 (Product/Material 통합, Soft Delete)
|
||||
* 사용처 정보를 한글 메시지로 변환
|
||||
*/
|
||||
private function formatUsageMessage(array $usage): string
|
||||
{
|
||||
$labels = [
|
||||
'bom_components' => 'BOM 구성품',
|
||||
'bom_parent' => 'BOM 상위품목',
|
||||
'bom_templates' => 'BOM 템플릿',
|
||||
'orders' => '주문',
|
||||
'order_items' => '주문 항목',
|
||||
'quotes' => '견적',
|
||||
'receipts' => '입고',
|
||||
'lots' => 'LOT',
|
||||
];
|
||||
|
||||
$parts = [];
|
||||
foreach ($usage as $key => $count) {
|
||||
$label = $labels[$key] ?? $key;
|
||||
$parts[] = "{$label} {$count}건";
|
||||
}
|
||||
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 일괄 삭제 (Product/Material 통합, Force Delete)
|
||||
*
|
||||
* - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가
|
||||
* - 다른 테이블에서 사용 중인 품목은 삭제 불가
|
||||
* - 사용 안함 → Force Delete (영구 삭제)
|
||||
*
|
||||
* @param array $ids 품목 ID 배열
|
||||
* @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS)
|
||||
@@ -740,7 +873,10 @@ public function batchDeleteItems(array $ids, string $itemType = 'FG'): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Product 일괄 삭제 (FG, PT)
|
||||
* Product 일괄 삭제 (FG, PT) - Force Delete
|
||||
*
|
||||
* - 사용 중인 품목이 있으면 전체 삭제 실패
|
||||
* - 모두 사용 안함 → 영구 삭제
|
||||
*/
|
||||
private function batchDeleteProducts(array $ids): void
|
||||
{
|
||||
@@ -755,26 +891,38 @@ private function batchDeleteProducts(array $ids): void
|
||||
throw new NotFoundHttpException(__('error.item.not_found'));
|
||||
}
|
||||
|
||||
// BOM 구성품으로 사용 중인 품목 체크
|
||||
$inUseIds = [];
|
||||
// 사용 중인 품목 체크 (하나라도 사용 중이면 전체 실패)
|
||||
$inUseItems = [];
|
||||
foreach ($products as $product) {
|
||||
$usageCount = $this->checkProductUsageInBom($tenantId, $product->id);
|
||||
if ($usageCount > 0) {
|
||||
$inUseIds[] = $product->id;
|
||||
$usageResult = $this->checkProductUsage($tenantId, $product->id);
|
||||
if ($usageResult['is_used']) {
|
||||
$inUseItems[] = [
|
||||
'id' => $product->id,
|
||||
'code' => $product->code,
|
||||
'usage' => $usageResult['usage'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($inUseIds)) {
|
||||
throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)]));
|
||||
if (! empty($inUseItems)) {
|
||||
$codes = array_column($inUseItems, 'code');
|
||||
throw new BadRequestHttpException(__('error.item.batch_in_use', [
|
||||
'codes' => implode(', ', $codes),
|
||||
'count' => count($inUseItems),
|
||||
]));
|
||||
}
|
||||
|
||||
// 모두 사용 안함 → Force Delete
|
||||
foreach ($products as $product) {
|
||||
$product->delete();
|
||||
$product->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material 일괄 삭제 (SM, RM, CS)
|
||||
* Material 일괄 삭제 (SM, RM, CS) - Force Delete
|
||||
*
|
||||
* - 사용 중인 자재가 있으면 전체 삭제 실패
|
||||
* - 모두 사용 안함 → 영구 삭제
|
||||
*/
|
||||
private function batchDeleteMaterials(array $ids): void
|
||||
{
|
||||
@@ -789,21 +937,30 @@ private function batchDeleteMaterials(array $ids): void
|
||||
throw new NotFoundHttpException(__('error.item.not_found'));
|
||||
}
|
||||
|
||||
// BOM 구성품으로 사용 중인 자재 체크
|
||||
$inUseIds = [];
|
||||
// 사용 중인 자재 체크 (하나라도 사용 중이면 전체 실패)
|
||||
$inUseItems = [];
|
||||
foreach ($materials as $material) {
|
||||
$usageCount = $this->checkMaterialUsageInBom($tenantId, $material->id);
|
||||
if ($usageCount > 0) {
|
||||
$inUseIds[] = $material->id;
|
||||
$usageResult = $this->checkMaterialUsage($tenantId, $material->id);
|
||||
if ($usageResult['is_used']) {
|
||||
$inUseItems[] = [
|
||||
'id' => $material->id,
|
||||
'code' => $material->material_code,
|
||||
'usage' => $usageResult['usage'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($inUseIds)) {
|
||||
throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)]));
|
||||
if (! empty($inUseItems)) {
|
||||
$codes = array_column($inUseItems, 'code');
|
||||
throw new BadRequestHttpException(__('error.item.batch_in_use', [
|
||||
'codes' => implode(', ', $codes),
|
||||
'count' => count($inUseItems),
|
||||
]));
|
||||
}
|
||||
|
||||
// 모두 사용 안함 → Force Delete
|
||||
foreach ($materials as $material) {
|
||||
$material->delete();
|
||||
$material->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,4 +76,15 @@
|
||||
'has_clients' => 'Cannot delete the client group because it has associated clients.',
|
||||
'code_exists_in_deleted' => 'The same code exists in deleted data. Please permanently delete that code first or use a different code.',
|
||||
|
||||
// Item management related
|
||||
'item' => [
|
||||
'not_found' => 'Item not found.',
|
||||
'already_deleted' => 'Item has already been deleted.',
|
||||
'in_use_as_bom_component' => 'Cannot delete item as it is used as a BOM component. (Usage: :count items)',
|
||||
'in_use' => 'Cannot delete item that is currently in use. (Usage: :usage)',
|
||||
'batch_in_use' => 'Cannot delete items because some are in use. (Items: :codes, :count items)',
|
||||
'invalid_item_type' => 'Invalid item type.',
|
||||
'duplicate_code' => 'Duplicate item code.',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
'not_found' => '품목 정보를 찾을 수 없습니다.',
|
||||
'already_deleted' => '이미 삭제된 품목입니다.',
|
||||
'in_use_as_bom_component' => '다른 제품의 BOM 구성품으로 사용 중이어서 삭제할 수 없습니다. (사용처: :count건)',
|
||||
'in_use' => '사용 중인 품목은 삭제할 수 없습니다. (사용처: :usage)',
|
||||
'batch_in_use' => '사용 중인 품목이 포함되어 있어 삭제할 수 없습니다. (품목: :codes, :count건)',
|
||||
'invalid_item_type' => '유효하지 않은 품목 유형입니다.',
|
||||
'duplicate_code' => '중복된 품목 코드입니다.',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user