fix: [order] 견적→수주 변환 개소별 분리 구현

- CreateFromQuoteRequest 검증 규칙 추가
- Order 모델 견적 연동 관계 보강
- OrderService 변환 시 개소별 분리 로직
This commit is contained in:
2026-03-17 13:55:28 +09:00
parent e5da452fde
commit afc31be642
3 changed files with 188 additions and 92 deletions

View File

@@ -16,6 +16,13 @@ public function rules(): array
return [
'delivery_date' => 'nullable|date',
'memo' => 'nullable|string',
'delivery_method_code' => 'nullable|string',
'options' => 'nullable|array',
'options.receiver' => 'nullable|string',
'options.receiver_contact' => 'nullable|string',
'options.shipping_address' => 'nullable|string',
'options.shipping_address_detail' => 'nullable|string',
'options.shipping_cost_code' => 'nullable|string',
];
}

View File

@@ -340,4 +340,46 @@ public static function createFromQuote(Quote $quote, string $orderNo): self
],
]);
}
/**
* 견적의 개소(location) 단위로 수주 생성
* 다중 개소 견적 → 개소별 독립 수주
*/
public static function createFromQuoteLocation(Quote $quote, string $orderNo, array $locItem, ?array $bomResult): self
{
$qty = (int) ($locItem['quantity'] ?? 1);
$grandTotal = $bomResult['grand_total'] ?? 0;
$supplyAmount = $grandTotal * $qty;
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$locLabel = trim("{$floor} {$symbol}") ?: '';
$siteName = $quote->site_name;
if ($locLabel) {
$siteName = "{$siteName} [{$locLabel}]";
}
return new self([
'tenant_id' => $quote->tenant_id,
'quote_id' => $quote->id,
'order_no' => $orderNo,
'order_type_code' => self::TYPE_ORDER,
'status_code' => self::STATUS_DRAFT,
'client_id' => $quote->client_id,
'client_name' => $quote->client?->name,
'client_contact' => $quote->contact,
'site_name' => $siteName,
'quantity' => $qty,
'supply_amount' => $supplyAmount,
'tax_amount' => round($supplyAmount * 0.1, 2),
'total_amount' => round($supplyAmount * 1.1, 2),
'delivery_date' => $quote->completion_date,
'memo' => $quote->remarks,
'options' => [
'manager_name' => $quote->manager,
'product_code' => $locItem['productCode'] ?? null,
'location_floor' => $floor,
'location_code' => $symbol,
],
]);
}
}

View File

