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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal file
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user