fix: [quote] QA 견적 관련 백엔드 버그 수정
- Quote.isEditable() 생산지시 존재 시 수정 차단 - BOM 탭 순서 정렬 + inspection→검사비 매핑 추가 - 제어기 수량 계산 오류 수정 (1개소 고정 → 수량 반영) - QuoteService for_order/status 필터 조건 수정
This commit is contained in:
@@ -52,10 +52,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
|
||||
// 수주 전환용 조회: 아직 수주가 생성되지 않은 견적만
|
||||
if ($forOrder) {
|
||||
// 1. Quote.order_id가 null인 것 (빠른 체크)
|
||||
$query->whereNull('order_id');
|
||||
// 2. Orders 테이블에 해당 quote_id가 없는 것 (이중 체크, 인덱스 있음)
|
||||
$query->whereDoesntHave('orders');
|
||||
}
|
||||
|
||||
// items 포함 (수주 전환용)
|
||||
@@ -77,7 +74,10 @@ public function index(array $params): LengthAwarePaginator
|
||||
if ($status === Quote::STATUS_CONVERTED) {
|
||||
$query->whereNotNull('order_id');
|
||||
} elseif ($status) {
|
||||
$query->where('status', $status)->whereNull('order_id');
|
||||
$query->where('status', $status);
|
||||
if (! $forOrder) {
|
||||
$query->whereNull('order_id');
|
||||
}
|
||||
}
|
||||
|
||||
// 제품 카테고리 필터
|
||||
@@ -196,6 +196,13 @@ public function show(int $id): Quote
|
||||
$quote->setAttribute('bom_materials', $bomMaterials);
|
||||
}
|
||||
|
||||
// 프론트 제어용 플래그
|
||||
$quote->setAttribute('is_editable', $quote->isEditable());
|
||||
$quote->setAttribute('has_work_orders', $quote->order_id
|
||||
? Order::where('id', $quote->order_id)->whereHas('workOrders')->exists()
|
||||
: false
|
||||
);
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
@@ -634,39 +641,86 @@ public function convertToOrder(int $id): Quote
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($quote, $userId, $tenantId) {
|
||||
// 수주번호 생성
|
||||
$orderNo = $this->generateOrderNumber($tenantId);
|
||||
|
||||
// 수주 마스터 생성
|
||||
$order = Order::createFromQuote($quote, $orderNo);
|
||||
$order->created_by = $userId;
|
||||
$order->save();
|
||||
|
||||
// calculation_inputs에서 개소(제품) 정보 추출
|
||||
$calculationInputs = $quote->calculation_inputs ?? [];
|
||||
$productItems = $calculationInputs['items'] ?? [];
|
||||
$bomResults = $calculationInputs['bomResults'] ?? [];
|
||||
$locationCount = count($productItems);
|
||||
|
||||
// OrderNode 생성 (개소별)
|
||||
$nodeMap = []; // productIndex → OrderNode
|
||||
// 품목→개소 매핑 사전 계산
|
||||
$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->resolveLocationIndex($quoteItem, $productItems);
|
||||
|
||||
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
|
||||
if ($locIdx === 0 && $itemsPerLocation > 0) {
|
||||
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||
}
|
||||
|
||||
$itemsByLocation[$locIdx][] = [
|
||||
'quoteItem' => $quoteItem,
|
||||
'mapping' => $this->resolveLocationMapping($quoteItem, $productItems),
|
||||
];
|
||||
}
|
||||
|
||||
// 개소(items) × 수량(quantity) = 총 수주 건수 계산
|
||||
// 예: items 1건(qty=10) → 10건, items 3건(각 qty=1) → 3건
|
||||
$expandedLocations = [];
|
||||
foreach ($productItems as $idx => $locItem) {
|
||||
$bomResult = $bomResults[$idx] ?? null;
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$qty = (int) ($locItem['quantity'] ?? 1);
|
||||
for ($q = 0; $q < $qty; $q++) {
|
||||
$expandedLocations[] = [
|
||||
'locItem' => $locItem,
|
||||
'bomResult' => $bomResult,
|
||||
'origIdx' => $idx,
|
||||
'unitIndex' => $q,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$totalOrders = count($expandedLocations);
|
||||
$orderNumbers = $this->generateOrderNumbers($tenantId, max($totalOrders, 1));
|
||||
|
||||
// 개소×수량별로 독립 수주 생성
|
||||
$firstOrderId = null;
|
||||
|
||||
foreach ($expandedLocations as $orderIdx => $expanded) {
|
||||
$locItem = $expanded['locItem'];
|
||||
$bomResult = $expanded['bomResult'];
|
||||
$origIdx = $expanded['origIdx'];
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$floor = $locItem['floor'] ?? '';
|
||||
$symbol = $locItem['code'] ?? '';
|
||||
|
||||
// 수주 마스터 생성 (qty=1 단위)
|
||||
$unitLocItem = array_merge($locItem, ['quantity' => 1]);
|
||||
$order = Order::createFromQuoteLocation($quote, $orderNumbers[$orderIdx], $unitLocItem, $bomResult);
|
||||
$order->created_by = $userId;
|
||||
$order->save();
|
||||
|
||||
if ($firstOrderId === null) {
|
||||
$firstOrderId = $order->id;
|
||||
}
|
||||
|
||||
// OrderNode 생성 (1수주 = 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' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$orderIdx}",
|
||||
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($orderIdx + 1),
|
||||
'status_code' => OrderNode::STATUS_PENDING,
|
||||
'quantity' => $qty,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $grandTotal,
|
||||
'total_price' => $grandTotal * $qty,
|
||||
'total_price' => $grandTotal,
|
||||
'options' => [
|
||||
'floor' => $floor,
|
||||
'symbol' => $symbol,
|
||||
@@ -682,50 +736,35 @@ public function convertToOrder(int $id): Quote
|
||||
'bom_result' => $bomResult,
|
||||
],
|
||||
'depth' => 0,
|
||||
'sort_order' => $idx,
|
||||
'sort_order' => 0,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
$nodeMap[$idx] = $node;
|
||||
}
|
||||
|
||||
// 수주 상세 품목 생성 (노드 연결 포함)
|
||||
// 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 복제 (모든 수량 분할 건에 동일 품목)
|
||||
$serialIndex = 1;
|
||||
foreach ($itemsByLocation[$origIdx] ?? [] as $entry) {
|
||||
$mapping = $entry['mapping'];
|
||||
$mapping['order_node_id'] = $node->id;
|
||||
|
||||
$serialIndex = 1;
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
|
||||
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
|
||||
|
||||
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
|
||||
if ($locIdx === 0 && $itemsPerLocation > 0) {
|
||||
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||
$orderItem = OrderItem::createFromQuoteItem($entry['quoteItem'], $order->id, $serialIndex, $mapping);
|
||||
$orderItem->created_by = $userId;
|
||||
$orderItem->save();
|
||||
$serialIndex++;
|
||||
}
|
||||
|
||||
$productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null;
|
||||
|
||||
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
|
||||
$orderItem->created_by = $userId;
|
||||
$orderItem->save();
|
||||
$serialIndex++;
|
||||
// 수주 합계 재계산
|
||||
$order->load('items');
|
||||
$order->recalculateTotals();
|
||||
$order->save();
|
||||
}
|
||||
|
||||
// 수주 합계 재계산
|
||||
$order->load('items');
|
||||
$order->recalculateTotals();
|
||||
$order->save();
|
||||
|
||||
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
|
||||
$quote->update([
|
||||
'order_id' => $order->id,
|
||||
'order_id' => $firstOrderId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return $quote->refresh()->load(['items', 'client', 'order']);
|
||||
return $quote->refresh()->load(['items', 'client', 'orders']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -816,10 +855,10 @@ private function extractProductCodeFromInputs(array $data): ?string
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주번호 생성
|
||||
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)
|
||||
* 수주번호 N개 연속 생성
|
||||
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001, -002, -003)
|
||||
*/
|
||||
private function generateOrderNumber(int $tenantId): string
|
||||
private function generateOrderNumbers(int $tenantId, int $count = 1): array
|
||||
{
|
||||
$dateStr = now()->format('ymd');
|
||||
$prefix = "ORD-{$dateStr}-";
|
||||
@@ -839,9 +878,13 @@ private function generateOrderNumber(int $tenantId): string
|
||||
}
|
||||
}
|
||||
|
||||
$seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
||||
$numbers = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$seqStr = str_pad((string) ($sequence + $i), 3, '0', STR_PAD_LEFT);
|
||||
$numbers[] = "{$prefix}{$seqStr}";
|
||||
}
|
||||
|
||||
return "{$prefix}{$seqStr}";
|
||||
return $numbers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user