From afc31be642942a0d9f003182be04b7daa03829e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 17 Mar 2026 13:55:28 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[order]=20=EA=B2=AC=EC=A0=81=E2=86=92?= =?UTF-8?q?=EC=88=98=EC=A3=BC=20=EB=B3=80=ED=99=98=20=EA=B0=9C=EC=86=8C?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateFromQuoteRequest 검증 규칙 추가 - Order 모델 견적 연동 관계 보강 - OrderService 변환 시 개소별 분리 로직 --- .../Requests/Order/CreateFromQuoteRequest.php | 7 + app/Models/Orders/Order.php | 42 ++++ app/Services/OrderService.php | 231 +++++++++++------- 3 files changed, 188 insertions(+), 92 deletions(-) diff --git a/app/Http/Requests/Order/CreateFromQuoteRequest.php b/app/Http/Requests/Order/CreateFromQuoteRequest.php index 843f7719..2250d08e 100644 --- a/app/Http/Requests/Order/CreateFromQuoteRequest.php +++ b/app/Http/Requests/Order/CreateFromQuoteRequest.php @@ -16,6 +16,13 @@ public function rules(): array return [ 'delivery_date' => 'nullable|date', 'memo' => 'nullable|string', + 'delivery_method_code' => 'nullable|string', + 'options' => 'nullable|array', + 'options.receiver' => 'nullable|string', + 'options.receiver_contact' => 'nullable|string', + 'options.shipping_address' => 'nullable|string', + 'options.shipping_address_detail' => 'nullable|string', + 'options.shipping_cost_code' => 'nullable|string', ]; } diff --git a/app/Models/Orders/Order.php b/app/Models/Orders/Order.php index 792ab592..5f673241 100644 --- a/app/Models/Orders/Order.php +++ b/app/Models/Orders/Order.php @@ -340,4 +340,46 @@ public static function createFromQuote(Quote $quote, string $orderNo): self ], ]); } + + /** + * 견적의 개소(location) 단위로 수주 생성 + * 다중 개소 견적 → 개소별 독립 수주 + */ + public static function createFromQuoteLocation(Quote $quote, string $orderNo, array $locItem, ?array $bomResult): self + { + $qty = (int) ($locItem['quantity'] ?? 1); + $grandTotal = $bomResult['grand_total'] ?? 0; + $supplyAmount = $grandTotal * $qty; + $floor = $locItem['floor'] ?? ''; + $symbol = $locItem['code'] ?? ''; + $locLabel = trim("{$floor} {$symbol}") ?: ''; + $siteName = $quote->site_name; + if ($locLabel) { + $siteName = "{$siteName} [{$locLabel}]"; + } + + return new self([ + 'tenant_id' => $quote->tenant_id, + 'quote_id' => $quote->id, + 'order_no' => $orderNo, + 'order_type_code' => self::TYPE_ORDER, + 'status_code' => self::STATUS_DRAFT, + 'client_id' => $quote->client_id, + 'client_name' => $quote->client?->name, + 'client_contact' => $quote->contact, + 'site_name' => $siteName, + 'quantity' => $qty, + 'supply_amount' => $supplyAmount, + 'tax_amount' => round($supplyAmount * 0.1, 2), + 'total_amount' => round($supplyAmount * 1.1, 2), + 'delivery_date' => $quote->completion_date, + 'memo' => $quote->remarks, + 'options' => [ + 'manager_name' => $quote->manager, + 'product_code' => $locItem['productCode'] ?? null, + 'location_floor' => $floor, + 'location_code' => $symbol, + ], + ]); + } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index f66ac5ee..45798fc6 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -306,6 +306,14 @@ public function store(array $data) $order->refresh(); $order->recalculateTotals()->save(); + // 견적 연결: Quote.order_id 동기화 + if ($order->quote_id) { + Quote::withoutGlobalScopes() + ->where('id', $order->quote_id) + ->whereNull('order_id') + ->update(['order_id' => $order->id]); + } + return $this->loadDetailRelations($order); }); } @@ -839,54 +847,99 @@ public function createFromQuote(int $quoteId, array $data = []) } return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { - // 수주번호 생성 $pairCode = $data['pair_code'] ?? null; - $orderNo = $this->generateOrderNo($tenantId, $pairCode); - // Order 모델의 createFromQuote 사용 + // calculation_inputs에서 제품 정보 추출 + $calculationInputs = $quote->calculation_inputs ?? []; + $productItems = $calculationInputs['items'] ?? []; + $bomResults = $calculationInputs['bomResults'] ?? []; + $locationCount = count($productItems); + + // 품목→개소 매핑 사전 계산 + $hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source)); + $itemsPerLocation = (! $hasFormulaSource && $locationCount > 1) + ? intdiv($quote->items->count(), $locationCount) + : 0; + + // 견적 품목을 개소별로 그룹핑 + $itemsByLocation = []; + foreach ($quote->items as $index => $quoteItem) { + $locIdx = $this->resolveQuoteItemLocationIndex($quoteItem, $productItems, $itemsPerLocation, $index, $locationCount); + $itemsByLocation[$locIdx][] = $quoteItem; + } + + // 개소 × 수량 → 노드 목록 확장 (qty=10 → 노드 10개, 각 qty=1) + $expandedNodes = []; + foreach ($productItems as $idx => $locItem) { + $qty = (int) ($locItem['quantity'] ?? 1); + for ($q = 0; $q < $qty; $q++) { + $expandedNodes[] = [ + 'locItem' => $locItem, + 'bomResult' => $bomResults[$idx] ?? null, + 'origIdx' => $idx, + 'seqNo' => $q + 1, + ]; + } + } + + // 수주 1건 생성 + $orderNo = $this->generateOrderNo($tenantId, $pairCode); $order = Order::createFromQuote($quote, $orderNo); $order->created_by = $userId; $order->updated_by = $userId; - // 추가 데이터 병합 (납품일, 메모 등) if (! empty($data['delivery_date'])) { $order->delivery_date = $data['delivery_date']; } if (! empty($data['memo'])) { $order->memo = $data['memo']; } + if (! empty($data['delivery_method_code'])) { + $order->delivery_method_code = $data['delivery_method_code']; + } + + // options 병합 (수신자, 수신처, 운임 등) + if (! empty($data['options'])) { + $order->options = array_merge($order->options ?? [], $data['options']); + } $order->save(); - // calculation_inputs에서 제품 정보 추출 - $calculationInputs = $quote->calculation_inputs ?? []; - $productItems = $calculationInputs['items'] ?? []; - $bomResults = $calculationInputs['bomResults'] ?? []; - - // OrderNode 생성 (개소별) - $nodeMap = []; - foreach ($productItems as $idx => $locItem) { - $bomResult = $bomResults[$idx] ?? null; + // 확장된 노드별로 OrderNode + OrderItem 생성 + foreach ($expandedNodes as $nodeIdx => $expanded) { + $locItem = $expanded['locItem']; + $bomResult = $expanded['bomResult']; + $origIdx = $expanded['origIdx']; $bomVars = $bomResult['variables'] ?? []; $grandTotal = $bomResult['grand_total'] ?? 0; - $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; $symbol = $locItem['code'] ?? ''; - $nodeMap[$idx] = OrderNode::create([ + // 노드명 = 제품명, 코드/부호에 수량 번호 부여 + $productName = $locItem['productName'] ?? ''; + $nodeCode = trim("{$floor}-{$symbol}", '-') ?: "LOC-{$nodeIdx}"; + $nodeSymbol = $symbol; + $totalQty = (int) ($locItem['quantity'] ?? 1); + if ($totalQty > 1) { + $nodeCode .= '-'.$expanded['seqNo']; + $nodeSymbol .= ' #'.$expanded['seqNo']; + } + $nodeName = $productName ?: trim("{$floor} {$nodeSymbol}") ?: '개소 '.($nodeIdx + 1); + + $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), + 'code' => $nodeCode, + 'name' => $nodeName, 'status_code' => OrderNode::STATUS_PENDING, - 'quantity' => $qty, + 'quantity' => 1, 'unit_price' => $grandTotal, - 'total_price' => $grandTotal * $qty, + 'total_price' => $grandTotal, 'options' => [ 'floor' => $floor, - 'symbol' => $symbol, + 'symbol' => $nodeSymbol, 'product_code' => $locItem['productCode'] ?? null, 'product_name' => $locItem['productName'] ?? null, 'open_width' => $locItem['openWidth'] ?? null, @@ -902,83 +955,35 @@ public function createFromQuote(int $quoteId, array $data = []) 'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem), ], 'depth' => 0, - 'sort_order' => $idx, + 'sort_order' => $nodeIdx, 'created_by' => $userId, ]); - } - // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) - // formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비 - $locationCount = count($productItems); - $hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source)); - $itemsPerLocation = (! $hasFormulaSource && $locationCount > 1) - ? intdiv($quote->items->count(), $locationCount) - : 0; + // 해당 개소 소속 품목 → OrderItem 복제 + foreach ($itemsByLocation[$origIdx] ?? [] as $serialIdx => $quoteItem) { + $floorCode = $locItem['floor'] ?? null; + $symbolCode = $locItem['code'] ?? null; - foreach ($quote->items as $index => $quoteItem) { - $floorCode = null; - $symbolCode = null; - $locIdx = 0; - - // 1순위: formula_source에서 인덱스 추출 - $formulaSource = $quoteItem->formula_source ?? ''; - if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { - $locIdx = (int) $matches[1]; + $order->items()->create([ + 'tenant_id' => $tenantId, + 'order_node_id' => $node->id, + 'serial_no' => $serialIdx + 1, + 'item_id' => $quoteItem->item_id, + 'item_code' => $quoteItem->item_code, + 'item_name' => $quoteItem->item_name, + 'specification' => $quoteItem->specification, + 'floor_code' => $floorCode, + 'symbol_code' => $symbolCode, + 'quantity' => $quoteItem->calculated_quantity, + 'unit' => $quoteItem->unit, + 'unit_price' => $quoteItem->unit_price, + 'supply_amount' => $quoteItem->total_price, + 'tax_amount' => round($quoteItem->total_price * 0.1, 2), + 'total_amount' => round($quoteItem->total_price * 1.1, 2), + 'note' => $quoteItem->formula_category, + 'sort_order' => $serialIdx, + ]); } - - // 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터) - if ($locIdx === 0 && $itemsPerLocation > 0) { - $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); - } - - // calculation_inputs에서 floor/code 가져오기 - 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; - } - - // calculation_inputs에서 못 찾은 경우 note에서 파싱 시도 - if (empty($floorCode) && empty($symbolCode)) { - $note = trim($quoteItem->note ?? ''); - if ($note !== '' && $note !== '-' && $note !== '- -') { - $parts = preg_split('/\s+/', $note, 2); - $floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null; - $symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null; - } - } - - // note 파싱으로 locIdx 결정 (formula_source 없는 경우) - if ($locIdx === 0 && ! $itemsPerLocation && ! 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, - 'item_name' => $quoteItem->item_name, - 'specification' => $quoteItem->specification, - 'floor_code' => $floorCode, - 'symbol_code' => $symbolCode, - 'quantity' => $quoteItem->calculated_quantity, - 'unit' => $quoteItem->unit, - 'unit_price' => $quoteItem->unit_price, - 'supply_amount' => $quoteItem->total_price, - 'tax_amount' => round($quoteItem->total_price * 0.1, 2), - 'total_amount' => round($quoteItem->total_price * 1.1, 2), - 'note' => $quoteItem->formula_category, - 'sort_order' => $index, - ]); } // 합계 재계산 @@ -995,6 +1000,48 @@ public function createFromQuote(int $quoteId, array $data = []) }); } + /** + * 견적 품목이 속하는 개소 인덱스 결정 + */ + private function resolveQuoteItemLocationIndex( + $quoteItem, + array $productItems, + int $itemsPerLocation, + int $itemIndex, + int $locationCount + ): int { + $locIdx = 0; + + // 1순위: formula_source에서 인덱스 추출 + $formulaSource = $quoteItem->formula_source ?? ''; + if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { + return (int) $matches[1]; + } + + // 2순위: sort_order 기반 분배 + if ($itemsPerLocation > 0) { + return min(intdiv($itemIndex, $itemsPerLocation), $locationCount - 1); + } + + // 3순위: note에서 floor/code 매칭 + $note = trim($quoteItem->note ?? ''); + if ($note !== '' && $note !== '-' && $note !== '- -') { + $parts = preg_split('/\s+/', $note, 2); + $floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null; + $symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null; + + if (! empty($floorCode) && ! empty($symbolCode)) { + foreach ($productItems as $pidx => $pItem) { + if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { + return $pidx; + } + } + } + } + + return $locIdx; + } + /** * 견적 변경사항을 수주에 동기화 *