diff --git a/app/Http/Controllers/Api/V1/EntertainmentController.php b/app/Http/Controllers/Api/V1/EntertainmentController.php index a010bee..a5e6e1f 100644 --- a/app/Http/Controllers/Api/V1/EntertainmentController.php +++ b/app/Http/Controllers/Api/V1/EntertainmentController.php @@ -33,4 +33,18 @@ public function summary(Request $request): JsonResponse return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter); }, __('message.fetched')); } + + /** + * 접대비 상세 조회 (모달용) + */ + public function detail(Request $request): JsonResponse + { + $companyType = $request->query('company_type', 'medium'); + $year = $request->query('year') ? (int) $request->query('year') : null; + $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; + + return ApiResponse::handle(function () use ($companyType, $year, $quarter) { + return $this->entertainmentService->getDetail($companyType, $year, $quarter); + }, __('message.fetched')); + } } diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php index fe27b67..34ff161 100644 --- a/app/Http/Controllers/Api/V1/LoanController.php +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -11,6 +11,7 @@ use App\Http\Requests\Loan\LoanUpdateRequest; use App\Services\LoanService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class LoanController extends Controller { @@ -42,9 +43,12 @@ public function summary(LoanIndexRequest $request): JsonResponse /** * 가지급금 대시보드 */ - public function dashboard(): JsonResponse + public function dashboard(Request $request): JsonResponse { - $result = $this->loanService->dashboard(); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + + $result = $this->loanService->dashboard($startDate, $endDate); return ApiResponse::success($result, __('message.fetched')); } diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index b47da70..e1f0892 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -217,6 +217,332 @@ private function getMissingReceiptRisk(int $tenantId, string $startDate, string ]; } + /** + * 접대비 상세 정보 조회 (모달용) + * + * @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium) + * @param int|null $year 연도 (기본: 현재 연도) + * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) + */ + public function getDetail( + ?string $companyType = 'medium', + ?int $year = null, + ?int $quarter = null + ): array { + $tenantId = $this->tenantId(); + $now = Carbon::now(); + + $year = $year ?? $now->year; + $companyType = $companyType ?? 'medium'; + $quarter = $quarter ?? $now->quarter; + + // 연간 기간 범위 + $annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); + $annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); + + // 분기 기간 범위 + $quarterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); + $quarterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); + + // 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만) + $baseLimit = $companyType === 'large' ? 12000000 : 36000000; + + // 수입금액 조회 (sales 테이블) + $revenue = $this->getAnnualRevenue($tenantId, $year); + + // 수입금액별 추가한도 계산 + $revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue); + + // 연간 총 한도 + $annualLimit = $baseLimit + $revenueAdditional; + $quarterlyLimit = $annualLimit / 4; + + // 연간/분기 사용액 조회 + $annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate); + $quarterlyUsed = $this->getUsedAmount($tenantId, $quarterStartDate, $quarterEndDate); + + // 잔여/초과 계산 + $annualRemaining = max(0, $annualLimit - $annualUsed); + $annualExceeded = max(0, $annualUsed - $annualLimit); + + // 1. 요약 데이터 + $summary = [ + 'annual_limit' => (int) $annualLimit, + 'annual_remaining' => (int) $annualRemaining, + 'annual_used' => (int) $annualUsed, + 'annual_exceeded' => (int) $annualExceeded, + ]; + + // 2. 리스크 검토 카드 (기존 getSummary의 리스크 쿼리 재활용) + $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $annualStartDate, $annualEndDate); + $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $annualStartDate, $annualEndDate); + $highAmount = $this->getHighAmountRisk($tenantId, $annualStartDate, $annualEndDate); + $missingReceipt = $this->getMissingReceiptRisk($tenantId, $annualStartDate, $annualEndDate); + + $riskReview = [ + ['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']], + ['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']], + ['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']], + ['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']], + ]; + + // 3. 월별 사용 추이 + $monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year); + + // 4. 사용자별 분포 + $userDistribution = $this->getUserDistribution($tenantId, $annualStartDate, $annualEndDate); + + // 5. 거래 내역 + $transactions = $this->getTransactions($tenantId, $quarterStartDate, $quarterEndDate); + + // 6. 손금한도 계산 정보 + $calculation = [ + 'company_type' => $companyType, + 'base_limit' => (int) $baseLimit, + 'revenue' => (int) $revenue, + 'revenue_additional' => (int) $revenueAdditional, + 'annual_limit' => (int) $annualLimit, + ]; + + // 7. 분기별 현황 + $quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit); + + return [ + 'summary' => $summary, + 'risk_review' => $riskReview, + 'monthly_usage' => $monthlyUsage, + 'user_distribution' => $userDistribution, + 'transactions' => $transactions, + 'calculation' => $calculation, + 'quarterly' => $quarterly, + ]; + } + + /** + * 접대비 사용액 조회 + */ + private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float + { + return DB::table('expense_accounts') + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereBetween('expense_date', [$startDate, $endDate]) + ->whereNull('deleted_at') + ->sum('amount') ?: 0; + } + + /** + * 연간 수입금액(매출) 조회 + */ + private function getAnnualRevenue(int $tenantId, int $year): float + { + return DB::table('sales') + ->where('tenant_id', $tenantId) + ->whereYear('sale_date', $year) + ->whereNull('deleted_at') + ->sum('total_amount') ?: 0; + } + + /** + * 수입금액별 추가한도 계산 (세법 기준) + * 100억 이하: 수입금액 × 0.2% + * 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1% + * 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03% + */ + private function calculateRevenueAdditionalLimit(float $revenue): float + { + $b10 = 10000000000; // 100억 + $b50 = 50000000000; // 500억 + + if ($revenue <= $b10) { + return $revenue * 0.002; + } elseif ($revenue <= $b50) { + return 20000000 + ($revenue - $b10) * 0.001; + } else { + return 60000000 + ($revenue - $b50) * 0.0003; + } + } + + /** + * 월별 사용 추이 조회 + */ + private function getMonthlyUsageTrend(int $tenantId, int $year): array + { + $monthlyData = DB::table('expense_accounts') + ->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount')) + ->where('tenant_id', $tenantId) + ->where('account_type', 'entertainment') + ->whereYear('expense_date', $year) + ->whereNull('deleted_at') + ->groupBy(DB::raw('MONTH(expense_date)')) + ->orderBy('month') + ->get(); + + $result = []; + for ($i = 1; $i <= 12; $i++) { + $found = $monthlyData->firstWhere('month', $i); + $result[] = [ + 'month' => $i, + 'label' => $i . '월', + 'amount' => $found ? (int) $found->amount : 0, + ]; + } + + return $result; + } + + /** + * 사용자별 분포 조회 + */ + private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array + { + $colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C']; + + $distribution = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount')) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->groupBy('ea.created_by', 'u.name') + ->orderByDesc('amount') + ->limit(5) + ->get(); + + $total = $distribution->sum('amount'); + $result = []; + $idx = 0; + + foreach ($distribution as $item) { + $result[] = [ + 'user_name' => $item->user_name ?? '사용자', + 'amount' => (int) $item->amount, + 'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0, + 'color' => $colors[$idx % count($colors)], + ]; + $idx++; + } + + return $result; + } + + /** + * 거래 내역 조회 + */ + private function getTransactions(int $tenantId, string $startDate, string $endDate): array + { + $transactions = DB::table('expense_accounts as ea') + ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_num') + ->on('ea.tenant_id', '=', 'bct.tenant_id'); + }) + ->select([ + 'ea.id', + 'ea.card_no', + 'u.name as user_name', + 'ea.expense_date', + 'ea.vendor_name', + 'ea.amount', + 'ea.receipt_no', + 'bct.use_time', + 'bct.merchant_biz_type', + ]) + ->where('ea.tenant_id', $tenantId) + ->where('ea.account_type', 'entertainment') + ->whereBetween('ea.expense_date', [$startDate, $endDate]) + ->whereNull('ea.deleted_at') + ->orderByDesc('ea.expense_date') + ->limit(100) + ->get(); + + $result = []; + foreach ($transactions as $t) { + $riskType = $this->detectTransactionRiskType($t); + + $result[] = [ + 'id' => $t->id, + 'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명', + 'user_name' => $t->user_name ?? '사용자', + 'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'), + 'vendor_name' => $t->vendor_name ?? '가맹점명', + 'amount' => (int) $t->amount, + 'risk_type' => $riskType, + ]; + } + + return $result; + } + + /** + * 거래 건별 리스크 유형 감지 + */ + private function detectTransactionRiskType(object $transaction): string + { + // 기피업종 + if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) { + return '기피업종'; + } + + // 고액 결제 + if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) { + return '고액 결제'; + } + + // 증빙 미비 + if (empty($transaction->receipt_no)) { + return '증빙 미비'; + } + + // 주말/심야 감지 + $expenseDate = Carbon::parse($transaction->expense_date); + if ($expenseDate->isWeekend()) { + return '주말/심야'; + } + if ($transaction->use_time) { + $hour = (int) substr($transaction->use_time, 0, 2); + if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) { + return '주말/심야'; + } + } + + return '정상'; + } + + /** + * 분기별 현황 조회 + */ + private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array + { + $result = []; + $previousRemaining = 0; + + for ($q = 1; $q <= 4; $q++) { + $startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d'); + + $used = $this->getUsedAmount($tenantId, $startDate, $endDate); + $carryover = $previousRemaining > 0 ? $previousRemaining : 0; + $totalLimit = $quarterlyLimit + $carryover; + $remaining = max(0, $totalLimit - $used); + $exceeded = max(0, $used - $totalLimit); + + $result[] = [ + 'quarter' => $q, + 'limit' => (int) $quarterlyLimit, + 'carryover' => (int) $carryover, + 'used' => (int) $used, + 'remaining' => (int) $remaining, + 'exceeded' => (int) $exceeded, + ]; + + $previousRemaining = $remaining; + } + + return $result; + } + /** * 리스크 감지 체크포인트 생성 */ diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 69d6fe5..1fac7b5 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -382,31 +382,54 @@ public function calculateInterest(int $year, ?int $userId = null): array * loans: array * } */ - public function dashboard(): array + public function dashboard(?string $startDate = null, ?string $endDate = null): array { $tenantId = $this->tenantId(); $currentYear = now()->year; - // 1. Summary 데이터 - $summaryData = $this->summary(); + // 날짜 필터 조건 클로저 + $applyDateFilter = function ($query) use ($startDate, $endDate) { + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + return $query; + }; - // 2. 인정이자 계산 (현재 연도 기준) + // 1. Summary 데이터 (날짜 필터 적용) + $summaryQuery = Loan::query()->where('tenant_id', $tenantId); + $applyDateFilter($summaryQuery); + + $stats = $summaryQuery->selectRaw(' + COUNT(*) as total_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, + SUM(amount) as total_amount, + SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding + ', [Loan::STATUS_OUTSTANDING]) + ->first(); + + // 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관) $interestData = $this->calculateInterest($currentYear); $recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0; - // 3. 카테고리별 집계 (D1.7) - $categoryBreakdown = $this->getCategoryBreakdown($tenantId); + // 3. 카테고리별 집계 (날짜 필터 적용) + $categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate); - // 4. 가지급금 목록 (최근 10건, 미정산 우선) - $loans = Loan::query() + // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용) + $loansQuery = Loan::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email', 'withdrawal']) + ->with(['user:id,name,email', 'withdrawal']); + $applyDateFilter($loansQuery); + + $loans = $loansQuery ->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [ Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL, ]) ->orderByDesc('loan_date') - ->limit(10) + ->limit(50) ->get() ->map(function ($loan) { return [ @@ -423,9 +446,9 @@ public function dashboard(): array return [ 'summary' => [ - 'total_outstanding' => (float) $summaryData['total_outstanding'], + 'total_outstanding' => (float) ($stats->total_outstanding ?? 0), 'recognized_interest' => (float) $recognizedInterest, - 'outstanding_count' => (int) $summaryData['outstanding_count'], + 'outstanding_count' => (int) ($stats->outstanding_count ?? 0), ], 'category_breakdown' => $categoryBreakdown, 'loans' => $loans, @@ -437,7 +460,7 @@ public function dashboard(): array * * @return array */ - private function getCategoryBreakdown(int $tenantId): array + private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array { // 기본값: 4개 카테고리 모두 0으로 초기화 $breakdown = []; @@ -449,9 +472,18 @@ private function getCategoryBreakdown(int $tenantId): array ]; } - // 카테고리별 집계 (summary와 동일하게 전체 대상) - $stats = Loan::query() - ->where('tenant_id', $tenantId) + // 카테고리별 집계 (summary와 동일하게 전체 대상, 날짜 필터 적용) + $query = Loan::query() + ->where('tenant_id', $tenantId); + + if ($startDate) { + $query->where('loan_date', '>=', $startDate); + } + if ($endDate) { + $query->where('loan_date', '<=', $endDate); + } + + $stats = $query ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount') ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') ->groupBy('category') diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index bc28827..d9627a6 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -206,6 +206,7 @@ // Entertainment API (CEO 대시보드 접대비 현황) Route::get('/entertainment/summary', [EntertainmentController::class, 'summary'])->name('v1.entertainment.summary'); +Route::get('/entertainment/detail', [EntertainmentController::class, 'detail'])->name('v1.entertainment.detail'); // Welfare API (CEO 대시보드 복리후생비 현황) Route::get('/welfare/summary', [WelfareController::class, 'summary'])->name('v1.welfare.summary');