diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index f6935bc..0138723 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -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')); } } diff --git a/app/Http/Requests/Order/OrderBulkDeleteRequest.php b/app/Http/Requests/Order/OrderBulkDeleteRequest.php new file mode 100644 index 0000000..7cdc85f --- /dev/null +++ b/app/Http/Requests/Order/OrderBulkDeleteRequest.php @@ -0,0 +1,22 @@ + 'required|array|min:1', + 'ids.*' => 'required|integer', + 'force' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 2ca6480..4715845 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -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, ]; /** diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 59d32ed..2738d57 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -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, + ]; + }); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 59f5b04..d48bf30 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -431,6 +431,8 @@ 'production_order_already_exists' => '이미 생산지시가 존재합니다.', 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', + 'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.', + 'cancel_reason_required' => '취소 사유를 입력해주세요.', 'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.', ], diff --git a/lang/ko/message.php b/lang/ko/message.php index a907f89..c30fbb1 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -468,6 +468,7 @@ 'created' => '수주가 등록되었습니다.', 'updated' => '수주가 수정되었습니다.', 'deleted' => '수주가 삭제되었습니다.', + 'bulk_deleted' => '수주가 일괄 삭제되었습니다.', 'status_updated' => '수주 상태가 변경되었습니다.', 'created_from_quote' => '견적에서 수주가 생성되었습니다.', 'production_order_created' => '생산지시가 생성되었습니다.', diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 3330cfb..1398d41 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -152,6 +152,7 @@ Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록 Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계 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::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정 Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제