feat(WEB): 수주 Bulk Delete API + 작업지시 Revert Force 통합

- 수주 일괄 삭제 API 추가 (DELETE /orders/bulk)
  - OrderBulkDeleteRequest (ids, force 검증)
  - force=true: hard delete (운영환경 차단), force=false: soft delete
  - 삭제 불가 건(상태/작업지시/출하) skip 처리 + skipped_ids 반환

- 작업지시 되돌리기 force/운영 모드 분기
  - force=true (개발): 기존 hard delete 로직 유지
  - force=false (운영): 작업지시 cancelled 상태 변경, options에 취소정보 기록, 자재 투입분 재고 역분개, 데이터 보존
  - reason 필수 (운영 모드)

- WorkOrder 모델에 STATUS_CANCELLED 상수 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 08:29:53 +09:00
parent d7ca8cfa00
commit 37424b9cef
7 changed files with 243 additions and 6 deletions

View File

@@ -402,6 +402,98 @@ public function destroy(int $id)
});
}
/**
* 일괄 삭제
*/
public function bulkDestroy(array $ids, bool $force = false): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// force=true는 운영 환경에서 차단
if ($force && app()->environment('production')) {
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
}
$orders = Order::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
$deletedCount = 0;
$skippedIds = [];
return DB::transaction(function () use ($orders, $force, $userId, &$deletedCount, &$skippedIds) {
foreach ($orders as $order) {
// 상태 검증: DRAFT/CONFIRMED/CANCELLED만 삭제 가능
if (! in_array($order->status_code, [
Order::STATUS_DRAFT,
Order::STATUS_CONFIRMED,
Order::STATUS_CANCELLED,
])) {
$skippedIds[] = $order->id;
continue;
}
// 작업지시 존재 시 skip
if ($order->workOrders()->exists()) {
$skippedIds[] = $order->id;
continue;
}
// 출하 존재 시 skip
if ($order->shipments()->exists()) {
$skippedIds[] = $order->id;
continue;
}
// 견적 연결 해제
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->where('order_id', $order->id)
->update([
'order_id' => null,
'status' => Quote::STATUS_FINALIZED,
]);
}
if ($force) {
// hard delete: 컴포넌트 → 품목 → 노드 → 마스터
foreach ($order->items as $item) {
$item->components()->forceDelete();
}
$order->items()->forceDelete();
$order->nodes()->forceDelete();
$order->forceDelete();
} else {
// soft delete: 기존 destroy() 로직과 동일
foreach ($order->items as $item) {
$item->components()->update(['deleted_by' => $userId]);
$item->components()->delete();
}
$order->items()->update(['deleted_by' => $userId]);
$order->items()->delete();
$order->nodes()->update(['deleted_by' => $userId]);
$order->nodes()->delete();
$order->deleted_by = $userId;
$order->save();
$order->delete();
}
$deletedCount++;
}
return [
'deleted_count' => $deletedCount,
'skipped_count' => count($skippedIds),
'skipped_ids' => $skippedIds,
];
});
}
/**
* 상태 변경
*/
@@ -1390,13 +1482,26 @@ public function revertOrderConfirmation(int $orderId): array
}
/**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
* 생산지시 되돌리기
*
* force=true: 개발 모드 - 모든 데이터 hard delete + 재고 복구 (운영환경 차단)
* force=false: 운영 모드 - 작업지시 취소 상태 변경 + 재고 역분개 (데이터 보존)
*/
public function revertProductionOrder(int $orderId): array
public function revertProductionOrder(int $orderId, bool $force = false, ?string $reason = null): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// force=true는 운영 환경에서 차단
if ($force && app()->environment('production')) {
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
}
// 운영 모드에서는 reason 필수
if (! $force && empty($reason)) {
throw new BadRequestHttpException(__('error.order.cancel_reason_required'));
}
// 수주 조회
$order = Order::where('tenant_id', $tenantId)
->find($orderId);
@@ -1410,8 +1515,19 @@ public function revertProductionOrder(int $orderId): array
throw new BadRequestHttpException(__('error.order.cannot_revert_completed'));
}
if ($force) {
return $this->revertProductionOrderForce($order, $tenantId, $userId);
}
return $this->revertProductionOrderCancel($order, $tenantId, $userId, $reason);
}
/**
* 생산지시 되돌리기 - 개발 모드 (hard delete)
*/
private function revertProductionOrderForce(Order $order, int $tenantId, int $userId): array
{
return DB::transaction(function () use ($order, $tenantId, $userId) {
// 관련 작업지시 ID 조회
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $order->id)
->pluck('id')
@@ -1514,4 +1630,77 @@ public function revertProductionOrder(int $orderId): array
];
});
}
/**
* 생산지시 되돌리기 - 운영 모드 (취소 상태 변경, 데이터 보존)
*/
private function revertProductionOrderCancel(Order $order, int $tenantId, int $userId, string $reason): array
{
return DB::transaction(function () use ($order, $tenantId, $userId, $reason) {
$workOrders = WorkOrder::where('tenant_id', $tenantId)
->where('sales_order_id', $order->id)
->get();
$cancelledCount = 0;
$skippedIds = [];
$stockService = app(StockService::class);
foreach ($workOrders as $workOrder) {
// completed/shipped 상태는 취소 거부
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
$skippedIds[] = $workOrder->id;
continue;
}
// 작업지시 상태를 cancelled로 변경
$workOrder->status = WorkOrder::STATUS_CANCELLED;
$workOrder->updated_by = $userId;
// options에 취소 정보 기록
$options = $workOrder->options ?? [];
$options['cancelled_at'] = now()->toIso8601String();
$options['cancelled_by'] = $userId;
$options['cancel_reason'] = $reason;
$workOrder->options = $options;
$workOrder->save();
// 자재 투입분 재고 역분개
$materialInputs = WorkOrderMaterialInput::where('work_order_id', $workOrder->id)->get();
foreach ($materialInputs as $input) {
try {
$stockService->increaseToLot(
stockLotId: $input->stock_lot_id,
qty: (float) $input->qty,
reason: 'work_order_cancel',
referenceId: $workOrder->id
);
} catch (\Exception $e) {
Log::warning('생산지시 취소: 재고 복원 실패', [
'input_id' => $input->id,
'stock_lot_id' => $input->stock_lot_id,
'error' => $e->getMessage(),
]);
}
}
$cancelledCount++;
}
// 수주 상태를 CONFIRMED로 복원
$previousStatus = $order->status_code;
$order->status_code = Order::STATUS_CONFIRMED;
$order->updated_by = $userId;
$order->save();
return [
'order' => $order->load(['client:id,name', 'items']),
'cancelled_count' => $cancelledCount,
'skipped_count' => count($skippedIds),
'skipped_ids' => $skippedIds,
'previous_status' => $previousStatus,
];
});
}
}