diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index 03198db..b8d8c1e 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -4,6 +4,8 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\Order\CreateFromQuoteRequest; +use App\Http\Requests\Order\CreateProductionOrderRequest; use App\Http\Requests\Order\StoreOrderRequest; use App\Http\Requests\Order\UpdateOrderRequest; use App\Http\Requests\Order\UpdateOrderStatusRequest; @@ -85,4 +87,24 @@ public function updateStatus(UpdateOrderStatusRequest $request, int $id) return $this->service->updateStatus($id, $request->validated()['status']); }, __('message.order.status_updated')); } + + /** + * 견적에서 수주 생성 + */ + public function createFromQuote(CreateFromQuoteRequest $request, int $quoteId) + { + return ApiResponse::handle(function () use ($request, $quoteId) { + return $this->service->createFromQuote($quoteId, $request->validated()); + }, __('message.order.created_from_quote')); + } + + /** + * 생산지시 생성 + */ + public function createProductionOrder(CreateProductionOrderRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return $this->service->createProductionOrder($id, $request->validated()); + }, __('message.order.production_order_created')); + } } diff --git a/app/Http/Requests/Order/CreateFromQuoteRequest.php b/app/Http/Requests/Order/CreateFromQuoteRequest.php new file mode 100644 index 0000000..843f771 --- /dev/null +++ b/app/Http/Requests/Order/CreateFromQuoteRequest.php @@ -0,0 +1,28 @@ + 'nullable|date', + 'memo' => 'nullable|string', + ]; + } + + public function messages(): array + { + return [ + 'delivery_date.date' => __('validation.date', ['attribute' => '납품일']), + ]; + } +} diff --git a/app/Http/Requests/Order/CreateProductionOrderRequest.php b/app/Http/Requests/Order/CreateProductionOrderRequest.php new file mode 100644 index 0000000..6359cf3 --- /dev/null +++ b/app/Http/Requests/Order/CreateProductionOrderRequest.php @@ -0,0 +1,36 @@ + ['nullable', Rule::in(WorkOrder::PROCESS_TYPES)], + 'assignee_id' => 'nullable|integer|exists:users,id', + 'team_id' => 'nullable|integer|exists:departments,id', + 'scheduled_date' => 'nullable|date', + 'memo' => 'nullable|string', + ]; + } + + public function messages(): array + { + return [ + 'process_type.in' => __('validation.in', ['attribute' => '공정 유형']), + 'assignee_id.exists' => __('validation.exists', ['attribute' => '담당자']), + 'team_id.exists' => __('validation.exists', ['attribute' => '팀']), + 'scheduled_date.date' => __('validation.date', ['attribute' => '예정일']), + ]; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 124102e..66abbd6 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\Orders\Order; +use App\Models\Production\WorkOrder; +use App\Models\Quote\Quote; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -319,4 +321,160 @@ private function generateOrderNo(int $tenantId): string return sprintf('%s%s%04d', $prefix, $date, $seq); } + + /** + * 견적에서 수주 생성 + */ + public function createFromQuote(int $quoteId, array $data = []) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 견적 조회 + $quote = Quote::where('tenant_id', $tenantId) + ->with(['items', 'client']) + ->find($quoteId); + + if (! $quote) { + throw new NotFoundHttpException(__('error.quote.not_found')); + } + + // 이미 수주가 생성된 견적인지 확인 + $existingOrder = Order::where('tenant_id', $tenantId) + ->where('quote_id', $quoteId) + ->first(); + + if ($existingOrder) { + throw new BadRequestHttpException(__('error.order.already_created_from_quote')); + } + + return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { + // 수주번호 생성 + $orderNo = $this->generateOrderNo($tenantId); + + // Order 모델의 createFromQuote 사용 + $order = Order::createFromQuote($quote, $orderNo); + $order->created_by = $userId; + $order->updated_by = $userId; + + // 추가 데이터 병합 (납품일, 메모 등) + if (! empty($data['delivery_date'])) { + $order->delivery_date = $data['delivery_date']; + } + if (! empty($data['memo'])) { + $order->memo = $data['memo']; + } + + $order->save(); + + // 견적 품목을 수주 품목으로 변환 + foreach ($quote->items as $index => $quoteItem) { + $order->items()->create([ + 'item_id' => $quoteItem->item_id, + 'item_name' => $quoteItem->item_name, + 'specification' => $quoteItem->specification, + 'quantity' => $quoteItem->calculated_quantity, + 'unit' => $quoteItem->unit, + 'unit_price' => $quoteItem->unit_price, + 'supply_amount' => $quoteItem->total_price, + 'tax_amount' => round($quoteItem->total_price * 0.1, 2), + 'total_amount' => round($quoteItem->total_price * 1.1, 2), + 'sort_order' => $index, + ]); + } + + // 합계 재계산 + $order->refresh(); + $order->recalculateTotals()->save(); + + return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); + }); + } + + /** + * 생산지시 생성 + */ + public function createProductionOrder(int $orderId, array $data) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 수주 조회 + $order = Order::where('tenant_id', $tenantId) + ->with('items') + ->find($orderId); + + if (! $order) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 상태 확인 (CONFIRMED 상태에서만 생산지시 가능) + if ($order->status_code !== Order::STATUS_CONFIRMED) { + throw new BadRequestHttpException(__('error.order.must_be_confirmed_for_production')); + } + + // 이미 생산지시가 존재하는지 확인 + $existingWorkOrder = WorkOrder::where('tenant_id', $tenantId) + ->where('sales_order_id', $orderId) + ->first(); + + if ($existingWorkOrder) { + throw new BadRequestHttpException(__('error.order.production_order_already_exists')); + } + + return DB::transaction(function () use ($order, $data, $tenantId, $userId) { + // 작업지시번호 생성 + $workOrderNo = $this->generateWorkOrderNo($tenantId); + + // 작업지시 생성 + $workOrder = WorkOrder::create([ + 'tenant_id' => $tenantId, + 'work_order_no' => $workOrderNo, + 'sales_order_id' => $order->id, + 'project_name' => $order->site_name ?? $order->client_name, + 'process_type' => $data['process_type'] ?? WorkOrder::PROCESS_SCREEN, + 'status' => WorkOrder::STATUS_PENDING, + 'assignee_id' => $data['assignee_id'] ?? null, + 'team_id' => $data['team_id'] ?? null, + 'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date, + 'memo' => $data['memo'] ?? null, + 'is_active' => true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 수주 상태를 IN_PROGRESS로 변경 + $order->status_code = Order::STATUS_IN_PROGRESS; + $order->updated_by = $userId; + $order->save(); + + return [ + 'work_order' => $workOrder->load(['assignee:id,name', 'team:id,name']), + 'order' => $order->load(['client:id,name', 'items']), + ]; + }); + } + + /** + * 작업지시번호 자동 생성 + */ + private function generateWorkOrderNo(int $tenantId): string + { + $prefix = 'WO'; + $date = now()->format('Ymd'); + + $lastNo = WorkOrder::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('work_order_no', 'like', "{$prefix}{$date}%") + ->orderByDesc('work_order_no') + ->value('work_order_no'); + + if ($lastNo) { + $seq = (int) substr($lastNo, -4) + 1; + } else { + $seq = 1; + } + + return sprintf('%s%s%04d', $prefix, $date, $seq); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 1b053b4..3274e9c 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -362,5 +362,13 @@ 'cannot_update_completed' => '완료 또는 취소된 수주는 수정할 수 없습니다.', 'cannot_delete_in_progress' => '진행 중이거나 완료된 수주는 삭제할 수 없습니다.', 'invalid_status_transition' => '유효하지 않은 상태 전환입니다.', + 'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.', + 'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.', + 'production_order_already_exists' => '이미 생산지시가 존재합니다.', + ], + + // 견적 관련 + 'quote' => [ + 'not_found' => '견적을 찾을 수 없습니다.', ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 2f97da3..99a4fd7 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -437,5 +437,7 @@ 'updated' => '수주가 수정되었습니다.', 'deleted' => '수주가 삭제되었습니다.', 'status_updated' => '수주 상태가 변경되었습니다.', + 'created_from_quote' => '견적에서 수주가 생성되었습니다.', + 'production_order_created' => '생산지시가 생성되었습니다.', ], ]; diff --git a/routes/api.php b/routes/api.php index 8988d2f..1e96abb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1083,6 +1083,12 @@ // 상태 관리 Route::patch('/{id}/status', [OrderController::class, 'updateStatus'])->whereNumber('id')->name('v1.orders.status'); // 상태 변경 + + // 견적에서 수주 생성 + Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); + + // 생산지시 생성 + Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order'); }); // 작업지시 관리 API (Production)