feat: AI 리포트 API 구현 (Google Gemini 연동)

- ai_reports 테이블 마이그레이션 추가
- AiReport 모델 생성 (daily/weekly/monthly 유형)
- AiReportService 구현 (비즈니스 데이터 수집 + Gemini API)
- 4개 API 엔드포인트 추가 (목록/생성/상세/삭제)
- Swagger 문서 및 i18n 메시지 추가
This commit is contained in:
2025-12-18 13:51:40 +09:00
parent c7eee97610
commit 98645316fc
11 changed files with 1051 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AiReport\AiReportGenerateRequest;
use App\Http\Requests\V1\AiReport\AiReportListRequest;
use App\Http\Responses\ApiResponse;
use App\Services\AiReportService;
use Illuminate\Http\JsonResponse;
class AiReportController extends Controller
{
public function __construct(
private readonly AiReportService $service
) {}
/**
* AI 리포트 목록 조회
*/
public function index(AiReportListRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->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'));
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\V1\AiReport;
use App\Models\Tenants\AiReport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AiReportGenerateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'report_date' => ['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')]),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\V1\AiReport;
use App\Models\Tenants\AiReport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AiReportListRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'per_page' => ['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'),
]),
];
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiReport extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'report_date',
'report_type',
'content',
'summary',
'input_data',
'status',
'error_message',
'created_by',
];
protected $casts = [
'report_date' => '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';
}
}

View File

@@ -0,0 +1,428 @@
<?php
namespace App\Services;
use App\Models\Tenants\AiReport;
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;
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'] ?? '';
// 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 <<<PROMPT
당신은 기업 재무 분석 전문가입니다. 아래 비즈니스 데이터를 분석하여 경영진을 위한 리포트를 생성해주세요.
## 작성 규칙
1. 문장은 간결하고 명확하게 작성
2. 숫자는 읽기 쉽게 "3,123,000원", "15%" 형식 사용
3. 계정과목명, 거래처명은 구체적으로 명시
4. 조치가 필요한 경우 구체적인 행동 권한 포함
5. 긍정적 변화도 반드시 실상 포함
6. 법인세, 소득세 영향이 있는 경우 세무 리스크 명시
## 상태 코드
- "경고" (critical): 즉시 조치 필요 (빨간색)
- "주의" (warning): 확인/점검 필요 (주황색)
- "긍정" (positive): 긍정적 변화 (녹색)
- "양호" (normal): 정상 상태 (파란색)
## 분석 영역
- 지출분석: 지출 증감, 비용 구조
- 가지급금: 미정산 항목, 인정이자 리스크
- 카드/계좌: 한도, 잔액 상태
- 미수금: 연체 현황, 회수 필요 항목
- 매출분석: 매출 추이, 거래처별 현황
- 매입분석: 매입 추이, 비용 증감
## 입력 데이터
{$dataJson}
## 출력 형식 (JSON)
{
"리포트": [
{"영역": "지출분석", "상태": "경고|주의|긍정|양호", "메시지": "핵심 메시지", "상세": "상세 설명"},
...
],
"요약": "전체 요약 메시지 (1-2문장)"
}
JSON 형식으로만 응답하세요.
PROMPT;
}
}

View File

