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 <<