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

@@ -177,6 +177,7 @@ public static function createFromQuoteItem(QuoteItem $quoteItem, int $orderId, i
return new self([ return new self([
'tenant_id' => $quoteItem->tenant_id, 'tenant_id' => $quoteItem->tenant_id,
'order_id' => $orderId, 'order_id' => $orderId,
'order_node_id' => $productMapping['order_node_id'] ?? null,
'quote_id' => $quoteItem->quote_id, 'quote_id' => $quoteItem->quote_id,
'quote_item_id' => $quoteItem->id, 'quote_item_id' => $quoteItem->id,
'serial_no' => str_pad($serialIndex, 3, '0', STR_PAD_LEFT), 'serial_no' => str_pad($serialIndex, 3, '0', STR_PAD_LEFT),

View File

@@ -4,6 +4,7 @@
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Orders\OrderHistory; use App\Models\Orders\OrderHistory;
use App\Models\Orders\OrderNode;
use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrder;
use App\Models\Quote\Quote; use App\Models\Quote\Quote;
use App\Models\Tenants\Sale; use App\Models\Tenants\Sale;
@@ -122,6 +123,7 @@ public function show(int $id)
->with([ ->with([
'client:id,name,contact_person,phone,email,manager_name', 'client:id,name,contact_person,phone,email,manager_name',
'items' => fn ($q) => $q->orderBy('sort_order'), 'items' => fn ($q) => $q->orderBy('sort_order'),
'rootNodes' => fn ($q) => $q->withRecursiveChildren(),
'quote:id,quote_number,site_name,calculation_inputs', 'quote:id,quote_number,site_name,calculation_inputs',
]) ])
->find($id); ->find($id);
@@ -554,7 +556,7 @@ public function createFromQuote(int $quoteId, array $data = [])
* *
* @param Quote $quote 수정된 견적 * @param Quote $quote 수정된 견적
* @param int $revision 견적 수정 차수 * @param int $revision 견적 수정 차수
* @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우) * @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우)
*/ */
public function syncFromQuote(Quote $quote, int $revision): ?Order public function syncFromQuote(Quote $quote, int $revision): ?Order
{ {
@@ -600,20 +602,68 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
'updated_by' => $userId, 'updated_by' => $userId,
]); ]);
// 기존 품목 삭제 후 새로 생성 // 기존 품목 및 노드 삭제 후 새로 생성
$order->items()->delete(); $order->items()->delete();
$order->nodes()->delete();
// calculation_inputs에서 제품 정보 추출 // calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? []; $calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? []; $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) { foreach ($quote->items as $index => $quoteItem) {
$floorCode = null; $floorCode = null;
$symbolCode = null; $symbolCode = null;
$locIdx = 0;
// 1순위: note에서 floor/code 파싱 (가장 정확한 정보) // 1순위: formula_source에서 인덱스 추출
// note 형식: "4F FSS-01" (공백으로 구분) $formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$locIdx = (int) $matches[1];
}
// note에서 floor/code 파싱
$note = trim($quoteItem->note ?? ''); $note = trim($quoteItem->note ?? '');
if ($note !== '') { if ($note !== '') {
$parts = preg_split('/\s+/', $note, 2); $parts = preg_split('/\s+/', $note, 2);
@@ -621,25 +671,30 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
$symbolCode = $parts[1] ?? null; $symbolCode = $parts[1] ?? null;
} }
// 2순위: formula_source에서 제품 인덱스 추출하여 calculation_inputs에서 가져오기 // 2순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기
if (empty($floorCode) && empty($symbolCode)) { if (empty($floorCode) && empty($symbolCode)) {
$productIndex = 0; if (isset($productItems[$locIdx])) {
$formulaSource = $quoteItem->formula_source ?? ''; $floorCode = $productItems[$locIdx]['floor'] ?? null;
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { $symbolCode = $productItems[$locIdx]['code'] ?? null;
$productIndex = (int) $matches[1];
}
if (isset($productItems[$productIndex])) {
$floorCode = $productItems[$productIndex]['floor'] ?? null;
$symbolCode = $productItems[$productIndex]['code'] ?? null;
} elseif (count($productItems) === 1) { } elseif (count($productItems) === 1) {
$floorCode = $productItems[0]['floor'] ?? null; $floorCode = $productItems[0]['floor'] ?? null;
$symbolCode = $productItems[0]['code'] ?? 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([ $order->items()->create([
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'order_node_id' => $nodeMap[$locIdx]->id ?? null,
'serial_no' => $index + 1, 'serial_no' => $index + 1,
'item_id' => $quoteItem->item_id, 'item_id' => $quoteItem->item_id,
'item_code' => $quoteItem->item_code, 'item_code' => $quoteItem->item_code,

View File

@@ -6,6 +6,7 @@
use App\Models\Items\Item; use App\Models\Items\Item;
use App\Models\Orders\Order; use App\Models\Orders\Order;
use App\Models\Orders\OrderItem; use App\Models\Orders\OrderItem;
use App\Models\Orders\OrderNode;
use App\Models\Quote\Quote; use App\Models\Quote\Quote;
use App\Models\Quote\QuoteItem; use App\Models\Quote\QuoteItem;
use App\Models\Quote\QuoteRevision; use App\Models\Quote\QuoteRevision;
@@ -597,13 +598,60 @@ public function convertToOrder(int $id): Quote
$order->created_by = $userId; $order->created_by = $userId;
$order->save(); $order->save();
// 수주 상세 품목 생성 (개소 매핑 포함) // calculation_inputs에서 개소(제품) 정보 추출
$calculationInputs = $quote->calculation_inputs ?? []; $calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? []; $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; $serialIndex = 1;
foreach ($quote->items as $quoteItem) { foreach ($quote->items as $quoteItem) {
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems); $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 = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
$orderItem->created_by = $userId; $orderItem->created_by = $userId;
$orderItem->save(); $orderItem->save();
@@ -665,6 +713,37 @@ private function resolveLocationMapping(QuoteItem $quoteItem, array $productItem
return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode]; 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) * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)