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:
2025-12-11 19:01:07 +09:00
parent 07f0db17a7
commit 86ef841277
5 changed files with 237 additions and 42 deletions

View File

@@ -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 &&

View File

@@ -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(

View File

@@ -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();
}
}

View File

@@ -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.',
],
];

View File

@@ -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' => '중복된 품목 코드입니다.',
],