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

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