diff --git a/app/Http/Controllers/Api/V1/QuoteController.php b/app/Http/Controllers/Api/V1/QuoteController.php index 3e3d3ce..469c7cd 100644 --- a/app/Http/Controllers/Api/V1/QuoteController.php +++ b/app/Http/Controllers/Api/V1/QuoteController.php @@ -17,6 +17,7 @@ use App\Services\Quote\QuoteDocumentService; use App\Services\Quote\QuoteNumberService; use App\Services\Quote\QuoteService; +use Illuminate\Support\Facades\Log; class QuoteController extends Controller { @@ -52,8 +53,26 @@ public function show(int $id) */ public function store(QuoteStoreRequest $request) { - return ApiResponse::handle(function () use ($request) { - return $this->quoteService->store($request->validated()); + // DEBUG: 요청 데이터 로깅 + Log::info('[QuoteController::store] 원본 요청:', [ + 'author' => $request->input('author'), + 'manager' => $request->input('manager'), + 'contact' => $request->input('contact'), + 'remarks' => $request->input('remarks'), + ]); + + $validated = $request->validated(); + + // DEBUG: validated 데이터 로깅 + Log::info('[QuoteController::store] validated 데이터:', [ + 'author' => $validated['author'] ?? 'NOT_SET', + 'manager' => $validated['manager'] ?? 'NOT_SET', + 'contact' => $validated['contact'] ?? 'NOT_SET', + 'remarks' => $validated['remarks'] ?? 'NOT_SET', + ]); + + return ApiResponse::handle(function () use ($validated) { + return $this->quoteService->store($validated); }, __('message.quote.created')); } diff --git a/app/Http/Requests/Quote/QuoteStoreRequest.php b/app/Http/Requests/Quote/QuoteStoreRequest.php index c484c65..effb09f 100644 --- a/app/Http/Requests/Quote/QuoteStoreRequest.php +++ b/app/Http/Requests/Quote/QuoteStoreRequest.php @@ -12,6 +12,24 @@ public function authorize(): bool return true; } + /** + * 프론트엔드 필드명을 백엔드 필드명으로 정규화 + * manager_name → manager, manager_contact → contact + */ + protected function prepareForValidation(): void + { + $mappings = [ + 'manager_name' => 'manager', + 'manager_contact' => 'contact', + ]; + + foreach ($mappings as $from => $to) { + if ($this->has($from) && ! $this->has($to)) { + $this->merge([$to => $this->input($from)]); + } + } + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Quote/QuoteUpdateRequest.php b/app/Http/Requests/Quote/QuoteUpdateRequest.php index c6137b0..cf0c260 100644 --- a/app/Http/Requests/Quote/QuoteUpdateRequest.php +++ b/app/Http/Requests/Quote/QuoteUpdateRequest.php @@ -12,6 +12,24 @@ public function authorize(): bool return true; } + /** + * 프론트엔드 필드명을 백엔드 필드명으로 정규화 + * manager_name → manager, manager_contact → contact + */ + protected function prepareForValidation(): void + { + $mappings = [ + 'manager_name' => 'manager', + 'manager_contact' => 'contact', + ]; + + foreach ($mappings as $from => $to) { + if ($this->has($from) && ! $this->has($to)) { + $this->merge([$to => $this->input($from)]); + } + } + } + public function rules(): array { return [ diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index b21fae9..d479b59 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1221,4 +1221,194 @@ private function evaluateQuantityFormula(string $formula, array $variables): flo return 1; } } + + // ========================================================================= + // BOM 원자재(Leaf Node) 조회 - 소요자재내역용 + // ========================================================================= + + /** + * BOM 트리에서 원자재(leaf nodes)만 추출 + * + * 완제품의 BOM을 재귀적으로 탐색하여 실제 구매해야 할 원자재 목록을 반환합니다. + * - Leaf node: BOM이 없는 품목 또는 item_type이 RM(원자재), SM(부자재), CS(소모품)인 품목 + * - 수량은 상위 구조의 수량을 누적하여 계산 + * + * @param string $finishedGoodsCode 완제품 코드 + * @param float $orderQuantity 주문 수량 (QTY) + * @param array $variables 변수 배열 (W0, H0 등) + * @param int|null $tenantId 테넌트 ID + * @return array 원자재 목록 (leaf nodes) + */ + public function getBomLeafMaterials( + string $finishedGoodsCode, + float $orderQuantity, + array $variables, + ?int $tenantId = null + ): array { + $tenantId = $tenantId ?? $this->tenantId(); + + if (! $tenantId) { + return []; + } + + // 완제품 조회 + $finishedGoods = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', $finishedGoodsCode) + ->whereNull('deleted_at') + ->first(); + + if (! $finishedGoods || empty($finishedGoods->bom)) { + return []; + } + + $bomData = json_decode($finishedGoods->bom, true); + + if (! is_array($bomData) || empty($bomData)) { + return []; + } + + // 재귀적으로 leaf nodes 수집 + $leafMaterials = []; + $this->collectLeafMaterials( + $bomData, + $tenantId, + $orderQuantity, + $variables, + $leafMaterials + ); + + // 동일 품목 코드 병합 (수량 합산) + return $this->mergeLeafMaterials($leafMaterials, $tenantId); + } + + /** + * 재귀적으로 BOM 트리를 탐색하여 leaf materials 수집 + * + * @param array $bomData BOM 데이터 배열 + * @param int $tenantId 테넌트 ID + * @param float $parentQuantity 상위 품목 수량 (누적) + * @param array $variables 변수 배열 + * @param array &$leafMaterials 결과 배열 (참조) + * @param int $depth 재귀 깊이 (무한루프 방지) + */ + private function collectLeafMaterials( + array $bomData, + int $tenantId, + float $parentQuantity, + array $variables, + array &$leafMaterials, + int $depth = 0 + ): void { + // 무한 루프 방지 + if ($depth > 10) { + return; + } + + foreach ($bomData as $bomEntry) { + $childItemId = $bomEntry['child_item_id'] ?? null; + $childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null; + $quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1'; + + // 수량 계산 + $itemQuantity = $this->evaluateQuantityFormula((string) $quantityFormula, $variables); + $totalQuantity = $parentQuantity * $itemQuantity; + + // 자식 품목 조회 + $childItem = null; + if ($childItemId) { + $childItem = DB::table('items') + ->leftJoin('item_details', 'items.id', '=', 'item_details.item_id') + ->where('items.tenant_id', $tenantId) + ->where('items.id', $childItemId) + ->whereNull('items.deleted_at') + ->select('items.*', 'item_details.specification') + ->first(); + } elseif ($childItemCode) { + $childItem = DB::table('items') + ->leftJoin('item_details', 'items.id', '=', 'item_details.item_id') + ->where('items.tenant_id', $tenantId) + ->where('items.code', $childItemCode) + ->whereNull('items.deleted_at') + ->select('items.*', 'item_details.specification') + ->first(); + } + + if (! $childItem) { + continue; + } + + // 자식의 BOM 확인 + $childBomData = json_decode($childItem->bom ?? '[]', true); + $hasChildBom = ! empty($childBomData) && is_array($childBomData); + + // Leaf node 판단 조건: + // 1. BOM이 없는 품목 + // 2. 또는 item_type이 원자재(RM), 부자재(SM), 소모품(CS)인 품목 + $isLeafType = in_array($childItem->item_type, ['RM', 'SM', 'CS']); + + if (! $hasChildBom || $isLeafType) { + // Leaf node - 결과에 추가 + $leafMaterials[] = [ + 'item_id' => $childItem->id, + 'item_code' => $childItem->code, + 'item_name' => $childItem->name, + 'item_type' => $childItem->item_type, + 'item_category' => $childItem->item_category, + 'specification' => $childItem->specification, + 'unit' => $childItem->unit ?? 'EA', + 'quantity' => $totalQuantity, + 'process_type' => $childItem->process_type, + ]; + } else { + // 중간 노드 (부품/반제품) - 재귀 탐색 + $this->collectLeafMaterials( + $childBomData, + $tenantId, + $totalQuantity, + $variables, + $leafMaterials, + $depth + 1 + ); + } + } + } + + /** + * 동일 품목 코드의 leaf materials 병합 (수량 합산) + * + * @param array $leafMaterials 원자재 목록 + * @param int $tenantId 테넌트 ID + * @return array 병합된 원자재 목록 + */ + private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array + { + $merged = []; + + foreach ($leafMaterials as $material) { + $code = $material['item_code']; + + if (isset($merged[$code])) { + // 동일 품목 - 수량 합산 + $merged[$code]['quantity'] += $material['quantity']; + } else { + // 새 품목 추가 + $merged[$code] = $material; + } + } + + // 단가 조회 및 금액 계산 + $result = []; + foreach ($merged as $material) { + $unitPrice = $this->getItemPrice($material['item_code']); + $totalPrice = $material['quantity'] * $unitPrice; + + $result[] = array_merge($material, [ + 'unit_price' => $unitPrice, + 'total_price' => round($totalPrice, 2), + ]); + } + + return array_values($result); + } } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 8325f23..6d222b2 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -101,7 +101,11 @@ public function show(int $id): Quote } /** - * 저장된 calculation_inputs를 기반으로 BOM 자재 목록 계산 + * 저장된 calculation_inputs를 기반으로 BOM 원자재(leaf nodes) 목록 조회 + * + * 세부산출내역과 달리, BOM 트리에서 실제 원자재만 추출합니다: + * - 세부산출내역: BOM 계산 결과 (수식 기반 산출 품목) + * - 소요자재내역: BOM 트리 leaf nodes (실제 구매 필요한 원자재) */ private function calculateBomMaterials(Quote $quote): array { @@ -112,6 +116,7 @@ private function calculateBomMaterials(Quote $quote): array return []; } + $tenantId = $this->tenantId(); $inputItems = $calculationInputs['items']; $allMaterials = []; @@ -122,11 +127,14 @@ private function calculateBomMaterials(Quote $quote): array continue; } + // 주문 수량 + $orderQuantity = (float) ($input['quantity'] ?? 1); + // BOM 계산을 위한 입력 변수 구성 - $bomInputs = [ + $variables = [ 'W0' => (float) ($input['openWidth'] ?? 0), 'H0' => (float) ($input['openHeight'] ?? 0), - 'QTY' => (float) ($input['quantity'] ?? 1), + 'QTY' => $orderQuantity, 'PC' => $input['productCategory'] ?? 'SCREEN', 'GT' => $input['guideRailType'] ?? 'wall', 'MP' => $input['motorPower'] ?? 'single', @@ -136,27 +144,33 @@ private function calculateBomMaterials(Quote $quote): array ]; try { - $result = $this->calculationService->calculateBom($finishedGoodsCode, $bomInputs, false); + // BOM 트리에서 원자재(leaf nodes)만 추출 + $leafMaterials = $this->calculationService->formulaEvaluator->getBomLeafMaterials( + $finishedGoodsCode, + $orderQuantity, + $variables, + $tenantId + ); - if (($result['success'] ?? false) && ! empty($result['items'])) { - // 각 자재 항목에 인덱스 정보 추가 - foreach ($result['items'] as $material) { - $allMaterials[] = [ - 'item_index' => $index, - 'finished_goods_code' => $finishedGoodsCode, - 'item_code' => $material['item_code'] ?? '', - 'item_name' => $material['item_name'] ?? '', - 'specification' => $material['specification'] ?? '', - 'unit' => $material['unit'] ?? 'EA', - 'quantity' => $material['quantity'] ?? 0, - 'unit_price' => $material['unit_price'] ?? 0, - 'total_price' => $material['total_price'] ?? 0, - 'formula_category' => $material['formula_category'] ?? '', - ]; - } + // 각 자재 항목에 인덱스 정보 추가 + foreach ($leafMaterials as $material) { + $allMaterials[] = [ + 'item_index' => $index, + 'finished_goods_code' => $finishedGoodsCode, + 'item_code' => $material['item_code'] ?? '', + 'item_name' => $material['item_name'] ?? '', + 'item_type' => $material['item_type'] ?? '', + 'item_category' => $material['item_category'] ?? '', + 'specification' => $material['specification'] ?? '', + 'unit' => $material['unit'] ?? 'EA', + 'quantity' => $material['quantity'] ?? 0, + 'unit_price' => $material['unit_price'] ?? 0, + 'total_price' => $material['total_price'] ?? 0, + 'process_type' => $material['process_type'] ?? '', + ]; } } catch (\Throwable) { - // BOM 계산 실패 시 해당 품목은 스킵 + // BOM 조회 실패 시 해당 품목은 스킵 continue; } }