From bcad646ea600f1af40a28451cb07772aaa9f679e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 11 Feb 2026 15:58:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(API):=20=EC=88=98=EC=A3=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- app/Services/OrderService.php | 182 +++++++++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 15 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 836dea8..8a78555 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Items\Item; use App\Models\Orders\Order; use App\Models\Orders\OrderHistory; use App\Models\Orders\OrderNode; @@ -165,12 +166,86 @@ public function store(array $data) $order = Order::create($data); + // quote_id가 있으면 OrderNode 생성 (개소별 사이즈 정보) + $nodeMap = []; + $productItems = []; + if ($order->quote_id) { + $quote = Quote::withoutGlobalScopes()->find($order->quote_id); + if ($quote) { + $ci = $quote->calculation_inputs ?? []; + $productItems = $ci['items'] ?? []; + $bomResults = $ci['bomResults'] ?? []; + + foreach ($productItems as $idx => $locItem) { + $bomResult = $bomResults[$idx] ?? null; + $grandTotal = $bomResult['grand_total'] ?? 0; + $bomVars = $bomResult['variables'] ?? []; + $qty = (int) ($locItem['quantity'] ?? 1); + $floor = $locItem['floor'] ?? ''; + $symbol = $locItem['code'] ?? ''; + + $nodeMap[$idx] = OrderNode::create([ + 'tenant_id' => $tenantId, + 'order_id' => $order->id, + 'parent_id' => null, + 'node_type' => 'location', + 'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}", + 'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1), + 'status_code' => OrderNode::STATUS_PENDING, + 'quantity' => $qty, + 'unit_price' => $grandTotal, + 'total_price' => $grandTotal * $qty, + 'options' => [ + 'floor' => $floor, + 'symbol' => $symbol, + 'product_code' => $locItem['productCode'] ?? null, + 'product_name' => $locItem['productName'] ?? null, + 'open_width' => $locItem['openWidth'] ?? null, + 'open_height' => $locItem['openHeight'] ?? null, + 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, + 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, + 'bom_result' => $bomResult, + ], + 'depth' => 0, + 'sort_order' => $idx, + 'created_by' => $userId, + ]); + } + } + } + // 품목 저장 foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 $item['sort_order'] = $index; $this->calculateItemAmounts($item); + + // item_id가 없고 item_code가 있으면 item_code로 조회하여 보완 + if (empty($item['item_id']) && ! empty($item['item_code'])) { + $foundItem = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code', $item['item_code']) + ->first(); + if ($foundItem) { + $item['item_id'] = $foundItem->id; + } + } + + // floor_code/symbol_code로 노드 매칭 + if (! empty($nodeMap) && ! empty($productItems)) { + $floorCode = $item['floor_code'] ?? null; + $symbolCode = $item['symbol_code'] ?? null; + if ($floorCode && $symbolCode) { + foreach ($productItems as $pidx => $pItem) { + if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { + $item['order_node_id'] = $nodeMap[$pidx]->id ?? null; + break; + } + } + } + } + $order->items()->create($item); } @@ -546,33 +621,71 @@ public function createFromQuote(int $quoteId, array $data = []) $order->save(); - // calculation_inputs에서 제품 정보 추출 (floor, code) - // 단일 제품인 경우 모든 BOM 품목에 동일한 floor_code/symbol_code 적용 + // calculation_inputs에서 제품 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; + $bomResults = $calculationInputs['bomResults'] ?? []; - // 견적 품목을 수주 품목으로 변환 + // OrderNode 생성 (개소별) + $nodeMap = []; + foreach ($productItems as $idx => $locItem) { + $bomResult = $bomResults[$idx] ?? null; + $bomVars = $bomResult['variables'] ?? []; + $grandTotal = $bomResult['grand_total'] ?? 0; + $qty = (int) ($locItem['quantity'] ?? 1); + $floor = $locItem['floor'] ?? ''; + $symbol = $locItem['code'] ?? ''; + + $nodeMap[$idx] = OrderNode::create([ + 'tenant_id' => $tenantId, + 'order_id' => $order->id, + 'parent_id' => null, + 'node_type' => 'location', + 'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}", + 'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1), + 'status_code' => OrderNode::STATUS_PENDING, + 'quantity' => $qty, + 'unit_price' => $grandTotal, + 'total_price' => $grandTotal * $qty, + 'options' => [ + 'floor' => $floor, + 'symbol' => $symbol, + 'product_code' => $locItem['productCode'] ?? null, + 'product_name' => $locItem['productName'] ?? null, + 'open_width' => $locItem['openWidth'] ?? null, + 'open_height' => $locItem['openHeight'] ?? null, + 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, + 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, + 'guide_rail_type' => $locItem['guideRailType'] ?? null, + 'motor_power' => $locItem['motorPower'] ?? null, + 'controller' => $locItem['controller'] ?? null, + 'wing_size' => $locItem['wingSize'] ?? null, + 'inspection_fee' => $locItem['inspectionFee'] ?? null, + 'bom_result' => $bomResult, + ], + 'depth' => 0, + 'sort_order' => $idx, + 'created_by' => $userId, + ]); + } + + // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) foreach ($quote->items as $index => $quoteItem) { - // floor_code/symbol_code 추출: - // 1순위: calculation_inputs.items[].floor, code (제품 정보) - // 2순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01) - // 3순위: NULL $floorCode = null; $symbolCode = null; + $locIdx = 0; - // formula_source에서 제품 인덱스 추출 시도 (예: "product_0" → 0) - $productIndex = 0; + // 1순위: formula_source에서 인덱스 추출 $formulaSource = $quoteItem->formula_source ?? ''; if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { - $productIndex = (int) $matches[1]; + $locIdx = (int) $matches[1]; } // calculation_inputs에서 floor/code 가져오기 - if (isset($productItems[$productIndex])) { - $floorCode = $productItems[$productIndex]['floor'] ?? null; - $symbolCode = $productItems[$productIndex]['code'] ?? null; + if (isset($productItems[$locIdx])) { + $floorCode = $productItems[$locIdx]['floor'] ?? null; + $symbolCode = $productItems[$locIdx]['code'] ?? null; } elseif (count($productItems) === 1) { - // 단일 제품인 경우 첫 번째 제품 사용 $floorCode = $productItems[0]['floor'] ?? null; $symbolCode = $productItems[0]['code'] ?? null; } @@ -587,8 +700,19 @@ public function createFromQuote(int $quoteId, array $data = []) } } + // note 파싱으로 locIdx 결정 (formula_source 없는 경우) + if ($locIdx === 0 && ! empty($floorCode) && ! empty($symbolCode)) { + foreach ($productItems as $pidx => $pItem) { + if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { + $locIdx = $pidx; + break; + } + } + } + $order->items()->create([ 'tenant_id' => $tenantId, + 'order_node_id' => $nodeMap[$locIdx]->id ?? null, 'serial_no' => $index + 1, 'item_id' => $quoteItem->item_id, 'item_code' => $quoteItem->item_code, @@ -688,6 +812,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order $nodeMap = []; foreach ($productItems as $idx => $locItem) { $bomResult = $bomResults[$idx] ?? null; + $bomVars = $bomResult['variables'] ?? []; $grandTotal = $bomResult['grand_total'] ?? 0; $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; @@ -711,6 +836,8 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order 'product_name' => $locItem['productName'] ?? null, 'open_width' => $locItem['openWidth'] ?? null, 'open_height' => $locItem['openHeight'] ?? null, + 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, + 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, 'guide_rail_type' => $locItem['guideRailType'] ?? null, 'motor_power' => $locItem['motorPower'] ?? null, 'controller' => $locItem['controller'] ?? null, @@ -984,13 +1111,37 @@ public function createProductionOrder(int $orderId, array $data) // work_order_items에 아이템 추가 $sortOrder = 1; foreach ($items as $orderItem) { - // item_id 결정: order_item에 있으면 사용, 없으면 BOM에서 가져오기 + // item_id 결정: order_item에 있으면 사용, 없으면 item_code로 조회, 최후에 BOM에서 가져오기 $itemId = $orderItem->item_id; + if (! $itemId && $orderItem->item_code) { + $foundItem = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code', $orderItem->item_code) + ->first(); + $itemId = $foundItem?->id; + } if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { $bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null; $itemId = $bomItem['item_id'] ?? null; } + // 수주 품목의 노드에서 options(사이즈 등) 조합 + $nodeOptions = []; + if ($orderItem->order_node_id) { + $node = $order->rootNodes->firstWhere('id', $orderItem->order_node_id); + $nodeOptions = $node ? ($node->options ?? []) : []; + } + $woItemOptions = array_filter([ + 'floor' => $orderItem->floor_code, + 'code' => $orderItem->symbol_code, + 'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null, + 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, + 'cutting_info' => $nodeOptions['cutting_info'] ?? null, + 'slat_info' => $nodeOptions['slat_info'] ?? null, + 'bending_info' => $nodeOptions['bending_info'] ?? null, + 'wip_info' => $nodeOptions['wip_info'] ?? null, + ], fn ($v) => $v !== null); + DB::table('work_order_items')->insert([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, @@ -1002,6 +1153,7 @@ public function createProductionOrder(int $orderId, array $data) 'unit' => $orderItem->unit, 'sort_order' => $sortOrder++, 'status' => 'pending', + 'options' => ! empty($woItemOptions) ? json_encode($woItemOptions) : null, 'created_at' => now(), 'updated_at' => now(), ]);