tenantId(); $query = Loan::query() ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); // 카테고리 필터 if (! empty($params['category'])) { $query->where('category', $params['category']); } // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 날짜 범위 필터 if (! empty($params['start_date'])) { $query->where('loan_date', '>=', $params['start_date']); } if (! empty($params['end_date'])) { $query->where('loan_date', '<=', $params['end_date']); } // 검색 (사용자명, 목적) if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->whereHas('user', function ($userQ) use ($search) { $userQ->where('name', 'like', "%{$search}%"); })->orWhere('purpose', 'like', "%{$search}%"); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'loan_date'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 가지급금 상세 */ public function show(int $id): Loan { $tenantId = $this->tenantId(); return Loan::query() ->where('tenant_id', $tenantId) ->with([ 'user:id,name,email', 'withdrawal', 'creator:id,name', 'updater:id,name', ]) ->findOrFail($id); } /** * 가지급금 요약 (특정 사용자 또는 전체) */ public function summary(?int $userId = null, ?string $category = null): array { $tenantId = $this->tenantId(); $query = Loan::query() ->where('tenant_id', $tenantId); if ($userId) { $query->where('user_id', $userId); } if ($category) { $query->where('category', $category); } // 상품권 카테고리: holding/used/disposed 상태별 집계 추가 $isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE; $selectRaw = ' COUNT(*) as total_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as partial_count, SUM(amount) as total_amount, SUM(COALESCE(settlement_amount, 0)) as total_settled, SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding '; $bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL]; if ($isGiftCertificate) { $selectRaw .= ', SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count, SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count, SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count '; $bindings = array_merge($bindings, [ Loan::STATUS_HOLDING, Loan::STATUS_HOLDING, Loan::STATUS_USED, Loan::STATUS_USED, Loan::STATUS_DISPOSED, ]); } $stats = $query->selectRaw($selectRaw, $bindings)->first(); $result = [ 'total_count' => (int) $stats->total_count, 'outstanding_count' => (int) $stats->outstanding_count, 'settled_count' => (int) $stats->settled_count, 'partial_count' => (int) $stats->partial_count, 'total_amount' => (float) $stats->total_amount, 'total_settled' => (float) $stats->total_settled, 'total_outstanding' => (float) $stats->total_outstanding, ]; if ($isGiftCertificate) { $result['holding_count'] = (int) $stats->holding_count; $result['holding_amount'] = (float) $stats->holding_amount; $result['used_count'] = (int) $stats->used_count; $result['used_amount'] = (float) $stats->used_amount; $result['disposed_count'] = (int) $stats->disposed_count; } return $result; } // ========================================================================= // 가지급금 생성/수정/삭제 // ========================================================================= /** * 가지급금 생성 */ public function store(array $data): Loan { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 출금 내역 연결 검증 $withdrawalId = null; if (! empty($data['withdrawal_id'])) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) ->where('id', $data['withdrawal_id']) ->first(); if (! $withdrawal) { throw new BadRequestHttpException(__('error.loan.invalid_withdrawal')); } $withdrawalId = $withdrawal->id; } // 상품권: user_id 미지정 시 현재 사용자로 대체 $loanUserId = $data['user_id'] ?? $userId; // 상태 결정: 상품권은 holding, 그 외는 outstanding $category = $data['category'] ?? null; $status = $data['status'] ?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING); return Loan::create([ 'tenant_id' => $tenantId, 'user_id' => $loanUserId, 'loan_date' => $data['loan_date'], 'amount' => $data['amount'], 'purpose' => $data['purpose'] ?? null, 'status' => $status, 'category' => $category, 'metadata' => $data['metadata'] ?? null, 'withdrawal_id' => $withdrawalId, 'created_by' => $userId, 'updated_by' => $userId, ]); }); } /** * 가지급금 수정 */ public function update(int $id, array $data): Loan { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $loan = Loan::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $loan->isEditable()) { throw new BadRequestHttpException(__('error.loan.not_editable')); } // 출금 내역 연결 검증 if (isset($data['withdrawal_id']) && $data['withdrawal_id']) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) ->where('id', $data['withdrawal_id']) ->first(); if (! $withdrawal) { throw new BadRequestHttpException(__('error.loan.invalid_withdrawal')); } } $fillData = [ 'user_id' => $data['user_id'] ?? $loan->user_id, 'loan_date' => $data['loan_date'] ?? $loan->loan_date, 'amount' => $data['amount'] ?? $loan->amount, 'purpose' => $data['purpose'] ?? $loan->purpose, 'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id, 'updated_by' => $userId, ]; if (isset($data['category'])) { $fillData['category'] = $data['category']; } if (array_key_exists('metadata', $data)) { $fillData['metadata'] = $data['metadata']; } if (isset($data['status'])) { $fillData['status'] = $data['status']; } if (array_key_exists('settlement_date', $data)) { $fillData['settlement_date'] = $data['settlement_date']; } $loan->fill($fillData); $loan->save(); return $loan->fresh(['user:id,name,email', 'creator:id,name']); } /** * 가지급금 삭제 */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $loan = Loan::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $loan->isDeletable()) { throw new BadRequestHttpException(__('error.loan.not_deletable')); } $loan->deleted_by = $userId; $loan->save(); $loan->delete(); return true; } // ========================================================================= // 정산 처리 // ========================================================================= /** * 가지급금 정산 */ public function settle(int $id, array $data): Loan { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $loan = Loan::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $loan->isSettleable()) { throw new BadRequestHttpException(__('error.loan.not_settleable')); } $settlementAmount = (float) $data['settlement_amount']; $currentSettled = (float) ($loan->settlement_amount ?? 0); $totalSettled = $currentSettled + $settlementAmount; $loanAmount = (float) $loan->amount; // 정산 금액이 가지급금액을 초과하는지 확인 if ($totalSettled > $loanAmount) { throw new BadRequestHttpException(__('error.loan.settlement_exceeds')); } // 상태 결정 $status = Loan::STATUS_PARTIAL; if (abs($totalSettled - $loanAmount) < 0.01) { // 부동소수점 비교 $status = Loan::STATUS_SETTLED; } $loan->settlement_date = $data['settlement_date']; $loan->settlement_amount = $totalSettled; $loan->status = $status; $loan->updated_by = $userId; $loan->save(); return $loan->fresh(['user:id,name,email']); }); } // ========================================================================= // 인정이자 계산 // ========================================================================= /** * 인정이자 일괄 계산 * * @param int $year 계산 연도 * @param int|null $userId 특정 사용자 (미지정시 전체) */ public function calculateInterest(int $year, ?int $userId = null): array { $tenantId = $this->tenantId(); $query = Loan::query() ->where('tenant_id', $tenantId) ->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]); if ($userId) { $query->where('user_id', $userId); } $loans = $query->with('user:id,name,email')->get(); $interestRate = Loan::getInterestRate($year); $baseDate = now()->endOfYear()->year === $year ? now() : now()->setYear($year)->endOfYear(); $results = []; $totalBalance = 0; $totalInterest = 0; $totalCorporateTax = 0; $totalIncomeTax = 0; $totalLocalTax = 0; foreach ($loans as $loan) { // 연도 내 경과일수 계산 $startOfYear = now()->setYear($year)->startOfYear(); $effectiveStartDate = $loan->loan_date->greaterThan($startOfYear) ? $loan->loan_date : $startOfYear; $elapsedDays = $effectiveStartDate->diffInDays($baseDate); $balance = $loan->outstanding_amount; $interest = $loan->calculateRecognizedInterest($elapsedDays, $year); $taxes = $loan->calculateTaxes($interest); $results[] = [ 'loan_id' => $loan->id, 'user' => [ 'id' => $loan->user->id, 'name' => $loan->user->name, 'email' => $loan->user->email, ], 'loan_date' => $loan->loan_date->toDateString(), 'amount' => (float) $loan->amount, 'settlement_amount' => (float) ($loan->settlement_amount ?? 0), 'outstanding_amount' => $balance, 'elapsed_days' => $elapsedDays, 'interest_rate' => $interestRate, 'recognized_interest' => $taxes['recognized_interest'], 'corporate_tax' => $taxes['corporate_tax'], 'income_tax' => $taxes['income_tax'], 'local_tax' => $taxes['local_tax'], 'total_tax' => $taxes['total_tax'], ]; $totalBalance += $balance; $totalInterest += $taxes['recognized_interest']; $totalCorporateTax += $taxes['corporate_tax']; $totalIncomeTax += $taxes['income_tax']; $totalLocalTax += $taxes['local_tax']; } return [ 'year' => $year, 'interest_rate' => $interestRate, 'base_date' => $baseDate->toDateString(), 'summary' => [ 'total_balance' => round($totalBalance, 2), 'total_recognized_interest' => round($totalInterest, 2), 'total_corporate_tax' => round($totalCorporateTax, 2), 'total_income_tax' => round($totalIncomeTax, 2), 'total_local_tax' => round($totalLocalTax, 2), 'total_tax' => round($totalCorporateTax + $totalIncomeTax + $totalLocalTax, 2), ], 'details' => $results, ]; } /** * 가지급금 대시보드 데이터 * * CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공 * D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류) * * @return array{ * summary: array{ * total_outstanding: float, * recognized_interest: float, * outstanding_count: int * }, * category_breakdown: array, * loans: array * } */ public function dashboard(?string $startDate = null, ?string $endDate = null): array { $tenantId = $this->tenantId(); $currentYear = now()->year; // 날짜 필터 조건 클로저 $applyDateFilter = function ($query) use ($startDate, $endDate) { if ($startDate) { $query->where('loan_date', '>=', $startDate); } if ($endDate) { $query->where('loan_date', '<=', $endDate); } return $query; }; // 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. 카테고리별 집계 (날짜 필터 적용) $categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate); // 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용) $loansQuery = Loan::query() ->where('tenant_id', $tenantId) ->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(50) ->get() ->map(function ($loan) { return [ 'id' => $loan->id, 'loan_date' => $loan->loan_date->format('Y-m-d'), 'user_name' => $loan->user?->name ?? '미지정', 'category' => $loan->category_label, 'amount' => (float) $loan->amount, 'status' => $loan->status, 'content' => $loan->purpose ?? '', ]; }) ->toArray(); return [ 'summary' => [ 'total_outstanding' => (float) ($stats->total_outstanding ?? 0), 'recognized_interest' => (float) $recognizedInterest, 'outstanding_count' => (int) ($stats->outstanding_count ?? 0), ], 'category_breakdown' => $categoryBreakdown, 'loans' => $loans, ]; } /** * 카테고리별 가지급금 집계 * * @return array */ private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array { // 기본값: 4개 카테고리 모두 0으로 초기화 $breakdown = []; foreach (Loan::CATEGORIES as $category) { $breakdown[$category] = [ 'outstanding_amount' => 0.0, 'total_count' => 0, 'unverified_count' => 0, ]; } // 카테고리별 집계 (summary와 동일하게 전체 대상, 날짜 필터 적용) $query = Loan::query() ->where('tenant_id', $tenantId); if ($startDate) { $query->where('loan_date', '>=', $startDate); } if ($endDate) { $query->where('loan_date', '<=', $endDate); } // NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의 // getOutstandingAmountAttribute() accessor와 이름 충돌 방지 $stats = $query ->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding') ->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count') ->groupBy('category') ->get(); foreach ($stats as $stat) { $cat = $stat->category ?? Loan::CATEGORY_CARD; if (isset($breakdown[$cat])) { $breakdown[$cat] = [ 'outstanding_amount' => (float) $stat->cat_outstanding, 'total_count' => (int) $stat->total_count, 'unverified_count' => (int) $stat->unverified_count, ]; } } return $breakdown; } /** * 세금 시뮬레이션 데이터 * * CEO 대시보드 카드/가지급금 관리 섹션(cm2) 세금 비교 분석용 데이터 제공 * * @param int $year 시뮬레이션 연도 * @return array{ * year: int, * loan_summary: array{ * total_outstanding: float, * recognized_interest: float, * interest_rate: float * }, * corporate_tax: array{ * without_loan: array{taxable_income: float, tax_amount: float}, * with_loan: array{taxable_income: float, tax_amount: float}, * difference: float, * rate_info: string * }, * income_tax: array{ * without_loan: array{taxable_income: float, tax_rate: string, tax_amount: float}, * with_loan: array{taxable_income: float, tax_rate: string, tax_amount: float}, * difference: float, * breakdown: array{income_tax: float, local_tax: float, insurance: float} * } * } */ public function taxSimulation(int $year): array { // 1. 가지급금 요약 데이터 $summaryData = $this->summary(); $totalOutstanding = (float) $summaryData['total_outstanding']; // 2. 인정이자 계산 $interestData = $this->calculateInterest($year); $recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0; $interestRate = Loan::getInterestRate($year); // 3. 법인세 비교 계산 // - 가지급금이 없을 때: 인정이자가 비용으로 처리되지 않음 // - 가지급금이 있을 때: 인정이자만큼 추가 과세 $corporateTaxRate = Loan::CORPORATE_TAX_RATE; $corporateTaxWithout = [ 'taxable_income' => 0.0, 'tax_amount' => 0.0, ]; $corporateTaxWith = [ 'taxable_income' => $recognizedInterest, 'tax_amount' => round($recognizedInterest * $corporateTaxRate, 2), ]; $corporateTaxDifference = $corporateTaxWith['tax_amount'] - $corporateTaxWithout['tax_amount']; // 4. 소득세 비교 계산 (대표이사 상여처분 시) $incomeTaxRate = Loan::INCOME_TAX_RATE; $localTaxRate = Loan::LOCAL_TAX_RATE; $insuranceRate = 0.09; // 4대보험 약 9% $incomeTaxWithout = [ 'taxable_income' => 0.0, 'tax_rate' => '0%', 'tax_amount' => 0.0, ]; $incomeTaxAmount = round($recognizedInterest * $incomeTaxRate, 2); $localTaxAmount = round($incomeTaxAmount * $localTaxRate, 2); $insuranceAmount = round($recognizedInterest * $insuranceRate, 2); $incomeTaxWith = [ 'taxable_income' => $recognizedInterest, 'tax_rate' => ($incomeTaxRate * 100).'%', 'tax_amount' => $incomeTaxAmount + $localTaxAmount, ]; $incomeTaxDifference = $incomeTaxWith['tax_amount'] - $incomeTaxWithout['tax_amount']; return [ 'year' => $year, 'loan_summary' => [ 'total_outstanding' => $totalOutstanding, 'recognized_interest' => (float) $recognizedInterest, 'interest_rate' => $interestRate, ], 'corporate_tax' => [ 'without_loan' => $corporateTaxWithout, 'with_loan' => $corporateTaxWith, 'difference' => round($corporateTaxDifference, 2), 'rate_info' => '법인세 '.($corporateTaxRate * 100).'% 적용', ], 'income_tax' => [ 'without_loan' => $incomeTaxWithout, 'with_loan' => $incomeTaxWith, 'difference' => round($incomeTaxDifference, 2), 'breakdown' => [ 'income_tax' => $incomeTaxAmount, 'local_tax' => $localTaxAmount, 'insurance' => $insuranceAmount, ], ], ]; } /** * 인정이자 리포트 (연도별 요약) */ public function interestReport(int $year): array { $tenantId = $this->tenantId(); // 사용자별 가지급금 집계 $userLoans = Loan::query() ->where('tenant_id', $tenantId) ->whereYear('loan_date', '<=', $year) ->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL]) ->select('user_id') ->selectRaw('SUM(amount) as total_amount') ->selectRaw('SUM(COALESCE(settlement_amount, 0)) as total_settled') ->selectRaw('SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding') ->selectRaw('COUNT(*) as loan_count') ->groupBy('user_id') ->with('user:id,name,email') ->get(); $interestRate = Loan::getInterestRate($year); $results = []; foreach ($userLoans as $userLoan) { $userInterest = $this->calculateInterest($year, $userLoan->user_id); $results[] = [ 'user' => [ 'id' => $userLoan->user_id, 'name' => $userLoan->user?->name ?? 'Unknown', 'email' => $userLoan->user?->email ?? '', ], 'loan_count' => $userLoan->loan_count, 'total_amount' => (float) $userLoan->total_amount, 'total_settled' => (float) $userLoan->total_settled, 'total_outstanding' => (float) $userLoan->total_outstanding, 'recognized_interest' => $userInterest['summary']['total_recognized_interest'], 'total_tax' => $userInterest['summary']['total_tax'], ]; } // 전체 합계 $grandTotal = [ 'total_amount' => array_sum(array_column($results, 'total_amount')), 'total_outstanding' => array_sum(array_column($results, 'total_outstanding')), 'recognized_interest' => array_sum(array_column($results, 'recognized_interest')), 'total_tax' => array_sum(array_column($results, 'total_tax')), ]; return [ 'year' => $year, 'interest_rate' => $interestRate, 'users' => $results, 'grand_total' => $grandTotal, ]; } }