tenantId(); $recentYear = $params['recent_year'] ?? false; $year = $params['year'] ?? date('Y'); $search = $params['search'] ?? null; // 월 기간 생성 (동적 월 지원) $periods = $this->generateMonthPeriods($recentYear, $year); $monthLabels = array_map(fn ($p) => $p['label'], $periods); // 이월잔액 기준일 (첫번째 월의 시작일 전날) $carryForwardDate = Carbon::parse($periods[0]['start'])->subDay()->format('Y-m-d'); // 거래처 목록 조회 $clientsQuery = Client::where('tenant_id', $tenantId) ->where('is_active', true); if ($search) { $clientsQuery->where('name', 'like', "%{$search}%"); } $clients = $clientsQuery->orderBy('name')->get(); $result = []; foreach ($clients as $client) { // 이월잔액 계산 (기준일 이전까지의 누적 미수금) $carryForwardBalance = $this->getCarryForwardBalance($tenantId, $client->id, $carryForwardDate); // 월별 데이터 수집 (년-월 키 기반) $salesByPeriod = $this->getSalesByPeriods($tenantId, $client->id, $periods); $depositsByPeriod = $this->getDepositsByPeriods($tenantId, $client->id, $periods); $billsByPeriod = $this->getBillsByPeriods($tenantId, $client->id, $periods); // 누적 미수금 계산 $receivablesByPeriod = $this->calculateCumulativeReceivables( $carryForwardBalance, $salesByPeriod, $depositsByPeriod, $billsByPeriod, count($periods) ); // 카테고리별 데이터 생성 (배열 형태) $categories = [ [ 'category' => 'sales', 'amounts' => $this->formatPeriodAmounts($salesByPeriod, count($periods)), ], [ 'category' => 'deposit', 'amounts' => $this->formatPeriodAmounts($depositsByPeriod, count($periods)), ], [ 'category' => 'bill', 'amounts' => $this->formatPeriodAmounts($billsByPeriod, count($periods)), ], [ 'category' => 'receivable', 'amounts' => $this->formatReceivableAmounts($receivablesByPeriod), ], ]; // 연체 여부: 최종 미수금이 양수인 경우 $finalReceivable = end($receivablesByPeriod); $isOverdue = $client->is_overdue ?? ($finalReceivable > 0); $result[] = [ 'id' => (string) $client->id, 'vendor_id' => $client->id, 'vendor_name' => $client->name, 'is_overdue' => $isOverdue, 'memo' => $client->memo ?? '', 'carry_forward_balance' => $carryForwardBalance, 'month_labels' => $monthLabels, 'categories' => $categories, ]; } // 미수금이 있는 거래처만 필터링 (선택적) if (! empty($params['has_receivable'])) { $result = array_filter($result, function ($item) { $receivableCat = collect($item['categories'])->firstWhere('category', 'receivable'); return $receivableCat && $receivableCat['amounts']['total'] > 0; }); $result = array_values($result); } // 공통 월 레이블 추가 (프론트엔드에서 헤더로 사용) return [ 'month_labels' => $monthLabels, 'items' => $result, ]; } /** * 요약 통계 조회 (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'); // 월 기간 생성 $periods = $this->generateMonthPeriods($recentYear, $year); $startDate = $periods[0]['start']; $endDate = end($periods)['end']; // 이월잔액 기준일 $carryForwardDate = Carbon::parse($startDate)->subDay()->format('Y-m-d'); // 전체 이월잔액 (모든 거래처) $totalCarryForward = $this->getTotalCarryForwardBalance($tenantId, $carryForwardDate); // 기간 내 총 매출 $totalSales = (float) Sale::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('sale_date', [$startDate, $endDate]) ->sum('total_amount'); // 기간 내 총 입금 $totalDeposits = (float) Deposit::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereBetween('deposit_date', [$startDate, $endDate]) ->sum('amount'); // 기간 내 총 어음 $totalBills = (float) Bill::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('bill_type', 'received') ->whereBetween('issue_date', [$startDate, $endDate]) ->sum('amount'); // 총 미수금 (이월잔액 + 매출 - 입금 - 어음) $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(); // 악성채권 건수 $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') ->whereNull('deleted_at') ->groupBy('client_id'); $depositsSub = \DB::table('deposits') ->select('client_id', \DB::raw('SUM(amount) as total')) ->where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereNull('deleted_at') ->groupBy('client_id'); $billsSub = \DB::table('bills') ->select('client_id', \DB::raw('SUM(amount) as total')) ->where('tenant_id', $tenantId) ->whereNotNull('client_id') ->whereNull('deleted_at') ->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(); } /** * 월 기간 배열 생성 * * @return array [['start' => 'Y-m-d', 'end' => 'Y-m-d', 'label' => 'YY.MM', 'year' => Y, 'month' => M], ...] */ private function generateMonthPeriods(bool $recentYear, string $year): array { $periods = []; if ($recentYear) { // 최근 1년: 현재 월 기준으로 12개월 전부터 $current = Carbon::now()->startOfMonth(); $start = $current->copy()->subMonths(11); for ($i = 0; $i < 12; $i++) { $month = $start->copy()->addMonths($i); $periods[] = [ 'start' => $month->format('Y-m-01'), 'end' => $month->endOfMonth()->format('Y-m-d'), 'label' => $month->format('y.m'), 'year' => (int) $month->format('Y'), 'month' => (int) $month->format('n'), ]; } } else { // 특정 연도: 1월~12월 for ($month = 1; $month <= 12; $month++) { $date = Carbon::createFromDate($year, $month, 1); $periods[] = [ 'start' => $date->format('Y-m-01'), 'end' => $date->endOfMonth()->format('Y-m-d'), 'label' => "{$month}월", 'year' => (int) $year, 'month' => $month, ]; } } return $periods; } /** * 이월잔액 계산 (기준일 이전까지의 누적 미수금) */ private function getCarryForwardBalance(int $tenantId, int $clientId, string $beforeDate): float { // 기준일 이전 총 매출 $totalSales = Sale::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->where('sale_date', '<=', $beforeDate) ->sum('total_amount'); // 기준일 이전 총 입금 $totalDeposits = Deposit::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->where('deposit_date', '<=', $beforeDate) ->sum('amount'); // 기준일 이전 총 어음 $totalBills = Bill::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->where('bill_type', 'received') ->where('issue_date', '<=', $beforeDate) ->sum('amount'); return (float) ($totalSales - $totalDeposits - $totalBills); } /** * 전체 거래처 이월잔액 합계 */ private function getTotalCarryForwardBalance(int $tenantId, string $beforeDate): float { $totalSales = Sale::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('sale_date', '<=', $beforeDate) ->sum('total_amount'); $totalDeposits = Deposit::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('deposit_date', '<=', $beforeDate) ->sum('amount'); $totalBills = Bill::where('tenant_id', $tenantId) ->whereNotNull('client_id') ->where('bill_type', 'received') ->where('issue_date', '<=', $beforeDate) ->sum('amount'); return (float) ($totalSales - $totalDeposits - $totalBills); } /** * 기간별 매출 조회 */ private function getSalesByPeriods(int $tenantId, int $clientId, array $periods): array { $result = []; foreach ($periods as $index => $period) { $total = Sale::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->whereBetween('sale_date', [$period['start'], $period['end']]) ->sum('total_amount'); $result[$index] = (float) $total; } return $result; } /** * 기간별 입금 조회 */ private function getDepositsByPeriods(int $tenantId, int $clientId, array $periods): array { $result = []; foreach ($periods as $index => $period) { $total = Deposit::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->whereBetween('deposit_date', [$period['start'], $period['end']]) ->sum('amount'); $result[$index] = (float) $total; } return $result; } /** * 기간별 어음 조회 */ private function getBillsByPeriods(int $tenantId, int $clientId, array $periods): array { $result = []; foreach ($periods as $index => $period) { $total = Bill::where('tenant_id', $tenantId) ->where('client_id', $clientId) ->where('bill_type', 'received') ->whereBetween('issue_date', [$period['start'], $period['end']]) ->sum('amount'); $result[$index] = (float) $total; } return $result; } /** * 누적 미수금 계산 * 1월: 이월잔액 + 1월 매출 - 1월 입금 - 1월 어음 * 2월: 1월 미수금 + 2월 매출 - 2월 입금 - 2월 어음 * ... */ private function calculateCumulativeReceivables( float $carryForward, array $sales, array $deposits, array $bills, int $periodCount ): array { $result = []; $cumulative = $carryForward; for ($i = 0; $i < $periodCount; $i++) { $monthSales = $sales[$i] ?? 0; $monthDeposits = $deposits[$i] ?? 0; $monthBills = $bills[$i] ?? 0; $cumulative = $cumulative + $monthSales - $monthDeposits - $monthBills; $result[$i] = $cumulative; } return $result; } /** * 기간별 금액을 프론트엔드 형식으로 변환 (매출, 입금, 어음용) */ private function formatPeriodAmounts(array $periodData, int $periodCount): array { $amounts = []; $total = 0; for ($i = 0; $i < $periodCount; $i++) { $amount = $periodData[$i] ?? 0; $amounts[] = $amount; $total += $amount; } return [ 'values' => $amounts, 'total' => $total, ]; } /** * 미수금 금액을 프론트엔드 형식으로 변환 (누적이므로 total = 마지막 값) */ private function formatReceivableAmounts(array $receivables): array { $values = array_values($receivables); $total = ! empty($values) ? end($values) : 0; return [ 'values' => $values, 'total' => $total, ]; } /** * 연체 상태 일괄 업데이트 */ public function updateOverdueStatus(array $updates): int { $tenantId = $this->tenantId(); $updatedCount = 0; foreach ($updates as $update) { $clientId = (int) $update['id']; $isOverdue = (bool) $update['is_overdue']; $affected = Client::where('tenant_id', $tenantId) ->where('id', $clientId) ->update(['is_overdue' => $isOverdue]); $updatedCount += $affected; } return $updatedCount; } /** * 거래처 메모 업데이트 */ public function updateMemo(int $clientId, string $memo): bool { $tenantId = $this->tenantId(); $affected = Client::where('tenant_id', $tenantId) ->where('id', $clientId) ->update(['memo' => $memo]); return $affected > 0; } /** * 거래처 메모 일괄 업데이트 */ public function updateMemos(array $memos): int { $tenantId = $this->tenantId(); $updatedCount = 0; foreach ($memos as $item) { $clientId = (int) $item['id']; $memo = $item['memo'] ?? ''; $affected = Client::where('tenant_id', $tenantId) ->where('id', $clientId) ->update(['memo' => $memo]); $updatedCount += $affected; } return $updatedCount; } }