200000, // 식대 20만원 'transportation' => 100000, // 교통비 10만원 'congratulation' => 50000, // 경조사 5만원 'health_check' => 30000, // 건강검진 3만원 'education' => 80000, // 교육비 8만원 'welfare_point' => 100000, // 복지포인트 10만원 ]; /** * 복리후생비 리스크 현황 요약 조회 (D1.7) * * @return array{cards: array, check_points: array} */ public function getSummary( ?string $limitType = 'quarterly', ?string $calculationType = 'fixed', ?int $fixedAmountPerMonth = 200000, ?float $ratio = 0.05, ?int $year = null, ?int $quarter = null ): array { $tenantId = $this->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'); $monthCount = 12; } 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'); $monthCount = 3; } // 직원 수 조회 $employeeCount = $this->getEmployeeCount($tenantId); // 리스크 감지 쿼리 $taxFreeExcess = $this->getTaxFreeExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); $privateUse = $this->getPrivateUseRisk($tenantId, $startDate, $endDate); $concentration = $this->getConcentrationRisk($tenantId, $startDate, $endDate); $categoryExcess = $this->getCategoryExcessRisk($tenantId, $startDate, $endDate, $employeeCount, $monthCount); // 카드 데이터 구성 $cards = [ [ 'id' => 'wf_tax_excess', 'label' => '비과세 한도 초과', 'amount' => (int) $taxFreeExcess['total'], 'subLabel' => "{$taxFreeExcess['count']}건", ], [ 'id' => 'wf_private_use', 'label' => '사적 사용 의심', 'amount' => (int) $privateUse['total'], 'subLabel' => "{$privateUse['count']}건", ], [ 'id' => 'wf_concentration', 'label' => '특정인 편중', 'amount' => (int) $concentration['total'], 'subLabel' => "{$concentration['count']}건", ], [ 'id' => 'wf_category_excess', 'label' => '항목별 한도 초과', 'amount' => (int) $categoryExcess['total'], 'subLabel' => "{$categoryExcess['count']}건", ], ]; // 체크포인트 생성 $checkPoints = $this->generateRiskCheckPoints( $tenantId, $employeeCount, $monthCount, $startDate, $endDate, $taxFreeExcess, $privateUse, $concentration, $categoryExcess ); return [ 'cards' => $cards, 'check_points' => $checkPoints, ]; } /** * 비과세 한도 초과 리스크 조회 * sub_type='meal' 1인당 월 > 200,000원 */ private function getTaxFreeExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array { if ($employeeCount <= 0) { return ['count' => 0, 'total' => 0]; } // 식대 총액 조회 $mealTotal = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->where('sub_type', 'meal') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->sum('amount'); $perPersonMonthly = $mealTotal / $employeeCount / max(1, $monthCount); $excessAmount = max(0, $perPersonMonthly - self::TAX_FREE_MEAL_LIMIT) * $employeeCount * $monthCount; if ($excessAmount > 0) { // 초과 건수 (식대 건수 기준) $count = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->where('sub_type', 'meal') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->count(); return ['count' => $count, 'total' => (int) $excessAmount]; } return ['count' => 0, 'total' => 0]; } /** * 사적 사용 의심 리스크 조회 * 주말/심야 사용 (접대비와 동일 로직, account_type='welfare') */ private function getPrivateUseRisk(int $tenantId, string $startDate, string $endDate): array { // 주말 사용 $weekendResult = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->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', 'welfare') ->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) >= 22') ->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < 6'); }) ->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total') ->first(); return [ 'count' => ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0), 'total' => ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0), ]; } /** * 특정인 편중 리스크 조회 * 1인 사용비율 > 전체의 5% */ private function getConcentrationRisk(int $tenantId, string $startDate, string $endDate): array { // 전체 복리후생비 사용액 $totalAmount = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->sum('amount'); if ($totalAmount <= 0) { return ['count' => 0, 'total' => 0]; } $threshold = $totalAmount * self::CONCENTRATION_THRESHOLD; // 사용자별 사용액 조회 (편중된 사용자) $concentrated = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->groupBy('created_by') ->havingRaw('SUM(amount) > ?', [$threshold]) ->selectRaw('COUNT(*) as count, SUM(amount) as total') ->get(); $totalConcentrated = $concentrated->sum('total'); $userCount = $concentrated->count(); return ['count' => $userCount, 'total' => (int) $totalConcentrated]; } /** * 항목별 한도 초과 리스크 조회 * 각 sub_type별 1인당 월 기준금액 초과 */ private function getCategoryExcessRisk(int $tenantId, string $startDate, string $endDate, int $employeeCount, int $monthCount): array { if ($employeeCount <= 0) { return ['count' => 0, 'total' => 0]; } $totalExcess = 0; $excessCount = 0; foreach (self::SUB_TYPE_LIMITS as $subType => $monthlyLimit) { $amount = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->where('sub_type', $subType) ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->sum('amount'); $perPersonMonthly = $amount / $employeeCount / max(1, $monthCount); if ($perPersonMonthly > $monthlyLimit) { $excess = ($perPersonMonthly - $monthlyLimit) * $employeeCount * $monthCount; $totalExcess += $excess; $excessCount++; } } return ['count' => $excessCount, 'total' => (int) $totalExcess]; } /** * 리스크 감지 체크포인트 생성 */ private function generateRiskCheckPoints( int $tenantId, int $employeeCount, int $monthCount, string $startDate, string $endDate, array $taxFreeExcess, array $privateUse, array $concentration, array $categoryExcess ): array { $checkPoints = []; // 1인당 월 복리후생비 계산 (업계 평균 비교) $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); $perPersonMonthly = $employeeCount > 0 && $monthCount > 0 ? $usedAmount / $employeeCount / $monthCount : 0; $perPersonFormatted = number_format($perPersonMonthly / 10000); if ($perPersonMonthly >= self::INDUSTRY_AVG_MIN && $perPersonMonthly <= self::INDUSTRY_AVG_MAX) { $checkPoints[] = [ 'id' => 'wf_cp_avg', 'type' => 'success', 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", 'highlights' => [ ['text' => "{$perPersonFormatted}만원", 'color' => 'green'], ], ]; } elseif ($perPersonMonthly > self::INDUSTRY_AVG_MAX) { $checkPoints[] = [ 'id' => 'wf_cp_avg_high', 'type' => 'warning', 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", 'highlights' => [ ['text' => "{$perPersonFormatted}만원", 'color' => 'orange'], ], ]; } // 식대 비과세 한도 체크 $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); $perPersonMeal = $employeeCount > 0 && $monthCount > 0 ? $mealAmount / $employeeCount / $monthCount : 0; if ($perPersonMeal > self::TAX_FREE_MEAL_LIMIT) { $mealFormatted = number_format($perPersonMeal / 10000); $limitFormatted = number_format(self::TAX_FREE_MEAL_LIMIT / 10000); $checkPoints[] = [ 'id' => 'wf_cp_meal', 'type' => 'error', 'message' => "식대가 월 {$mealFormatted}만원으로 비과세 한도({$limitFormatted}만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.", 'highlights' => [ ['text' => "월 {$mealFormatted}만원", 'color' => 'red'], ['text' => '초과', 'color' => 'red'], ], ]; } // 사적 사용 의심 if ($privateUse['count'] > 0) { $amountFormatted = number_format($privateUse['total'] / 10000); $checkPoints[] = [ 'id' => 'wf_cp_private', 'type' => 'warning', 'message' => "주말/심야 사용 {$privateUse['count']}건({$amountFormatted}만원) 감지. 사적 사용 여부를 확인해주세요.", 'highlights' => [ ['text' => "{$privateUse['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } // 특정인 편중 if ($concentration['count'] > 0) { $amountFormatted = number_format($concentration['total'] / 10000); $checkPoints[] = [ 'id' => 'wf_cp_concentration', 'type' => 'warning', 'message' => "특정인 편중 {$concentration['count']}명({$amountFormatted}만원). 전체의 5% 초과 사용자가 있습니다.", 'highlights' => [ ['text' => "{$concentration['count']}명({$amountFormatted}만원)", 'color' => 'orange'], ], ]; } // 리스크 0건이면 정상 $totalRisk = $taxFreeExcess['count'] + $privateUse['count'] + $concentration['count'] + $categoryExcess['count']; if ($totalRisk === 0 && empty($checkPoints)) { $checkPoints[] = [ 'id' => 'wf_cp_normal', 'type' => 'success', 'message' => '복리후생비 사용 현황이 정상입니다.', 'highlights' => [ ['text' => '정상', 'color' => 'green'], ], ]; } return $checkPoints; } /** * 직원 수 조회 (급여 대상 직원 기준) * * salaries 테이블에서 유니크한 employee_id 수를 카운트합니다. * 급여 데이터가 없으면 user_tenants 테이블에서 조회합니다. */ private function getEmployeeCount(int $tenantId): int { // 1차: salaries 테이블에서 급여 대상 직원 수 조회 $count = DB::table('salaries') ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->distinct('employee_id') ->count('employee_id'); if ($count > 0) { return $count; } // 2차: salaries 데이터가 없으면 user_tenants에서 조회 $count = DB::table('users') ->join('user_tenants', 'users.id', '=', 'user_tenants.user_id') ->where('user_tenants.tenant_id', $tenantId) ->where('user_tenants.is_active', true) ->whereNull('users.deleted_at') ->count(); return $count ?: 0; } /** * 연간 급여 총액 조회 * * salaries 테이블에서 해당 연도의 base_salary 합계를 조회합니다. * 연간 데이터가 부족한 경우, 최근 월 데이터를 12배하여 추정합니다. */ private function getTotalSalary(int $tenantId, int $year): float { // 해당 연도의 급여 합계 조회 $yearlyTotal = DB::table('salaries') ->where('tenant_id', $tenantId) ->where('year', $year) ->whereNull('deleted_at') ->sum('base_salary'); if ($yearlyTotal > 0) { // 데이터가 있는 월 수 확인 $monthCount = DB::table('salaries') ->where('tenant_id', $tenantId) ->where('year', $year) ->whereNull('deleted_at') ->distinct('month') ->count('month'); // 연간 추정 (데이터가 일부 월만 있을 경우 12개월로 환산) if ($monthCount > 0 && $monthCount < 12) { return ($yearlyTotal / $monthCount) * 12; } return $yearlyTotal; } // 해당 연도 데이터가 없으면 최근 월 데이터로 추정 $latestMonth = DB::table('salaries') ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->sum('base_salary'); $employeeCount = $this->getEmployeeCount($tenantId); if ($latestMonth > 0 && $employeeCount > 0) { // 최근 월 급여를 12배하여 연간 추정 return ($latestMonth / $employeeCount) * $employeeCount * 12; } return 0; } /** * 복리후생비 사용액 조회 * * expense_accounts 테이블에서 welfare 타입 지출액을 조회합니다. * 데이터가 없으면 0을 반환합니다. */ private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float { return DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->sum('amount') ?: 0; } /** * 월 식대 조회 */ private function getMonthlyMealAmount(int $tenantId, string $startDate, string $endDate): float { // TODO: 식대 항목 조회 $amount = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->where('sub_type', 'meal') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->sum('amount'); return $amount ?: 0; } /** * 복리후생비 상세 정보 조회 (모달용) * * @param string|null $calculationType 계산 방식 (fixed|ratio, 기본: fixed) * @param int|null $fixedAmountPerMonth 1인당 월 정액 (기본: 200000) * @param float|null $ratio 급여 대비 비율 (기본: 0.05) * @param int|null $year 연도 (기본: 현재 연도) * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) */ public function getDetail( ?string $calculationType = 'fixed', ?int $fixedAmountPerMonth = 200000, ?float $ratio = 0.05, ?int $year = null, ?int $quarter = null, ?string $startDate = null, ?string $endDate = null ): array { $tenantId = $this->tenantId(); $now = Carbon::now(); // 기본값 설정 $year = $year ?? $now->year; $calculationType = $calculationType ?? 'fixed'; $fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000; $ratio = $ratio ?? 0.05; $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'); // 직원 수 조회 $employeeCount = $this->getEmployeeCount($tenantId); // 한도 계산 if ($calculationType === 'fixed') { $annualLimit = $fixedAmountPerMonth * 12 * $employeeCount; $totalSalary = 0; } else { $totalSalary = $this->getTotalSalary($tenantId, $year); $annualLimit = $totalSalary * $ratio; } $quarterlyLimit = $annualLimit / 4; // 연간/분기 사용액 조회 $annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate); $quarterlyUsed = $this->getUsedAmount($tenantId, $quarterStartDate, $quarterEndDate); // 복리후생비 계정 (연간) $annualAccount = $this->getAccountBalance($tenantId, $year); // 잔여/초과 계산 $annualRemaining = max(0, $annualLimit - $annualUsed); $quarterlyRemaining = max(0, $quarterlyLimit - $quarterlyUsed); $quarterlyExceeded = max(0, $quarterlyUsed - $quarterlyLimit); // 1. 요약 데이터 $summary = [ 'annual_account' => (int) $annualAccount, 'annual_limit' => (int) $annualLimit, 'annual_used' => (int) $annualUsed, 'annual_remaining' => (int) $annualRemaining, 'quarterly_limit' => (int) $quarterlyLimit, 'quarterly_remaining' => (int) $quarterlyRemaining, 'quarterly_used' => (int) $quarterlyUsed, 'quarterly_exceeded' => (int) $quarterlyExceeded, ]; // 2. 월별 사용 추이 $monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year); // 3. 항목별 분포 $categoryDistribution = $this->getCategoryDistribution($tenantId, $annualStartDate, $annualEndDate); // 4. 일별 사용 내역 (커스텀 날짜 범위가 있으면 해당 범위, 없으면 분기 기준) $txStartDate = $startDate ?? $quarterStartDate; $txEndDate = $endDate ?? $quarterEndDate; $transactions = $this->getTransactions($tenantId, $txStartDate, $txEndDate); // 5. 계산 정보 $calculation = [ 'type' => $calculationType, 'employee_count' => $employeeCount, 'annual_limit' => (int) $annualLimit, ]; if ($calculationType === 'fixed') { $calculation['monthly_amount'] = $fixedAmountPerMonth; } else { $calculation['total_salary'] = (int) $totalSalary; $calculation['ratio'] = $ratio * 100; // 백분율로 변환 } // 6. 분기별 현황 $quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit); return [ 'summary' => $summary, 'monthly_usage' => $monthlyUsage, 'category_distribution' => $categoryDistribution, 'transactions' => $transactions, 'calculation' => $calculation, 'quarterly' => $quarterly, ]; } /** * 복리후생비 계정 잔액 조회 * * 해당 연도의 복리후생비 계정 지출액을 조회합니다. * 데이터가 없으면 0을 반환합니다. */ private function getAccountBalance(int $tenantId, int $year): float { return DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->whereYear('expense_date', $year) ->whereNull('deleted_at') ->sum('amount') ?: 0; } /** * 월별 사용 추이 조회 */ 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', 'welfare') ->whereYear('expense_date', $year) ->whereNull('deleted_at') ->groupBy(DB::raw('MONTH(expense_date)')) ->orderBy('month') ->get(); // 12개월 모두 포함 (데이터 없는 달은 0) $result = []; for ($i = 1; $i <= 12; $i++) { $found = $monthlyData->firstWhere('month', $i); $result[] = [ 'month' => $i, 'amount' => $found ? (int) $found->amount : 0, ]; } return $result; } /** * 항목별 분포 조회 */ private function getCategoryDistribution(int $tenantId, string $startDate, string $endDate): array { $categoryLabels = [ 'meal' => '식비', 'health_check' => '건강검진', 'congratulation' => '경조사비', 'other' => '기타', ]; $distribution = DB::table('expense_accounts') ->select('sub_type', DB::raw('SUM(amount) as amount')) ->where('tenant_id', $tenantId) ->where('account_type', 'welfare') ->whereBetween('expense_date', [$startDate, $endDate]) ->whereNull('deleted_at') ->groupBy('sub_type') ->get(); $total = $distribution->sum('amount'); $result = []; foreach ($distribution as $item) { $subType = $item->sub_type ?? 'other'; $result[] = [ 'category' => $subType, 'label' => $categoryLabels[$subType] ?? '기타', 'amount' => (int) $item->amount, 'ratio' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0, ]; } // 데이터가 없는 경우 빈 배열 반환 (mock 데이터 제거) return $result; } /** * 일별 사용 내역 조회 */ private function getTransactions(int $tenantId, string $startDate, string $endDate): array { $categoryLabels = [ 'meal' => '식비', 'health_check' => '건강검진', 'congratulation' => '경조사비', 'other' => '기타', ]; $transactions = DB::table('expense_accounts as ea') ->leftJoin('users as u', 'ea.created_by', '=', 'u.id') ->select([ 'ea.id', 'ea.card_no', 'u.name as user_name', 'ea.expense_date', 'ea.vendor_name', 'ea.amount', 'ea.sub_type', ]) ->where('ea.tenant_id', $tenantId) ->where('ea.account_type', 'welfare') ->whereBetween('ea.expense_date', [$startDate, $endDate]) ->whereNull('ea.deleted_at') ->orderByDesc('ea.expense_date') ->limit(100) ->get(); $result = []; foreach ($transactions as $t) { $subType = $t->sub_type ?? 'other'; $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, 'sub_type' => $subType, 'sub_type_label' => $categoryLabels[$subType] ?? '기타', ]; } // 데이터가 없는 경우 빈 배열 반환 (mock 데이터 제거) return $result; } /** * 분기별 현황 조회 */ 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; } }