@@ -0,0 +1,284 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="AI Reports", description="AI 리포트 관리")
*
* @OA\Schema(
* schema="AiReport",
* type="object",
* description="AI 리포트",
*
* @OA\Property(property="id", type="integer", example=1, description="리포트 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="report_date", type="string", format="date", example="2025-12-18", description="리포트 기준일"),
* @OA\Property(property="report_type", type="string", enum={"daily","weekly","monthly"}, example="daily", description="리포트 유형"),
* @OA\Property(property="content", type="array", description="리포트 내용",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="영역", type="string", example="지출분석", description="분석 영역"),
* @OA\Property(property="상태", type="string", enum={"경고","주의","긍정","양호"}, example="양호", description="상태 코드"),
* @OA\Property(property="메시지", type="string", example="당월 지출이 전월 대비 5% 감소했습니다.", description="핵심 메시지"),
* @OA\Property(property="상세", type="string", example="주요 비용 절감 항목: 외주비 (-15%), 소모품비 (-8%)", description="상세 설명")
* )
* ),
* @OA\Property(property="summary", type="string", example="전반적으로 재정 상태가 양호합니다. 매출이 5% 증가하고 지출이 3% 감소했습니다.", description="전체 요약"),
* @OA\Property(property="input_data", type="object", description="입력 데이터 (비즈니스 데이터 스냅샷)"),
* @OA\Property(property="status", type="string", enum={"pending","completed","failed"}, example="completed", description="처리 상태"),
* @OA\Property(property="error_message", type="string", nullable=true, example=null, description="오류 메시지 (실패 시)"),
* @OA\Property(property="created_by", type="integer", example=1, description="생성자 ID"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-12-18T13:30:00Z", description="생성일시"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-12-18T13:30:05Z", description="수정일시")
* )
*
* @OA\Schema(
* schema="AiReportPagination",
* type="object",
* description="AI 리포트 페이지네이션",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/AiReport")),
* @OA\Property(property="first_page_url", type="string", example="https://api.example.com/api/v1/reports/ai?page=1"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="last_page_url", type="string", example="https://api.example.com/api/v1/reports/ai?page=5"),
* @OA\Property(property="next_page_url", type="string", nullable=true, example="https://api.example.com/api/v1/reports/ai?page=2"),
* @OA\Property(property="path", type="string", example="https://api.example.com/api/v1/reports/ai"),
* @OA\Property(property="per_page", type="integer", example=15),
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
* @OA\Property(property="to", type="integer", example=15),
* @OA\Property(property="total", type="integer", example=50)
* )
*
* @OA\Schema(
* schema="AiReportGenerateRequest",
* type="object",
* description="AI 리포트 생성 요청",
*
* @OA\Property(property="report_date", type="string", format="date", example="2025-12-18", description="리포트 기준일 (오늘 이전, 기본값: 오늘)"),
* @OA\Property(property="report_type", type="string", enum={"daily","weekly","monthly"}, example="daily", description="리포트 유형 (기본값: daily)")
* )
*
* @OA\Schema(
* schema="AiReportInputData",
* type="object",
* description="AI 리포트 입력 데이터",
*
* @OA\Property(property="report_date", type="string", format="date", example="2025-12-18", description="기준일"),
* @OA\Property(property="report_type", type="string", example="daily", description="리포트 유형"),
* @OA\Property(property="period", type="object", description="분석 기간",
* @OA\Property(property="start", type="string", format="date", example="2025-12-18"),
* @OA\Property(property="end", type="string", format="date", example="2025-12-18")
* ),
* @OA\Property(property="expense", type="object", description="지출 데이터",
* @OA\Property(property="current_total", type="number", format="float", example=5000000),
* @OA\Property(property="previous_total", type="number", format="float", example=5500000),
* @OA\Property(property="change_rate", type="number", format="float", example=-9.1)
* ),
* @OA\Property(property="sales", type="object", description="매출 데이터",
* @OA\Property(property="current_total", type="number", format="float", example=10000000),
* @OA\Property(property="previous_total", type="number", format="float", example=9500000),
* @OA\Property(property="change_rate", type="number", format="float", example=5.3)
* ),
* @OA\Property(property="purchase", type="object", description="매입 데이터",
* @OA\Property(property="current_total", type="number", format="float", example=3000000),
* @OA\Property(property="previous_total", type="number", format="float", example=2800000),
* @OA\Property(property="change_rate", type="number", format="float", example=7.1)
* ),
* @OA\Property(property="deposit_withdrawal", type="object", description="입출금 데이터",
* @OA\Property(property="total_deposit", type="number", format="float", example=15000000),
* @OA\Property(property="total_withdrawal", type="number", format="float", example=8000000),
* @OA\Property(property="net_flow", type="number", format="float", example=7000000)
* ),
* @OA\Property(property="card_account", type="object", description="카드/계좌 데이터",
* @OA\Property(property="active_cards", type="integer", example=3),
* @OA\Property(property="current_balance", type="number", format="float", example=50000000)
* ),
* @OA\Property(property="receivable", type="object", description="미수금 데이터",
* @OA\Property(property="total_amount", type="number", format="float", example=8000000),
* @OA\Property(property="count", type="integer", example=5),
* @OA\Property(property="overdue_amount", type="number", format="float", example=2000000),
* @OA\Property(property="overdue_count", type="integer", example=2)
* )
* )
*/
class AiReportApi
{
/**
* @OA\Get(
* path="/api/v1/reports/ai",
* tags={"AI Reports"},
* summary="AI 리포트 목록 조회",
* description="AI 리포트 목록을 페이지네이션으로 조회합니다.",
* security={{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
*
* @OA\Parameter(
* name="per_page",
* in="query",
* description="페이지당 항목 수 (1-100, 기본값: 15)",
* required=false,
*
* @OA\Schema(type="integer", minimum=1, maximum=100, example=15)
* ),
*
* @OA\Parameter(
* name="report_type",
* in="query",
* description="리포트 유형 필터",
* required=false,
*
* @OA\Schema(type="string", enum={"daily","weekly","monthly"}, example="daily")
* ),
*
* @OA\Parameter(
* name="status",
* in="query",
* description="처리 상태 필터",
* required=false,
*
* @OA\Schema(type="string", enum={"pending","completed","failed"}, example="completed")
* ),
*
* @OA\Parameter(
* name="start_date",
* in="query",
* description="시작일 필터 (YYYY-MM-DD)",
* required=false,
*
* @OA\Schema(type="string", format="date", example="2025-12-01")
* ),
*
* @OA\Parameter(
* name="end_date",
* in="query",
* description="종료일 필터 (YYYY-MM-DD, start_date 이상)",
* required=false,
*
* @OA\Schema(type="string", format="date", example="2025-12-31")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="AI 리포트 목록을 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/AiReportPagination")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="유효성 검사 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/reports/ai/generate",
* tags={"AI Reports"},
* summary="AI 리포트 생성",
* description="Google Gemini AI를 사용하여 비즈니스 데이터 기반 리포트를 생성합니다. 지출, 매출, 매입, 입출금, 카드/계좌, 미수금 데이터를 종합 분석합니다.",
* security={{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
*
* @OA\RequestBody(
* required=false,
* description="리포트 생성 요청 (모든 필드 선택적, 기본값 사용 가능)",
*
* @OA\JsonContent(ref="#/components/schemas/AiReportGenerateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="리포트 생성 완료",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="AI 리포트가 생성되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/AiReport")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="유효성 검사 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="AI API 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function generate() {}
/**
* @OA\Get(
* path="/api/v1/reports/ai/{id}",
* tags={"AI Reports"},
* summary="AI 리포트 상세 조회",
* description="특정 AI 리포트의 상세 정보를 조회합니다.",
* security={{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* description="리포트 ID",
* required=true,
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="AI 리포트를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/AiReport")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="리포트 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Delete(
* path="/api/v1/reports/ai/{id}",
* tags={"AI Reports"},
* summary="AI 리포트 삭제",
* description="특정 AI 리포트를 삭제합니다.",
* security={{"BearerAuth": {}}, {"ApiKeyAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* description="리포트 ID",
* required=true,
*
* @OA\Schema(type="integer", example=1)
* ),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="AI 리포트가 삭제되었습니다."),
* @OA\Property(property="data", type="null")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="리포트 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -35,4 +35,27 @@
], ],
], ],
/*
|--------------------------------------------------------------------------
| Google Gemini AI
|--------------------------------------------------------------------------
| AI 리포트 생성을 위한 Google Gemini API 설정
*/
'gemini' => [
'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'),
],
]; ];

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ai_reports', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -233,4 +233,20 @@
'dashboard' => [ 'dashboard' => [
'invalid_period' => '기간은 week, month, quarter 중 하나여야 합니다.', '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' => '토큰 발급에 실패했습니다.',
],
]; ];

