Files
sam-api/app/Services/PayrollService.php

563 lines
20 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Models\Tenants\Payroll;
use App\Models\Tenants\PayrollSetting;
use App\Models\Tenants\Withdrawal;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class PayrollService extends Service
{
// =========================================================================
// 급여 목록/상세
// =========================================================================
/**
* 급여 목록
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Payroll::query()
->where('tenant_id', $tenantId)
->with(['user:id,name,email', 'creator:id,name']);
// 연도 필터
if (! empty($params['year'])) {
$query->where('pay_year', $params['year']);
}
// 월 필터
if (! empty($params['month'])) {
$query->where('pay_month', $params['month']);
}
// 사용자 필터
if (! empty($params['user_id'])) {
$query->where('user_id', $params['user_id']);
}
// 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 검색 (사용자명)
if (! empty($params['search'])) {
$query->whereHas('user', function ($q) use ($params) {
$q->where('name', 'like', "%{$params['search']}%");
});
}
// 정렬
$sortBy = $params['sort_by'] ?? 'pay_year';
$sortDir = $params['sort_dir'] ?? 'desc';
if ($sortBy === 'period') {
$query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir);
} else {
$query->orderBy($sortBy, $sortDir);
}
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 특정 연월 급여 요약
*/
public function summary(int $year, int $month): array
{
$tenantId = $this->tenantId();
$stats = Payroll::query()
->where('tenant_id', $tenantId)
->where('pay_year', $year)
->where('pay_month', $month)
->selectRaw('
COUNT(*) as total_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as confirmed_count,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as paid_count,
SUM(gross_salary) as total_gross,
SUM(total_deductions) as total_deductions,
SUM(net_salary) as total_net
', [Payroll::STATUS_DRAFT, Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID])
->first();
return [
'year' => $year,
'month' => $month,
'total_count' => (int) $stats->total_count,
'draft_count' => (int) $stats->draft_count,
'confirmed_count' => (int) $stats->confirmed_count,
'paid_count' => (int) $stats->paid_count,
'total_gross' => (float) $stats->total_gross,
'total_deductions' => (float) $stats->total_deductions,
'total_net' => (float) $stats->total_net,
];
}
/**
* 급여 상세
*/
public function show(int $id): Payroll
{
$tenantId = $this->tenantId();
return Payroll::query()
->where('tenant_id', $tenantId)
->with([
'user:id,name,email',
'confirmer:id,name',
'withdrawal',
'creator:id,name',
])
->findOrFail($id);
}
// =========================================================================
// 급여 생성/수정/삭제
// =========================================================================
/**
* 급여 생성
*/
public function store(array $data): Payroll
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 중복 확인
$exists = Payroll::query()
->where('tenant_id', $tenantId)
->where('user_id', $data['user_id'])
->where('pay_year', $data['pay_year'])
->where('pay_month', $data['pay_month'])
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.payroll.already_exists'));
}
// 금액 계산
$grossSalary = $this->calculateGross($data);
$totalDeductions = $this->calculateDeductions($data);
$netSalary = $grossSalary - $totalDeductions;
return Payroll::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'pay_year' => $data['pay_year'],
'pay_month' => $data['pay_month'],
'base_salary' => $data['base_salary'] ?? 0,
'overtime_pay' => $data['overtime_pay'] ?? 0,
'bonus' => $data['bonus'] ?? 0,
'allowances' => $data['allowances'] ?? null,
'gross_salary' => $grossSalary,
'income_tax' => $data['income_tax'] ?? 0,
'resident_tax' => $data['resident_tax'] ?? 0,
'health_insurance' => $data['health_insurance'] ?? 0,
'pension' => $data['pension'] ?? 0,
'employment_insurance' => $data['employment_insurance'] ?? 0,
'deductions' => $data['deductions'] ?? null,
'total_deductions' => $totalDeductions,
'net_salary' => $netSalary,
'status' => Payroll::STATUS_DRAFT,
'note' => $data['note'] ?? null,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
/**
* 급여 수정
*/
public function update(int $id, array $data): Payroll
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$payroll = Payroll::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $payroll->isEditable()) {
throw new BadRequestHttpException(__('error.payroll.not_editable'));
}
// 연월 변경 시 중복 확인
$newYear = $data['pay_year'] ?? $payroll->pay_year;
$newMonth = $data['pay_month'] ?? $payroll->pay_month;
$newUserId = $data['user_id'] ?? $payroll->user_id;
if ($newYear != $payroll->pay_year || $newMonth != $payroll->pay_month || $newUserId != $payroll->user_id) {
$exists = Payroll::query()
->where('tenant_id', $tenantId)
->where('user_id', $newUserId)
->where('pay_year', $newYear)
->where('pay_month', $newMonth)
->where('id', '!=', $id)
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.payroll.already_exists'));
}
}
// 금액 업데이트
$updateData = array_merge($payroll->toArray(), $data);
$grossSalary = $this->calculateGross($updateData);
$totalDeductions = $this->calculateDeductions($updateData);
$netSalary = $grossSalary - $totalDeductions;
$payroll->fill([
'user_id' => $data['user_id'] ?? $payroll->user_id,
'pay_year' => $data['pay_year'] ?? $payroll->pay_year,
'pay_month' => $data['pay_month'] ?? $payroll->pay_month,
'base_salary' => $data['base_salary'] ?? $payroll->base_salary,
'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay,
'bonus' => $data['bonus'] ?? $payroll->bonus,
'allowances' => $data['allowances'] ?? $payroll->allowances,
'gross_salary' => $grossSalary,
'income_tax' => $data['income_tax'] ?? $payroll->income_tax,
'resident_tax' => $data['resident_tax'] ?? $payroll->resident_tax,
'health_insurance' => $data['health_insurance'] ?? $payroll->health_insurance,
'pension' => $data['pension'] ?? $payroll->pension,
'employment_insurance' => $data['employment_insurance'] ?? $payroll->employment_insurance,
'deductions' => $data['deductions'] ?? $payroll->deductions,
'total_deductions' => $totalDeductions,
'net_salary' => $netSalary,
'note' => $data['note'] ?? $payroll->note,
'updated_by' => $userId,
]);
$payroll->save();
return $payroll->fresh(['user:id,name,email', 'creator:id,name']);
}
/**
* 급여 삭제
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$payroll = Payroll::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $payroll->isDeletable()) {
throw new BadRequestHttpException(__('error.payroll.not_deletable'));
}
$payroll->deleted_by = $userId;
$payroll->save();
$payroll->delete();
return true;
}
// =========================================================================
// 급여 확정/지급
// =========================================================================
/**
* 급여 확정
*/
public function confirm(int $id): Payroll
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$payroll = Payroll::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $payroll->isConfirmable()) {
throw new BadRequestHttpException(__('error.payroll.not_confirmable'));
}
$payroll->status = Payroll::STATUS_CONFIRMED;
$payroll->confirmed_at = now();
$payroll->confirmed_by = $userId;
$payroll->updated_by = $userId;
$payroll->save();
return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']);
}
/**
* 급여 지급 처리
*/
public function pay(int $id, ?int $withdrawalId = null): Payroll
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $withdrawalId, $tenantId, $userId) {
$payroll = Payroll::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
if (! $payroll->isPayable()) {
throw new BadRequestHttpException(__('error.payroll.not_payable'));
}
// 출금 내역 연결 검증
if ($withdrawalId) {
$withdrawal = Withdrawal::query()
->where('tenant_id', $tenantId)
->where('id', $withdrawalId)
->first();
if (! $withdrawal) {
throw new BadRequestHttpException(__('error.payroll.invalid_withdrawal'));
}
}
$payroll->status = Payroll::STATUS_PAID;
$payroll->paid_at = now();
$payroll->withdrawal_id = $withdrawalId;
$payroll->updated_by = $userId;
$payroll->save();
return $payroll->fresh(['user:id,name,email', 'withdrawal']);
});
}
/**
* 일괄 확정
*/
public function bulkConfirm(int $year, int $month): int
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return Payroll::query()
->where('tenant_id', $tenantId)
->where('pay_year', $year)
->where('pay_month', $month)
->where('status', Payroll::STATUS_DRAFT)
->update([
'status' => Payroll::STATUS_CONFIRMED,
'confirmed_at' => now(),
'confirmed_by' => $userId,
'updated_by' => $userId,
]);
}
// =========================================================================
// 급여명세서
// =========================================================================
/**
* 급여명세서 데이터
*/
public function payslip(int $id): array
{
$payroll = $this->show($id);
// 수당 목록
$allowances = collect($payroll->allowances ?? [])->map(function ($item) {
return [
'name' => $item['name'] ?? '',
'amount' => (float) ($item['amount'] ?? 0),
];
})->toArray();
// 공제 목록
$deductions = collect($payroll->deductions ?? [])->map(function ($item) {
return [
'name' => $item['name'] ?? '',
'amount' => (float) ($item['amount'] ?? 0),
];
})->toArray();
return [
'payroll' => $payroll,
'period' => $payroll->period_label,
'employee' => [
'id' => $payroll->user->id,
'name' => $payroll->user->name,
'email' => $payroll->user->email,
],
'earnings' => [
'base_salary' => (float) $payroll->base_salary,
'overtime_pay' => (float) $payroll->overtime_pay,
'bonus' => (float) $payroll->bonus,
'allowances' => $allowances,
'allowances_total' => (float) $payroll->allowances_total,
'gross_total' => (float) $payroll->gross_salary,
],
'deductions' => [
'income_tax' => (float) $payroll->income_tax,
'resident_tax' => (float) $payroll->resident_tax,
'health_insurance' => (float) $payroll->health_insurance,
'pension' => (float) $payroll->pension,
'employment_insurance' => (float) $payroll->employment_insurance,
'other_deductions' => $deductions,
'other_total' => (float) $payroll->deductions_total,
'total' => (float) $payroll->total_deductions,
],
'net_salary' => (float) $payroll->net_salary,
'status' => $payroll->status,
'status_label' => $payroll->status_label,
'paid_at' => $payroll->paid_at?->toIso8601String(),
];
}
// =========================================================================
// 급여 일괄 계산
// =========================================================================
/**
* 급여 일괄 계산 (생성 또는 업데이트)
*/
public function calculate(int $year, int $month, ?array $userIds = null): Collection
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 급여 설정 가져오기
$settings = PayrollSetting::getOrCreate($tenantId);
// 대상 사용자 조회
// TODO: 실제로는 직원 목록에서 급여 대상자를 조회해야 함
// 여기서는 기존 급여 데이터만 업데이트
return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) {
$query = Payroll::query()
->where('tenant_id', $tenantId)
->where('pay_year', $year)
->where('pay_month', $month)
->where('status', Payroll::STATUS_DRAFT);
if ($userIds) {
$query->whereIn('user_id', $userIds);
}
$payrolls = $query->get();
foreach ($payrolls as $payroll) {
// 4대보험 재계산
$baseSalary = (float) $payroll->base_salary;
$healthInsurance = $settings->calculateHealthInsurance($baseSalary);
$longTermCare = $settings->calculateLongTermCare($healthInsurance);
$pension = $settings->calculatePension($baseSalary);
$employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary);
// 건강보험에 장기요양보험 포함
$totalHealthInsurance = $healthInsurance + $longTermCare;
$payroll->health_insurance = $totalHealthInsurance;
$payroll->pension = $pension;
$payroll->employment_insurance = $employmentInsurance;
// 주민세 재계산
$payroll->resident_tax = $settings->calculateResidentTax($payroll->income_tax);
// 총액 재계산
$payroll->total_deductions = $payroll->calculateTotalDeductions();
$payroll->net_salary = $payroll->calculateNetSalary();
$payroll->updated_by = $userId;
$payroll->save();
}
return $payrolls->fresh(['user:id,name,email']);
});
}
// =========================================================================
// 급여 설정
// =========================================================================
/**
* 급여 설정 조회
*/
public function getSettings(): PayrollSetting
{
$tenantId = $this->tenantId();
return PayrollSetting::getOrCreate($tenantId);
}
/**
* 급여 설정 수정
*/
public function updateSettings(array $data): PayrollSetting
{
$tenantId = $this->tenantId();
$settings = PayrollSetting::getOrCreate($tenantId);
$settings->fill([
'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate,
'resident_tax_rate' => $data['resident_tax_rate'] ?? $settings->resident_tax_rate,
'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate,
'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate,
'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate,
'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate,
'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary,
'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary,
'pay_day' => $data['pay_day'] ?? $settings->pay_day,
'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate,
'allowance_types' => $data['allowance_types'] ?? $settings->allowance_types,
'deduction_types' => $data['deduction_types'] ?? $settings->deduction_types,
]);
$settings->save();
return $settings;
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 총지급액 계산
*/
private function calculateGross(array $data): float
{
$baseSalary = (float) ($data['base_salary'] ?? 0);
$overtimePay = (float) ($data['overtime_pay'] ?? 0);
$bonus = (float) ($data['bonus'] ?? 0);
$allowancesTotal = 0;
if (! empty($data['allowances'])) {
$allowancesTotal = collect($data['allowances'])->sum('amount');
}
return $baseSalary + $overtimePay + $bonus + $allowancesTotal;
}
/**
* 총공제액 계산
*/
private function calculateDeductions(array $data): float
{
$incomeTax = (float) ($data['income_tax'] ?? 0);
$residentTax = (float) ($data['resident_tax'] ?? 0);
$healthInsurance = (float) ($data['health_insurance'] ?? 0);
$pension = (float) ($data['pension'] ?? 0);
$employmentInsurance = (float) ($data['employment_insurance'] ?? 0);
$deductionsTotal = 0;
if (! empty($data['deductions'])) {
$deductionsTotal = collect($data['deductions'])->sum('amount');
}
return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal;
}
}