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

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

View File

@@ -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,
};
}

View File

@@ -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) {

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