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

@@ -6,6 +6,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Order\CreateFromQuoteRequest;
use App\Http\Requests\Order\CreateProductionOrderRequest;
use App\Http\Requests\Order\OrderBulkDeleteRequest;
use App\Http\Requests\Order\StoreOrderRequest;
use App\Http\Requests\Order\UpdateOrderRequest;
use App\Http\Requests\Order\UpdateOrderStatusRequest;
@@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id)
}, __('message.order.updated'));
}
/**
* 일괄 삭제
*/
public function bulkDestroy(OrderBulkDeleteRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validated();
return $this->service->bulkDestroy(
$validated['ids'],
$validated['force'] ?? false
);
}, __('message.order.bulk_deleted'));
}
/**
* 삭제
*/
@@ -121,10 +137,13 @@ public function revertOrderConfirmation(int $id)
/**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
*/
public function revertProductionOrder(int $id)
public function revertProductionOrder(Request $request, int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->revertProductionOrder($id);
$force = $request->boolean('force', false);
$reason = $request->input('reason');
return ApiResponse::handle(function () use ($id, $force, $reason) {
return $this->service->revertProductionOrder($id, $force, $reason);
}, __('message.order.production_order_reverted'));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Order;
use Illuminate\Foundation\Http\FormRequest;
class OrderBulkDeleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => 'required|array|min:1',
'ids.*' => 'required|integer',
'force' => 'sometimes|boolean',
];
}
}

View File

@@ -101,6 +101,8 @@ class WorkOrder extends Model
public const STATUS_SHIPPED = 'shipped'; // 출하
public const STATUS_CANCELLED = 'cancelled'; // 취소
public const STATUSES = [
self::STATUS_UNASSIGNED,
self::STATUS_PENDING,
@@ -108,6 +110,7 @@ class WorkOrder extends Model
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
self::STATUS_SHIPPED,
self::STATUS_CANCELLED,
];
/**

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,
];
});
}
}