From af833194ea5a2fe0e4f7f9303b7e081fb86c325b Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 18 Dec 2025 14:27:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=80=EC=A7=80=EA=B8=89=EA=B8=88=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loans 테이블 마이그레이션 추가 - Loan 모델 (인정이자 계산, 세금 계산 로직) - LoanService (CRUD, 정산, 인정이자 계산/리포트) - LoanController, FormRequest 5개 - 9개 API 라우트 등록 - i18n 키 추가 (validation) --- .../Controllers/Api/V1/LoanController.php | 115 +++++ .../Loan/LoanCalculateInterestRequest.php | 42 ++ app/Http/Requests/Loan/LoanIndexRequest.php | 56 +++ app/Http/Requests/Loan/LoanSettleRequest.php | 42 ++ app/Http/Requests/Loan/LoanStoreRequest.php | 48 ++ app/Http/Requests/Loan/LoanUpdateRequest.php | 48 ++ app/Models/Tenants/Loan.php | 239 ++++++++++ app/Services/LoanService.php | 422 ++++++++++++++++++ .../2025_12_18_120001_create_loans_table.php | 44 ++ lang/ko/validation.php | 8 + routes/api.php | 1 + 11 files changed, 1065 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/LoanController.php create mode 100644 app/Http/Requests/Loan/LoanCalculateInterestRequest.php create mode 100644 app/Http/Requests/Loan/LoanIndexRequest.php create mode 100644 app/Http/Requests/Loan/LoanSettleRequest.php create mode 100644 app/Http/Requests/Loan/LoanStoreRequest.php create mode 100644 app/Http/Requests/Loan/LoanUpdateRequest.php create mode 100644 app/Models/Tenants/Loan.php create mode 100644 app/Services/LoanService.php create mode 100644 database/migrations/2025_12_18_120001_create_loans_table.php diff --git a/app/Http/Controllers/Api/V1/LoanController.php b/app/Http/Controllers/Api/V1/LoanController.php new file mode 100644 index 0000000..8c78317 --- /dev/null +++ b/app/Http/Controllers/Api/V1/LoanController.php @@ -0,0 +1,115 @@ +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); + } +} diff --git a/app/Http/Requests/Loan/LoanCalculateInterestRequest.php b/app/Http/Requests/Loan/LoanCalculateInterestRequest.php new file mode 100644 index 0000000..712d65e --- /dev/null +++ b/app/Http/Requests/Loan/LoanCalculateInterestRequest.php @@ -0,0 +1,42 @@ +|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 + */ + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.year'), + 'user_id' => __('validation.attributes.user_id'), + ]; + } +} diff --git a/app/Http/Requests/Loan/LoanIndexRequest.php b/app/Http/Requests/Loan/LoanIndexRequest.php new file mode 100644 index 0000000..e346b22 --- /dev/null +++ b/app/Http/Requests/Loan/LoanIndexRequest.php @@ -0,0 +1,56 @@ +|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 + */ + 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'), + ]; + } +} diff --git a/app/Http/Requests/Loan/LoanSettleRequest.php b/app/Http/Requests/Loan/LoanSettleRequest.php new file mode 100644 index 0000000..2e642d0 --- /dev/null +++ b/app/Http/Requests/Loan/LoanSettleRequest.php @@ -0,0 +1,42 @@ +|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 + */ + public function attributes(): array + { + return [ + 'settlement_date' => __('validation.attributes.settlement_date'), + 'settlement_amount' => __('validation.attributes.settlement_amount'), + ]; + } +} diff --git a/app/Http/Requests/Loan/LoanStoreRequest.php b/app/Http/Requests/Loan/LoanStoreRequest.php new file mode 100644 index 0000000..345baac --- /dev/null +++ b/app/Http/Requests/Loan/LoanStoreRequest.php @@ -0,0 +1,48 @@ +|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 + */ + 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'), + ]; + } +} diff --git a/app/Http/Requests/Loan/LoanUpdateRequest.php b/app/Http/Requests/Loan/LoanUpdateRequest.php new file mode 100644 index 0000000..ba8449c --- /dev/null +++ b/app/Http/Requests/Loan/LoanUpdateRequest.php @@ -0,0 +1,48 @@ +|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 + */ + 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'), + ]; + } +} diff --git a/app/Models/Tenants/Loan.php b/app/Models/Tenants/Loan.php new file mode 100644 index 0000000..80b403e --- /dev/null +++ b/app/Models/Tenants/Loan.php @@ -0,0 +1,239 @@ + 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), + ]; + } +} diff --git a/app/Services/LoanService.php b/app/Services/LoanService.php new file mode 100644 index 0000000..98d052d --- /dev/null +++ b/app/Services/LoanService.php @@ -0,0 +1,422 @@ +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, + ]; + } +} diff --git a/database/migrations/2025_12_18_120001_create_loans_table.php b/database/migrations/2025_12_18_120001_create_loans_table.php new file mode 100644 index 0000000..304ac11 --- /dev/null +++ b/database/migrations/2025_12_18_120001_create_loans_table.php @@ -0,0 +1,44 @@ +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'); + } +}; \ No newline at end of file diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 58e9580..f015a26 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -213,6 +213,14 @@ 'auto_calculate' => '자동계산 여부', 'allowance_types' => '수당 유형', 'deduction_types' => '공제 유형', + + // 가지급금 관련 + 'loan_date' => '지급일', + 'amount' => '금액', + 'purpose' => '사용목적', + 'settlement_date' => '정산일', + 'settlement_amount' => '정산금액', + 'year' => '연도', ], ]; diff --git a/routes/api.php b/routes/api.php index a482796..f981d57 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');