diff --git a/app/Http/Controllers/Api/V1/AiReportController.php b/app/Http/Controllers/Api/V1/AiReportController.php new file mode 100644 index 0000000..aaf4c7d --- /dev/null +++ b/app/Http/Controllers/Api/V1/AiReportController.php @@ -0,0 +1,59 @@ +service->list($request->validated()); + }, __('message.ai_report.fetched')); + } + + /** + * AI 리포트 생성 + */ + public function generate(AiReportGenerateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->generate($request->validated()); + }, __('message.ai_report.generated'), 201); + } + + /** + * AI 리포트 상세 조회 + */ + public function show(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->show($id); + }, __('message.ai_report.fetched')); + } + + /** + * AI 리포트 삭제 + */ + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + $this->service->delete($id); + + return null; + }, __('message.ai_report.deleted')); + } +} diff --git a/app/Http/Requests/V1/AiReport/AiReportGenerateRequest.php b/app/Http/Requests/V1/AiReport/AiReportGenerateRequest.php new file mode 100644 index 0000000..27272ea --- /dev/null +++ b/app/Http/Requests/V1/AiReport/AiReportGenerateRequest.php @@ -0,0 +1,34 @@ + ['nullable', 'date', 'before_or_equal:today'], + 'report_type' => ['nullable', 'string', Rule::in(array_keys(AiReport::REPORT_TYPES))], + ]; + } + + public function messages(): array + { + return [ + 'report_date.before_or_equal' => __('validation.before_or_equal', [ + 'attribute' => __('validation.attributes.report_date'), + 'date' => __('validation.attributes.today'), + ]), + 'report_type.in' => __('validation.in', ['attribute' => __('validation.attributes.report_type')]), + ]; + } +} diff --git a/app/Http/Requests/V1/AiReport/AiReportListRequest.php b/app/Http/Requests/V1/AiReport/AiReportListRequest.php new file mode 100644 index 0000000..294dc8c --- /dev/null +++ b/app/Http/Requests/V1/AiReport/AiReportListRequest.php @@ -0,0 +1,38 @@ + ['nullable', 'integer', 'min:1', 'max:100'], + 'report_type' => ['nullable', 'string', Rule::in(array_keys(AiReport::REPORT_TYPES))], + 'status' => ['nullable', 'string', Rule::in(array_keys(AiReport::STATUSES))], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + ]; + } + + public function messages(): array + { + return [ + 'report_type.in' => __('validation.in', ['attribute' => __('validation.attributes.report_type')]), + 'status.in' => __('validation.in', ['attribute' => __('validation.attributes.status')]), + 'end_date.after_or_equal' => __('validation.after_or_equal', [ + 'attribute' => __('validation.attributes.end_date'), + 'date' => __('validation.attributes.start_date'), + ]), + ]; + } +} diff --git a/app/Models/Tenants/AiReport.php b/app/Models/Tenants/AiReport.php new file mode 100644 index 0000000..d50785a --- /dev/null +++ b/app/Models/Tenants/AiReport.php @@ -0,0 +1,110 @@ + 'date', + 'content' => 'array', + 'input_data' => 'array', + ]; + + /** + * 리포트 유형 + */ + public const REPORT_TYPES = [ + 'daily' => '일일', + 'weekly' => '주간', + 'monthly' => '월간', + ]; + + /** + * 리포트 상태 + */ + public const STATUSES = [ + 'pending' => '생성중', + 'completed' => '완료', + 'failed' => '실패', + ]; + + /** + * 분석 영역 + */ + public const ANALYSIS_AREAS = [ + 'expense' => '지출분석', + 'loan' => '가지급금', + 'card_account' => '카드/계좌', + 'receivable' => '미수금', + 'sales' => '매출분석', + 'purchase' => '매입분석', + ]; + + /** + * 상태 코드 + */ + public const STATUS_CODES = [ + 'critical' => '경고', + 'warning' => '주의', + 'positive' => '긍정', + 'normal' => '양호', + ]; + + /** + * 생성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * 리포트 유형 라벨 + */ + public function getReportTypeLabelAttribute(): string + { + return self::REPORT_TYPES[$this->report_type] ?? $this->report_type; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUSES[$this->status] ?? $this->status; + } + + /** + * 완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === 'completed'; + } + + /** + * 실패 여부 + */ + public function isFailed(): bool + { + return $this->status === 'failed'; + } +} diff --git a/app/Services/AiReportService.php b/app/Services/AiReportService.php new file mode 100644 index 0000000..46a79c8 --- /dev/null +++ b/app/Services/AiReportService.php @@ -0,0 +1,428 @@ +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'] ?? ''; + + // 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')); + } + } + + /** + * AI 프롬프트 생성 + */ + private function buildPrompt(array $inputData): string + { + $dataJson = json_encode($inputData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + return << [ + 'api_key' => env('GEMINI_API_KEY'), + 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'), + 'base_url' => env('GEMINI_BASE_URL', 'https://generativelanguage.googleapis.com/v1beta'), + ], + + /* + |-------------------------------------------------------------------------- + | Internal Server Communication + |-------------------------------------------------------------------------- + | MNG ↔ API 서버간 내부 통신 설정 + | exchange_secret: HMAC 서명용 공유 시크릿 (MNG와 동일해야 함) + */ + 'internal' => [ + 'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'), + ], + ]; diff --git a/database/migrations/2025_12_18_132727_create_ai_reports_table.php b/database/migrations/2025_12_18_132727_create_ai_reports_table.php new file mode 100644 index 0000000..4e96555 --- /dev/null +++ b/database/migrations/2025_12_18_132727_create_ai_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('tenant_id')->constrained()->comment('테넌트 ID'); + $table->date('report_date')->comment('리포트 기준일'); + $table->string('report_type', 50)->default('daily')->comment('리포트 유형: daily/weekly/monthly'); + $table->json('content')->nullable()->comment('리포트 내용 (영역별 분석 배열)'); + $table->text('summary')->nullable()->comment('요약 메시지'); + $table->json('input_data')->nullable()->comment('AI 분석에 사용된 입력 데이터'); + $table->string('status', 20)->default('completed')->comment('상태: pending/completed/failed'); + $table->text('error_message')->nullable()->comment('실패 시 에러 메시지'); + $table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID'); + $table->timestamps(); + + $table->index(['tenant_id', 'report_date'], 'idx_tenant_date'); + $table->index(['tenant_id', 'report_type'], 'idx_tenant_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_reports'); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index be00441..2d87e27 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -233,4 +233,20 @@ 'dashboard' => [ 'invalid_period' => '기간은 week, month, quarter 중 하나여야 합니다.', ], + + // AI 리포트 관련 + 'ai_report' => [ + 'api_key_not_configured' => 'AI API 키가 설정되지 않았습니다.', + 'api_call_failed' => 'AI API 호출에 실패했습니다.', + 'connection_failed' => 'AI 서버 연결에 실패했습니다.', + ], + + // 내부 서버간 통신 관련 + 'internal' => [ + 'secret_not_configured' => '내부 교환 비밀키가 설정되지 않았습니다.', + 'signature_expired' => '서명이 만료되었습니다.', + 'invalid_exp' => '만료 시간이 유효하지 않습니다.', + 'invalid_signature' => '서명이 유효하지 않습니다.', + 'token_issue_failed' => '토큰 발급에 실패했습니다.', + ], ]; diff --git a/lang/ko/message.php b/lang/ko/message.php index 3d8ca2a..556ae2a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -293,4 +293,11 @@ 'dashboard' => [ 'unknown_client' => '미지정', ], + + // AI 리포트 + 'ai_report' => [ + 'fetched' => 'AI 리포트를 조회했습니다.', + 'generated' => 'AI 리포트가 생성되었습니다.', + 'deleted' => 'AI 리포트가 삭제되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index e28de01..421c5de 100644 --- a/routes/api.php +++ b/routes/api.php @@ -58,6 +58,7 @@ use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RegisterController; use App\Http\Controllers\Api\V1\ReportController; +use App\Http\Controllers\Api\V1\AiReportController; use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\SaleController; @@ -73,12 +74,18 @@ use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\WithdrawalController; +use App\Http\Controllers\Api\V1\InternalController; use App\Http\Controllers\Api\V1\WorkSettingController; use Illuminate\Support\Facades\Route; // V1 초기 개발 Route::prefix('v1')->group(function () { + // 내부 서버간 통신 (API Key, Bearer 인증 제외 - HMAC 인증 사용) + Route::prefix('internal')->group(function () { + Route::post('/exchange-token', [InternalController::class, 'exchangeToken'])->name('v1.internal.exchange-token'); + }); + // API KEY 인증 (글로벌 미들웨어로 이미 적용됨) Route::get('/debug-apikey', [ApiController::class, 'debugApikey']); @@ -412,6 +419,12 @@ Route::get('/daily/export', [ReportController::class, 'dailyExport'])->name('v1.reports.daily.export'); Route::get('/expense-estimate', [ReportController::class, 'expenseEstimate'])->name('v1.reports.expense-estimate'); Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); + + // AI Report API (AI 리포트) + Route::get('/ai', [AiReportController::class, 'index'])->name('v1.reports.ai.index'); + Route::post('/ai/generate', [AiReportController::class, 'generate'])->name('v1.reports.ai.generate'); + Route::get('/ai/{id}', [AiReportController::class, 'show'])->whereNumber('id')->name('v1.reports.ai.show'); + Route::delete('/ai/{id}', [AiReportController::class, 'destroy'])->whereNumber('id')->name('v1.reports.ai.destroy'); }); // Dashboard API (대시보드)