tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; $orderType = $params['order_type'] ?? null; $clientId = $params['client_id'] ?? null; $dateFrom = $params['date_from'] ?? null; $dateTo = $params['date_to'] ?? null; $query = Order::query() ->where('tenant_id', $tenantId) ->with(['client:id,name', 'items']); // 검색어 (수주번호, 현장명, 거래처명) if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('order_no', 'like', "%{$q}%") ->orWhere('site_name', 'like', "%{$q}%") ->orWhere('client_name', 'like', "%{$q}%"); }); } // 상태 필터 if ($status !== null) { $query->where('status_code', $status); } // 주문유형 필터 (ORDER/PURCHASE) if ($orderType !== null) { $query->where('order_type_code', $orderType); } // 거래처 필터 if ($clientId !== null) { $query->where('client_id', $clientId); } // 날짜 범위 (수주일 기준) if ($dateFrom !== null) { $query->where('received_at', '>=', $dateFrom); } if ($dateTo !== null) { $query->where('received_at', '<=', $dateTo); } $query->orderByDesc('created_at'); return $query->paginate($size, ['*'], 'page', $page); } /** * 통계 조회 */ public function stats(): array { $tenantId = $this->tenantId(); $counts = Order::where('tenant_id', $tenantId) ->select('status_code', DB::raw('count(*) as count')) ->groupBy('status_code') ->pluck('count', 'status_code') ->toArray(); $amounts = Order::where('tenant_id', $tenantId) ->select('status_code', DB::raw('sum(total_amount) as total')) ->groupBy('status_code') ->pluck('total', 'status_code') ->toArray(); return [ 'total' => array_sum($counts), 'draft' => $counts[Order::STATUS_DRAFT] ?? 0, 'confirmed' => $counts[Order::STATUS_CONFIRMED] ?? 0, 'in_progress' => $counts[Order::STATUS_IN_PROGRESS] ?? 0, 'completed' => $counts[Order::STATUS_COMPLETED] ?? 0, 'cancelled' => $counts[Order::STATUS_CANCELLED] ?? 0, 'total_amount' => array_sum($amounts), 'confirmed_amount' => ($amounts[Order::STATUS_CONFIRMED] ?? 0) + ($amounts[Order::STATUS_IN_PROGRESS] ?? 0), ]; } /** * 단건 조회 */ public function show(int $id) { $tenantId = $this->tenantId(); $order = Order::where('tenant_id', $tenantId) ->with([ 'client:id,name,business_no,representative,phone,email', 'items' => fn ($q) => $q->orderBy('sort_order'), 'quote:id,quote_no,site_name', ]) ->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } return $order; } /** * 생성 */ public function store(array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 수주번호 자동 생성 $data['order_no'] = $this->generateOrderNo($tenantId); $data['tenant_id'] = $tenantId; $data['created_by'] = $userId; $data['updated_by'] = $userId; // 기본 상태 설정 $data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT; $data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER; $items = $data['items'] ?? []; unset($data['items']); $order = Order::create($data); // 품목 저장 foreach ($items as $index => $item) { $item['sort_order'] = $index; $this->calculateItemAmounts($item); $order->items()->create($item); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); return $order->load(['client:id,name', 'items']); }); } /** * 수정 */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 완료/취소 상태에서는 수정 불가 if (in_array($order->status_code, [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED])) { throw new BadRequestHttpException(__('error.order.cannot_update_completed')); } return DB::transaction(function () use ($order, $data, $userId) { $data['updated_by'] = $userId; $items = $data['items'] ?? null; unset($data['items'], $data['order_no']); // 번호 변경 불가 $order->update($data); // 품목 교체 (있는 경우) if ($items !== null) { $order->items()->delete(); foreach ($items as $index => $item) { $item['sort_order'] = $index; $this->calculateItemAmounts($item); $order->items()->create($item); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); } return $order->load(['client:id,name', 'items']); }); } /** * 삭제 */ public function destroy(int $id) { $tenantId = $this->tenantId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 진행 중이거나 완료된 수주는 삭제 불가 if (in_array($order->status_code, [ Order::STATUS_IN_PROGRESS, Order::STATUS_COMPLETED, ])) { throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress')); } $order->deleted_by = $this->apiUserId(); $order->save(); $order->delete(); return 'success'; } /** * 상태 변경 */ public function updateStatus(int $id, string $status) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 상태 유효성 검증 $validStatuses = [ Order::STATUS_DRAFT, Order::STATUS_CONFIRMED, Order::STATUS_IN_PROGRESS, Order::STATUS_COMPLETED, Order::STATUS_CANCELLED, ]; if (! in_array($status, $validStatuses)) { throw new BadRequestHttpException(__('error.invalid_status')); } // 상태 전환 규칙 검증 $this->validateStatusTransition($order->status_code, $status); $order->status_code = $status; $order->updated_by = $userId; $order->save(); return $order->load(['client:id,name', 'items']); } /** * 상태 전환 규칙 검증 */ private function validateStatusTransition(string $from, string $to): void { $allowedTransitions = [ Order::STATUS_DRAFT => [Order::STATUS_CONFIRMED, Order::STATUS_CANCELLED], Order::STATUS_CONFIRMED => [Order::STATUS_IN_PROGRESS, Order::STATUS_CANCELLED], Order::STATUS_IN_PROGRESS => [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED], Order::STATUS_COMPLETED => [], // 완료 상태에서는 변경 불가 Order::STATUS_CANCELLED => [Order::STATUS_DRAFT], // 취소에서 임시저장으로만 복구 가능 ]; if (! in_array($to, $allowedTransitions[$from] ?? [])) { throw new BadRequestHttpException(__('error.order.invalid_status_transition')); } } /** * 품목 금액 계산 */ private function calculateItemAmounts(array &$item): void { $quantity = (float) ($item['quantity'] ?? 0); $unitPrice = (float) ($item['unit_price'] ?? 0); $item['supply_amount'] = $quantity * $unitPrice; $item['tax_amount'] = round($item['supply_amount'] * 0.1, 2); $item['total_amount'] = $item['supply_amount'] + $item['tax_amount']; } /** * 수주번호 자동 생성 */ private function generateOrderNo(int $tenantId): string { $prefix = 'ORD'; $date = now()->format('Ymd'); // 오늘 날짜 기준 마지막 번호 조회 $lastNo = Order::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('order_no', 'like', "{$prefix}{$date}%") ->orderByDesc('order_no') ->value('order_no'); if ($lastNo) { $seq = (int) substr($lastNo, -4) + 1; } else { $seq = 1; } 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); } }