feat(API): 수주 서비스 기능 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 15:58:40 +09:00
parent 51aad4e522
commit bcad646ea6

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Items\Item;
use App\Models\Orders\Order;
use App\Models\Orders\OrderHistory;
use App\Models\Orders\OrderNode;
@@ -165,12 +166,86 @@ public function store(array $data)
$order = Order::create($data);
// quote_id가 있으면 OrderNode 생성 (개소별 사이즈 정보)
$nodeMap = [];
$productItems = [];
if ($order->quote_id) {
$quote = Quote::withoutGlobalScopes()->find($order->quote_id);
if ($quote) {
$ci = $quote->calculation_inputs ?? [];
$productItems = $ci['items'] ?? [];
$bomResults = $ci['bomResults'] ?? [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$grandTotal = $bomResult['grand_total'] ?? 0;
$bomVars = $bomResult['variables'] ?? [];
$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,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null,
'bom_result' => $bomResult,
],
'depth' => 0,
'sort_order' => $idx,
'created_by' => $userId,
]);
}
}
}
// 품목 저장
foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId;
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
$item['sort_order'] = $index;
$this->calculateItemAmounts($item);
// item_id가 없고 item_code가 있으면 item_code로 조회하여 보완
if (empty($item['item_id']) && ! empty($item['item_code'])) {
$foundItem = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('code', $item['item_code'])
->first();
if ($foundItem) {
$item['item_id'] = $foundItem->id;
}
}
// floor_code/symbol_code로 노드 매칭
if (! empty($nodeMap) && ! empty($productItems)) {
$floorCode = $item['floor_code'] ?? null;
$symbolCode = $item['symbol_code'] ?? null;
if ($floorCode && $symbolCode) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
$item['order_node_id'] = $nodeMap[$pidx]->id ?? null;
break;
}
}
}
}
$order->items()->create($item);
}
@@ -546,33 +621,71 @@ public function createFromQuote(int $quoteId, array $data = [])
$order->save();
// calculation_inputs에서 제품 정보 추출 (floor, code)
// 단일 제품인 경우 모든 BOM 품목에 동일한 floor_code/symbol_code 적용
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
// 견적 품목을 수주 품목으로 변환
// OrderNode 생성 (개소별)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$bomVars = $bomResult['variables'] ?? [];
$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,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $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) {
// floor_code/symbol_code 추출:
// 1순위: calculation_inputs.items[].floor, code (제품 정보)
// 2순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01)
// 3순위: NULL
$floorCode = null;
$symbolCode = null;
$locIdx = 0;
// formula_source에서 제품 인덱스 추출 시도 (예: "product_0" → 0)
$productIndex = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$productIndex = (int) $matches[1];
$locIdx = (int) $matches[1];
}
// calculation_inputs에서 floor/code 가져오기
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;
}
@@ -587,8 +700,19 @@ public function createFromQuote(int $quoteId, array $data = [])
}
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && ! 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,
@@ -688,6 +812,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
$bomVars = $bomResult['variables'] ?? [];
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
@@ -711,6 +836,8 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
'open_height' => $locItem['openHeight'] ?? null,
'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null,
'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null,
'guide_rail_type' => $locItem['guideRailType'] ?? null,
'motor_power' => $locItem['motorPower'] ?? null,
'controller' => $locItem['controller'] ?? null,
@@ -984,13 +1111,37 @@ public function createProductionOrder(int $orderId, array $data)
// work_order_items에 아이템 추가
$sortOrder = 1;
foreach ($items as $orderItem) {
// item_id 결정: order_item에 있으면 사용, 없으면 BOM에서 가져오기
// item_id 결정: order_item에 있으면 사용, 없으면 item_code로 조회, 최후에 BOM에서 가져오기
$itemId = $orderItem->item_id;
if (! $itemId && $orderItem->item_code) {
$foundItem = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('code', $orderItem->item_code)
->first();
$itemId = $foundItem?->id;
}
if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null;
$itemId = $bomItem['item_id'] ?? null;
}
// 수주 품목의 노드에서 options(사이즈 등) 조합
$nodeOptions = [];
if ($orderItem->order_node_id) {
$node = $order->rootNodes->firstWhere('id', $orderItem->order_node_id);
$nodeOptions = $node ? ($node->options ?? []) : [];
}
$woItemOptions = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,
'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null,
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
'slat_info' => $nodeOptions['slat_info'] ?? null,
'bending_info' => $nodeOptions['bending_info'] ?? null,
'wip_info' => $nodeOptions['wip_info'] ?? null,
], fn ($v) => $v !== null);
DB::table('work_order_items')->insert([
'tenant_id' => $tenantId,
'work_order_id' => $workOrder->id,
@@ -1002,6 +1153,7 @@ public function createProductionOrder(int $orderId, array $data)
'unit' => $orderItem->unit,
'sort_order' => $sortOrder++,
'status' => 'pending',
'options' => ! empty($woItemOptions) ? json_encode($woItemOptions) : null,
'created_at' => now(),
'updated_at' => now(),
]);