feat: AI 리포트 API 구현 (Google Gemini 연동)
- ai_reports 테이블 마이그레이션 추가 - AiReport 모델 생성 (daily/weekly/monthly 유형) - AiReportService 구현 (비즈니스 데이터 수집 + Gemini API) - 4개 API 엔드포인트 추가 (목록/생성/상세/삭제) - Swagger 문서 및 i18n 메시지 추가
This commit is contained in:
59
app/Http/Controllers/Api/V1/AiReportController.php
Normal file
59
app/Http/Controllers/Api/V1/AiReportController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Requests/V1/AiReport/AiReportGenerateRequest.php
Normal file
34
app/Http/Requests/V1/AiReport/AiReportGenerateRequest.php
Normal 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')]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Requests/V1/AiReport/AiReportListRequest.php
Normal file
38
app/Http/Requests/V1/AiReport/AiReportListRequest.php
Normal 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'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/Models/Tenants/AiReport.php
Normal file
110
app/Models/Tenants/AiReport.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
428
app/Services/AiReportService.php
Normal file
428
app/Services/AiReportService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
284
app/Swagger/v1/AiReportApi.php
Normal file
284
app/Swagger/v1/AiReportApi.php
Normal 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() {}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' => '토큰 발급에 실패했습니다.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -293,4 +293,11 @@
|
|||||||
'dashboard' => [
|
'dashboard' => [
|
||||||
'unknown_client' => '미지정',
|
'unknown_client' => '미지정',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// AI 리포트
|
||||||
|
'ai_report' => [
|
||||||
|
'fetched' => 'AI 리포트를 조회했습니다.',
|
||||||
|
'generated' => 'AI 리포트가 생성되었습니다.',
|
||||||
|
'deleted' => 'AI 리포트가 삭제되었습니다.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 (대시보드)
|
||||||
|
|||||||
Reference in New Issue
Block a user