with('creator:id,name') ->orderBy('created_at', 'desc'); if (! empty($params['status'])) { $query->where('status', $params['status']); } if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('input_text', 'like', "%{$search}%"); }); } return $query->paginate($params['per_page'] ?? 10); } /** * 상세 조회 */ public function getById(int $id): ?AiQuotation { return AiQuotation::with(['items', 'creator:id,name'])->find($id); } /** * 견적 요청 생성 + AI 분석 실행 */ public function createAndAnalyze(array $data): array { $provider = $data['ai_provider'] ?? 'gemini'; $quoteMode = $data['quote_mode'] ?? AiQuotation::MODE_MODULE; $quotation = AiQuotation::create([ 'tenant_id' => session('selected_tenant_id', 1), 'quote_mode' => $quoteMode, 'product_category' => $data['product_category'] ?? null, 'title' => $data['title'], 'input_type' => $data['input_type'] ?? 'text', 'input_text' => $data['input_text'] ?? null, 'ai_provider' => $provider, 'status' => AiQuotation::STATUS_PENDING, 'created_by' => Auth::id(), ]); // 제조 모드: 고객 정보를 options에 저장 if ($quoteMode === AiQuotation::MODE_MANUFACTURE) { $quotation->update([ 'quote_number' => $this->generateQuoteNumber($data['product_category'] ?? 'SC'), 'options' => array_filter([ 'client' => array_filter([ 'company' => $data['client_company'] ?? null, 'contact' => $data['client_contact'] ?? null, 'phone' => $data['client_phone'] ?? null, 'email' => $data['client_email'] ?? null, ]), ]), ]); return $this->runManufactureAnalysis($quotation); } return $this->runAnalysis($quotation); } /** * AI 분석 실행 (재분석 가능) */ public function runAnalysis(AiQuotation $quotation): array { try { $quotation->update(['status' => AiQuotation::STATUS_PROCESSING]); $provider = $quotation->ai_provider; $config = AiConfig::getActive($provider); if (! $config) { throw new \RuntimeException("{$provider} API 설정이 없습니다."); } // 1차 호출: 업무 분석 $modules = AiQuotationModule::getActiveModulesForPrompt(); $analysisPrompt = $this->buildAnalysisPrompt($quotation->input_text, $modules); $analysisRaw = $this->callAi($config, $provider, $analysisPrompt, 'AI견적-업무분석'); $analysisResult = $this->parseJsonResponse($analysisRaw); if (! $analysisResult) { throw new \RuntimeException('AI 업무 분석 결과 파싱 실패'); } $quotation->update(['analysis_result' => $analysisResult]); // 2차 호출: 견적 생성 $quotationPrompt = $this->buildQuotationPrompt($analysisResult, $modules); $quotationRaw = $this->callAi($config, $provider, $quotationPrompt, 'AI견적-견적생성'); $quotationResult = $this->parseJsonResponse($quotationRaw); if (! $quotationResult) { throw new \RuntimeException('AI 견적 생성 결과 파싱 실패'); } // 추천 모듈 아이템 저장 $this->saveQuotationItems($quotation, $quotationResult, $modules); // 합계 계산 $totals = $quotation->items()->selectRaw( 'SUM(dev_cost) as total_dev, SUM(monthly_fee) as total_monthly' )->first(); $quotation->update([ 'quotation_result' => $quotationResult, 'ai_model' => $config->model, 'total_dev_cost' => $totals->total_dev ?? 0, 'total_monthly_fee' => $totals->total_monthly ?? 0, 'status' => AiQuotation::STATUS_COMPLETED, ]); return [ 'ok' => true, 'quotation' => $quotation->fresh(['items', 'creator:id,name']), ]; } catch (\Exception $e) { Log::error('AI 견적 분석 실패', [ 'quotation_id' => $quotation->id, 'error' => $e->getMessage(), ]); $quotation->update(['status' => AiQuotation::STATUS_FAILED]); return [ 'ok' => false, 'error' => $e->getMessage(), 'quotation' => $quotation->fresh(), ]; } } /** * AI API 호출 (Gemini / Claude) */ private function callAi(AiConfig $config, string $provider, string $prompt, string $menuName): ?string { return match ($provider) { 'gemini' => $this->callGemini($config, $prompt, $menuName), 'claude' => $this->callClaude($config, $prompt, $menuName), default => throw new \RuntimeException("지원하지 않는 AI Provider: {$provider}"), }; } /** * Gemini API 호출 */ private function callGemini(AiConfig $config, string $prompt, string $menuName): ?string { $model = $config->model; $apiKey = $config->api_key; $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; $response = Http::timeout(120) ->withHeaders(['Content-Type' => 'application/json']) ->post($url, [ 'contents' => [ ['parts' => [['text' => $prompt]]], ], 'generationConfig' => [ 'temperature' => 0.3, 'maxOutputTokens' => 8192, 'responseMimeType' => 'application/json', ], ]); if (! $response->successful()) { Log::error('Gemini API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('Gemini API 호출 실패: '.$response->status()); } $result = $response->json(); AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? $model, $menuName); return $result['candidates'][0]['content']['parts'][0]['text'] ?? null; } /** * Claude API 호출 */ private function callClaude(AiConfig $config, string $prompt, string $menuName): ?string { $response = Http::timeout(120) ->withHeaders([ 'x-api-key' => $config->api_key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ]) ->post($config->base_url.'/messages', [ 'model' => $config->model, 'max_tokens' => 8192, 'temperature' => 0.3, 'messages' => [ ['role' => 'user', 'content' => $prompt], ], ]); if (! $response->successful()) { Log::error('Claude API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('Claude API 호출 실패: '.$response->status()); } $result = $response->json(); AiTokenHelper::saveClaudeUsage($result, $config->model, $menuName); return $result['content'][0]['text'] ?? null; } /** * 1차 프롬프트: 업무 분석 */ private function buildAnalysisPrompt(string $interviewText, array $modules): string { $modulesJson = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return << json_last_error_msg(), 'response_preview' => mb_substr($response, 0, 500), ]); return null; } return $decoded; } /** * AI 견적 결과를 아이템으로 저장 */ private function saveQuotationItems(AiQuotation $quotation, array $quotationResult, array $modules): void { // 기존 아이템 삭제 (재분석 시) $quotation->items()->delete(); $items = $quotationResult['items'] ?? []; $moduleMap = collect($modules)->keyBy('module_code'); foreach ($items as $index => $item) { $moduleCode = $item['module_code'] ?? ''; $catalogModule = $moduleMap->get($moduleCode); // 카탈로그에 있는 모듈이면 DB의 가격 사용 (AI hallucination 방지) $devCost = $catalogModule ? $catalogModule['dev_cost'] : ($item['dev_cost'] ?? 0); $monthlyFee = $catalogModule ? $catalogModule['monthly_fee'] : ($item['monthly_fee'] ?? 0); // DB에서 module_id 조회 $dbModule = AiQuotationModule::where('module_code', $moduleCode)->first(); AiQuotationItem::create([ 'ai_quotation_id' => $quotation->id, 'module_id' => $dbModule?->id, 'module_code' => $moduleCode, 'module_name' => $item['module_name'] ?? $moduleCode, 'is_required' => $item['is_required'] ?? false, 'reason' => $item['reason'] ?? null, 'dev_cost' => $devCost, 'monthly_fee' => $monthlyFee, 'sort_order' => $index, ]); } } // =================================================== // 제조 견적 (Manufacture) 전용 메서드 // =================================================== /** * 제조 견적 AI 분석 실행 */ public function runManufactureAnalysis(AiQuotation $quotation): array { try { $quotation->update(['status' => AiQuotation::STATUS_PROCESSING]); $provider = $quotation->ai_provider; $config = AiConfig::getActive($provider); if (! $config) { throw new \RuntimeException("{$provider} API 설정이 없습니다."); } $productCategory = $quotation->product_category ?? 'SCREEN'; $priceTables = AiQuotePriceTable::getPriceTablesForPrompt($productCategory); // 1차: 요구사항 분석 $analysisPrompt = $this->buildManufactureAnalysisPrompt( $quotation->input_text, $productCategory ); $analysisRaw = $this->callAi($config, $provider, $analysisPrompt, 'AI제조견적-분석'); $analysisResult = $this->parseJsonResponse($analysisRaw); if (! $analysisResult) { throw new \RuntimeException('AI 요구사항 분석 결과 파싱 실패'); } $quotation->update(['analysis_result' => $analysisResult]); // 2차: 견적 산출 $quotationPrompt = $this->buildManufactureQuotationPrompt( $analysisResult, $priceTables, $productCategory ); $quotationRaw = $this->callAi($config, $provider, $quotationPrompt, 'AI제조견적-산출'); $quotationResult = $this->parseJsonResponse($quotationRaw); if (! $quotationResult) { throw new \RuntimeException('AI 견적 산출 결과 파싱 실패'); } // 품목 저장 $this->saveManufactureItems($quotation, $quotationResult); // 합계 계산 + options에 pricing 저장 $totals = $quotation->items()->selectRaw( 'SUM(total_price) as subtotal' )->first(); $subtotal = (int) ($totals->subtotal ?? 0); $pricing = $quotationResult['pricing'] ?? []; $discountRate = (float) ($pricing['discount_rate'] ?? 0); $discountAmount = (int) round($subtotal * $discountRate / 100); $afterDiscount = $subtotal - $discountAmount; $vatAmount = (int) round($afterDiscount * 0.1); $finalAmount = $afterDiscount + $vatAmount; // 고객 정보 업데이트 (AI가 추출한 정보로 보강) $clientFromAi = $analysisResult['client'] ?? []; $existingOptions = $quotation->options ?? []; $existingClient = $existingOptions['client'] ?? []; $mergedClient = array_filter(array_merge($clientFromAi, $existingClient)); $existingOptions['client'] = $mergedClient; $existingOptions['project'] = $analysisResult['project'] ?? []; $existingOptions['pricing'] = [ 'subtotal' => $subtotal, 'material_cost' => (int) ($pricing['material_cost'] ?? 0), 'labor_cost' => (int) ($pricing['labor_cost'] ?? 0), 'install_cost' => (int) ($pricing['install_cost'] ?? 0), 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'vat_amount' => $vatAmount, 'final_amount' => $finalAmount, ]; $existingOptions['terms'] = $quotationResult['terms'] ?? [ 'valid_until' => now()->addDays(30)->format('Y-m-d'), 'payment' => '계약 시 50%, 설치 완료 후 50%', 'delivery' => '계약 후 4주 이내', ]; $quotation->update([ 'quotation_result' => $quotationResult, 'ai_model' => $config->model, 'total_dev_cost' => $finalAmount, 'total_monthly_fee' => 0, 'options' => $existingOptions, 'status' => AiQuotation::STATUS_COMPLETED, ]); return [ 'ok' => true, 'quotation' => $quotation->fresh(['items', 'creator:id,name']), ]; } catch (\Exception $e) { Log::error('AI 제조 견적 분석 실패', [ 'quotation_id' => $quotation->id, 'error' => $e->getMessage(), ]); $quotation->update(['status' => AiQuotation::STATUS_FAILED]); return [ 'ok' => false, 'error' => $e->getMessage(), 'quotation' => $quotation->fresh(), ]; } } /** * 견적번호 자동 생성 */ public function generateQuoteNumber(?string $productCategory): string { $prefix = match (strtoupper($productCategory ?? 'SC')) { 'SCREEN' => 'SC', 'STEEL' => 'ST', default => 'SC', }; $dateStr = now()->format('ymd'); $baseNumber = "AQ-{$prefix}-{$dateStr}"; $count = AiQuotation::where('quote_number', 'like', "{$baseNumber}-%")->count(); $seq = str_pad($count + 1, 2, '0', STR_PAD_LEFT); return "{$baseNumber}-{$seq}"; } /** * 제조 견적 품목 저장 */ private function saveManufactureItems(AiQuotation $quotation, array $quotationResult): void { $quotation->items()->delete(); $items = $quotationResult['items'] ?? []; foreach ($items as $index => $item) { $quantity = (float) ($item['quantity'] ?? 1); $unitPrice = (float) ($item['unit_price'] ?? 0); $totalPrice = (float) ($item['total_price'] ?? ($quantity * $unitPrice)); AiQuotationItem::create([ 'ai_quotation_id' => $quotation->id, 'module_code' => $item['item_code'] ?? '', 'module_name' => $item['item_name'] ?? '', 'specification' => $item['specification'] ?? null, 'unit' => $item['unit'] ?? 'SET', 'quantity' => $quantity, 'unit_price' => $unitPrice, 'total_price' => $totalPrice, 'item_category' => $item['item_category'] ?? 'material', 'floor_code' => $item['floor_code'] ?? null, 'reason' => $item['description'] ?? null, 'sort_order' => $index, ]); } } /** * 견적 편집 저장 */ public function updateQuotation(int $id, array $data): array { $quotation = $this->getById($id); if (! $quotation) { return ['ok' => false, 'error' => '견적을 찾을 수 없습니다.']; } DB::beginTransaction(); try { // options 업데이트 $options = $quotation->options ?? []; if (isset($data['client'])) { $options['client'] = $data['client']; } if (isset($data['project'])) { $options['project'] = $data['project']; } if (isset($data['terms'])) { $options['terms'] = $data['terms']; } // 품목 업데이트 if (isset($data['items'])) { $quotation->items()->delete(); $subtotal = 0; $materialCost = 0; $laborCost = 0; $installCost = 0; foreach ($data['items'] as $index => $item) { $qty = (float) ($item['quantity'] ?? 1); $price = (float) ($item['unit_price'] ?? 0); $total = round($qty * $price, 2); $subtotal += $total; $cat = $item['item_category'] ?? 'material'; if ($cat === 'material') { $materialCost += $total; } elseif ($cat === 'labor') { $laborCost += $total; } elseif ($cat === 'install') { $installCost += $total; } AiQuotationItem::create([ 'ai_quotation_id' => $quotation->id, 'module_code' => $item['item_code'] ?? '', 'module_name' => $item['item_name'] ?? '', 'specification' => $item['specification'] ?? null, 'unit' => $item['unit'] ?? 'SET', 'quantity' => $qty, 'unit_price' => $price, 'total_price' => $total, 'item_category' => $cat, 'floor_code' => $item['floor_code'] ?? null, 'reason' => $item['description'] ?? null, 'sort_order' => $index, ]); } // 가격 재계산 $discountRate = (float) ($data['discount_rate'] ?? $options['pricing']['discount_rate'] ?? 0); $discountAmount = (int) round($subtotal * $discountRate / 100); $afterDiscount = $subtotal - $discountAmount; $vatAmount = (int) round($afterDiscount * 0.1); $finalAmount = $afterDiscount + $vatAmount; $options['pricing'] = [ 'subtotal' => (int) $subtotal, 'material_cost' => (int) $materialCost, 'labor_cost' => (int) $laborCost, 'install_cost' => (int) $installCost, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'vat_amount' => $vatAmount, 'final_amount' => $finalAmount, ]; $quotation->update([ 'total_dev_cost' => $finalAmount, 'total_monthly_fee' => 0, ]); } $quotation->update(['options' => $options]); DB::commit(); return [ 'ok' => true, 'quotation' => $quotation->fresh(['items', 'creator:id,name']), ]; } catch (\Exception $e) { DB::rollBack(); Log::error('견적 편집 저장 실패', [ 'quotation_id' => $id, 'error' => $e->getMessage(), ]); return ['ok' => false, 'error' => $e->getMessage()]; } } /** * 금액을 한글로 변환 */ public static function numberToKorean(int $number): string { if ($number === 0) { return '영'; } $units = ['', '만', '억', '조']; $digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구']; $subUnits = ['', '십', '백', '천']; $result = ''; $unitIndex = 0; while ($number > 0) { $chunk = $number % 10000; if ($chunk > 0) { $chunkStr = ''; $subIndex = 0; $temp = $chunk; while ($temp > 0) { $digit = $temp % 10; if ($digit > 0) { $prefix = ($digit === 1 && $subIndex > 0) ? '' : $digits[$digit]; $chunkStr = $prefix.$subUnits[$subIndex].$chunkStr; } $temp = (int) ($temp / 10); $subIndex++; } $result = $chunkStr.$units[$unitIndex].$result; } $number = (int) ($number / 10000); $unitIndex++; } return $result; } // =================================================== // 제조 견적 AI 프롬프트 // =================================================== /** * 제조 견적용 1단계: 요구사항 분석 */ private function buildManufactureAnalysisPrompt(string $interviewText, string $productCategory): string { $categoryLabel = $productCategory === 'STEEL' ? '철재(방화문/방화셔터)' : '방화스크린'; return <<selectRaw(" COUNT(*) as total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending ") ->first(); $recent = AiQuotation::with('creator:id,name') ->orderBy('created_at', 'desc') ->limit(5) ->get(); return [ 'stats' => [ 'total' => (int) $stats->total, 'completed' => (int) $stats->completed, 'processing' => (int) $stats->processing, 'failed' => (int) $stats->failed, 'pending' => (int) $stats->pending, ], 'recent' => $recent, ]; } }