- ai_pricing_configs 테이블 마이그레이션 생성 (기본 시드 데이터 포함) - AiPricingConfig 모델 추가 (캐시 적용 단가/환율 조회) - AiReportService 하드코딩 단가를 DB 조회로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
16 KiB
PHP
476 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenants\AiPricingConfig;
|
|
use App\Models\Tenants\AiReport;
|
|
use App\Models\Tenants\AiTokenUsage;
|
|
use App\Models\Tenants\Card;
|
|
use App\Models\Tenants\Deposit;
|
|
use App\Models\Tenants\Purchase;
|
|
use App\Models\Tenants\Sale;
|
|
use App\Models\Tenants\Withdrawal;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class AiReportService extends Service
|
|
{
|
|
/**
|
|
* AI 리포트 목록 조회
|
|
*/
|
|
public function list(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$perPage = $params['per_page'] ?? 15;
|
|
|
|
$query = AiReport::query()
|
|
->where('tenant_id', $tenantId)
|
|
->orderByDesc('report_date')
|
|
->orderByDesc('created_at');
|
|
|
|
// 리포트 유형 필터
|
|
if (! empty($params['report_type'])) {
|
|
$query->where('report_type', $params['report_type']);
|
|
}
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
// 날짜 범위 필터
|
|
if (! empty($params['start_date'])) {
|
|
$query->whereDate('report_date', '>=', $params['start_date']);
|
|
}
|
|
if (! empty($params['end_date'])) {
|
|
$query->whereDate('report_date', '<=', $params['end_date']);
|
|
}
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* AI 리포트 상세 조회
|
|
*/
|
|
public function show(int $id): AiReport
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return AiReport::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* AI 리포트 생성
|
|
*/
|
|
public function generate(array $params): AiReport
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
$userId = $this->apiUserId();
|
|
$reportDate = Carbon::parse($params['report_date'] ?? now()->toDateString());
|
|
$reportType = $params['report_type'] ?? 'daily';
|
|
|
|
// 비즈니스 데이터 수집
|
|
$inputData = $this->collectBusinessData($tenantId, $reportDate, $reportType);
|
|
|
|
// AI 리포트 레코드 생성 (pending 상태)
|
|
$report = AiReport::create([
|
|
'tenant_id' => $tenantId,
|
|
'report_date' => $reportDate,
|
|
'report_type' => $reportType,
|
|
'status' => 'pending',
|
|
'input_data' => $inputData,
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
try {
|
|
// Gemini API 호출
|
|
$aiResponse = $this->callGeminiApi($inputData);
|
|
|
|
// 결과 저장
|
|
$report->update([
|
|
'content' => $aiResponse['리포트'] ?? [],
|
|
'summary' => $aiResponse['요약'] ?? '',
|
|
'status' => 'completed',
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::error('AI Report generation failed', [
|
|
'report_id' => $report->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$report->update([
|
|
'status' => 'failed',
|
|
'error_message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return $report->fresh();
|
|
}
|
|
|
|
/**
|
|
* AI 리포트 삭제
|
|
*/
|
|
public function delete(int $id): bool
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$report = AiReport::query()
|
|
->where('tenant_id', $tenantId)
|
|
->findOrFail($id);
|
|
|
|
return $report->delete();
|
|
}
|
|
|
|
/**
|
|
* 비즈니스 데이터 수집
|
|
*/
|
|
private function collectBusinessData(int $tenantId, Carbon $reportDate, string $reportType): array
|
|
{
|
|
$startDate = $this->getStartDate($reportDate, $reportType);
|
|
$endDate = $reportDate;
|
|
|
|
// 전월 동기간 계산
|
|
$prevStartDate = $startDate->copy()->subMonth();
|
|
$prevEndDate = $endDate->copy()->subMonth();
|
|
|
|
return [
|
|
'report_date' => $reportDate->toDateString(),
|
|
'report_type' => $reportType,
|
|
'period' => [
|
|
'start' => $startDate->toDateString(),
|
|
'end' => $endDate->toDateString(),
|
|
],
|
|
'expense' => $this->getExpenseData($tenantId, $startDate, $endDate, $prevStartDate, $prevEndDate),
|
|
'sales' => $this->getSalesData($tenantId, $startDate, $endDate, $prevStartDate, $prevEndDate),
|
|
'purchase' => $this->getPurchaseData($tenantId, $startDate, $endDate, $prevStartDate, $prevEndDate),
|
|
'deposit_withdrawal' => $this->getDepositWithdrawalData($tenantId, $startDate, $endDate),
|
|
'card_account' => $this->getCardAccountData($tenantId),
|
|
'receivable' => $this->getReceivableData($tenantId, $reportDate),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 리포트 유형별 시작일 계산
|
|
*/
|
|
private function getStartDate(Carbon $reportDate, string $reportType): Carbon
|
|
{
|
|
return match ($reportType) {
|
|
'weekly' => $reportDate->copy()->subDays(7),
|
|
'monthly' => $reportDate->copy()->startOfMonth(),
|
|
default => $reportDate->copy()->startOfDay(), // daily
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 지출 데이터 수집
|
|
*/
|
|
private function getExpenseData(int $tenantId, Carbon $start, Carbon $end, Carbon $prevStart, Carbon $prevEnd): array
|
|
{
|
|
$currentTotal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('withdrawal_date', [$start, $end])
|
|
->sum('amount');
|
|
|
|
$prevTotal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('withdrawal_date', [$prevStart, $prevEnd])
|
|
->sum('amount');
|
|
|
|
$changeRate = $prevTotal > 0 ? (($currentTotal - $prevTotal) / $prevTotal) * 100 : 0;
|
|
|
|
return [
|
|
'current_total' => (float) $currentTotal,
|
|
'previous_total' => (float) $prevTotal,
|
|
'change_rate' => round($changeRate, 1),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 매출 데이터 수집
|
|
*/
|
|
private function getSalesData(int $tenantId, Carbon $start, Carbon $end, Carbon $prevStart, Carbon $prevEnd): array
|
|
{
|
|
$currentTotal = Sale::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('sale_date', [$start, $end])
|
|
->sum('total_amount');
|
|
|
|
$prevTotal = Sale::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('sale_date', [$prevStart, $prevEnd])
|
|
->sum('total_amount');
|
|
|
|
$changeRate = $prevTotal > 0 ? (($currentTotal - $prevTotal) / $prevTotal) * 100 : 0;
|
|
|
|
return [
|
|
'current_total' => (float) $currentTotal,
|
|
'previous_total' => (float) $prevTotal,
|
|
'change_rate' => round($changeRate, 1),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 매입 데이터 수집
|
|
*/
|
|
private function getPurchaseData(int $tenantId, Carbon $start, Carbon $end, Carbon $prevStart, Carbon $prevEnd): array
|
|
{
|
|
$currentTotal = Purchase::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('purchase_date', [$start, $end])
|
|
->sum('total_amount');
|
|
|
|
$prevTotal = Purchase::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('purchase_date', [$prevStart, $prevEnd])
|
|
->sum('total_amount');
|
|
|
|
$changeRate = $prevTotal > 0 ? (($currentTotal - $prevTotal) / $prevTotal) * 100 : 0;
|
|
|
|
return [
|
|
'current_total' => (float) $currentTotal,
|
|
'previous_total' => (float) $prevTotal,
|
|
'change_rate' => round($changeRate, 1),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 입출금 데이터 수집
|
|
*/
|
|
private function getDepositWithdrawalData(int $tenantId, Carbon $start, Carbon $end): array
|
|
{
|
|
$totalDeposit = Deposit::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('deposit_date', [$start, $end])
|
|
->sum('amount');
|
|
|
|
$totalWithdrawal = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereBetween('withdrawal_date', [$start, $end])
|
|
->sum('amount');
|
|
|
|
return [
|
|
'total_deposit' => (float) $totalDeposit,
|
|
'total_withdrawal' => (float) $totalWithdrawal,
|
|
'net_flow' => (float) ($totalDeposit - $totalWithdrawal),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 카드/계좌 데이터 수집
|
|
*/
|
|
private function getCardAccountData(int $tenantId): array
|
|
{
|
|
$activeCards = Card::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('status', 'active')
|
|
->count();
|
|
|
|
// 계좌 잔액은 입출금 내역 기반으로 계산
|
|
$totalDeposits = Deposit::query()
|
|
->where('tenant_id', $tenantId)
|
|
->sum('amount');
|
|
|
|
$totalWithdrawals = Withdrawal::query()
|
|
->where('tenant_id', $tenantId)
|
|
->sum('amount');
|
|
|
|
$balance = $totalDeposits - $totalWithdrawals;
|
|
|
|
return [
|
|
'active_cards' => $activeCards,
|
|
'current_balance' => (float) $balance,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 미수금 데이터 수집
|
|
*/
|
|
private function getReceivableData(int $tenantId, Carbon $reportDate): array
|
|
{
|
|
// 미결제 매출 (미수금)
|
|
$receivables = Sale::query()
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('status', ['draft', 'confirmed'])
|
|
->whereNull('deposit_id')
|
|
->get();
|
|
|
|
$totalReceivable = $receivables->sum('total_amount');
|
|
$count = $receivables->count();
|
|
|
|
// 연체 미수금 (30일 이상)
|
|
$overdueDate = $reportDate->copy()->subDays(30);
|
|
$overdueReceivables = $receivables->filter(function ($sale) use ($overdueDate) {
|
|
return $sale->sale_date <= $overdueDate;
|
|
});
|
|
|
|
return [
|
|
'total_amount' => (float) $totalReceivable,
|
|
'count' => $count,
|
|
'overdue_amount' => (float) $overdueReceivables->sum('total_amount'),
|
|
'overdue_count' => $overdueReceivables->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Gemini API 호출
|
|
*/
|
|
private function callGeminiApi(array $inputData): array
|
|
{
|
|
$apiKey = config('services.gemini.api_key');
|
|
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
|
$baseUrl = config('services.gemini.base_url');
|
|
|
|
if (empty($apiKey)) {
|
|
throw new \RuntimeException(__('error.ai_report.api_key_not_configured'));
|
|
}
|
|
|
|
$prompt = $this->buildPrompt($inputData);
|
|
|
|
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
|
|
|
try {
|
|
$response = Http::timeout(30)
|
|
->post($url, [
|
|
'contents' => [
|
|
[
|
|
'parts' => [
|
|
['text' => $prompt],
|
|
],
|
|
],
|
|
],
|
|
'generationConfig' => [
|
|
'temperature' => 0.7,
|
|
'topK' => 40,
|
|
'topP' => 0.95,
|
|
'maxOutputTokens' => 2048,
|
|
'responseMimeType' => 'application/json',
|
|
],
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Gemini API error', [
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
throw new \RuntimeException(__('error.ai_report.api_call_failed'));
|
|
}
|
|
|
|
$result = $response->json();
|
|
$text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
|
|
// 토큰 사용량 저장
|
|
$this->saveTokenUsage($result, $model, 'AI리포트');
|
|
|
|
// JSON 파싱
|
|
$parsed = json_decode($text, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::warning('AI response JSON parse failed', ['text' => $text]);
|
|
|
|
return [
|
|
'리포트' => [],
|
|
'요약' => $text,
|
|
];
|
|
}
|
|
|
|
return $parsed;
|
|
} catch (ConnectionException $e) {
|
|
throw new \RuntimeException(__('error.ai_report.connection_failed'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 토큰 사용량 저장
|
|
*/
|
|
private function saveTokenUsage(array $apiResult, string $model, string $menuName): void
|
|
{
|
|
try {
|
|
$usage = $apiResult['usageMetadata'] ?? null;
|
|
if (! $usage) {
|
|
return;
|
|
}
|
|
|
|
$promptTokens = $usage['promptTokenCount'] ?? 0;
|
|
$completionTokens = $usage['candidatesTokenCount'] ?? 0;
|
|
$totalTokens = $usage['totalTokenCount'] ?? 0;
|
|
|
|
// DB 단가 조회 (fallback: 하드코딩 기본값)
|
|
$pricing = AiPricingConfig::getActivePricing('gemini');
|
|
$inputPricePerToken = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000;
|
|
$outputPricePerToken = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000;
|
|
|
|
$costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken);
|
|
$exchangeRate = AiPricingConfig::getExchangeRate();
|
|
$costKrw = $costUsd * $exchangeRate;
|
|
|
|
AiTokenUsage::create([
|
|
'tenant_id' => $this->tenantId(),
|
|
'model' => $model,
|
|
'menu_name' => $menuName,
|
|
'prompt_tokens' => $promptTokens,
|
|
'completion_tokens' => $completionTokens,
|
|
'total_tokens' => $totalTokens,
|
|
'cost_usd' => $costUsd,
|
|
'cost_krw' => $costKrw,
|
|
'request_id' => Str::uuid()->toString(),
|
|
'created_by' => $this->apiUserId(),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::warning('AI token usage save failed', ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* AI 프롬프트 생성
|
|
*/
|
|
private function buildPrompt(array $inputData): string
|
|
{
|
|
$dataJson = json_encode($inputData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
|
|
|
return <<<PROMPT
|
|
당신은 기업 재무 분석 전문가입니다. 아래 비즈니스 데이터를 분석하여 경영진을 위한 리포트를 생성해주세요.
|
|
|
|
## 작성 규칙
|
|
1. 문장은 간결하고 명확하게 작성
|
|
2. 숫자는 읽기 쉽게 "3,123,000원", "15%" 형식 사용
|
|
3. 계정과목명, 거래처명은 구체적으로 명시
|
|
4. 조치가 필요한 경우 구체적인 행동 권한 포함
|
|
5. 긍정적 변화도 반드시 실상 포함
|
|
6. 법인세, 소득세 영향이 있는 경우 세무 리스크 명시
|
|
|
|
## 상태 코드
|
|
- "경고" (critical): 즉시 조치 필요 (빨간색)
|
|
- "주의" (warning): 확인/점검 필요 (주황색)
|
|
- "긍정" (positive): 긍정적 변화 (녹색)
|
|
- "양호" (normal): 정상 상태 (파란색)
|
|
|
|
## 분석 영역
|
|
- 지출분석: 지출 증감, 비용 구조
|
|
- 가지급금: 미정산 항목, 인정이자 리스크
|
|
- 카드/계좌: 한도, 잔액 상태
|
|
- 미수금: 연체 현황, 회수 필요 항목
|
|
- 매출분석: 매출 추이, 거래처별 현황
|
|
- 매입분석: 매입 추이, 비용 증감
|
|
|
|
## 입력 데이터
|
|
{$dataJson}
|
|
|
|
## 출력 형식 (JSON)
|
|
{
|
|
"리포트": [
|
|
{"영역": "지출분석", "상태": "경고|주의|긍정|양호", "메시지": "핵심 메시지", "상세": "상세 설명"},
|
|
...
|
|
],
|
|
"요약": "전체 요약 메시지 (1-2문장)"
|
|
}
|
|
|
|
JSON 형식으로만 응답하세요.
|
|
PROMPT;
|
|
}
|
|
}
|