Files
sam-api/app/Services/LoanService.php
유병철 8c9f2fcfb5 feat: [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리 지원
- Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등)
- Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계
- BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터
- BillInstallment: type/counterparty 필드 추가
- Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가
- LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest: V8 확장 필드 검증 규칙 추가
- 마이그레이션: bills V8 필드 + loans metadata 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:45:54 +09:00

731 lines
26 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\Loan;
use App\Models\Tenants\Withdrawal;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class LoanService extends Service
{
// =========================================================================
// 가지급금 목록/상세
// =========================================================================
/**
* 가지급금 목록
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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<string, array{
* outstanding_amount: float,
* total_count: int,
* unverified_count: int
* }>,
* 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<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
*/
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,
];
}
}