diff --git a/app/Http/Controllers/Api/V1/CardTransactionController.php b/app/Http/Controllers/Api/V1/CardTransactionController.php index 33f5a5f..25a457a 100644 --- a/app/Http/Controllers/Api/V1/CardTransactionController.php +++ b/app/Http/Controllers/Api/V1/CardTransactionController.php @@ -53,6 +53,18 @@ public function summary(Request $request): JsonResponse }, __('message.fetched')); } + /** + * 카드 거래 대시보드 데이터 + * + * CEO 대시보드 카드/가지급금 관리 섹션의 cm1 모달용 상세 데이터 + */ + public function dashboard(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->dashboard(); + }, __('message.fetched')); + } + /** * 계정과목 일괄 수정 */ diff --git a/app/Services/CardTransactionService.php b/app/Services/CardTransactionService.php index 633681d..9ecb406 100644 --- a/app/Services/CardTransactionService.php +++ b/app/Services/CardTransactionService.php @@ -263,4 +263,175 @@ public function destroy(int $id): bool return true; }); } + + /** + * 카드 거래 대시보드 데이터 + * + * CEO 대시보드 카드/가지급금 관리 섹션의 cm1 모달용 상세 데이터 제공 + * + * @return array{ + * summary: array{ + * current_month_total: float, + * previous_month_total: float, + * change_rate: float, + * unprocessed_count: int + * }, + * monthly_trend: array, + * user_ratio: array, + * recent_transactions: array + * } + */ + public function dashboard(): array + { + $currentDate = now(); + + // 당월/전월 기간 계산 + $currentMonthStart = $currentDate->copy()->startOfMonth()->toDateString(); + $currentMonthEnd = $currentDate->copy()->endOfMonth()->toDateString(); + $previousMonthStart = $currentDate->copy()->subMonth()->startOfMonth()->toDateString(); + $previousMonthEnd = $currentDate->copy()->subMonth()->endOfMonth()->toDateString(); + + // 1. Summary 데이터 + $currentMonthTotal = $this->getMonthTotal($currentMonthStart, $currentMonthEnd); + $previousMonthTotal = $this->getMonthTotal($previousMonthStart, $previousMonthEnd); + + $changeRate = $previousMonthTotal > 0 + ? round((($currentMonthTotal - $previousMonthTotal) / $previousMonthTotal) * 100, 1) + : ($currentMonthTotal > 0 ? 100 : 0); + + // 미정리 건수 (account_code가 없는 건) + $unprocessedCount = Withdrawal::query() + ->where('payment_method', 'card') + ->whereNull('account_code') + ->count(); + + // 2. 최근 6개월 추이 + $monthlyTrend = $this->getMonthlyTrend(6); + + // 3. 사용자별 비율 (당월 기준) + $userRatio = $this->getUserRatio($currentMonthStart, $currentMonthEnd); + + // 4. 최근 거래 10건 + $recentTransactions = $this->getRecentTransactions(10); + + return [ + 'summary' => [ + 'current_month_total' => (float) $currentMonthTotal, + 'previous_month_total' => (float) $previousMonthTotal, + 'change_rate' => $changeRate, + 'unprocessed_count' => $unprocessedCount, + ], + 'monthly_trend' => $monthlyTrend, + 'user_ratio' => $userRatio, + 'recent_transactions' => $recentTransactions, + ]; + } + + /** + * 특정 기간 카드 사용액 합계 + */ + private function getMonthTotal(string $startDate, string $endDate): float + { + return (float) Withdrawal::query() + ->where('payment_method', 'card') + ->whereBetween(DB::raw('DATE(COALESCE(used_at, withdrawal_date))'), [$startDate, $endDate]) + ->sum('amount'); + } + + /** + * 최근 N개월 월별 추이 + * + * @return array + */ + private function getMonthlyTrend(int $months): array + { + $result = []; + $currentDate = now(); + + for ($i = $months - 1; $i >= 0; $i--) { + $targetDate = $currentDate->copy()->subMonths($i); + $monthStart = $targetDate->copy()->startOfMonth()->toDateString(); + $monthEnd = $targetDate->copy()->endOfMonth()->toDateString(); + + $amount = $this->getMonthTotal($monthStart, $monthEnd); + + $result[] = [ + 'month' => $targetDate->format('Y-m'), + 'amount' => $amount, + ]; + } + + return $result; + } + + /** + * 사용자별 카드 사용 비율 + * + * @return array + */ + private function getUserRatio(string $startDate, string $endDate): array + { + $data = Withdrawal::query() + ->select([ + 'cards.assigned_user_id', + DB::raw('COALESCE(users.name, "미지정") as user_name'), + DB::raw('SUM(withdrawals.amount) as total_amount'), + ]) + ->leftJoin('cards', 'withdrawals.card_id', '=', 'cards.id') + ->leftJoin('users', 'cards.assigned_user_id', '=', 'users.id') + ->where('withdrawals.payment_method', 'card') + ->whereBetween(DB::raw('DATE(COALESCE(withdrawals.used_at, withdrawals.withdrawal_date))'), [$startDate, $endDate]) + ->groupBy('cards.assigned_user_id', 'users.name') + ->orderByDesc('total_amount') + ->get(); + + $totalAmount = $data->sum('total_amount'); + + return $data->map(function ($item) use ($totalAmount) { + return [ + 'user_name' => $item->user_name, + 'amount' => (float) $item->total_amount, + 'percentage' => $totalAmount > 0 + ? round(($item->total_amount / $totalAmount) * 100, 1) + : 0, + ]; + })->toArray(); + } + + /** + * 최근 거래 목록 + */ + private function getRecentTransactions(int $limit): array + { + return Withdrawal::query() + ->select([ + 'withdrawals.id', + 'withdrawals.used_at', + 'withdrawals.withdrawal_date', + 'withdrawals.merchant_name', + 'withdrawals.amount', + 'withdrawals.account_code', + 'cards.card_name', + DB::raw('COALESCE(users.name, "미지정") as user_name'), + ]) + ->leftJoin('cards', 'withdrawals.card_id', '=', 'cards.id') + ->leftJoin('users', 'cards.assigned_user_id', '=', 'users.id') + ->where('withdrawals.payment_method', 'card') + ->orderByDesc(DB::raw('COALESCE(withdrawals.used_at, withdrawals.withdrawal_date)')) + ->limit($limit) + ->get() + ->map(function ($item) { + return [ + 'id' => $item->id, + 'card_name' => $item->card_name ?? '미지정', + 'user_name' => $item->user_name, + 'used_at' => $item->used_at?->format('Y-m-d H:i:s') + ?? $item->withdrawal_date?->format('Y-m-d'), + 'merchant_name' => $item->merchant_name ?? '', + 'amount' => (float) $item->amount, + 'usage_type' => $item->account_code, + ]; + }) + ->toArray(); + } } diff --git a/app/Swagger/v1/CardTransactionApi.php b/app/Swagger/v1/CardTransactionApi.php index 04c1db4..f1081b8 100644 --- a/app/Swagger/v1/CardTransactionApi.php +++ b/app/Swagger/v1/CardTransactionApi.php @@ -87,6 +87,63 @@ * * @OA\Property(property="account_code", type="string", description="계정과목 코드", example="expenses") * ) + * + * @OA\Schema( + * schema="CardTransactionDashboard", + * description="카드 거래 대시보드 데이터 (CEO 대시보드 cm1 모달용)", + * + * @OA\Property( + * property="summary", + * type="object", + * description="요약 통계", + * @OA\Property(property="current_month_total", type="number", format="float", description="당월 카드 사용액", example=30123000), + * @OA\Property(property="previous_month_total", type="number", format="float", description="전월 카드 사용액", example=27250000), + * @OA\Property(property="change_rate", type="number", format="float", description="전월 대비 증감률 (%)", example=10.5), + * @OA\Property(property="unprocessed_count", type="integer", description="미정리 건수", example=5) + * ), + * @OA\Property( + * property="monthly_trend", + * type="array", + * description="최근 6개월 추이", + * + * @OA\Items( + * type="object", + * + * @OA\Property(property="month", type="string", description="년월", example="2026-01"), + * @OA\Property(property="amount", type="number", format="float", description="사용액", example=25000000) + * ) + * ), + * @OA\Property( + * property="user_ratio", + * type="array", + * description="사용자별 비율", + * + * @OA\Items( + * type="object", + * + * @OA\Property(property="user_name", type="string", description="사용자명", example="홍길동"), + * @OA\Property(property="amount", type="number", format="float", description="사용액", example=15000000), + * @OA\Property(property="percentage", type="number", format="float", description="비율 (%)", example=49.8) + * ) + * ), + * @OA\Property( + * property="recent_transactions", + * type="array", + * description="최근 거래 10건", + * + * @OA\Items( + * type="object", + * + * @OA\Property(property="id", type="integer", description="거래 ID", example=1), + * @OA\Property(property="card_name", type="string", description="카드명", example="법인카드1"), + * @OA\Property(property="user_name", type="string", description="사용자명", example="홍길동"), + * @OA\Property(property="used_at", type="string", description="사용일시", example="2026-01-15 14:30:00"), + * @OA\Property(property="merchant_name", type="string", description="가맹점명", example="스타벅스 강남역점"), + * @OA\Property(property="amount", type="number", format="float", description="사용금액", example=15000), + * @OA\Property(property="usage_type", type="string", description="계정과목", example="expenses", nullable=true) + * ) + * ) + * ) */ class CardTransactionApi { @@ -224,6 +281,33 @@ public function index() {} */ public function summary() {} + /** + * @OA\Get( + * path="/api/v1/card-transactions/dashboard", + * operationId="getCardTransactionDashboard", + * tags={"CardTransaction"}, + * summary="카드 거래 대시보드 데이터", + * description="CEO 대시보드 카드/가지급금 관리 섹션(cm1)의 모달 팝업용 상세 데이터를 조회합니다. 요약 통계, 월별 추이, 사용자별 비율, 최근 거래 목록을 포함합니다.", + * security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}}, + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string", example="데이터를 조회했습니다."), + * @OA\Property(property="data", ref="#/components/schemas/CardTransactionDashboard") + * ) + * ), + * + * @OA\Response(response=401, description="인증 실패"), + * @OA\Response(response=403, description="권한 없음") + * ) + */ + public function dashboard() {} + /** * @OA\Put( * path="/api/v1/card-transactions/bulk-update-account", diff --git a/routes/api.php b/routes/api.php index 28a3ab5..43bb6c5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -594,6 +594,7 @@ Route::prefix('card-transactions')->group(function () { Route::get('', [CardTransactionController::class, 'index'])->name('v1.card-transactions.index'); Route::get('/summary', [CardTransactionController::class, 'summary'])->name('v1.card-transactions.summary'); + Route::get('/dashboard', [CardTransactionController::class, 'dashboard'])->name('v1.card-transactions.dashboard'); Route::post('', [CardTransactionController::class, 'store'])->name('v1.card-transactions.store'); Route::put('/bulk-update-account', [CardTransactionController::class, 'bulkUpdateAccountCode'])->name('v1.card-transactions.bulk-update-account'); Route::get('/{id}', [CardTransactionController::class, 'show'])->whereNumber('id')->name('v1.card-transactions.show'); @@ -719,6 +720,7 @@ Route::get('', [PurchaseController::class, 'index'])->name('v1.purchases.index'); Route::post('', [PurchaseController::class, 'store'])->name('v1.purchases.store'); Route::get('/summary', [PurchaseController::class, 'summary'])->name('v1.purchases.summary'); + Route::get('/dashboard-detail', [PurchaseController::class, 'dashboardDetail'])->name('v1.purchases.dashboard-detail'); Route::post('/bulk-update-type', [PurchaseController::class, 'bulkUpdatePurchaseType'])->name('v1.purchases.bulk-update-type'); Route::post('/bulk-update-tax-received', [PurchaseController::class, 'bulkUpdateTaxReceived'])->name('v1.purchases.bulk-update-tax-received'); Route::get('/{id}', [PurchaseController::class, 'show'])->whereNumber('id')->name('v1.purchases.show');