Files
sam-manage/app/Services/Rd/AiQuotationService.php
김보곤 25795f8612 feat: [ai-quotation] 제조 견적서 자동 생성 기능 추가
- AI 2단계 분석: 고객 인터뷰 → 요구사항 추출 → 견적 산출
- 모델 확장: AiQuotation(모드/견적번호), AiQuotationItem(규격/단가/금액)
- AiQuotePriceTable 모델 신규 생성
- Create 페이지: 모듈/제조 모드 탭, 제품 카테고리, 고객 정보 입력
- Show 페이지: 제조 모드 분기 렌더링 (품목/금액/고객정보)
- Edit 페이지: 품목 인라인 편집, 할인/부가세/조건 입력
- Document: 한국 표준 제조업 견적서 양식 템플릿
- Controller/Route: update 엔드포인트, edit 라우트 추가
2026-03-03 15:58:16 +09:00

919 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\Rd\AiQuotePriceTable;
use App\Models\System\AiConfig;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
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';
$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 <<<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,
]);
}
}
// ===================================================
// 제조 견적 (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 <<<PROMPT
당신은 {$categoryLabel} 제조업체의 영업 전문가입니다.
고객 인터뷰/상담 내용을 분석하여 견적에 필요한 정보를 구조화된 JSON으로 추출하세요.
## 상담 내용
{$interviewText}
## 추출 대상
1. 고객 정보 (회사명, 담당자, 연락처, 이메일, 주소)
2. 프로젝트 정보 (현장명, 위치, 건물유형, 용도)
3. 위치별 제품 사양 (층/부호, 제품유형, 개구부 크기 W×H mm, 수량, 가이드레일 유형, 모터, 특이사항)
4. 설치 조건 (접근성, 전원, 층고 등)
5. 고객 특이 요청사항
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
{
"client": {
"company": "회사명 또는 null",
"contact": "담당자명 또는 null",
"phone": "연락처 또는 null",
"email": "이메일 또는 null",
"address": "주소 또는 null"
},
"project": {
"name": "현장명/프로젝트명",
"location": "현장 위치",
"building_type": "건물유형 (오피스/상가/공장 등)",
"purpose": "용도"
},
"product_specs": [
{
"floor_code": "위치 코드 (예: B1-A01)",
"floor_name": "위치 설명 (예: 지하1층 A구역)",
"product_type": "SCREEN 또는 STEEL",
"width_mm": 3000,
"height_mm": 2500,
"quantity": 1,
"guide_rail": "벽부형/노출형/매립형",
"motor": "단상/삼상",
"note": "특이사항 또는 null"
}
],
"install_conditions": {
"accessibility": "양호/보통/불량",
"power_source": "단상/삼상",
"ceiling_height": "층고 정보 또는 null",
"special_requirements": "특수 요구사항 또는 null"
},
"customer_requests": "고객 특이 요청사항 텍스트 또는 null"
}
중요: JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
인터뷰에서 명시되지 않은 정보는 null로 설정하세요.
PROMPT;
}
/**
* 제조 견적용 2단계: 견적 산출
*/
private function buildManufactureQuotationPrompt(array $analysisResult, array $priceTables, string $productCategory): string
{
$analysisJson = json_encode($analysisResult, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$priceJson = json_encode($priceTables, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$categoryLabel = $productCategory === 'STEEL' ? '철재' : '방화스크린';
return <<<PROMPT
아래 분석 결과를 바탕으로 {$categoryLabel} 제조 견적을 산출하세요.
## 분석 결과
{$analysisJson}
## 단가표
{$priceJson}
## 산출 규칙 (방화스크린 기준)
1. 유효 면적 계산: M = (W + 140) × (H + 350) / 1,000,000 (단위: ㎡)
- W: 개구부 폭(mm), H: 개구부 높이(mm)
- 140, 350은 가이드레일/하부 여유분
2. 단가 적용: 면적 구간별 단가표 참조 (단가표가 없으면 ㎡당 350,000원 기본 적용)
3. 재료비 = 면적 × 단가 × 수량
4. 노무비 = 재료비 × 노무비율 (단가표 labor_rate, 기본 15%)
5. 설치비 = 재료비 × 설치비율 (단가표 install_rate, 기본 10%)
6. 가이드레일, 케이스, 모터 등 부대재료는 별도 행으로 산출
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
{
"items": [
{
"item_code": "SCR-001",
"item_name": "방화스크린 본체",
"specification": "3140×2850",
"unit": "SET",
"quantity": 1,
"unit_price": 350000,
"total_price": 350000,
"item_category": "material",
"floor_code": "B1-A01",
"description": "지하1층 A구역 방화스크린"
},
{
"item_code": "LBR-001",
"item_name": "설치 노무비",
"specification": "",
"unit": "식",
"quantity": 1,
"unit_price": 52500,
"total_price": 52500,
"item_category": "labor",
"floor_code": "",
"description": "스크린 설치 인건비"
}
],
"pricing": {
"material_cost": 0,
"labor_cost": 0,
"install_cost": 0,
"discount_rate": 0,
"note": "할인 근거 또는 null"
},
"terms": {
"valid_until": "유효기간 날짜 (YYYY-MM-DD)",
"payment": "결제 조건",
"delivery": "납기 조건"
}
}
중요:
- JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
- item_category는 반드시 "material", "labor", "install" 중 하나여야 합니다.
- 모든 금액은 원 단위 정수로 작성하세요.
- 위치별로 재료비 품목을 각각 작성하고, 노무비와 설치비는 전체 합산 1행으로 작성하세요.
PROMPT;
}
/**
* 대시보드 통계
*/
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,
];
}
}