feat: 가지급금 관리 API 구현

- loans 테이블 마이그레이션 추가
- Loan 모델 (인정이자 계산, 세금 계산 로직)
- LoanService (CRUD, 정산, 인정이자 계산/리포트)
- LoanController, FormRequest 5개
- 9개 API 라우트 등록
- i18n 키 추가 (validation)
This commit is contained in:
2025-12-18 14:27:10 +09:00
parent 8b30a555d2
commit af833194ea
11 changed files with 1065 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Loan\LoanCalculateInterestRequest;
use App\Http\Requests\Loan\LoanIndexRequest;
use App\Http\Requests\Loan\LoanSettleRequest;
use App\Http\Requests\Loan\LoanStoreRequest;
use App\Http\Requests\Loan\LoanUpdateRequest;
use App\Http\Response\ApiResponse;
use App\Services\LoanService;
use Illuminate\Http\JsonResponse;
class LoanController extends Controller
{
public function __construct(
private readonly LoanService $loanService
) {}
/**
* 가지급금 목록
*/
public function index(LoanIndexRequest $request): JsonResponse
{
$result = $this->loanService->index($request->validated());
return ApiResponse::handle('message.fetched', $result);
}
/**
* 가지급금 요약
*/
public function summary(LoanIndexRequest $request): JsonResponse
{
$userId = $request->validated()['user_id'] ?? null;
$result = $this->loanService->summary($userId);
return ApiResponse::handle('message.fetched', $result);
}
/**
* 가지급금 등록
*/
public function store(LoanStoreRequest $request): JsonResponse
{
$result = $this->loanService->store($request->validated());
return ApiResponse::handle('message.created', $result, 201);
}
/**
* 가지급금 상세
*/
public function show(int $id): JsonResponse
{
$result = $this->loanService->show($id);
return ApiResponse::handle('message.fetched', $result);
}
/**
* 가지급금 수정
*/
public function update(LoanUpdateRequest $request, int $id): JsonResponse
{
$result = $this->loanService->update($id, $request->validated());
return ApiResponse::handle('message.updated', $result);
}
/**
* 가지급금 삭제
*/
public function destroy(int $id): JsonResponse
{
$this->loanService->destroy($id);
return ApiResponse::handle('message.deleted');
}
/**
* 가지급금 정산
*/
public function settle(LoanSettleRequest $request, int $id): JsonResponse
{
$result = $this->loanService->settle($id, $request->validated());
return ApiResponse::handle('message.loan.settled', $result);
}
/**
* 인정이자 계산
*/
public function calculateInterest(LoanCalculateInterestRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->loanService->calculateInterest(
$validated['year'],
$validated['user_id'] ?? null
);
return ApiResponse::handle('message.fetched', $result);
}
/**
* 인정이자 리포트
*/
public function interestReport(int $year): JsonResponse
{
$result = $this->loanService->interestReport($year);
return ApiResponse::handle('message.fetched', $result);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Loan;
use Illuminate\Foundation\Http\FormRequest;
class LoanCalculateInterestRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
];
}
/**
* Get the validation attribute names.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'year' => __('validation.attributes.year'),
'user_id' => __('validation.attributes.user_id'),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanIndexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'search' => ['nullable', 'string', 'max:100'],
'sort_by' => ['nullable', 'string', Rule::in(['loan_date', 'amount', 'status', 'created_at'])],
'sort_dir' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
/**
* Get the validation attribute names.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'user_id' => __('validation.attributes.user_id'),
'status' => __('validation.attributes.status'),
'start_date' => __('validation.attributes.start_date'),
'end_date' => __('validation.attributes.end_date'),
'search' => __('validation.attributes.search'),
'sort_by' => __('validation.attributes.sort_by'),
'sort_dir' => __('validation.attributes.sort_dir'),
'per_page' => __('validation.attributes.per_page'),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Loan;
use Illuminate\Foundation\Http\FormRequest;
class LoanSettleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'settlement_date' => ['required', 'date', 'date_format:Y-m-d'],
'settlement_amount' => ['required', 'numeric', 'min:0.01', 'max:999999999999.99'],
];
}
/**
* Get the validation attribute names.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'settlement_date' => __('validation.attributes.settlement_date'),
'settlement_amount' => __('validation.attributes.settlement_amount'),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Loan;
use Illuminate\Foundation\Http\FormRequest;
class LoanStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
];
}
/**
* Get the validation attribute names.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'user_id' => __('validation.attributes.user_id'),
'loan_date' => __('validation.attributes.loan_date'),
'amount' => __('validation.attributes.amount'),
'purpose' => __('validation.attributes.purpose'),
'withdrawal_id' => __('validation.attributes.withdrawal_id'),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests\Loan;
use Illuminate\Foundation\Http\FormRequest;
class LoanUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['sometimes', 'integer', 'exists:users,id'],
'loan_date' => ['sometimes', 'date', 'date_format:Y-m-d'],
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
];
}
/**
* Get the validation attribute names.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'user_id' => __('validation.attributes.user_id'),
'loan_date' => __('validation.attributes.loan_date'),
'amount' => __('validation.attributes.amount'),
'purpose' => __('validation.attributes.purpose'),
'withdrawal_id' => __('validation.attributes.withdrawal_id'),
];
}
}

239
app/Models/Tenants/Loan.php Normal file
View File

@@ -0,0 +1,239 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Loan extends Model
{
use BelongsToTenant, HasFactory, ModelTrait, SoftDeletes;
// =========================================================================
// 상수 정의
// =========================================================================
/**
* 상태 상수
*/
public const STATUS_OUTSTANDING = 'outstanding'; // 미정산
public const STATUS_SETTLED = 'settled'; // 정산완료
public const STATUS_PARTIAL = 'partial'; // 부분정산
/**
* 상태 목록
*/
public const STATUSES = [
self::STATUS_OUTSTANDING,
self::STATUS_SETTLED,
self::STATUS_PARTIAL,
];
/**
* 인정이자율 (연도별)
*/
public const INTEREST_RATES = [
2024 => 4.6,
2025 => 4.6,
];
/**
* 기본 인정이자율 (연도 미설정시)
*/
public const DEFAULT_INTEREST_RATE = 4.6;
/**
* 세금 요율
*/
public const CORPORATE_TAX_RATE = 0.19; // 법인세 추가 19%
public const INCOME_TAX_RATE = 0.35; // 소득세 추가 35%
public const LOCAL_TAX_RATE = 0.10; // 지방소득세 10%
// =========================================================================
// 모델 설정
// =========================================================================
protected $fillable = [
'tenant_id',
'user_id',
'loan_date',
'amount',
'purpose',
'settlement_date',
'settlement_amount',
'status',
'withdrawal_id',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'loan_date' => 'date',
'settlement_date' => 'date',
'amount' => 'decimal:2',
'settlement_amount' => 'decimal:2',
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 가지급금 수령자
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 출금 내역
*/
public function withdrawal(): BelongsTo
{
return $this->belongsTo(Withdrawal::class);
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 접근자 (Accessors)
// =========================================================================
/**
* 상태 레이블
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_OUTSTANDING => '미정산',
self::STATUS_SETTLED => '정산완료',
self::STATUS_PARTIAL => '부분정산',
default => $this->status,
};
}
/**
* 미정산 잔액
*/
public function getOutstandingAmountAttribute(): float
{
$settlementAmount = (float) ($this->settlement_amount ?? 0);
return (float) $this->amount - $settlementAmount;
}
/**
* 경과일수 계산
*/
public function getElapsedDaysAttribute(): int
{
if ($this->settlement_date) {
return $this->loan_date->diffInDays($this->settlement_date);
}
return $this->loan_date->diffInDays(now());
}
// =========================================================================
// 상태 체크 메서드
// =========================================================================
/**
* 수정 가능 여부 (미정산 상태만)
*/
public function isEditable(): bool
{
return $this->status === self::STATUS_OUTSTANDING;
}
/**
* 삭제 가능 여부 (미정산 상태만)
*/
public function isDeletable(): bool
{
return $this->status === self::STATUS_OUTSTANDING;
}
/**
* 정산 가능 여부
*/
public function isSettleable(): bool
{
return in_array($this->status, [self::STATUS_OUTSTANDING, self::STATUS_PARTIAL]);
}
// =========================================================================
// 인정이자 계산 메서드
// =========================================================================
/**
* 연도별 인정이자율 조회
*/
public static function getInterestRate(int $year): float
{
return self::INTEREST_RATES[$year] ?? self::DEFAULT_INTEREST_RATE;
}
/**
* 인정이자 계산
*
* @param int|null $elapsedDays 경과일수 (미지정시 자동 계산)
* @param int|null $year 연도 (미지정시 지급일 연도)
*/
public function calculateRecognizedInterest(?int $elapsedDays = null, ?int $year = null): float
{
$days = $elapsedDays ?? $this->elapsed_days;
$rateYear = $year ?? $this->loan_date->year;
$annualRate = self::getInterestRate($rateYear);
$dailyRate = $annualRate / 365 / 100;
return (float) $this->outstanding_amount * $dailyRate * $days;
}
/**
* 세금 계산 (인정이자 기반)
*
* @param float|null $recognizedInterest 인정이자 (미지정시 자동 계산)
*/
public function calculateTaxes(?float $recognizedInterest = null): array
{
$interest = $recognizedInterest ?? $this->calculateRecognizedInterest();
$corporateTax = $interest * self::CORPORATE_TAX_RATE;
$incomeTax = $interest * self::INCOME_TAX_RATE;
$localTax = $incomeTax * self::LOCAL_TAX_RATE;
$totalTax = $corporateTax + $incomeTax + $localTax;
return [
'recognized_interest' => round($interest, 2),
'corporate_tax' => round($corporateTax, 2),
'income_tax' => round($incomeTax, 2),
'local_tax' => round($localTax, 2),
'total_tax' => round($totalTax, 2),
];
}
}

View File

@@ -0,0 +1,422 @@
<?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['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): array
{
$tenantId = $this->tenantId();
$query = Loan::query()
->where('tenant_id', $tenantId);
if ($userId) {
$query->where('user_id', $userId);
}
$stats = $query->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
', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL])
->first();
return [
'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,
];
}
// =========================================================================
// 가지급금 생성/수정/삭제
// =========================================================================
/**
* 가지급금 생성
*/
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;
}
return Loan::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'loan_date' => $data['loan_date'],
'amount' => $data['amount'],
'purpose' => $data['purpose'] ?? null,
'status' => Loan::STATUS_OUTSTANDING,
'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'));
}
}
$loan->fill([
'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,
]);
$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,
];
}
/**
* 인정이자 리포트 (연도별 요약)
*/
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,
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('loans', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('user_id')->comment('가지급금 수령자');
$table->date('loan_date')->comment('지급일');
$table->decimal('amount', 15, 2)->comment('가지급금액');
$table->text('purpose')->nullable()->comment('사용목적');
$table->date('settlement_date')->nullable()->comment('정산일');
$table->decimal('settlement_amount', 15, 2)->nullable()->comment('정산금액');
$table->string('status', 20)->default('outstanding')->comment('상태: outstanding/settled/partial');
$table->unsignedBigInteger('withdrawal_id')->nullable()->comment('출금 연결');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
$table->softDeletes();
$table->timestamps();
$table->index(['tenant_id', 'user_id'], 'idx_tenant_user');
$table->index('status', 'idx_status');
$table->index(['tenant_id', 'loan_date'], 'idx_tenant_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('loans');
}
};

View File

@@ -213,6 +213,14 @@
'auto_calculate' => '자동계산 여부',
'allowance_types' => '수당 유형',
'deduction_types' => '공제 유형',
// 가지급금 관련
'loan_date' => '지급일',
'amount' => '금액',
'purpose' => '사용목적',
'settlement_date' => '정산일',
'settlement_amount' => '정산금액',
'year' => '연도',
],
];

View File

@@ -398,6 +398,7 @@
Route::post('', [LoanController::class, 'store'])->name('v1.loans.store');
Route::get('/summary', [LoanController::class, 'summary'])->name('v1.loans.summary');
Route::post('/calculate-interest', [LoanController::class, 'calculateInterest'])->name('v1.loans.calculate-interest');
Route::get('/interest-report/{year}', [LoanController::class, 'interestReport'])->whereNumber('year')->name('v1.loans.interest-report');
Route::get('/{id}', [LoanController::class, 'show'])->whereNumber('id')->name('v1.loans.show');
Route::put('/{id}', [LoanController::class, 'update'])->whereNumber('id')->name('v1.loans.update');
Route::delete('/{id}', [LoanController::class, 'destroy'])->whereNumber('id')->name('v1.loans.destroy');