diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index 2a6a9003..08e70a45 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -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; } diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 853688b7..33271ae6 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -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, }; } diff --git a/app/Services/Quote/Handlers/Tenant287/FormulaHandler.php b/app/Services/Quote/Handlers/Tenant287/FormulaHandler.php index f282856a..506fa72f 100644 --- a/app/Services/Quote/Handlers/Tenant287/FormulaHandler.php +++ b/app/Services/Quote/Handlers/Tenant287/FormulaHandler.php @@ -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) { diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 9dc55bba..f5bcb4ed 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -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; } /**