From 2df8ecf7650a37a2dc24137fc15315558f3d73f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 5 Mar 2026 16:41:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=83=9D=EC=82=B0=EC=A7=80=EC=8B=9C]?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20API=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=8B=A0=EA=B7=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현 - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED) - workOrderProgress 가공 필드 (total/completed/inProgress) - production_ordered_at (첫 WorkOrder created_at 기반) - BOM 공정 분류 추출 (order_nodes.options.bom_result) - ProductionOrderController: FormRequest + ApiResponse 패턴 - ProductionOrderIndexRequest: search, production_status, sort, pagination 검증 - ProductionOrderApi.php: Swagger 문서 (목록/통계/상세) - production.php: GET /production-orders, /stats, /{orderId} 라우트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/ProductionOrderController.php | 50 ++++ .../ProductionOrderIndexRequest.php | 25 ++ app/Services/ProductionOrderService.php | 231 ++++++++++++++++++ app/Swagger/v1/ProductionOrderApi.php | 186 ++++++++++++++ routes/api/v1/production.php | 8 + 5 files changed, 500 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/ProductionOrderController.php create mode 100644 app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php create mode 100644 app/Services/ProductionOrderService.php create mode 100644 app/Swagger/v1/ProductionOrderApi.php diff --git a/app/Http/Controllers/Api/V1/ProductionOrderController.php b/app/Http/Controllers/Api/V1/ProductionOrderController.php new file mode 100644 index 0000000..ea7f794 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ProductionOrderController.php @@ -0,0 +1,50 @@ +service->index($request->validated()); + + return ApiResponse::success($result, __('message.fetched')); + } + + /** + * 생산지시 상태별 통계 + */ + public function stats(): JsonResponse + { + $stats = $this->service->stats(); + + return ApiResponse::success($stats, __('message.fetched')); + } + + /** + * 생산지시 상세 조회 + */ + public function show(int $orderId): JsonResponse + { + try { + $detail = $this->service->show($orderId); + + return ApiResponse::success($detail, __('message.fetched')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.order.not_found'), 404); + } + } +} diff --git a/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php new file mode 100644 index 0000000..39a848c --- /dev/null +++ b/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php @@ -0,0 +1,25 @@ + 'nullable|string|max:100', + 'production_status' => 'nullable|in:waiting,in_production,completed', + 'sort_by' => 'nullable|in:created_at,delivery_date,order_no', + 'sort_dir' => 'nullable|in:asc,desc', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]; + } +} diff --git a/app/Services/ProductionOrderService.php b/app/Services/ProductionOrderService.php new file mode 100644 index 0000000..9d7cd9c --- /dev/null +++ b/app/Services/ProductionOrderService.php @@ -0,0 +1,231 @@ +tenantId(); + + $query = Order::query() + ->where('tenant_id', $tenantId) + ->whereIn('status_code', self::PRODUCTION_STATUSES) + ->with(['client', 'workOrders.process', 'workOrders.assignees.user']) + ->withCount('workOrders'); + + // 검색어 필터 + 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); + + // 가공 필드 추가 + $result->getCollection()->transform(function (Order $order) { + $order->production_ordered_at = $order->workOrders->min('created_at'); + + $workOrders = $order->workOrders; + $order->work_order_progress = [ + 'total' => $workOrders->count(), + 'completed' => $workOrders->where('status', 'completed')->count() + + $workOrders->where('status', 'shipped')->count(), + 'in_progress' => $workOrders->where('status', 'in_progress')->count(), + ]; + + // 프론트 탭용 production_status 매핑 + $order->production_status = $this->mapProductionStatus($order->status_code); + + return $order; + }); + + return $result; + } + + /** + * 상태별 통계 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $waiting = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PROGRESS) + ->count(); + + $inProduction = Order::where('tenant_id', $tenantId) + ->where('status_code', Order::STATUS_IN_PRODUCTION) + ->count(); + + $completed = Order::where('tenant_id', $tenantId) + ->whereIn('status_code', [ + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, + ]) + ->count(); + + 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', + ]) + ->findOrFail($orderId); + + // 생산지시일 + $order->production_ordered_at = $order->workOrders->min('created_at'); + $order->production_status = $this->mapProductionStatus($order->status_code); + + // WorkOrder 진행 현황 + $workOrderProgress = [ + 'total' => $order->workOrders->count(), + 'completed' => $order->workOrders->where('status', 'completed')->count() + + $order->workOrders->where('status', 'shipped')->count(), + 'in_progress' => $order->workOrders->where('status', 'in_progress')->count(), + ]; + + // WorkOrder 목록 가공 + $workOrders = $order->workOrders->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, + '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 공정 분류 추출 + */ + private function extractBomProcessGroups($nodes): array + { + $groups = []; + + foreach ($nodes as $node) { + $bomResult = $node->options['bom_result'] ?? null; + if (! $bomResult) { + continue; + } + + // bom_result 구조에 따라 공정별 그룹화 + foreach ($bomResult as $item) { + $processName = $item['process_name'] ?? '기타'; + + if (! isset($groups[$processName])) { + $groups[$processName] = [ + 'process_name' => $processName, + 'size_spec' => $item['size_spec'] ?? null, + 'items' => [], + ]; + } + + $groups[$processName]['items'][] = [ + 'id' => $item['id'] ?? null, + 'item_code' => $item['item_code'] ?? '', + 'item_name' => $item['item_name'] ?? '', + 'spec' => $item['spec'] ?? '', + 'lot_no' => $item['lot_no'] ?? '', + 'required_qty' => $item['required_qty'] ?? 0, + 'qty' => $item['qty'] ?? 0, + ]; + } + } + + return array_values($groups); + } +} diff --git a/app/Swagger/v1/ProductionOrderApi.php b/app/Swagger/v1/ProductionOrderApi.php new file mode 100644 index 0000000..cbd1ed6 --- /dev/null +++ b/app/Swagger/v1/ProductionOrderApi.php @@ -0,0 +1,186 @@ +whereNumber('id')->name('v1.inspections.destroy'); // 삭제 Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); + +// Production Order API (생산지시 조회) +Route::prefix('production-orders')->group(function () { + Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index'); + Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats'); + Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show'); +});