fix: [order] 견적→수주 변환 개소별 분리 구현
- CreateFromQuoteRequest 검증 규칙 추가 - Order 모델 견적 연동 관계 보강 - OrderService 변환 시 개소별 분리 로직
This commit is contained in:
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 변경사항을 수주에 동기화
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user