View File

@@ -293,4 +293,11 @@
'dashboard' => [ 'dashboard' => [
'unknown_client' => '미지정', 'unknown_client' => '미지정',
], ],
// AI 리포트
'ai_report' => [
'fetched' => 'AI 리포트를 조회했습니다.',
'generated' => 'AI 리포트가 생성되었습니다.',
'deleted' => 'AI 리포트가 삭제되었습니다.',
],
]; ];

View File

@@ -58,6 +58,7 @@
use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RefreshController;
use App\Http\Controllers\Api\V1\RegisterController; use App\Http\Controllers\Api\V1\RegisterController;
use App\Http\Controllers\Api\V1\ReportController; 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\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SaleController;
@@ -73,12 +74,18 @@
use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WithdrawalController;
use App\Http\Controllers\Api\V1\InternalController;
use App\Http\Controllers\Api\V1\WorkSettingController; use App\Http\Controllers\Api\V1\WorkSettingController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// V1 초기 개발 // V1 초기 개발
Route::prefix('v1')->group(function () { 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 인증 (글로벌 미들웨어로 이미 적용됨) // API KEY 인증 (글로벌 미들웨어로 이미 적용됨)
Route::get('/debug-apikey', [ApiController::class, 'debugApikey']); 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('/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', [ReportController::class, 'expenseEstimate'])->name('v1.reports.expense-estimate');
Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); 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 (대시보드) // Dashboard API (대시보드)