- 확정 상태에서 작성중으로 되돌리는 기능 추가
- Model: isUnconfirmable() 상태 헬퍼 추가
- Service: unconfirmPayroll() 메서드 추가
- Controller: unconfirm() 엔드포인트 추가
- Route: POST /{id}/unconfirm 라우트 추가
- View: 확정 취소 버튼 및 JS 함수 추가
781 lines
29 KiB
PHP
781 lines
29 KiB
PHP
<?php
|
||
|
||
namespace App\Services\HR;
|
||
|
||
use App\Models\HR\Employee;
|
||
use App\Models\HR\IncomeTaxBracket;
|
||
use App\Models\HR\Payroll;
|
||
use App\Models\HR\PayrollSetting;
|
||
use App\Models\Tenants\Department;
|
||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class PayrollService
|
||
{
|
||
private const TAX_TABLE_YEAR = 2024;
|
||
|
||
/**
|
||
* 필터 적용 쿼리 생성 (목록/엑셀 공통)
|
||
*/
|
||
private function buildFilteredQuery(array $filters = [])
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$query = Payroll::query()
|
||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department'])
|
||
->forTenant($tenantId);
|
||
|
||
if (! empty($filters['q'])) {
|
||
$search = $filters['q'];
|
||
$query->whereHas('user', function ($q) use ($search) {
|
||
$q->where('name', 'like', "%{$search}%");
|
||
});
|
||
}
|
||
|
||
if (! empty($filters['department_id'])) {
|
||
$deptId = $filters['department_id'];
|
||
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
||
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
||
});
|
||
}
|
||
|
||
if (! empty($filters['status'])) {
|
||
$query->where('status', $filters['status']);
|
||
}
|
||
|
||
$year = $filters['year'] ?? now()->year;
|
||
$month = $filters['month'] ?? now()->month;
|
||
$query->forPeriod((int) $year, (int) $month);
|
||
|
||
return $query->orderBy('created_at', 'desc');
|
||
}
|
||
|
||
/**
|
||
* 급여 목록 조회 (페이지네이션)
|
||
*/
|
||
public function getPayrolls(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||
{
|
||
return $this->buildFilteredQuery($filters)->paginate($perPage);
|
||
}
|
||
|
||
/**
|
||
* 엑셀 내보내기용 데이터 (전체)
|
||
*/
|
||
public function getExportData(array $filters = []): Collection
|
||
{
|
||
return $this->buildFilteredQuery($filters)->get();
|
||
}
|
||
|
||
/**
|
||
* 월간 통계 (통계 카드용)
|
||
*/
|
||
public function getMonthlyStats(?int $year = null, ?int $month = null): array
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
$year = $year ?? now()->year;
|
||
$month = $month ?? now()->month;
|
||
|
||
$result = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->forPeriod($year, $month)
|
||
->select(
|
||
DB::raw('COUNT(*) as total_count'),
|
||
DB::raw('SUM(gross_salary) as total_gross'),
|
||
DB::raw('SUM(total_deductions) as total_deductions'),
|
||
DB::raw('SUM(net_salary) as total_net'),
|
||
DB::raw("SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count"),
|
||
DB::raw("SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed_count"),
|
||
DB::raw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count"),
|
||
)
|
||
->first();
|
||
|
||
return [
|
||
'total_gross' => (int) ($result->total_gross ?? 0),
|
||
'total_deductions' => (int) ($result->total_deductions ?? 0),
|
||
'total_net' => (int) ($result->total_net ?? 0),
|
||
'total_count' => (int) ($result->total_count ?? 0),
|
||
'draft_count' => (int) ($result->draft_count ?? 0),
|
||
'confirmed_count' => (int) ($result->confirmed_count ?? 0),
|
||
'paid_count' => (int) ($result->paid_count ?? 0),
|
||
'year' => $year,
|
||
'month' => $month,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 급여 등록
|
||
*/
|
||
public function storePayroll(array $data): Payroll
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
return DB::transaction(function () use ($data, $tenantId) {
|
||
// 동일 대상/기간 중복 체크 (트랜잭션 내 + 행 잠금으로 Race Condition 방지)
|
||
$existing = Payroll::withTrashed()
|
||
->where('tenant_id', $tenantId)
|
||
->where('user_id', $data['user_id'])
|
||
->where('pay_year', $data['pay_year'])
|
||
->where('pay_month', $data['pay_month'])
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if ($existing) {
|
||
if ($existing->trashed()) {
|
||
$existing->forceDelete();
|
||
} else {
|
||
// 이미 존재하는 레코드가 있으면 수정 모드로 전환
|
||
return $this->updatePayroll($existing->id, $data);
|
||
}
|
||
}
|
||
$familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']);
|
||
$calculated = $this->calculateAmounts($data, null, $familyCount);
|
||
$this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null);
|
||
|
||
$payroll = 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' => $calculated['gross_salary'],
|
||
'income_tax' => $calculated['income_tax'],
|
||
'resident_tax' => $calculated['resident_tax'],
|
||
'health_insurance' => $calculated['health_insurance'],
|
||
'long_term_care' => $calculated['long_term_care'],
|
||
'pension' => $calculated['pension'],
|
||
'employment_insurance' => $calculated['employment_insurance'],
|
||
'deductions' => $data['deductions'] ?? null,
|
||
'total_deductions' => $calculated['total_deductions'],
|
||
'net_salary' => $calculated['net_salary'],
|
||
'status' => 'draft',
|
||
'note' => $data['note'] ?? null,
|
||
'created_by' => auth()->id(),
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
return $payroll->load('user');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 급여 수정
|
||
*/
|
||
public function updatePayroll(int $id, array $data): ?Payroll
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$payroll = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->find($id);
|
||
|
||
if (! $payroll || ! $payroll->isEditable()) {
|
||
return null;
|
||
}
|
||
|
||
// 지급 항목 (변경된 값 또는 기존값 유지)
|
||
$baseSalary = (float) ($data['base_salary'] ?? $payroll->base_salary);
|
||
$overtimePay = (float) ($data['overtime_pay'] ?? $payroll->overtime_pay);
|
||
$bonus = (float) ($data['bonus'] ?? $payroll->bonus);
|
||
$allowances = array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances;
|
||
|
||
$allowancesTotal = 0;
|
||
$allowancesArr = is_string($allowances) ? json_decode($allowances, true) : $allowances;
|
||
foreach ($allowancesArr ?? [] as $allowance) {
|
||
$allowancesTotal += (float) ($allowance['amount'] ?? 0);
|
||
}
|
||
|
||
$grossSalary = (int) ($baseSalary + $overtimePay + $bonus + $allowancesTotal);
|
||
|
||
// 공제 항목 (수동 수정값 우선, 없으면 기존 저장값 유지 — 요율 재계산 안 함)
|
||
$overrides = $data['deduction_overrides'] ?? [];
|
||
$incomeTax = isset($overrides['income_tax']) ? (int) $overrides['income_tax'] : $payroll->income_tax;
|
||
$residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : $payroll->resident_tax;
|
||
$healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : $payroll->health_insurance;
|
||
$longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : $payroll->long_term_care;
|
||
$pension = isset($overrides['pension']) ? (int) $overrides['pension'] : $payroll->pension;
|
||
$employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : $payroll->employment_insurance;
|
||
$deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions;
|
||
|
||
// 추가 공제 합계
|
||
$extraDeductions = 0;
|
||
$deductionsArr = is_string($deductions) ? json_decode($deductions, true) : $deductions;
|
||
foreach ($deductionsArr ?? [] as $deduction) {
|
||
$extraDeductions += (float) ($deduction['amount'] ?? 0);
|
||
}
|
||
|
||
$totalDeductions = (int) ($incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions);
|
||
$netSalary = (int) max(0, $grossSalary - $totalDeductions);
|
||
|
||
$payroll->update([
|
||
'base_salary' => $baseSalary,
|
||
'overtime_pay' => $overtimePay,
|
||
'bonus' => $bonus,
|
||
'allowances' => $allowances,
|
||
'gross_salary' => $grossSalary,
|
||
'income_tax' => $incomeTax,
|
||
'resident_tax' => $residentTax,
|
||
'health_insurance' => $healthInsurance,
|
||
'long_term_care' => $longTermCare,
|
||
'pension' => $pension,
|
||
'employment_insurance' => $employmentInsurance,
|
||
'deductions' => $deductions,
|
||
'total_deductions' => $totalDeductions,
|
||
'net_salary' => $netSalary,
|
||
'note' => $data['note'] ?? $payroll->note,
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
return $payroll->fresh(['user']);
|
||
}
|
||
|
||
/**
|
||
* 급여 삭제
|
||
*/
|
||
public function deletePayroll(int $id): bool
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$payroll = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->find($id);
|
||
|
||
if (! $payroll || ! $payroll->isDeletable()) {
|
||
return false;
|
||
}
|
||
|
||
$payroll->update(['deleted_by' => auth()->id()]);
|
||
$payroll->delete();
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 급여 확정
|
||
*/
|
||
public function confirmPayroll(int $id): ?Payroll
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$payroll = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->find($id);
|
||
|
||
if (! $payroll || ! $payroll->isConfirmable()) {
|
||
return null;
|
||
}
|
||
|
||
$payroll->update([
|
||
'status' => Payroll::STATUS_CONFIRMED,
|
||
'confirmed_at' => now(),
|
||
'confirmed_by' => auth()->id(),
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
return $payroll->fresh(['user']);
|
||
}
|
||
|
||
/**
|
||
* 급여 확정 취소 (confirmed → draft)
|
||
*/
|
||
public function unconfirmPayroll(int $id): ?Payroll
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$payroll = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->find($id);
|
||
|
||
if (! $payroll || ! $payroll->isUnconfirmable()) {
|
||
return null;
|
||
}
|
||
|
||
$payroll->update([
|
||
'status' => Payroll::STATUS_DRAFT,
|
||
'confirmed_at' => null,
|
||
'confirmed_by' => null,
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
return $payroll->fresh(['user']);
|
||
}
|
||
|
||
/**
|
||
* 급여 지급 처리
|
||
*/
|
||
public function payPayroll(int $id): ?Payroll
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$payroll = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->find($id);
|
||
|
||
if (! $payroll || ! $payroll->isPayable()) {
|
||
return null;
|
||
}
|
||
|
||
$payroll->update([
|
||
'status' => Payroll::STATUS_PAID,
|
||
'paid_at' => now(),
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
return $payroll->fresh(['user']);
|
||
}
|
||
|
||
/**
|
||
* 전월 급여 복사 등록
|
||
*/
|
||
public function copyFromPreviousMonth(int $year, int $month): array
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
// 전월 계산 (1월 → 전년 12월)
|
||
$prevYear = $month === 1 ? $year - 1 : $year;
|
||
$prevMonth = $month === 1 ? 12 : $month - 1;
|
||
|
||
$previousPayrolls = Payroll::query()
|
||
->forTenant($tenantId)
|
||
->forPeriod($prevYear, $prevMonth)
|
||
->get();
|
||
|
||
if ($previousPayrolls->isEmpty()) {
|
||
return ['created' => 0, 'skipped' => 0, 'no_previous' => true];
|
||
}
|
||
|
||
$created = 0;
|
||
$skipped = 0;
|
||
|
||
DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, &$created, &$skipped) {
|
||
foreach ($previousPayrolls as $prev) {
|
||
// SoftDeletes 포함하여 유니크 제약 충돌 방지
|
||
$existing = Payroll::withTrashed()
|
||
->where('tenant_id', $tenantId)
|
||
->where('user_id', $prev->user_id)
|
||
->forPeriod($year, $month)
|
||
->first();
|
||
|
||
if ($existing && ! $existing->trashed()) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
// soft-deleted 레코드가 있으면 삭제 후 재생성
|
||
if ($existing && $existing->trashed()) {
|
||
$existing->forceDelete();
|
||
}
|
||
|
||
Payroll::create([
|
||
'tenant_id' => $tenantId,
|
||
'user_id' => $prev->user_id,
|
||
'pay_year' => $year,
|
||
'pay_month' => $month,
|
||
'base_salary' => $prev->base_salary,
|
||
'overtime_pay' => $prev->overtime_pay,
|
||
'bonus' => $prev->bonus,
|
||
'allowances' => $prev->allowances,
|
||
'gross_salary' => $prev->gross_salary,
|
||
'income_tax' => $prev->income_tax,
|
||
'resident_tax' => $prev->resident_tax,
|
||
'health_insurance' => $prev->health_insurance,
|
||
'long_term_care' => $prev->long_term_care,
|
||
'pension' => $prev->pension,
|
||
'employment_insurance' => $prev->employment_insurance,
|
||
'deductions' => $prev->deductions,
|
||
'total_deductions' => $prev->total_deductions,
|
||
'net_salary' => $prev->net_salary,
|
||
'status' => 'draft',
|
||
'created_by' => auth()->id(),
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
$created++;
|
||
}
|
||
});
|
||
|
||
return ['created' => $created, 'skipped' => $skipped];
|
||
}
|
||
|
||
/**
|
||
* 일괄 생성 (재직 사원 전체)
|
||
*/
|
||
public function bulkGenerate(int $year, int $month): array
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
$settings = PayrollSetting::getOrCreate($tenantId);
|
||
$created = 0;
|
||
$skipped = 0;
|
||
|
||
$employees = Employee::query()
|
||
->with('user:id,name')
|
||
->forTenant($tenantId)
|
||
->activeEmployees()
|
||
->get();
|
||
|
||
DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, &$created, &$skipped) {
|
||
foreach ($employees as $employee) {
|
||
// SoftDeletes 포함하여 유니크 제약 충돌 방지
|
||
$existing = Payroll::withTrashed()
|
||
->where('tenant_id', $tenantId)
|
||
->where('user_id', $employee->user_id)
|
||
->forPeriod($year, $month)
|
||
->first();
|
||
|
||
if ($existing && ! $existing->trashed()) {
|
||
$skipped++;
|
||
|
||
continue;
|
||
}
|
||
|
||
// soft-deleted 레코드가 있으면 삭제 후 재생성
|
||
if ($existing && $existing->trashed()) {
|
||
$existing->forceDelete();
|
||
}
|
||
|
||
$annualSalary = $employee->getJsonExtraValue('salary', 0);
|
||
$baseSalary = $annualSalary > 0 ? round($annualSalary / 12) : 0;
|
||
|
||
$data = [
|
||
'base_salary' => $baseSalary,
|
||
'overtime_pay' => 0,
|
||
'bonus' => 0,
|
||
'allowances' => null,
|
||
'deductions' => null,
|
||
];
|
||
|
||
// 본인(1) + is_dependent=true인 피부양자 수
|
||
$familyCount = 1 + collect($employee->dependents)
|
||
->where('is_dependent', true)->count();
|
||
|
||
$calculated = $this->calculateAmounts($data, $settings, $familyCount);
|
||
|
||
Payroll::create([
|
||
'tenant_id' => $tenantId,
|
||
'user_id' => $employee->user_id,
|
||
'pay_year' => $year,
|
||
'pay_month' => $month,
|
||
'base_salary' => $baseSalary,
|
||
'overtime_pay' => 0,
|
||
'bonus' => 0,
|
||
'allowances' => null,
|
||
'gross_salary' => $calculated['gross_salary'],
|
||
'income_tax' => $calculated['income_tax'],
|
||
'resident_tax' => $calculated['resident_tax'],
|
||
'health_insurance' => $calculated['health_insurance'],
|
||
'long_term_care' => $calculated['long_term_care'],
|
||
'pension' => $calculated['pension'],
|
||
'employment_insurance' => $calculated['employment_insurance'],
|
||
'deductions' => null,
|
||
'total_deductions' => $calculated['total_deductions'],
|
||
'net_salary' => $calculated['net_salary'],
|
||
'status' => 'draft',
|
||
'created_by' => auth()->id(),
|
||
'updated_by' => auth()->id(),
|
||
]);
|
||
|
||
$created++;
|
||
}
|
||
});
|
||
|
||
return ['created' => $created, 'skipped' => $skipped];
|
||
}
|
||
|
||
/**
|
||
* 급여 금액 자동 계산
|
||
*
|
||
* 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만
|
||
* 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다.
|
||
*/
|
||
public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array
|
||
{
|
||
$settings = $settings ?? PayrollSetting::getOrCreate();
|
||
|
||
$baseSalary = (float) ($data['base_salary'] ?? 0);
|
||
$overtimePay = (float) ($data['overtime_pay'] ?? 0);
|
||
$bonus = (float) ($data['bonus'] ?? 0); // 식대 (비과세)
|
||
|
||
// 수당 합계
|
||
$allowancesTotal = 0;
|
||
if (! empty($data['allowances'])) {
|
||
$allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances'];
|
||
foreach ($allowances ?? [] as $allowance) {
|
||
$allowancesTotal += (float) ($allowance['amount'] ?? 0);
|
||
}
|
||
}
|
||
|
||
// 총 지급액 (비과세 포함)
|
||
$grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal;
|
||
|
||
// 과세표준 = 총 지급액 - 식대(비과세)
|
||
$taxableBase = $grossSalary - $bonus;
|
||
|
||
// 4대보험 계산 (과세표준 기준)
|
||
$healthInsurance = $this->calculateHealthInsurance($taxableBase, $settings);
|
||
$longTermCare = $this->calculateLongTermCare($taxableBase, $settings);
|
||
$pension = $this->calculatePension($taxableBase, $settings);
|
||
$employmentInsurance = $this->calculateEmploymentInsurance($taxableBase, $settings);
|
||
|
||
// 근로소득세 (간이세액표, 과세표준 기준, 공제대상가족수 반영)
|
||
$incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount);
|
||
// 지방소득세 (근로소득세의 10%, 10원 단위 절삭)
|
||
$residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10);
|
||
|
||
// 추가 공제 합계
|
||
$extraDeductions = 0;
|
||
if (! empty($data['deductions'])) {
|
||
$deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions'];
|
||
foreach ($deductions ?? [] as $deduction) {
|
||
$extraDeductions += (float) ($deduction['amount'] ?? 0);
|
||
}
|
||
}
|
||
|
||
// 총 공제액
|
||
$totalDeductions = $incomeTax + $residentTax + $healthInsurance + $longTermCare + $pension + $employmentInsurance + $extraDeductions;
|
||
|
||
// 실수령액
|
||
$netSalary = $grossSalary - $totalDeductions;
|
||
|
||
return [
|
||
'gross_salary' => (int) $grossSalary,
|
||
'taxable_base' => (int) $taxableBase,
|
||
'income_tax' => $incomeTax,
|
||
'resident_tax' => $residentTax,
|
||
'health_insurance' => $healthInsurance,
|
||
'long_term_care' => $longTermCare,
|
||
'pension' => $pension,
|
||
'employment_insurance' => $employmentInsurance,
|
||
'total_deductions' => (int) $totalDeductions,
|
||
'net_salary' => (int) max(0, $netSalary),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 수동 수정된 공제 항목 반영
|
||
*/
|
||
private function applyDeductionOverrides(array &$calculated, ?array $overrides): void
|
||
{
|
||
if (empty($overrides)) {
|
||
return;
|
||
}
|
||
|
||
// 추가공제 금액 산출 (오버라이드 적용 전 법정공제 합계와 비교)
|
||
$oldStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
||
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
||
$extraDeductions = max(0, $calculated['total_deductions'] - $oldStatutory);
|
||
|
||
// 수동 수정값 적용
|
||
$fields = ['pension', 'health_insurance', 'long_term_care', 'employment_insurance', 'income_tax', 'resident_tax'];
|
||
foreach ($fields as $field) {
|
||
if (isset($overrides[$field])) {
|
||
$calculated[$field] = (int) $overrides[$field];
|
||
}
|
||
}
|
||
|
||
// 총 공제액·실수령액 재계산
|
||
$newStatutory = $calculated['pension'] + $calculated['health_insurance'] + $calculated['long_term_care']
|
||
+ $calculated['employment_insurance'] + $calculated['income_tax'] + $calculated['resident_tax'];
|
||
$calculated['total_deductions'] = (int) ($newStatutory + $extraDeductions);
|
||
$calculated['net_salary'] = (int) max(0, $calculated['gross_salary'] - $calculated['total_deductions']);
|
||
}
|
||
|
||
/**
|
||
* 근로소득세 계산 (2024 국세청 간이세액표 기반)
|
||
*
|
||
* @param float $taxableBase 과세표준 (원)
|
||
* @param int $familyCount 공제대상가족수 (1~11, 본인 포함)
|
||
*/
|
||
public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int
|
||
{
|
||
if ($taxableBase <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
$salaryThousand = (int) floor($taxableBase / 1000);
|
||
$familyCount = max(1, min(11, $familyCount));
|
||
|
||
// 770천원 미만: 세액 없음
|
||
if ($salaryThousand < 770) {
|
||
return 0;
|
||
}
|
||
|
||
// 10,000천원 초과: 공식 계산
|
||
if ($salaryThousand > 10000) {
|
||
return $this->calculateHighIncomeTax($salaryThousand, $familyCount);
|
||
}
|
||
|
||
// DB 조회 (간이세액표)
|
||
return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount);
|
||
}
|
||
|
||
/**
|
||
* 10,000천원 초과 구간 근로소득세 공식 계산
|
||
*
|
||
* 소득세법 시행령 별표2 기준:
|
||
* - 10,000~14,000천원: (1천만원 세액) + (초과분 × 98% × 35%) + 25,000
|
||
* - 14,000~28,000천원: (1천만원 세액) + 1,397,000 + (초과분 × 98% × 38%)
|
||
* - 28,000~30,000천원: (1천만원 세액) + 6,610,600 + (초과분 × 98% × 40%)
|
||
* - 30,000~45,000천원: (1천만원 세액) + 7,394,600 + (초과분 × 40%)
|
||
* - 45,000~87,000천원: (1천만원 세액) + 13,394,600 + (초과분 × 42%)
|
||
* - 87,000천원 초과: (1천만원 세액) + 31,034,600 + (초과분 × 45%)
|
||
*/
|
||
private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int
|
||
{
|
||
// 1천만원(10,000천원) 정확값의 가족수별 세액 (DB에서 조회)
|
||
$baseTax = IncomeTaxBracket::where('tax_year', self::TAX_TABLE_YEAR)
|
||
->where('salary_from', 10000)
|
||
->whereColumn('salary_from', 'salary_to')
|
||
->where('family_count', $familyCount)
|
||
->value('tax_amount') ?? 0;
|
||
|
||
// 초과분 계산 (천원 단위 → 원 단위)
|
||
$excessThousand = $salaryThousand - 10000;
|
||
$excessWon = $excessThousand * 1000;
|
||
|
||
if ($salaryThousand <= 14000) {
|
||
$tax = $baseTax + ($excessWon * 0.98 * 0.35) + 25000;
|
||
} elseif ($salaryThousand <= 28000) {
|
||
$excessWon = ($salaryThousand - 14000) * 1000;
|
||
$tax = $baseTax + 1397000 + ($excessWon * 0.98 * 0.38);
|
||
} elseif ($salaryThousand <= 30000) {
|
||
$excessWon = ($salaryThousand - 28000) * 1000;
|
||
$tax = $baseTax + 6610600 + ($excessWon * 0.98 * 0.40);
|
||
} elseif ($salaryThousand <= 45000) {
|
||
$excessWon = ($salaryThousand - 30000) * 1000;
|
||
$tax = $baseTax + 7394600 + ($excessWon * 0.40);
|
||
} elseif ($salaryThousand <= 87000) {
|
||
$excessWon = ($salaryThousand - 45000) * 1000;
|
||
$tax = $baseTax + 13394600 + ($excessWon * 0.42);
|
||
} else {
|
||
// 87,000천원 초과
|
||
$excessWon = ($salaryThousand - 87000) * 1000;
|
||
$tax = $baseTax + 31034600 + ($excessWon * 0.45);
|
||
}
|
||
|
||
// 10원 단위 절삭
|
||
return (int) (floor($tax / 10) * 10);
|
||
}
|
||
|
||
/**
|
||
* 건강보험료 계산
|
||
*/
|
||
private function calculateHealthInsurance(float $grossSalary, PayrollSetting $settings): int
|
||
{
|
||
return (int) (floor($grossSalary * ($settings->health_insurance_rate / 100) / 10) * 10);
|
||
}
|
||
|
||
/**
|
||
* 장기요양보험료 계산 (건강보험료의 일정 비율)
|
||
*/
|
||
private function calculateLongTermCare(float $grossSalary, PayrollSetting $settings): int
|
||
{
|
||
$healthInsurance = $grossSalary * ($settings->health_insurance_rate / 100);
|
||
|
||
return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10);
|
||
}
|
||
|
||
/**
|
||
* 국민연금 계산
|
||
*/
|
||
private function calculatePension(float $grossSalary, PayrollSetting $settings): int
|
||
{
|
||
$base = min(max($grossSalary, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary);
|
||
|
||
return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10);
|
||
}
|
||
|
||
/**
|
||
* 고용보험료 계산
|
||
*/
|
||
private function calculateEmploymentInsurance(float $grossSalary, PayrollSetting $settings): int
|
||
{
|
||
return (int) (floor($grossSalary * ($settings->employment_insurance_rate / 100) / 10) * 10);
|
||
}
|
||
|
||
/**
|
||
* 부서 목록 (드롭다운용)
|
||
*/
|
||
public function getDepartments(): Collection
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
return Department::query()
|
||
->where('is_active', true)
|
||
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||
->orderBy('sort_order')
|
||
->orderBy('name')
|
||
->get(['id', 'name', 'code']);
|
||
}
|
||
|
||
/**
|
||
* 활성 사원 목록 (드롭다운용)
|
||
*/
|
||
public function getActiveEmployees(): Collection
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
return Employee::query()
|
||
->with('user:id,name')
|
||
->forTenant($tenantId)
|
||
->activeEmployees()
|
||
->orderBy('display_name')
|
||
->get(['id', 'user_id', 'display_name', 'department_id', 'json_extra']);
|
||
}
|
||
|
||
/**
|
||
* 급여 설정 조회/생성
|
||
*/
|
||
public function getSettings(): PayrollSetting
|
||
{
|
||
return PayrollSetting::getOrCreate();
|
||
}
|
||
|
||
/**
|
||
* user_id로 공제대상가족수 산출 (본인 1 + is_dependent 피부양자)
|
||
*/
|
||
public function resolveFamilyCount(int $userId): int
|
||
{
|
||
$tenantId = session('selected_tenant_id', 1);
|
||
|
||
$employee = Employee::query()
|
||
->forTenant($tenantId)
|
||
->where('user_id', $userId)
|
||
->first(['json_extra']);
|
||
|
||
if (! $employee) {
|
||
return 1;
|
||
}
|
||
|
||
$dependentCount = collect($employee->dependents)
|
||
->where('is_dependent', true)
|
||
->count();
|
||
|
||
return max(1, min(11, 1 + $dependentCount));
|
||
}
|
||
|
||
/**
|
||
* 급여 설정 수정
|
||
*/
|
||
public function updateSettings(array $data): PayrollSetting
|
||
{
|
||
$settings = PayrollSetting::getOrCreate();
|
||
|
||
$settings->update([
|
||
'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,
|
||
]);
|
||
|
||
return $settings->fresh();
|
||
}
|
||
}
|