feat: [ai-quotation] 제조 견적서 자동 생성 기능 추가
- AI 2단계 분석: 고객 인터뷰 → 요구사항 추출 → 견적 산출 - 모델 확장: AiQuotation(모드/견적번호), AiQuotationItem(규격/단가/금액) - AiQuotePriceTable 모델 신규 생성 - Create 페이지: 모듈/제조 모드 탭, 제품 카테고리, 고객 정보 입력 - Show 페이지: 제조 모드 분기 렌더링 (품목/금액/고객정보) - Edit 페이지: 품목 인라인 편집, 할인/부가세/조건 입력 - Document: 한국 표준 제조업 견적서 양식 템플릿 - Controller/Route: update 엔드포인트, edit 라우트 추가
This commit is contained in:
@@ -90,7 +90,12 @@ public function analyze(int $id): JsonResponse
|
||||
], 404);
|
||||
}
|
||||
|
||||
$result = $this->quotationService->runAnalysis($quotation);
|
||||
// 제조 모드는 제조용 분석 실행
|
||||
if ($quotation->isManufacture()) {
|
||||
$result = $this->quotationService->runManufactureAnalysis($quotation);
|
||||
} else {
|
||||
$result = $this->quotationService->runAnalysis($quotation);
|
||||
}
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
@@ -106,4 +111,26 @@ public function analyze(int $id): JsonResponse
|
||||
'error' => $result['error'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 편집 저장
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$result = $this->quotationService->updateQuotation($id, $request->all());
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '견적이 저장되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '견적 저장에 실패했습니다.',
|
||||
'error' => $result['error'] ?? null,
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,4 +91,26 @@ public function showQuotation(Request $request, int $id): View|\Illuminate\Http\
|
||||
|
||||
return view('rd.ai-quotation.show', compact('quotation'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 편집 (제조 모드)
|
||||
*/
|
||||
public function editQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.edit', $id));
|
||||
}
|
||||
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (! $quotation->isCompleted()) {
|
||||
abort(403, '완료된 견적만 편집할 수 있습니다.');
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.edit', compact('quotation'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ public function rules(): array
|
||||
'input_type' => 'required|in:text,voice,document',
|
||||
'input_text' => 'required_if:input_type,text|nullable|string',
|
||||
'ai_provider' => 'nullable|in:gemini,claude',
|
||||
'quote_mode' => 'nullable|in:module,manufacture',
|
||||
'product_category' => 'nullable|in:SCREEN,STEEL',
|
||||
'client_company' => 'nullable|string|max:200',
|
||||
'client_contact' => 'nullable|string|max:100',
|
||||
'client_phone' => 'nullable|string|max:50',
|
||||
'client_email' => 'nullable|email|max:200',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ class AiQuotation extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'quote_mode',
|
||||
'quote_number',
|
||||
'product_category',
|
||||
'title',
|
||||
'input_type',
|
||||
'input_text',
|
||||
@@ -49,6 +52,10 @@ class AiQuotation extends Model
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const MODE_MODULE = 'module';
|
||||
|
||||
public const MODE_MANUFACTURE = 'manufacture';
|
||||
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
@@ -118,6 +125,16 @@ public function setOption(string $key, $value): void
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function isManufacture(): bool
|
||||
{
|
||||
return $this->quote_mode === self::MODE_MANUFACTURE;
|
||||
}
|
||||
|
||||
public function isModule(): bool
|
||||
{
|
||||
return $this->quote_mode === self::MODE_MODULE;
|
||||
}
|
||||
|
||||
public function getFormattedDevCostAttribute(): string
|
||||
{
|
||||
return number_format((int) $this->total_dev_cost).'원';
|
||||
|
||||
@@ -14,6 +14,13 @@ class AiQuotationItem extends Model
|
||||
'module_id',
|
||||
'module_code',
|
||||
'module_name',
|
||||
'specification',
|
||||
'unit',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'total_price',
|
||||
'item_category',
|
||||
'floor_code',
|
||||
'is_required',
|
||||
'reason',
|
||||
'dev_cost',
|
||||
@@ -27,6 +34,9 @@ class AiQuotationItem extends Model
|
||||
'options' => 'array',
|
||||
'dev_cost' => 'decimal:0',
|
||||
'monthly_fee' => 'decimal:0',
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2',
|
||||
'total_price' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function quotation(): BelongsTo
|
||||
|
||||
78
app/Models/Rd/AiQuotePriceTable.php
Normal file
78
app/Models/Rd/AiQuotePriceTable.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Rd;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiQuotePriceTable extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'ai_quote_price_tables';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'product_category',
|
||||
'price_type',
|
||||
'min_value',
|
||||
'max_value',
|
||||
'unit_price',
|
||||
'labor_rate',
|
||||
'install_rate',
|
||||
'is_active',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'array',
|
||||
'min_value' => 'decimal:4',
|
||||
'max_value' => 'decimal:4',
|
||||
'unit_price' => 'decimal:2',
|
||||
'labor_rate' => 'decimal:2',
|
||||
'install_rate' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, string $category)
|
||||
{
|
||||
return $query->where('product_category', $category);
|
||||
}
|
||||
|
||||
public static function getPriceTablesForPrompt(?string $productCategory = null): array
|
||||
{
|
||||
$query = static::active()->orderBy('product_category')->orderBy('min_value');
|
||||
|
||||
if ($productCategory) {
|
||||
$query->byCategory($productCategory);
|
||||
}
|
||||
|
||||
return $query->get()->map(fn ($row) => [
|
||||
'product_category' => $row->product_category,
|
||||
'price_type' => $row->price_type,
|
||||
'min_value' => (float) $row->min_value,
|
||||
'max_value' => (float) $row->max_value,
|
||||
'unit_price' => (float) $row->unit_price,
|
||||
'labor_rate' => (float) $row->labor_rate,
|
||||
'install_rate' => (float) $row->install_rate,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public function getOption(string $key, $default = null)
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, $value): void
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@
|
||||
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;
|
||||
|
||||
@@ -52,9 +54,12 @@ public function getById(int $id): ?AiQuotation
|
||||
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,
|
||||
@@ -63,6 +68,23 @@ public function createAndAnalyze(array $data): array
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -400,6 +422,468 @@ private function saveQuotationItems(AiQuotation $quotation, array $quotationResu
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================
|
||||
// 제조 견적 (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 통계
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user