From ae73275cf9305680794e2174b620a8da2ba7104a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 16 Mar 2026 22:24:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[order]=20=EC=9E=AC=EA=B3=A0=EC=83=9D?= =?UTF-8?q?=EC=82=B0=20=EC=83=9D=EC=82=B0=EC=A7=80=EC=8B=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STOCK 타입 store() 시 site_name='재고생산' 자동 설정 - createProductionOrder() STOCK 분기 추가: - project_name='재고생산' 고정 - 절곡 공정 자동 선택 (BOM 매칭 스킵) - scheduled_date=now() 자동 설정 - 절곡 공정 미존재 시 에러 메시지 추가 --- app/Services/OrderService.php | 238 +++++++++++++++++++--------------- lang/en/error.php | 1 + lang/ko/error.php | 1 + 3 files changed, 134 insertions(+), 106 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index dd1ab49..0ed30b6 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -182,6 +182,11 @@ public function store(array $data) $data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT; $data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER; + // 재고생산: 현장명 자동 설정 + if ($isStock) { + $data['site_name'] = '재고생산'; + } + $items = $data['items'] ?? []; unset($data['items']); @@ -1233,121 +1238,142 @@ public function createProductionOrder(int $orderId, array $data) throw new BadRequestHttpException(__('error.order.production_order_already_exists')); } - // order_nodes의 BOM 결과를 기반으로 공정별 자동 분류 - $bomItemIds = []; - $nodesBomMap = []; // node_id => [item_name => bom_item] + // 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵) + $isStock = $order->order_type_code === Order::TYPE_STOCK; + $nodesBomMap = []; - foreach ($order->rootNodes as $node) { - $bomResult = $node->options['bom_result'] ?? []; - $bomItems = $bomResult['items'] ?? []; - - foreach ($bomItems as $bomItem) { - if (! empty($bomItem['item_id'])) { - $bomItemIds[] = $bomItem['item_id']; - $nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem; - } - } - } - - $bomItemIds = array_unique($bomItemIds); - - // process_items 테이블에서 item_id → process_id 매핑 조회 - $itemProcessMap = []; - if (! empty($bomItemIds)) { - $processItems = DB::table('process_items as pi') - ->join('processes as p', 'pi.process_id', '=', 'p.id') - ->where('p.tenant_id', $tenantId) - ->whereIn('pi.item_id', $bomItemIds) - ->where('pi.is_active', true) - ->select('pi.item_id', 'pi.process_id') - ->get(); - - foreach ($processItems as $pi) { - $itemProcessMap[$pi->item_id] = $pi->process_id; - } - } - - // item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회) - $codeToIdMap = []; - if (! empty($bomItemIds)) { - $codeToIdRows = DB::table('items') - ->where('tenant_id', $tenantId) - ->whereIn('id', $bomItemIds) - ->whereNull('deleted_at') - ->select('id', 'code') - ->get(); - foreach ($codeToIdRows as $row) { - $codeToIdMap[$row->code] = $row->id; - } - } - - // order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지) - $orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all(); - $unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap)); - if (! empty($unmappedCodes)) { - $extraRows = DB::table('items') - ->where('tenant_id', $tenantId) - ->whereIn('code', $unmappedCodes) - ->whereNull('deleted_at') - ->select('id', 'code') - ->get(); - foreach ($extraRows as $row) { - $codeToIdMap[$row->code] = $row->id; - } - } - - // 사전 매핑된 item_id에 대한 process_items도 일괄 조회 - $allResolvedIds = array_values(array_unique(array_merge( - array_keys($itemProcessMap), - array_values($codeToIdMap) - ))); - $unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap)); - if (! empty($unmappedProcessIds)) { - $extraProcessItems = DB::table('process_items') - ->whereIn('item_id', $unmappedProcessIds) + if ($isStock) { + $bendingProcess = \App\Models\Process::where('tenant_id', $tenantId) + ->where('process_name', '절곡') ->where('is_active', true) - ->select('item_id', 'process_id') - ->get(); - foreach ($extraProcessItems as $pi) { - $itemProcessMap[$pi->item_id] = $pi->process_id; - } - } + ->first(); - // order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용) - $itemsByProcess = []; - foreach ($order->items as $orderItem) { - $processId = null; - - // 1. order_item의 item_id가 있으면 직접 매핑 - if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) { - $processId = $itemProcessMap[$orderItem->item_id]; + if (! $bendingProcess) { + throw new BadRequestHttpException(__('error.order.bending_process_not_found')); } - // 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기 - elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { - $nodeBom = $nodesBomMap[$orderItem->order_node_id]; - $bomItem = $nodeBom[$orderItem->item_name] ?? null; - if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) { - $processId = $itemProcessMap[$bomItem['item_id']]; + + $itemsByProcess = [ + $bendingProcess->id => [ + 'process_id' => $bendingProcess->id, + 'items' => $order->items->all(), + ], + ]; + } else { + // 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류 + $bomItemIds = []; + + foreach ($order->rootNodes as $node) { + $bomResult = $node->options['bom_result'] ?? []; + $bomItems = $bomResult['items'] ?? []; + + foreach ($bomItems as $bomItem) { + if (! empty($bomItem['item_id'])) { + $bomItemIds[] = $bomItem['item_id']; + $nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem; + } } } - // 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거) - if ($processId === null && $orderItem->item_code) { - $resolvedId = $codeToIdMap[$orderItem->item_code] ?? null; - if ($resolvedId && isset($itemProcessMap[$resolvedId])) { - $processId = $itemProcessMap[$resolvedId]; + $bomItemIds = array_unique($bomItemIds); + + // process_items 테이블에서 item_id → process_id 매핑 조회 + $itemProcessMap = []; + if (! empty($bomItemIds)) { + $processItems = DB::table('process_items as pi') + ->join('processes as p', 'pi.process_id', '=', 'p.id') + ->where('p.tenant_id', $tenantId) + ->whereIn('pi.item_id', $bomItemIds) + ->where('pi.is_active', true) + ->select('pi.item_id', 'pi.process_id') + ->get(); + + foreach ($processItems as $pi) { + $itemProcessMap[$pi->item_id] = $pi->process_id; } } - $key = $processId ?? 'none'; - - if (! isset($itemsByProcess[$key])) { - $itemsByProcess[$key] = [ - 'process_id' => $processId, - 'items' => [], - ]; + // item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회) + $codeToIdMap = []; + if (! empty($bomItemIds)) { + $codeToIdRows = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('id', $bomItemIds) + ->whereNull('deleted_at') + ->select('id', 'code') + ->get(); + foreach ($codeToIdRows as $row) { + $codeToIdMap[$row->code] = $row->id; + } + } + + // order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지) + $orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all(); + $unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap)); + if (! empty($unmappedCodes)) { + $extraRows = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('code', $unmappedCodes) + ->whereNull('deleted_at') + ->select('id', 'code') + ->get(); + foreach ($extraRows as $row) { + $codeToIdMap[$row->code] = $row->id; + } + } + + // 사전 매핑된 item_id에 대한 process_items도 일괄 조회 + $allResolvedIds = array_values(array_unique(array_merge( + array_keys($itemProcessMap), + array_values($codeToIdMap) + ))); + $unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap)); + if (! empty($unmappedProcessIds)) { + $extraProcessItems = DB::table('process_items') + ->whereIn('item_id', $unmappedProcessIds) + ->where('is_active', true) + ->select('item_id', 'process_id') + ->get(); + foreach ($extraProcessItems as $pi) { + $itemProcessMap[$pi->item_id] = $pi->process_id; + } + } + + // order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용) + $itemsByProcess = []; + foreach ($order->items as $orderItem) { + $processId = null; + + // 1. order_item의 item_id가 있으면 직접 매핑 + if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) { + $processId = $itemProcessMap[$orderItem->item_id]; + } + // 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기 + elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { + $nodeBom = $nodesBomMap[$orderItem->order_node_id]; + $bomItem = $nodeBom[$orderItem->item_name] ?? null; + if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) { + $processId = $itemProcessMap[$bomItem['item_id']]; + } + } + + // 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거) + if ($processId === null && $orderItem->item_code) { + $resolvedId = $codeToIdMap[$orderItem->item_code] ?? null; + if ($resolvedId && isset($itemProcessMap[$resolvedId])) { + $processId = $itemProcessMap[$resolvedId]; + } + } + + $key = $processId ?? 'none'; + + if (! isset($itemsByProcess[$key])) { + $itemsByProcess[$key] = [ + 'process_id' => $processId, + 'items' => [], + ]; + } + $itemsByProcess[$key]['items'][] = $orderItem; } - $itemsByProcess[$key]['items'][] = $orderItem; } return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) { @@ -1409,13 +1435,13 @@ public function createProductionOrder(int $orderId, array $data) 'tenant_id' => $tenantId, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, - 'project_name' => $order->site_name ?? $order->client_name, + 'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name), 'process_id' => $processId, 'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED, 'priority' => $priority, 'assignee_id' => $primaryAssigneeId, 'team_id' => $teamId, - 'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date, + 'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date), 'memo' => $data['memo'] ?? null, 'options' => $workOrderOptions, 'is_active' => true, diff --git a/lang/en/error.php b/lang/en/error.php index cea9ee2..01aa5b4 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -122,6 +122,7 @@ 'not_found' => 'Work order not found.', 'cannot_delete_in_progress' => 'Cannot delete in progress or completed work order.', 'not_bending_process' => 'This is not a bending process.', + 'bending_process_not_found' => 'Bending process not found. Please check process settings.', 'invalid_transition' => "Cannot transition status from ':from' to ':to'. Allowed statuses: :allowed", ], diff --git a/lang/ko/error.php b/lang/ko/error.php index 29d03c1..68027f3 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -428,6 +428,7 @@ 'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.', 'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.', 'production_order_already_exists' => '이미 생산지시가 존재합니다.', + 'bending_process_not_found' => '절곡 공정을 찾을 수 없습니다. 공정 설정을 확인해주세요.', 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', 'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.',