tenantId(); $now = Carbon::now(); $year = $year ?? $now->year; $quarter = $quarter ?? $now->quarter; // 기간 범위 계산 if ($limitType === 'annual') { $startDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, 12, 31)->format('Y-m-d'); } else { $startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d'); } // 리스크 감지 쿼리 $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate); $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate); $highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate); $missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate); // 카드 데이터 구성 $cards = [ [ 'id' => 'et_weekend', 'label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'subLabel' => "{$weekendLateNight['count']}건", ], [ 'id' => 'et_prohibited', 'label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건', ], [ 'id' => 'et_high_amount', 'label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'subLabel' => "{$highAmount['count']}건", ], [ 'id' => 'et_no_receipt', 'label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'subLabel' => "{$missingReceipt['count']}건", ], ]; // 체크포인트 생성 $checkPoints = $this->generateRiskCheckPoints( $weekendLateNight, $prohibitedBiz, $highAmount, $missingReceipt ); return [ 'cards' => $cards, 'check_points' => $checkPoints, ]; } /** * 주말/심야 사용 리스크 조회 * expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시 */ private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array { // 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK) $weekendResult = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); // 심야 사용 (barobill 카드 거래 내역에서 시간 확인) $lateNightResult = DB::table('expense_accounts as ea') ->leftJoin('barobill_card_transactions as bct', function ($join) { $join->on('ea.receipt_no', '=', 'bct.approval_num') ->on('ea.tenant_id', '=', 'bct.tenant_id'); }) ->where('ea.tenant_id', $tenantId) ->where('ea.account_type', 'entertainment') ->whereBetween('ea.expense_date', [$startDate, $endDate]) ->whereNull('ea.deleted_at') ->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지) ->whereNotNull('bct.use_time') ->where(function ($q) { $q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START]) ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]); }) ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') ->first(); $totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0); $totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0); return ['count' => $totalCount, 'total' => $totalAmount]; } /** * 기피업종 사용 리스크 조회 * barobill의 merchant_biz_type가 MCC 코드 매칭 */ private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array { $result = DB::table('expense_accounts as ea') ->join('barobill_card_transactions as bct', function ($join) { $join->on('ea.receipt_no', '=', 'bct.approval_num') ->on('ea.tenant_id', '=', 'bct.tenant_id'); }) ->where('ea.tenant_id', $tenantId) ->where('ea.account_type', 'entertainment') ->whereBetween('ea.expense_date', [$startDate, $endDate]) ->whereNull('ea.deleted_at') ->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES) ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') ->first(); return [ 'count' => $result->count ?? 0, 'total' => $result->total ?? 0, ]; } /** * 고액 결제 리스크 조회 * 1회 50만원 초과 결제 */ private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array { $result = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD) ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); return [ 'count' => $result->count ?? 0, 'total' => $result->total ?? 0, ]; } /** * 증빙 미비 리스크 조회 * receipt_no가 NULL 또는 빈 값 */ private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array { $result = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->where(function ($q) { $q->whereNull('receipt_no') ->orWhere('receipt_no', ''); }) ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); return [ 'count' => $result->count ?? 0, 'total' => $result->total ?? 0, ]; } /** * 접대비 상세 정보 조회 (모달용) * * @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, ?string $startDate = null, ?string $endDate = null ): array { $tenantId = $this->tenantId(); $now = Carbon::now(); $year = $year ?? $now->year; $companyType = $companyType ?? 'medium'; $quarter = $quarter ?? $now->quarter; // 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간) $annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); $annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); // 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본) if ($startDate && $endDate) { $filterStartDate = $startDate; $filterEndDate = $endDate; } else { $filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d'); $filterEndDate = 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, $filterStartDate, $filterEndDate); // 잔여/초과 계산 $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. 리스크 검토 카드 (날짜 필터 적용) $weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate); $prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate); $highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate); $missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate); $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, $filterStartDate, $filterEndDate); // 5. 거래 내역 (날짜 필터 적용) $transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate); // 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; } /** * 리스크 감지 체크포인트 생성 */ private function generateRiskCheckPoints( array $weekendLateNight, array $prohibitedBiz, array $highAmount, array $missingReceipt ): array { $checkPoints = []; $totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count'] + $highAmount['count'] + $missingReceipt['count']; // 주말/심야 if ($weekendLateNight['count'] > 0) { $amountFormatted = number_format($weekendLateNight['total'] / 10000); $checkPoints[] = [ 'id' => 'et_cp_weekend', 'type' => 'warning', 'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.", 'highlights' => [ ['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } // 기피업종 if ($prohibitedBiz['count'] > 0) { $amountFormatted = number_format($prohibitedBiz['total'] / 10000); $checkPoints[] = [ 'id' => 'et_cp_prohibited', 'type' => 'error', 'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.", 'highlights' => [ ['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'], ['text' => '접대비 불인정', 'color' => 'red'], ], ]; } // 고액 결제 if ($highAmount['count'] > 0) { $amountFormatted = number_format($highAmount['total'] / 10000); $checkPoints[] = [ 'id' => 'et_cp_high', 'type' => 'warning', 'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.", 'highlights' => [ ['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } // 증빙 미비 if ($missingReceipt['count'] > 0) { $amountFormatted = number_format($missingReceipt['total'] / 10000); $checkPoints[] = [ 'id' => 'et_cp_receipt', 'type' => 'error', 'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.", 'highlights' => [ ['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } // 리스크 0건이면 정상 메시지 if ($totalRiskCount === 0) { $checkPoints[] = [ 'id' => 'et_cp_normal', 'type' => 'success', 'message' => '접대비 사용 현황이 정상입니다.', 'highlights' => [ ['text' => '정상', 'color' => 'green'], ], ]; } return $checkPoints; } }