tenantId(); $query = Order::query() ->where('tenant_id', $tenantId) ->whereIn('status_code', self::PRODUCTION_STATUSES) ->with(['client']) ->withCount([ 'workOrders' => fn ($q) => $q->whereNotNull('process_id') ->where(fn ($q2) => $q2->whereNull('options->is_auxiliary') ->orWhere('options->is_auxiliary', false)), 'nodes', ]) ->withMin('workOrders', 'created_at'); // 검색어 필터 if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('order_no', 'like', "%{$search}%") ->orWhere('client_name', 'like', "%{$search}%") ->orWhere('site_name', 'like', "%{$search}%"); }); } // 생산 상태 필터 if (! empty($params['production_status'])) { switch ($params['production_status']) { case 'waiting': $query->where('status_code', Order::STATUS_IN_PROGRESS); break; case 'in_production': $query->where('status_code', Order::STATUS_IN_PRODUCTION); break; case 'completed': $query->whereIn('status_code', [ Order::STATUS_PRODUCED, Order::STATUS_SHIPPING, Order::STATUS_SHIPPED, ]); break; } } // 정렬 $sortBy = $params['sort_by'] ?? 'created_at'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); // 페이지네이션 $perPage = $params['per_page'] ?? 20; $result = $query->paginate($perPage); // 작업지시 진행률을 일괄 서브쿼리로 조회 (N+1 방지) $orderIds = $result->getCollection()->pluck('id')->toArray(); $woProgress = $this->getWorkOrderProgressBatch($orderIds); // 가공 필드 추가 $result->getCollection()->transform(function (Order $order) use ($woProgress) { $minCreatedAt = $order->work_orders_min_created_at; $order->production_ordered_at = $minCreatedAt ? \Carbon\Carbon::parse($minCreatedAt)->format('Y-m-d') : null; // 개소수 (order_nodes 수) $order->node_count = $order->nodes_count ?? 0; // 주요 생산 공정 WO 진행률 (서브쿼리 결과 사용) $progress = $woProgress[$order->id] ?? ['total' => 0, 'completed' => 0, 'in_progress' => 0]; $order->work_order_progress = $progress; // 프론트 탭용 production_status 매핑 $order->production_status = $this->mapProductionStatus($order->status_code); return $order; }); return $result; } /** * 상태별 통계 */ public function stats(): array { $tenantId = $this->tenantId(); $counts = Order::where('tenant_id', $tenantId) ->whereIn('status_code', self::PRODUCTION_STATUSES) ->selectRaw('status_code, COUNT(*) as cnt') ->groupBy('status_code') ->pluck('cnt', 'status_code'); $waiting = $counts->get(Order::STATUS_IN_PROGRESS, 0); $inProduction = $counts->get(Order::STATUS_IN_PRODUCTION, 0); $completed = $counts->get(Order::STATUS_PRODUCED, 0) + $counts->get(Order::STATUS_SHIPPING, 0) + $counts->get(Order::STATUS_SHIPPED, 0); return [ 'total' => $waiting + $inProduction + $completed, 'waiting' => $waiting, 'in_production' => $inProduction, 'completed' => $completed, ]; } /** * 생산지시 상세 조회 */ public function show(int $orderId): array { $tenantId = $this->tenantId(); $order = Order::query() ->where('tenant_id', $tenantId) ->whereIn('status_code', self::PRODUCTION_STATUSES) ->with([ 'client', 'workOrders.process', 'workOrders.items', 'workOrders.assignees.user', 'nodes', ]) ->withCount('nodes') ->findOrFail($orderId); // 생산지시일 (날짜만) $minCreatedAt = $order->workOrders->min('created_at'); $order->production_ordered_at = $minCreatedAt ? $minCreatedAt->format('Y-m-d') : null; $order->production_status = $this->mapProductionStatus($order->status_code); // 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외) $productionWorkOrders = $this->filterMainProductionWOs($order->workOrders); // WorkOrder 진행 현황 (생산 공정 기준) $workOrderProgress = [ 'total' => $productionWorkOrders->count(), 'completed' => $productionWorkOrders->where('status', 'completed')->count() + $productionWorkOrders->where('status', 'shipped')->count(), 'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(), ]; // WorkOrder 목록 가공 (생산 공정만) $workOrders = $productionWorkOrders->values()->map(function ($wo) { return [ 'id' => $wo->id, 'work_order_no' => $wo->work_order_no, 'process_name' => $wo->process?->process_name ?? '', 'quantity' => $wo->items->count(), 'status' => $wo->status, 'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(), ]; }); // BOM 데이터 (order_nodes에서 추출) $bomProcessGroups = $this->extractBomProcessGroups($order->nodes); return [ 'order' => $order->makeHidden(['workOrders', 'nodes']), 'production_ordered_at' => $order->production_ordered_at, 'production_status' => $order->production_status, 'node_count' => $order->nodes_count ?? 0, 'work_order_progress' => $workOrderProgress, 'work_orders' => $workOrders, 'bom_process_groups' => $bomProcessGroups, ]; } /** * Order status_code → 프론트 production_status 매핑 */ private function mapProductionStatus(string $statusCode): string { return match ($statusCode) { Order::STATUS_IN_PROGRESS => 'waiting', Order::STATUS_IN_PRODUCTION => 'in_production', default => 'completed', }; } /** * order_nodes에서 BOM 공정 분류 추출 * * bom_result 구조: { items: [...], success, subtotals, ... } * 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... } */ private function extractBomProcessGroups($nodes): array { $groups = []; foreach ($nodes as $node) { $bomResult = $node->options['bom_result'] ?? null; if (! $bomResult || ! is_array($bomResult)) { continue; } // bom_result.items 배열에서 추출 $items = $bomResult['items'] ?? []; if (! is_array($items)) { continue; } $nodeName = $node->name ?? ''; foreach ($items as $item) { if (! is_array($item)) { continue; } $processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타'; if (! isset($groups[$processGroup])) { $groups[$processGroup] = [ 'process_name' => $processGroup, 'items' => [], ]; } $groups[$processGroup]['items'][] = [ 'id' => $item['item_id'] ?? null, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'] ?? '', 'spec' => $item['specification'] ?? '', 'unit' => $item['unit'] ?? '', 'quantity' => $item['quantity'] ?? 0, 'unit_price' => $item['unit_price'] ?? 0, 'total_price' => $item['total_price'] ?? 0, 'node_name' => $nodeName, ]; } } return array_values($groups); } /** * 주문 ID 목록에 대해 작업지시 진행률을 일괄 조회 (단일 쿼리) */ private function getWorkOrderProgressBatch(array $orderIds): array { if (empty($orderIds)) { return []; } $rows = \DB::table('work_orders') ->selectRaw('sales_order_id, status, COUNT(*) as cnt') ->whereIn('sales_order_id', $orderIds) ->whereNotNull('process_id') ->where(function ($q) { $q->whereNull('options->is_auxiliary') ->orWhere('options->is_auxiliary', false); }) ->whereNull('deleted_at') ->groupBy('sales_order_id', 'status') ->get(); $result = []; foreach ($rows as $row) { if (! isset($result[$row->sales_order_id])) { $result[$row->sales_order_id] = ['total' => 0, 'completed' => 0, 'in_progress' => 0]; } $result[$row->sales_order_id]['total'] += $row->cnt; if (in_array($row->status, ['completed', 'shipped'])) { $result[$row->sales_order_id]['completed'] += $row->cnt; } elseif ($row->status === 'in_progress') { $result[$row->sales_order_id]['in_progress'] += $row->cnt; } } return $result; } /** * 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외) * * 제외 대상: * - process_id가 null인 WO (구매품/서비스) * - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정) */ private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection { return $workOrders->filter(function ($wo) { if (empty($wo->process_id)) { return false; } $options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []); return empty($options['is_auxiliary']); }); } }