diff --git a/app/Services/EntertainmentService.php b/app/Services/EntertainmentService.php index a7010284..773d3cd2 100644 --- a/app/Services/EntertainmentService.php +++ b/app/Services/EntertainmentService.php @@ -6,29 +6,35 @@ use Illuminate\Support\Facades\DB; /** - * 접대비 현황 서비스 + * 접대비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 접대비 데이터를 제공합니다. + * CEO 대시보드용 접대비 리스크 데이터를 제공합니다. + * 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비 */ class EntertainmentService extends Service { - // 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%) - private const DEFAULT_LIMIT_RATE = 0.003; + // 고액 결제 기준 (1회 50만원 초과) + private const HIGH_AMOUNT_THRESHOLD = 500000; - // 기업 규모별 기본 한도 (연간) - private const COMPANY_TYPE_LIMITS = [ - 'large' => 36000000, // 대기업: 연 3,600만원 - 'medium' => 36000000, // 중견기업: 연 3,600만원 - 'small' => 24000000, // 중소기업: 연 2,400만원 + // 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등) + private const PROHIBITED_MCC_CODES = [ + '5813', // 음주업소 + '7011', // 숙박업 + '5944', // 귀금속 + '7941', // 레저/스포츠 + '7992', // 골프장 + '7273', // 데이트서비스 + '5932', // 골동품 ]; + // 심야 시간대 (22시 ~ 06시) + private const LATE_NIGHT_START = 22; + + private const LATE_NIGHT_END = 6; + /** - * 접대비 현황 요약 조회 + * 접대비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium) - * @param int|null $year 연도 (기본: 현재 연도) - * @param int|null $quarter 분기 (1-4, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -40,73 +46,58 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $companyType = $companyType ?? 'medium'; $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'); - $periodLabel = "{$year}년"; } 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'); - $periodLabel = "{$quarter}사분기"; } - // 연간 시작일 (매출 계산용) - $yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d'); - $yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d'); - - // 매출액 조회 (연간) - $annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate); - - // 접대비 한도 계산 - $annualLimit = $this->calculateLimit($annualSales, $companyType); - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 접대비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $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_sales', - 'label' => '매출', - 'amount' => (int) $annualSales, + 'id' => 'et_weekend', + 'label' => '주말/심야', + 'amount' => (int) $weekendLateNight['total'], + 'subLabel' => "{$weekendLateNight['count']}건", ], [ - 'id' => 'et_limit', - 'label' => "{{$periodLabel}} 접대비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'et_prohibited', + 'label' => '기피업종', + 'amount' => (int) $prohibitedBiz['total'], + 'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건', ], [ - 'id' => 'et_remaining', - 'label' => "{{$periodLabel}} 접대비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'et_high_amount', + 'label' => '고액 결제', + 'amount' => (int) $highAmount['total'], + 'subLabel' => "{$highAmount['count']}건", ], [ - 'id' => 'et_used', - 'label' => "{{$periodLabel}} 접대비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'et_no_receipt', + 'label' => '증빙 미비', + 'amount' => (int) $missingReceipt['total'], + 'subLabel' => "{$missingReceipt['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( - $periodLabel, - $periodLimit, - $usedAmount, - $remainingLimit, - $tenantId, - $startDate, - $endDate + $checkPoints = $this->generateRiskCheckPoints( + $weekendLateNight, + $prohibitedBiz, + $highAmount, + $missingReceipt ); return [ @@ -116,65 +107,82 @@ public function getSummary( } /** - * 연간 매출액 조회 + * 주말/심야 사용 리스크 조회 + * expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시 */ - private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float + private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array { - // orders 테이블에서 확정된 수주 합계 조회 - $amount = DB::table('orders') - ->where('tenant_id', $tenantId) - ->where('status_code', 'confirmed') - ->whereBetween('received_at', [$startDate, $endDate]) - ->whereNull('deleted_at') - ->sum('total_amount'); - - return $amount ?: 30530000000; // 임시 기본값 (305억) - } - - /** - * 접대비 한도 계산 - */ - private function calculateLimit(float $annualSales, string $companyType): float - { - // 기본 한도 (기업 규모별) - $baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium']; - - // 매출 기반 한도 (0.3%) - $salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE; - - // 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화) - return $baseLimit + $salesBasedLimit; - } - - /** - * 접대비 사용액 조회 - */ - private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float - { - // TODO: 실제 접대비 계정과목에서 조회 - // expense_accounts 또는 card_transactions에서 접대비 항목 합계 - $amount = DB::table('expense_accounts') + // 주말 사용 (토요일=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') - ->sum('amount'); + ->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)') + ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') + ->first(); - return $amount ?: 10000000; // 임시 기본값 + // 심야 사용 (barobill 카드 거래 내역에서 시간 확인) + $lateNightResult = DB::table('expense_accounts as ea') + ->leftJoin('barobill_card_transactions as bct', function ($join) { + $join->on('ea.receipt_no', '=', 'bct.approval_no') + ->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)') // 주말 제외 (중복 방지) + ->where(function ($q) { + $q->whereRaw('HOUR(bct.use_time) >= ?', [self::LATE_NIGHT_START]) + ->orWhereRaw('HOUR(bct.use_time) < ?', [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 getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array + 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_no') + ->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 { - // TODO: 거래처 정보 누락 건수 조회 $result = DB::table('expense_accounts') ->where('tenant_id', $tenantId) ->where('account_type', 'entertainment') ->whereBetween('expense_date', [$startDate, $endDate]) - ->whereNull('vendor_id') ->whereNull('deleted_at') + ->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD) ->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total') ->first(); @@ -185,72 +193,103 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string } /** - * 체크포인트 생성 + * 증빙 미비 리스크 조회 + * receipt_no가 NULL 또는 빈 값 */ - private function generateCheckPoints( - string $periodLabel, - float $limit, - float $used, - float $remaining, - int $tenantId, - string $startDate, - string $endDate + 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, + ]; + } + + /** + * 리스크 감지 체크포인트 생성 + */ + private function generateRiskCheckPoints( + array $weekendLateNight, + array $prohibitedBiz, + array $highAmount, + array $missingReceipt ): array { $checkPoints = []; - $usageRate = $limit > 0 ? ($used / $limit) * 100 : 0; - $usedFormatted = number_format($used / 10000); - $limitFormatted = number_format($limit / 10000); - $remainingFormatted = number_format($remaining / 10000); + $totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count'] + + $highAmount['count'] + $missingReceipt['count']; - // 사용률에 따른 체크포인트 - if ($usageRate <= 75) { - // 정상 운영 - $remainingRate = round(100 - $usageRate); + // 주말/심야 + if ($weekendLateNight['count'] > 0) { + $amountFormatted = number_format($weekendLateNight['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_normal', - 'type' => 'success', - 'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.", - 'highlights' => [ - ['text' => "{$usedFormatted}만원", 'color' => 'green'], - ['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'], - ], - ]; - } elseif ($usageRate <= 100) { - // 주의 (85% 이상) - $usageRateRounded = round($usageRate); - $checkPoints[] = [ - 'id' => 'et_cp_warning', + 'id' => 'et_cp_weekend', 'type' => 'warning', - 'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.", + 'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.", 'highlights' => [ - ['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - // 한도 초과 - $overAmount = $used - $limit; - $overFormatted = number_format($overAmount / 10000); - $checkPoints[] = [ - 'id' => 'et_cp_over', - 'type' => 'error', - 'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.", - 'highlights' => [ - ['text' => "{$overFormatted}만원 발생", 'color' => 'red'], + ['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'], ], ]; } - // 거래처 정보 누락 체크 - $missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate); - if ($missingVendor['count'] > 0) { - $missingTotal = number_format($missingVendor['total'] / 10000); + // 기피업종 + if ($prohibitedBiz['count'] > 0) { + $amountFormatted = number_format($prohibitedBiz['total'] / 10000); $checkPoints[] = [ - 'id' => 'et_cp_missing', + 'id' => 'et_cp_prohibited', 'type' => 'error', - 'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.", + 'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.", 'highlights' => [ - ['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'], - ['text' => '거래처 정보가 누락', 'color' => 'red'], + ['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'], ], ]; } diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php index 56b905ad..69d6fe56 100644 --- a/app/Services/LoanService.php +++ b/app/Services/LoanService.php @@ -449,10 +449,9 @@ private function getCategoryBreakdown(int $tenantId): array ]; } - // 카테고리별 미정산 집계 + // 카테고리별 집계 (summary와 동일하게 전체 대상) $stats = Loan::query() ->where('tenant_id', $tenantId) - ->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]) ->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/app/Services/ReceivablesService.php b/app/Services/ReceivablesService.php index 3c7b2c90..9eaf56d3 100644 --- a/app/Services/ReceivablesService.php +++ b/app/Services/ReceivablesService.php @@ -117,11 +117,14 @@ public function index(array $params): array } /** - * 요약 통계 조회 + * 요약 통계 조회 (D1.7 cards + check_points 구조) + * + * @return array{cards: array, check_points: array} */ public function summary(array $params): array { $tenantId = $this->tenantId(); + $now = Carbon::now(); $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); @@ -137,19 +140,19 @@ public function summary(array $params): array $totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate); // 기간 내 총 매출 - $totalSales = Sale::where('tenant_id', $tenantId) + $totalSales = (float) Sale::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('sale_date', [$startDate, $endDate]) ->sum('total_amount'); // 기간 내 총 입금 - $totalDeposits = Deposit::where('tenant_id', $tenantId) + $totalDeposits = (float) Deposit::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('deposit_date', [$startDate, $endDate]) ->sum('amount'); // 기간 내 총 어음 - $totalBills = Bill::where('tenant_id', $tenantId) + $totalBills = (float) Bill::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('bill_type', 'received') ->whereBetween('issue_date', [$startDate, $endDate]) @@ -158,26 +161,239 @@ public function summary(array $params): array // 총 미수금 (이월잔액 + 매출 - 입금 - 어음) $totalReceivables = $totalCarryForward + $totalSales - $totalDeposits - $totalBills; + // 당월 미수금 + $currentMonthStart = $now->copy()->startOfMonth()->format('Y-m-d'); + $currentMonthEnd = $now->copy()->endOfMonth()->format('Y-m-d'); + + $currentMonthSales = (float) Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('sale_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('total_amount'); + + $currentMonthDeposits = (float) Deposit::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->whereBetween('deposit_date', [$currentMonthStart, $currentMonthEnd]) + ->sum('amount'); + + $currentMonthReceivables = $currentMonthSales - $currentMonthDeposits; + // 거래처 수 $vendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->count(); - // 연체 거래처 수 (미수금이 양수인 거래처) + // 연체 거래처 수 $overdueVendorCount = Client::where('tenant_id', $tenantId) ->where('is_active', true) ->where('is_overdue', true) ->count(); - return [ - 'total_carry_forward' => (float) $totalCarryForward, - 'total_sales' => (float) $totalSales, - 'total_deposits' => (float) $totalDeposits, - 'total_bills' => (float) $totalBills, - 'total_receivables' => (float) $totalReceivables, - 'vendor_count' => $vendorCount, - 'overdue_vendor_count' => $overdueVendorCount, + // 악성채권 건수 + $badDebtCount = $this->getBadDebtCount($tenantId); + + // Top 3 미수금 거래처 + $topVendors = $this->getTopReceivableVendors($tenantId, 3); + + // 카드 데이터 구성 + $cards = [ + [ + 'id' => 'rv_cumulative', + 'label' => '누적 미수금', + 'amount' => (int) $totalReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $totalSales], + ['label' => '입금', 'value' => (int) $totalDeposits], + ], + ], + [ + 'id' => 'rv_monthly', + 'label' => '당월 미수금', + 'amount' => (int) $currentMonthReceivables, + 'sub_items' => [ + ['label' => '매출', 'value' => (int) $currentMonthSales], + ['label' => '입금', 'value' => (int) $currentMonthDeposits], + ], + ], + [ + 'id' => 'rv_vendors', + 'label' => '미수금 거래처', + 'amount' => $vendorCount, + 'unit' => '건', + 'subLabel' => "연체 {$overdueVendorCount}건" . ($badDebtCount > 0 ? " · 악성채권 {$badDebtCount}건" : ''), + ], + [ + 'id' => 'rv_top3', + 'label' => '미수금 Top 3', + 'amount' => ! empty($topVendors) ? (int) $topVendors[0]['amount'] : 0, + 'top_items' => $topVendors, + ], ]; + + // 체크포인트 생성 + $checkPoints = $this->generateSummaryCheckPoints( + $tenantId, + $totalReceivables, + $overdueVendorCount, + $topVendors, + $vendorCount + ); + + return [ + 'cards' => $cards, + 'check_points' => $checkPoints, + ]; + } + + /** + * 악성채권 건수 조회 + */ + private function getBadDebtCount(int $tenantId): int + { + // bad_debts 테이블이 존재하면 사용, 없으면 0 + try { + return \DB::table('bad_debts') + ->where('tenant_id', $tenantId) + ->whereIn('status', ['collecting', 'legal_action']) + ->whereNull('deleted_at') + ->count(); + } catch (\Exception $e) { + return 0; + } + } + + /** + * 미수금 Top N 거래처 조회 + */ + private function getTopReceivableVendors(int $tenantId, int $limit = 3): array + { + $salesSub = \DB::table('sales') + ->select('client_id', \DB::raw('SUM(total_amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->groupBy('client_id'); + + $depositsSub = \DB::table('deposits') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->groupBy('client_id'); + + $billsSub = \DB::table('bills') + ->select('client_id', \DB::raw('SUM(amount) as total')) + ->where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('bill_type', 'received') + ->groupBy('client_id'); + + $results = \DB::table('clients as c') + ->leftJoinSub($salesSub, 's', 'c.id', '=', 's.client_id') + ->leftJoinSub($depositsSub, 'd', 'c.id', '=', 'd.client_id') + ->leftJoinSub($billsSub, 'b', 'c.id', '=', 'b.client_id') + ->select( + 'c.name', + \DB::raw('(COALESCE(s.total, 0) - COALESCE(d.total, 0) - COALESCE(b.total, 0)) as receivable') + ) + ->where('c.tenant_id', $tenantId) + ->where('c.is_active', true) + ->having('receivable', '>', 0) + ->orderByDesc('receivable') + ->limit($limit) + ->get(); + + return $results->map(fn ($v) => [ + 'name' => $v->name, + 'amount' => (int) $v->receivable, + ])->toArray(); + } + + /** + * 대시보드 요약 체크포인트 생성 + */ + private function generateSummaryCheckPoints( + int $tenantId, + float $totalReceivables, + int $overdueVendorCount, + array $topVendors, + int $vendorCount + ): array { + $checkPoints = []; + + // 연체 거래처 경고 + if ($overdueVendorCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_overdue', + 'type' => 'warning', + 'message' => "연체 거래처 {$overdueVendorCount}곳. 회수 조치가 필요합니다.", + 'highlights' => [ + ['text' => "연체 거래처 {$overdueVendorCount}곳", 'color' => 'red'], + ], + ]; + } + + // 90일 이상 장기 미수금 체크 + $longTermCount = $this->getLongTermReceivableCount($tenantId, 90); + if ($longTermCount > 0) { + $checkPoints[] = [ + 'id' => 'rv_cp_longterm', + 'type' => 'error', + 'message' => "90일 이상 장기 미수금 {$longTermCount}건 감지. 악성채권 전환 위험이 있습니다.", + 'highlights' => [ + ['text' => "90일 이상 장기 미수금 {$longTermCount}건", 'color' => 'red'], + ], + ]; + } + + // Top1 거래처 집중도 경고 + if (! empty($topVendors) && $totalReceivables > 0) { + $top1Ratio = round(($topVendors[0]['amount'] / $totalReceivables) * 100); + if ($top1Ratio >= 50) { + $checkPoints[] = [ + 'id' => 'rv_cp_concentration', + 'type' => 'warning', + 'message' => "{$topVendors[0]['name']} 미수금이 전체의 {$top1Ratio}%를 차지합니다. 리스크 분산이 필요합니다.", + 'highlights' => [ + ['text' => "{$topVendors[0]['name']}", 'color' => 'orange'], + ['text' => "전체의 {$top1Ratio}%", 'color' => 'orange'], + ], + ]; + } + } + + // 정상 상태 메시지 + if (empty($checkPoints)) { + $totalFormatted = number_format($totalReceivables / 10000); + $checkPoints[] = [ + 'id' => 'rv_cp_normal', + 'type' => 'success', + 'message' => "총 미수금 {$totalFormatted}만원. 정상적으로 관리되고 있습니다.", + 'highlights' => [ + ['text' => "{$totalFormatted}만원", 'color' => 'green'], + ], + ]; + } + + return $checkPoints; + } + + /** + * N일 이상 장기 미수금 거래처 수 조회 + */ + private function getLongTermReceivableCount(int $tenantId, int $days): int + { + $cutoffDate = Carbon::now()->subDays($days)->format('Y-m-d'); + + // 연체 상태이면서 오래된 매출이 있는 거래처 수 + $clientIds = Sale::where('tenant_id', $tenantId) + ->whereNotNull('client_id') + ->where('sale_date', '<=', $cutoffDate) + ->distinct() + ->pluck('client_id'); + + return Client::where('tenant_id', $tenantId) + ->where('is_active', true) + ->where('is_overdue', true) + ->whereIn('id', $clientIds) + ->count(); } /** diff --git a/app/Services/WelfareService.php b/app/Services/WelfareService.php index c17aa548..7cad5122 100644 --- a/app/Services/WelfareService.php +++ b/app/Services/WelfareService.php @@ -6,9 +6,10 @@ use Illuminate\Support\Facades\DB; /** - * 복리후생비 현황 서비스 + * 복리후생비 현황 서비스 (D1.7 리스크 감지형) * - * CEO 대시보드용 복리후생비 데이터를 제공합니다. + * CEO 대시보드용 복리후생비 리스크 데이터를 제공합니다. + * 카드 4개: 비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과 */ class WelfareService extends Service { @@ -20,15 +21,22 @@ class WelfareService extends Service private const INDUSTRY_AVG_MAX = 250000; + // 특정인 편중 기준 (전체 대비 5% 초과) + private const CONCENTRATION_THRESHOLD = 0.05; + + // 항목별 1인당 월 기준 금액 + private const SUB_TYPE_LIMITS = [ + 'meal' => 200000, // 식대 20만원 + 'transportation' => 100000, // 교통비 10만원 + 'congratulation' => 50000, // 경조사 5만원 + 'health_check' => 30000, // 건강검진 3만원 + 'education' => 80000, // 교육비 8만원 + 'welfare_point' => 100000, // 복지포인트 10만원 + ]; + /** - * 복리후생비 현황 요약 조회 + * 복리후생비 리스크 현황 요약 조회 (D1.7) * - * @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly) - * @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, 기본: 현재 분기) * @return array{cards: array, check_points: array} */ public function getSummary( @@ -42,79 +50,68 @@ public function getSummary( $tenantId = $this->tenantId(); $now = Carbon::now(); - // 기본값 설정 $year = $year ?? $now->year; - $limitType = $limitType ?? 'quarterly'; - $calculationType = $calculationType ?? 'fixed'; - $fixedAmountPerMonth = $fixedAmountPerMonth ?? 200000; - $ratio = $ratio ?? 0.05; $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'); - $periodLabel = "{$year}년"; $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'); - $periodLabel = "{$quarter}사분기"; $monthCount = 3; } // 직원 수 조회 $employeeCount = $this->getEmployeeCount($tenantId); - // 한도 계산 - if ($calculationType === 'fixed') { - $annualLimit = $fixedAmountPerMonth * 12 * $employeeCount; - } else { - // 급여 총액 기반 비율 계산 - $totalSalary = $this->getTotalSalary($tenantId, $year); - $annualLimit = $totalSalary * $ratio; - } - - $periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4); - - // 복리후생비 사용액 조회 - $usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate); - - // 잔여 한도 - $remainingLimit = max(0, $periodLimit - $usedAmount); + // 리스크 감지 쿼리 + $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_annual_limit', - 'label' => '당해년도 복리후생비 한도', - 'amount' => (int) $annualLimit, + 'id' => 'wf_tax_excess', + 'label' => '비과세 한도 초과', + 'amount' => (int) $taxFreeExcess['total'], + 'subLabel' => "{$taxFreeExcess['count']}건", ], [ - 'id' => 'wf_period_limit', - 'label' => "{{$periodLabel}} 복리후생비 총 한도", - 'amount' => (int) $periodLimit, + 'id' => 'wf_private_use', + 'label' => '사적 사용 의심', + 'amount' => (int) $privateUse['total'], + 'subLabel' => "{$privateUse['count']}건", ], [ - 'id' => 'wf_remaining', - 'label' => "{{$periodLabel}} 복리후생비 잔여한도", - 'amount' => (int) $remainingLimit, + 'id' => 'wf_concentration', + 'label' => '특정인 편중', + 'amount' => (int) $concentration['total'], + 'subLabel' => "{$concentration['count']}건", ], [ - 'id' => 'wf_used', - 'label' => "{{$periodLabel}} 복리후생비 사용금액", - 'amount' => (int) $usedAmount, + 'id' => 'wf_category_excess', + 'label' => '항목별 한도 초과', + 'amount' => (int) $categoryExcess['total'], + 'subLabel' => "{$categoryExcess['count']}건", ], ]; // 체크포인트 생성 - $checkPoints = $this->generateCheckPoints( + $checkPoints = $this->generateRiskCheckPoints( $tenantId, $employeeCount, - $usedAmount, $monthCount, $startDate, - $endDate + $endDate, + $taxFreeExcess, + $privateUse, + $concentration, + $categoryExcess ); return [ @@ -123,6 +120,259 @@ public function getSummary( ]; } + /** + * 비과세 한도 초과 리스크 조회 + * 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_no') + ->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)') + ->where(function ($q) { + $q->whereRaw('HOUR(bct.use_time) >= 22') + ->orWhereRaw('HOUR(bct.use_time) < 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; + } + /** * 직원 수 조회 (급여 대상 직원 기준) * @@ -506,73 +756,4 @@ private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLi return $result; } - /** - * 체크포인트 생성 - */ - private function generateCheckPoints( - int $tenantId, - int $employeeCount, - float $usedAmount, - int $monthCount, - string $startDate, - string $endDate - ): array { - $checkPoints = []; - - // 1인당 월 복리후생비 계산 - $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_normal', - 'type' => 'success', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'green'], - ], - ]; - } elseif ($perPersonMonthly < self::INDUSTRY_AVG_MIN) { - $checkPoints[] = [ - 'id' => 'wf_cp_low', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 낮습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } else { - $checkPoints[] = [ - 'id' => 'wf_cp_high', - 'type' => 'warning', - 'message' => "1인당 월 복리후생비 {$perPersonFormatted}만원. 업계 평균(15~25만원) 대비 높습니다.", - 'highlights' => [ - ['text' => "1인당 월 복리후생비 {$perPersonFormatted}만원", 'color' => 'orange'], - ], - ]; - } - - // 식대 비과세 한도 체크 - $mealAmount = $this->getMonthlyMealAmount($tenantId, $startDate, $endDate); - $perPersonMeal = $employeeCount > 0 ? $mealAmount / $employeeCount : 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'], - ], - ]; - } - - return $checkPoints; - } }