diff --git a/app/Models/Orders/OrderItem.php b/app/Models/Orders/OrderItem.php index 7b1762c..8a4dc10 100644 --- a/app/Models/Orders/OrderItem.php +++ b/app/Models/Orders/OrderItem.php @@ -177,6 +177,7 @@ public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, i return new self([ 'tenant_id' => $quoteItem->tenant_id, 'order_id' => $orderId, + 'order_node_id' => $productMapping['order_node_id'] ?? null, 'quote_id' => $quoteItem->quote_id, 'quote_item_id' => $quoteItem->id, 'serial_no' => str_pad($serialIndex, 3, '0', STR_PAD_LEFT), diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 00e3b52..7866440 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -4,6 +4,7 @@ use App\Models\Orders\Order; use App\Models\Orders\OrderHistory; +use App\Models\Orders\OrderNode; use App\Models\Production\WorkOrder; use App\Models\Quote\Quote; use App\Models\Tenants\Sale; @@ -122,6 +123,7 @@ public function show(int $id) ->with([ 'client:id,name,contact_person,phone,email,manager_name', 'items' => fn ($q) => $q->orderBy('sort_order'), + 'rootNodes' => fn ($q) => $q->withRecursiveChildren(), 'quote:id,quote_number,site_name,calculation_inputs', ]) ->find($id); @@ -554,7 +556,7 @@ public function createFromQuote(int $quoteId, array $data = []) * * @param Quote $quote 수정된 견적 * @param int $revision 견적 수정 차수 - * @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우) + * @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우) */ public function syncFromQuote(Quote $quote, int $revision): ?Order { @@ -600,20 +602,68 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order 'updated_by' => $userId, ]); - // 기존 품목 삭제 후 새로 생성 + // 기존 품목 및 노드 삭제 후 새로 생성 $order->items()->delete(); + $order->nodes()->delete(); // calculation_inputs에서 제품 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; + $bomResults = $calculationInputs['bomResults'] ?? []; - // 견적 품목을 수주 품목으로 변환 + // OrderNode 생성 (개소별) + $nodeMap = []; + foreach ($productItems as $idx => $locItem) { + $bomResult = $bomResults[$idx] ?? null; + $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, + '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) { $floorCode = null; $symbolCode = null; + $locIdx = 0; - // 1순위: note에서 floor/code 파싱 (가장 정확한 정보) - // note 형식: "4F FSS-01" (공백으로 구분) + // 1순위: formula_source에서 인덱스 추출 + $formulaSource = $quoteItem->formula_source ?? ''; + if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { + $locIdx = (int) $matches[1]; + } + + // note에서 floor/code 파싱 $note = trim($quoteItem->note ?? ''); if ($note !== '') { $parts = preg_split('/\s+/', $note, 2); @@ -621,25 +671,30 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order $symbolCode = $parts[1] ?? null; } - // 2순위: formula_source에서 제품 인덱스 추출하여 calculation_inputs에서 가져오기 + // 2순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기 if (empty($floorCode) && empty($symbolCode)) { - $productIndex = 0; - $formulaSource = $quoteItem->formula_source ?? ''; - if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { - $productIndex = (int) $matches[1]; - } - - 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; } } + // note 파싱으로 locIdx 결정 (formula_source 없는 경우) + if ($locIdx === 0 && $note !== '') { + 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, diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index a27fddc..00b4f88 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -6,6 +6,7 @@ use App\Models\Items\Item; use App\Models\Orders\Order; use App\Models\Orders\OrderItem; +use App\Models\Orders\OrderNode; use App\Models\Quote\Quote; use App\Models\Quote\QuoteItem; use App\Models\Quote\QuoteRevision; @@ -597,13 +598,60 @@ public function convertToOrder(int $id): Quote $order->created_by = $userId; $order->save(); - // 수주 상세 품목 생성 (개소 매핑 포함) + // calculation_inputs에서 개소(제품) 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; + $bomResults = $calculationInputs['bomResults'] ?? []; + // OrderNode 생성 (개소별) + $nodeMap = []; // productIndex → OrderNode + foreach ($productItems as $idx => $locItem) { + $bomResult = $bomResults[$idx] ?? null; + $grandTotal = $bomResult['grand_total'] ?? 0; + $qty = (int) ($locItem['quantity'] ?? 1); + $floor = $locItem['floor'] ?? ''; + $symbol = $locItem['code'] ?? ''; + + $node = 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, + '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, + ]); + $nodeMap[$idx] = $node; + } + + // 수주 상세 품목 생성 (노드 연결 포함) $serialIndex = 1; foreach ($quote->items as $quoteItem) { $productMapping = $this->resolveLocationMapping($quoteItem, $productItems); + $locIdx = $this->resolveLocationIndex($quoteItem, $productItems); + + $productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null; + $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping); $orderItem->created_by = $userId; $orderItem->save(); @@ -665,6 +713,37 @@ private function resolveLocationMapping(QuoteItem $quoteItem, array $productItem return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode]; } + /** + * 견적 품목이 속하는 개소(productItems) 인덱스 반환 + * + * 1순위: formula_source에서 product_N 패턴 추출 + * 2순위: note 파싱 후 productItems에서 floor/code 매칭 + * 매칭 실패 시 0 반환 + */ + private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems): int + { + // 1순위: formula_source + $formulaSource = $quoteItem->formula_source ?? ''; + if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { + return (int) $matches[1]; + } + + // 2순위: note에서 floor/code 매칭 + $note = trim($quoteItem->note ?? ''); + if ($note !== '') { + $parts = preg_split('/\s+/', $note, 2); + $floor = $parts[0] ?? ''; + $code = $parts[1] ?? ''; + foreach ($productItems as $idx => $item) { + if (($item['floor'] ?? '') === $floor && ($item['code'] ?? '') === $code) { + return $idx; + } + } + } + + return 0; + } + /** * 수주번호 생성 * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)