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\Controllers\Controller;
|
||||||
use App\Http\Requests\Order\CreateFromQuoteRequest;
|
use App\Http\Requests\Order\CreateFromQuoteRequest;
|
||||||
use App\Http\Requests\Order\CreateProductionOrderRequest;
|
use App\Http\Requests\Order\CreateProductionOrderRequest;
|
||||||
|
use App\Http\Requests\Order\OrderBulkDeleteRequest;
|
||||||
use App\Http\Requests\Order\StoreOrderRequest;
|
use App\Http\Requests\Order\StoreOrderRequest;
|
||||||
use App\Http\Requests\Order\UpdateOrderRequest;
|
use App\Http\Requests\Order\UpdateOrderRequest;
|
||||||
use App\Http\Requests\Order\UpdateOrderStatusRequest;
|
use App\Http\Requests\Order\UpdateOrderStatusRequest;
|
||||||
@@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id)
|
|||||||
}, __('message.order.updated'));
|
}, __('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) {
|
$force = $request->boolean('force', false);
|
||||||
return $this->service->revertProductionOrder($id);
|
$reason = $request->input('reason');
|
||||||
|
|
||||||
|
return ApiResponse::handle(function () use ($id, $force, $reason) {
|
||||||
|
return $this->service->revertProductionOrder($id, $force, $reason);
|
||||||
}, __('message.order.production_order_reverted'));
|
}, __('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_SHIPPED = 'shipped'; // 출하
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled'; // 취소
|
||||||
|
|
||||||
public const STATUSES = [
|
public const STATUSES = [
|
||||||
self::STATUS_UNASSIGNED,
|
self::STATUS_UNASSIGNED,
|
||||||
self::STATUS_PENDING,
|
self::STATUS_PENDING,
|
||||||
@@ -108,6 +110,7 @@ class WorkOrder extends Model
|
|||||||
self::STATUS_IN_PROGRESS,
|
self::STATUS_IN_PROGRESS,
|
||||||
self::STATUS_COMPLETED,
|
self::STATUS_COMPLETED,
|
||||||
self::STATUS_SHIPPED,
|
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();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
$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)
|
$order = Order::where('tenant_id', $tenantId)
|
||||||
->find($orderId);
|
->find($orderId);
|
||||||
@@ -1410,8 +1515,19 @@ public function revertProductionOrder(int $orderId): array
|
|||||||
throw new BadRequestHttpException(__('error.order.cannot_revert_completed'));
|
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) {
|
return DB::transaction(function () use ($order, $tenantId, $userId) {
|
||||||
// 관련 작업지시 ID 조회
|
|
||||||
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
|
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
|
||||||
->where('sales_order_id', $order->id)
|
->where('sales_order_id', $order->id)
|
||||||
->pluck('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,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,6 +431,8 @@
|
|||||||
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
|
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
|
||||||
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
|
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
|
||||||
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
|
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
|
||||||
|
'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.',
|
||||||
|
'cancel_reason_required' => '취소 사유를 입력해주세요.',
|
||||||
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
|
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -468,6 +468,7 @@
|
|||||||
'created' => '수주가 등록되었습니다.',
|
'created' => '수주가 등록되었습니다.',
|
||||||
'updated' => '수주가 수정되었습니다.',
|
'updated' => '수주가 수정되었습니다.',
|
||||||
'deleted' => '수주가 삭제되었습니다.',
|
'deleted' => '수주가 삭제되었습니다.',
|
||||||
|
'bulk_deleted' => '수주가 일괄 삭제되었습니다.',
|
||||||
'status_updated' => '수주 상태가 변경되었습니다.',
|
'status_updated' => '수주 상태가 변경되었습니다.',
|
||||||
'created_from_quote' => '견적에서 수주가 생성되었습니다.',
|
'created_from_quote' => '견적에서 수주가 생성되었습니다.',
|
||||||
'production_order_created' => '생산지시가 생성되었습니다.',
|
'production_order_created' => '생산지시가 생성되었습니다.',
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록
|
Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록
|
||||||
Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계
|
Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계
|
||||||
Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성
|
Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성
|
||||||
|
Route::delete('/bulk', [OrderController::class, 'bulkDestroy'])->name('v1.orders.bulk-destroy'); // 일괄 삭제
|
||||||
Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세
|
Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세
|
||||||
Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정
|
Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정
|
||||||
Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제
|
Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제
|
||||||
|
|||||||
Reference in New Issue
Block a user