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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user