feat: [수주관리] 전환/동기화 로직에 OrderNode 생성 및 아이템 연결

- convertToOrder: calculation_inputs.items[]로 OrderNode(location) 생성, order_items에 order_node_id 연결
- resolveLocationIndex() 헬퍼 추가 (formula_source/note 기반 개소 인덱스 매칭)
- syncFromQuote: 기존 nodes 삭제 후 재생성, 아이템 node 연결 동기화
- show(): rootNodes + withRecursiveChildren eager loading 추가
- createFromQuoteItem: order_node_id 매핑 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 20:15:00 +09:00
parent 874bf97b8f
commit d2b0f028d4
3 changed files with 151 additions and 16 deletions

View File

@@ -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)