fix: [quote] QA 견적 관련 백엔드 버그 수정

- Quote.isEditable() 생산지시 존재 시 수정 차단
- BOM 탭 순서 정렬 + inspection→검사비 매핑 추가
- 제어기 수량 계산 오류 수정 (1개소 고정 → 수량 반영)
- QuoteService for_order/status 필터 조건 수정
This commit is contained in:
2026-03-17 13:55:18 +09:00
parent 5e65cbc93e
commit e5da452fde
4 changed files with 132 additions and 64 deletions

View File

@@ -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;
}
/**