fix: [quote] QA 견적 관련 백엔드 버그 수정
- Quote.isEditable() 생산지시 존재 시 수정 차단 - BOM 탭 순서 정렬 + inspection→검사비 매핑 추가 - 제어기 수량 계산 오류 수정 (1개소 고정 → 수량 반영) - QuoteService for_order/status 필터 조건 수정
This commit is contained in:
@@ -331,11 +331,21 @@ public function scopeSearch($query, ?string $keyword)
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 확인
|
||||
* - 모든 상태에서 수정 가능 (finalized, converted 포함)
|
||||
* - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨
|
||||
* - 생산지시가 존재하는 수주에 연결된 견적은 수정 불가
|
||||
* - 그 외 모든 상태에서 수정 가능 (finalized, converted 포함)
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
if ($this->order_id) {
|
||||
$hasWorkOrders = Order::where('id', $this->order_id)
|
||||
->whereHas('workOrders')
|
||||
->exists();
|
||||
|
||||
if ($hasWorkOrders) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1848,7 +1848,8 @@ private function calculateTenantBom(
|
||||
'formulas' => $itemFormulas,
|
||||
]);
|
||||
|
||||
// Step 8: 카테고리별 그룹화
|
||||
// Step 8: 카테고리별 그룹화 (고정 순서: 주자재→모터→제어기→절곡품→부자재→검사비→기타)
|
||||
$categoryOrder = ['material', 'motor', 'controller', 'steel', 'parts', 'inspection'];
|
||||
$groupedItems = [];
|
||||
foreach ($calculatedItems as $item) {
|
||||
$category = $item['category_group'];
|
||||
@@ -1862,6 +1863,19 @@ private function calculateTenantBom(
|
||||
$groupedItems[$category]['items'][] = $item;
|
||||
$groupedItems[$category]['subtotal'] += $item['total_price'];
|
||||
}
|
||||
// 고정 순서로 정렬 (미정의 카테고리는 뒤에 배치)
|
||||
$sorted = [];
|
||||
foreach ($categoryOrder as $cat) {
|
||||
if (isset($groupedItems[$cat])) {
|
||||
$sorted[$cat] = $groupedItems[$cat];
|
||||
}
|
||||
}
|
||||
foreach ($groupedItems as $cat => $group) {
|
||||
if (! isset($sorted[$cat])) {
|
||||
$sorted[$cat] = $group;
|
||||
}
|
||||
}
|
||||
$groupedItems = $sorted;
|
||||
|
||||
$this->addDebugStep(8, '카테고리그룹화', [
|
||||
'groups' => array_map(fn ($g) => [
|
||||
@@ -1927,6 +1941,7 @@ private function getTenantCategoryName(string $category): string
|
||||
'controller' => '제어기',
|
||||
'steel' => '절곡품',
|
||||
'parts' => '부자재',
|
||||
'inspection' => '검사비',
|
||||
default => $category,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1090,11 +1090,11 @@ public function calculateDynamicItems(array $inputs): array
|
||||
'total_price' => $motorPrice * $quantity,
|
||||
], $motorCode);
|
||||
|
||||
// 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17)
|
||||
// 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17
|
||||
// col15/col16/col17은 고정 수량 (QTY와 무관, $su를 곱하지 않음)
|
||||
// 3. 제어기 — 셔터 수량(QTY)만큼 필요
|
||||
// 5130 원본은 col15/col16/col17을 QTY와 무관하게 처리했으나,
|
||||
// SAM에서는 개소별 수량(QTY)에 비례하여 계산
|
||||
$controllerType = $inputs['controller_type'] ?? '매립형';
|
||||
$controllerQty = (int) ($inputs['controller_qty'] ?? 1);
|
||||
$controllerQty = (int) ($inputs['controller_qty'] ?? 1) * $quantity;
|
||||
$controllerPrice = $this->getControllerPrice($controllerType);
|
||||
if ($controllerPrice > 0 && $controllerQty > 0) {
|
||||
$ctrlCode = "EST-CTRL-{$controllerType}";
|
||||
@@ -1109,8 +1109,8 @@ public function calculateDynamicItems(array $inputs): array
|
||||
], $ctrlCode);
|
||||
}
|
||||
|
||||
// 뒷박스 (5130: col17 수량, QTY와 무관)
|
||||
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1);
|
||||
// 뒷박스 — 제어기와 동일하게 수량(QTY) 반영
|
||||
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1) * $quantity;
|
||||
if ($backboxQty > 0) {
|
||||
$backboxPrice = $this->getControllerPrice('뒷박스');
|
||||
if ($backboxPrice > 0) {
|
||||
|
||||
@@ -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