From 86ef8412778bf127f9b5d2c2dc4f2562dc3b3052 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 11 Dec 2025 19:01:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 삭제 시 모든 참조 테이블 사용 여부 체크 (Force Delete) - Product: BOM 구성품/상위품목, BOM 템플릿, 주문, 견적 - Material: BOM 구성품, BOM 템플릿, 입고, LOT - 사용 중인 품목 삭제 불가, 미사용 품목만 영구 삭제 - 일괄 삭제도 동일 로직 적용 - DuplicateCodeException 예외 처리 추가 - ApiResponse.handle()에서 정상 처리되도록 수정 - Handler.php에도 fallback 처리 추가 - i18n 에러 메시지 추가 (in_use, batch_in_use) --- app/Exceptions/Handler.php | 13 ++ app/Helpers/ApiResponse.php | 12 ++ app/Services/ItemsService.php | 241 ++++++++++++++++++++++++++++------ lang/en/error.php | 11 ++ lang/ko/error.php | 2 + 5 files changed, 237 insertions(+), 42 deletions(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 3a94f0b..67bffb6 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -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 && diff --git a/app/Helpers/ApiResponse.php b/app/Helpers/ApiResponse.php index e627306..70bf7f2 100644 --- a/app/Helpers/ApiResponse.php +++ b/app/Helpers/ApiResponse.php @@ -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( diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index d809b86..25e4576 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -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(); } } diff --git a/lang/en/error.php b/lang/en/error.php index 2791f85..b883e46 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -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.', + ], + ]; diff --git a/lang/ko/error.php b/lang/ko/error.php index 5f3a25d..8e2d554 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -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' => '중복된 품목 코드입니다.', ],