435 lines
14 KiB
PHP
435 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\Rd;
|
||
|
|
|
||
|
|
use App\Helpers\AiTokenHelper;
|
||
|
|
use App\Models\Rd\AiQuotation;
|
||
|
|
use App\Models\Rd\AiQuotationItem;
|
||
|
|
use App\Models\Rd\AiQuotationModule;
|
||
|
|
use App\Models\System\AiConfig;
|
||
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||
|
|
use Illuminate\Support\Facades\Auth;
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
use Illuminate\Support\Facades\Log;
|
||
|
|
|
||
|
|
class AiQuotationService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* 목록 조회
|
||
|
|
*/
|
||
|
|
public function getList(array $params = []): LengthAwarePaginator
|
||
|
|
{
|
||
|
|
$query = AiQuotation::query()
|
||
|
|
->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';
|
||
|
|
|
||
|
|
$quotation = AiQuotation::create([
|
||
|
|
'tenant_id' => session('selected_tenant_id', 1),
|
||
|
|
'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(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
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 <<<PROMPT
|
||
|
|
당신은 SAM(Smart Automation Management) ERP/MES 솔루션의 전문 컨설턴트입니다.
|
||
|
|
|
||
|
|
아래는 고객사 직원과의 인터뷰 내용입니다. 이를 분석하여 구조화된 업무 분석 보고서를 JSON으로 작성하세요.
|
||
|
|
|
||
|
|
## 인터뷰 내용
|
||
|
|
{$interviewText}
|
||
|
|
|
||
|
|
## SAM 모듈 카탈로그 (분석 기준)
|
||
|
|
{$modulesJson}
|
||
|
|
|
||
|
|
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
|
||
|
|
{
|
||
|
|
"company_analysis": {
|
||
|
|
"industry": "업종 분류",
|
||
|
|
"scale": "소규모/중소/중견",
|
||
|
|
"employee_count_estimate": 0,
|
||
|
|
"current_systems": ["현재 사용 중인 시스템"],
|
||
|
|
"digitalization_level": "상/중/하"
|
||
|
|
},
|
||
|
|
"business_domains": [
|
||
|
|
{
|
||
|
|
"domain": "업무 영역명",
|
||
|
|
"current_process": "현재 처리 방식 설명",
|
||
|
|
"pain_points": ["문제점 1", "문제점 2"],
|
||
|
|
"improvement_needs": ["개선 필요사항"],
|
||
|
|
"priority": "필수/높음/보통/낮음",
|
||
|
|
"matched_modules": ["모듈코드"]
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"recommendations": {
|
||
|
|
"essential_modules": ["반드시 필요한 모듈 코드"],
|
||
|
|
"recommended_modules": ["권장 모듈 코드"],
|
||
|
|
"optional_modules": ["선택 모듈 코드"],
|
||
|
|
"package_suggestion": "BASIC_PKG 또는 INTEGRATED 또는 individual",
|
||
|
|
"reasoning": "패키지 추천 근거"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
중요: JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
|
||
|
|
PROMPT;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 2차 프롬프트: 견적 생성
|
||
|
|
*/
|
||
|
|
private function buildQuotationPrompt(array $analysisResult, array $modules): string
|
||
|
|
{
|
||
|
|
$analysisJson = json_encode($analysisResult, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||
|
|
$modulesJson = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||
|
|
|
||
|
|
return <<<PROMPT
|
||
|
|
아래 업무 분석 결과를 바탕으로 SAM 견적서를 생성하세요.
|
||
|
|
|
||
|
|
## 업무 분석 결과
|
||
|
|
{$analysisJson}
|
||
|
|
|
||
|
|
## SAM 모듈 카탈로그 (가격 포함)
|
||
|
|
{$modulesJson}
|
||
|
|
|
||
|
|
## 견적 생성 규칙
|
||
|
|
1. 필수 모듈은 반드시 포함
|
||
|
|
2. 기본 패키지(BASIC_PKG)에 포함된 모듈(HR, ATTENDANCE, PAYROLL, BOARD)은 개별 추가하지 않음
|
||
|
|
3. 통합 패키지(INTEGRATED)가 개별 합산보다 저렴하면 패키지 추천
|
||
|
|
4. is_required가 true인 항목은 반드시 필요한 모듈, false는 선택
|
||
|
|
|
||
|
|
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
|
||
|
|
{
|
||
|
|
"quotation_title": "견적서 제목",
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"module_code": "모듈코드",
|
||
|
|
"module_name": "모듈명",
|
||
|
|
"is_required": true,
|
||
|
|
"reason": "이 모듈이 필요한 이유 (인터뷰 내용 기반)",
|
||
|
|
"dev_cost": 0,
|
||
|
|
"monthly_fee": 0
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"summary": {
|
||
|
|
"total_dev_cost": 0,
|
||
|
|
"total_monthly_fee": 0,
|
||
|
|
"discount_type": "패키지할인/볼륨할인/없음",
|
||
|
|
"discount_rate": 0,
|
||
|
|
"final_dev_cost": 0,
|
||
|
|
"final_monthly_fee": 0
|
||
|
|
},
|
||
|
|
"implementation_plan": {
|
||
|
|
"estimated_months": 0,
|
||
|
|
"phases": [
|
||
|
|
{
|
||
|
|
"phase": 1,
|
||
|
|
"name": "단계명",
|
||
|
|
"modules": ["모듈코드"],
|
||
|
|
"duration_weeks": 0
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"analysis_summary": "고객에게 전달할 업무 분석 요약 (2~3문장)"
|
||
|
|
}
|
||
|
|
|
||
|
|
중요: JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
|
||
|
|
PROMPT;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* AI 응답에서 JSON 추출
|
||
|
|
*/
|
||
|
|
private function parseJsonResponse(?string $response): ?array
|
||
|
|
{
|
||
|
|
if (! $response) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 마크다운 코드 블록 제거
|
||
|
|
$cleaned = preg_replace('/^```(?:json)?\s*/m', '', $response);
|
||
|
|
$cleaned = preg_replace('/\s*```$/m', '', $cleaned);
|
||
|
|
$cleaned = trim($cleaned);
|
||
|
|
|
||
|
|
$decoded = json_decode($cleaned, true);
|
||
|
|
|
||
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
|
|
Log::warning('AI JSON 파싱 실패', [
|
||
|
|
'error' => 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,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 대시보드 통계
|
||
|
|
*/
|
||
|
|
public function getDashboardStats(): array
|
||
|
|
{
|
||
|
|
$stats = AiQuotation::query()
|
||
|
|
->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,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|