@@ -306,6 +306,14 @@ public function store(array $data)
$order->refresh();
$order->recalculateTotals()->save();
// 견적 연결: Quote.order_id 동기화
if ($order->quote_id) {
Quote::withoutGlobalScopes()
->where('id', $order->quote_id)
->whereNull('order_id')
->update(['order_id' => $order->id]);
}
return $this->loadDetailRelations($order);
});
}
@@ -839,54 +847,99 @@ public function createFromQuote(int $quoteId, array $data = [])
}
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
// 수주번호 생성
$pairCode = $data['pair_code'] ?? null;
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
// Order 모델의 createFromQuote 사용
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
$locationCount = count($productItems);
// 품목→개소 매핑 사전 계산
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 견적 품목을 개소별로 그룹핑
$itemsByLocation = [];
foreach ($quote->items as $index => $quoteItem) {
$locIdx = $this->resolveQuoteItemLocationIndex($quoteItem, $productItems, $itemsPerLocation, $index, $locationCount);
$itemsByLocation[$locIdx][] = $quoteItem;
}
// 개소 × 수량 → 노드 목록 확장 (qty=10 → 노드 10개, 각 qty=1)
$expandedNodes = [];
foreach ($productItems as $idx => $locItem) {
$qty = (int) ($locItem['quantity'] ?? 1);
for ($q = 0; $q < $qty; $q++) {
$expandedNodes[] = [
'locItem' => $locItem,
'bomResult' => $bomResults[$idx] ?? null,
'origIdx' => $idx,
'seqNo' => $q + 1,
];
}
}
// 수주 1건 생성
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
$order = Order::createFromQuote($quote, $orderNo);
$order->created_by = $userId;
$order->updated_by = $userId;
// 추가 데이터 병합 (납품일, 메모 등)
if (! empty($data['delivery_date'])) {
$order->delivery_date = $data['delivery_date'];
}
if (! empty($data['memo'])) {
$order->memo = $data['memo'];
}
if (! empty($data['delivery_method_code'])) {
$order->delivery_method_code = $data['delivery_method_code'];
}
// options 병합 (수신자, 수신처, 운임 등)
if (! empty($data['options'])) {
$order->options = array_merge($order->options ?? [], $data['options']);
}
$order->save();
// calculation_inputs에서 제품 정보 추출
$calculationInputs = $quote->calculation_inputs ?? [];
$productItems = $calculationInputs['items'] ?? [];
$bomResults = $calculationInputs['bomResults'] ?? [];
// OrderNode 생성 (개소별)
$nodeMap = [];
foreach ($productItems as $idx => $locItem) {
$bomResult = $bomResults[$idx] ?? null;
// 확장된 노드별로 OrderNode + OrderItem 생성
foreach ($expandedNodes as $nodeIdx => $expanded) {
$locItem = $expanded['locItem'];
$bomResult = $expanded['bomResult'];
$origIdx = $expanded['origIdx'];
$bomVars = $bomResult['variables'] ?? [];
$grandTotal = $bomResult['grand_total'] ?? 0;
$qty = (int) ($locItem['quantity'] ?? 1);
$floor = $locItem['floor'] ?? '';
$symbol = $locItem['code'] ?? '';
$nodeMap[$idx] = OrderNode::create([
// 노드명 = 제품명, 코드/부호에 수량 번호 부여
$productName = $locItem['productName'] ?? '';
$nodeCode = trim("{$floor}-{$symbol}", '-') ?: "LOC-{$nodeIdx}";
$nodeSymbol = $symbol;
$totalQty = (int) ($locItem['quantity'] ?? 1);
if ($totalQty > 1) {
$nodeCode .= '-'.$expanded['seqNo'];
$nodeSymbol .= ' #'.$expanded['seqNo'];
}
$nodeName = $productName ?: trim("{$floor} {$nodeSymbol}") ?: '개소 '.($nodeIdx + 1);
$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),
'code' => $nodeCode,
'name' => $nodeName,
'status_code' => OrderNode::STATUS_PENDING,
'quantity' => $qty,
'quantity' => 1,
'unit_price' => $grandTotal,
'total_price' => $grandTotal * $qty,
'total_price' => $grandTotal,
'options' => [
'floor' => $floor,
'symbol' => $symbol,
'symbol' => $nodeSymbol,
'product_code' => $locItem['productCode'] ?? null,
'product_name' => $locItem['productName'] ?? null,
'open_width' => $locItem['openWidth'] ?? null,
@@ -902,83 +955,35 @@ public function createFromQuote(int $quoteId, array $data = [])
'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem),
],
'depth' => 0,
'sort_order' => $idx,
'sort_order' => $nodeIdx,
'created_by' => $userId,
]);
}
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
$locationCount = count($productItems);
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
? intdiv($quote->items->count(), $locationCount)
: 0;
// 해당 개소 소속 품목 → OrderItem 복제
foreach ($itemsByLocation[$origIdx] ?? [] as $serialIdx => $quoteItem) {
$floorCode = $locItem['floor'] ?? null;
$symbolCode = $locItem['code'] ?? null;
foreach ($quote->items as $index => $quoteItem) {
$floorCode = null;
$symbolCode = null;
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
$locIdx = (int) $matches[1];
$order->items()->create([
'tenant_id' => $tenantId,
'order_node_id' => $node->id,
'serial_no' => $serialIdx + 1,
'item_id' => $quoteItem->item_id,
'item_code' => $quoteItem->item_code,
'item_name' => $quoteItem->item_name,
'specification' => $quoteItem->specification,
'floor_code' => $floorCode,
'symbol_code' => $symbolCode,
'quantity' => $quoteItem->calculated_quantity,
'unit' => $quoteItem->unit,
'unit_price' => $quoteItem->unit_price,
'supply_amount' => $quoteItem->total_price,
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
'total_amount' => round($quoteItem->total_price * 1.1, 2),
'note' => $quoteItem->formula_category,
'sort_order' => $serialIdx,
]);
}
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
if ($locIdx === 0 && $itemsPerLocation > 0) {
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
}
// calculation_inputs에서 floor/code 가져오기
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;
}
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
if (empty($floorCode) && empty($symbolCode)) {
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
}
}
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
if ($locIdx === 0 && ! $itemsPerLocation && ! 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,
'item_name' => $quoteItem->item_name,
'specification' => $quoteItem->specification,
'floor_code' => $floorCode,
'symbol_code' => $symbolCode,
'quantity' => $quoteItem->calculated_quantity,
'unit' => $quoteItem->unit,
'unit_price' => $quoteItem->unit_price,
'supply_amount' => $quoteItem->total_price,
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
'total_amount' => round($quoteItem->total_price * 1.1, 2),
'note' => $quoteItem->formula_category,
'sort_order' => $index,
]);
}
// 합계 재계산
@@ -995,6 +1000,48 @@ public function createFromQuote(int $quoteId, array $data = [])
});
}
/**
* 견적 품목이 속하는 개소 인덱스 결정
*/
private function resolveQuoteItemLocationIndex(
$quoteItem,
array $productItems,
int $itemsPerLocation,
int $itemIndex,
int $locationCount
): int {
$locIdx = 0;
// 1순위: formula_source에서 인덱스 추출
$formulaSource = $quoteItem->formula_source ?? '';
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
return (int) $matches[1];
}
// 2순위: sort_order 기반 분배
if ($itemsPerLocation > 0) {
return min(intdiv($itemIndex, $itemsPerLocation), $locationCount - 1);
}
// 3순위: note에서 floor/code 매칭
$note = trim($quoteItem->note ?? '');
if ($note !== '' && $note !== '-' && $note !== '- -') {
$parts = preg_split('/\s+/', $note, 2);
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
if (! empty($floorCode) && ! empty($symbolCode)) {
foreach ($productItems as $pidx => $pItem) {
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
return $pidx;
}
}
}
}
return $locIdx;
}
/**
* 견적 변경사항을 수주에 동기화
*