Files
sam-api/app/Services/AiReportService.php
권혁성 95371fd841 feat: [CEO 대시보드] 섹션별 API + 일일보고서 엑셀
- DashboardCeo 리스크 감지형 서비스 리팩토링
- 일일보고서 어음/외상매출채권 현황 섹션 추가
- 엑셀 내보내기 화면 데이터 기반 리팩토링
- 공정명 컬럼 및 근태 부서 조인 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:05 +09:00

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.5-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;
}
}