feat: 가지급금 관리 API 구현
- loans 테이블 마이그레이션 추가 - Loan 모델 (인정이자 계산, 세금 계산 로직) - LoanService (CRUD, 정산, 인정이자 계산/리포트) - LoanController, FormRequest 5개 - 9개 API 라우트 등록 - i18n 키 추가 (validation)
This commit is contained in:
115
app/Http/Controllers/Api/V1/LoanController.php
Normal file
115
app/Http/Controllers/Api/V1/LoanController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Loan/LoanCalculateInterestRequest.php
Normal file
42
app/Http/Requests/Loan/LoanCalculateInterestRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/Loan/LoanIndexRequest.php
Normal file
56
app/Http/Requests/Loan/LoanIndexRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Loan/LoanSettleRequest.php
Normal file
42
app/Http/Requests/Loan/LoanSettleRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/Loan/LoanStoreRequest.php
Normal file
48
app/Http/Requests/Loan/LoanStoreRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/Loan/LoanUpdateRequest.php
Normal file
48
app/Http/Requests/Loan/LoanUpdateRequest.php
Normal 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
239
app/Models/Tenants/Loan.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
422
app/Services/LoanService.php
Normal file
422
app/Services/LoanService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
44
database/migrations/2025_12_18_120001_create_loans_table.php
Normal file
44
database/migrations/2025_12_18_120001_create_loans_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -213,6 +213,14 @@
|
||||
'auto_calculate' => '자동계산 여부',
|
||||
'allowance_types' => '수당 유형',
|
||||
'deduction_types' => '공제 유형',
|
||||
|
||||
// 가지급금 관련
|
||||
'loan_date' => '지급일',
|
||||
'amount' => '금액',
|
||||
'purpose' => '사용목적',
|
||||
'settlement_date' => '정산일',
|
||||
'settlement_amount' => '정산금액',
|
||||
'year' => '연도',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user