feat: [payroll] 근로소득세 간이세액표 기반 자동 계산 기능
- IncomeTaxBracket 모델 생성 (DB 조회 방식) - PayrollService: calculateIncomeTax DB 기반으로 리팩토링 - 10,000천원 초과 구간 공식 계산 (calculateHighIncomeTax) - 지방소득세 10원 단위 절삭 적용 - 공제대상가족수(1~11명) 반영 (본인 + 피부양자) - calculate API에 user_id 파라미터 추가 - 사원 select에 data-dependents 속성 추가 - 모달에 공제대상가족수 표시
This commit is contained in:
@@ -406,13 +406,19 @@ public function calculate(Request $request): JsonResponse
|
||||
'bonus' => 'nullable|numeric|min:0',
|
||||
'allowances' => 'nullable|array',
|
||||
'deductions' => 'nullable|array',
|
||||
'user_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$result = $this->payrollService->calculateAmounts($validated);
|
||||
$familyCount = 1;
|
||||
if (! empty($validated['user_id'])) {
|
||||
$familyCount = $this->payrollService->resolveFamilyCount($validated['user_id']);
|
||||
}
|
||||
|
||||
$result = $this->payrollService->calculateAmounts($validated, null, $familyCount);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'data' => array_merge($result, ['family_count' => $familyCount]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Models/HR/IncomeTaxBracket.php
Normal file
69
app/Models/HR/IncomeTaxBracket.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IncomeTaxBracket extends Model
|
||||
{
|
||||
protected $table = 'income_tax_brackets';
|
||||
|
||||
protected $fillable = [
|
||||
'tax_year',
|
||||
'salary_from',
|
||||
'salary_to',
|
||||
'family_count',
|
||||
'tax_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tax_year' => 'integer',
|
||||
'salary_from' => 'integer',
|
||||
'salary_to' => 'integer',
|
||||
'family_count' => 'integer',
|
||||
'tax_amount' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeForYear(Builder $query, int $year): Builder
|
||||
{
|
||||
return $query->where('tax_year', $year);
|
||||
}
|
||||
|
||||
public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder
|
||||
{
|
||||
return $query->where('salary_from', '<=', $salaryThousand)
|
||||
->where(function ($q) use ($salaryThousand) {
|
||||
$q->where('salary_to', '>', $salaryThousand)
|
||||
->orWhere(function ($q2) use ($salaryThousand) {
|
||||
// 10,000천원 정확값 (salary_from == salary_to == 10000)
|
||||
$q2->whereColumn('salary_from', 'salary_to')
|
||||
->where('salary_from', $salaryThousand);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForFamilyCount(Builder $query, int $count): Builder
|
||||
{
|
||||
return $query->where('family_count', $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 간이세액표에서 세액 조회
|
||||
*
|
||||
* @param int $year 세액표 적용 연도
|
||||
* @param int $salaryThousand 월급여 (천원 단위)
|
||||
* @param int $familyCount 공제대상가족수 (1~11)
|
||||
*/
|
||||
public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int
|
||||
{
|
||||
$familyCount = max(1, min(11, $familyCount));
|
||||
|
||||
$bracket = static::forYear($year)
|
||||
->forSalaryRange($salaryThousand)
|
||||
->forFamilyCount($familyCount)
|
||||
->first();
|
||||
|
||||
return $bracket ? $bracket->tax_amount : 0;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -12,34 +13,7 @@
|
||||
|
||||
class PayrollService
|
||||
{
|
||||
// =========================================================================
|
||||
// 간이세액표 (국세청 근로소득 간이세액표 기준, 부양가족 1인)
|
||||
// 월 급여액 구간별 세액 (원)
|
||||
// =========================================================================
|
||||
|
||||
private const INCOME_TAX_TABLE = [
|
||||
// [하한, 상한, 세액]
|
||||
[0, 1060000, 0],
|
||||
[1060000, 1500000, 19060],
|
||||
[1500000, 2000000, 34680],
|
||||
[2000000, 2500000, 60430],
|
||||
[2500000, 3000000, 95960],
|
||||
[3000000, 3500000, 137900],
|
||||
[3500000, 4000000, 179000],
|
||||
[4000000, 4500000, 215060],
|
||||
[4500000, 5000000, 258190],
|
||||
[5000000, 6000000, 311810],
|
||||
[6000000, 7000000, 414810],
|
||||
[7000000, 8000000, 519370],
|
||||
[8000000, 9000000, 633640],
|
||||
[9000000, 10000000, 758640],
|
||||
[10000000, 14000000, 974780],
|
||||
[14000000, 28000000, 1801980],
|
||||
[28000000, 30000000, 5765680],
|
||||
[30000000, 45000000, 6387680],
|
||||
[45000000, 87000000, 11462680],
|
||||
[87000000, PHP_INT_MAX, 29082680],
|
||||
];
|
||||
private const TAX_TABLE_YEAR = 2024;
|
||||
|
||||
/**
|
||||
* 필터 적용 쿼리 생성 (목록/엑셀 공통)
|
||||
@@ -155,7 +129,8 @@ public function storePayroll(array $data): Payroll
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
$calculated = $this->calculateAmounts($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([
|
||||
@@ -203,7 +178,8 @@ public function updatePayroll(int $id, array $data): ?Payroll
|
||||
}
|
||||
|
||||
$mergedData = array_merge($payroll->toArray(), $data);
|
||||
$calculated = $this->calculateAmounts($mergedData);
|
||||
$familyCount = $data['family_count'] ?? $this->resolveFamilyCount($payroll->user_id);
|
||||
$calculated = $this->calculateAmounts($mergedData, null, $familyCount);
|
||||
$this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null);
|
||||
|
||||
$payroll->update([
|
||||
@@ -339,7 +315,11 @@ public function bulkGenerate(int $year, int $month): array
|
||||
'deductions' => null,
|
||||
];
|
||||
|
||||
$calculated = $this->calculateAmounts($data, $settings);
|
||||
// 본인(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,
|
||||
@@ -378,7 +358,7 @@ public function bulkGenerate(int $year, int $month): array
|
||||
* 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만
|
||||
* 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다.
|
||||
*/
|
||||
public function calculateAmounts(array $data, ?PayrollSetting $settings = null): array
|
||||
public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array
|
||||
{
|
||||
$settings = $settings ?? PayrollSetting::getOrCreate();
|
||||
|
||||
@@ -407,10 +387,10 @@ public function calculateAmounts(array $data, ?PayrollSetting $settings = null):
|
||||
$pension = $this->calculatePension($taxableBase, $settings);
|
||||
$employmentInsurance = $this->calculateEmploymentInsurance($taxableBase, $settings);
|
||||
|
||||
// 근로소득세 (간이세액표, 과세표준 기준)
|
||||
$incomeTax = $this->calculateIncomeTax($taxableBase);
|
||||
// 지방소득세 (근로소득세의 10%)
|
||||
$residentTax = (int) floor($incomeTax * ($settings->resident_tax_rate / 100));
|
||||
// 근로소득세 (간이세액표, 과세표준 기준, 공제대상가족수 반영)
|
||||
$incomeTax = $this->calculateIncomeTax($taxableBase, $familyCount);
|
||||
// 지방소득세 (근로소득세의 10%, 10원 단위 절삭)
|
||||
$residentTax = (int) (floor($incomeTax * ($settings->resident_tax_rate / 100) / 10) * 10);
|
||||
|
||||
// 추가 공제 합계
|
||||
$extraDeductions = 0;
|
||||
@@ -471,28 +451,86 @@ private function applyDeductionOverrides(array &$calculated, ?array $overrides):
|
||||
}
|
||||
|
||||
/**
|
||||
* 근로소득세 계산 (간이세액표 기준, 부양가족 1인)
|
||||
* 근로소득세 계산 (2024 국세청 간이세액표 기반)
|
||||
*
|
||||
* @param float $taxableBase 과세표준 (원)
|
||||
* @param int $familyCount 공제대상가족수 (1~11, 본인 포함)
|
||||
*/
|
||||
public function calculateIncomeTax(float $grossSalary, int $dependents = 1): int
|
||||
public function calculateIncomeTax(float $taxableBase, int $familyCount = 1): int
|
||||
{
|
||||
if ($grossSalary <= 0) {
|
||||
if ($taxableBase <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tax = 0;
|
||||
foreach (self::INCOME_TAX_TABLE as [$min, $max, $amount]) {
|
||||
if ($grossSalary > $min && $grossSalary <= $max) {
|
||||
$tax = $amount;
|
||||
break;
|
||||
}
|
||||
$salaryThousand = (int) floor($taxableBase / 1000);
|
||||
$familyCount = max(1, min(11, $familyCount));
|
||||
|
||||
// 770천원 미만: 세액 없음
|
||||
if ($salaryThousand < 770) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 마지막 구간 초과
|
||||
if ($grossSalary > 87000000) {
|
||||
$tax = self::INCOME_TAX_TABLE[count(self::INCOME_TAX_TABLE) - 1][2];
|
||||
// 10,000천원 초과: 공식 계산
|
||||
if ($salaryThousand > 10000) {
|
||||
return $this->calculateHighIncomeTax($salaryThousand, $familyCount);
|
||||
}
|
||||
|
||||
return (int) $tax;
|
||||
// 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) {
|
||||
return (int) ($baseTax + ($excessWon * 0.98 * 0.35) + 25000);
|
||||
}
|
||||
if ($salaryThousand <= 28000) {
|
||||
$excessWon = ($salaryThousand - 14000) * 1000;
|
||||
|
||||
return (int) ($baseTax + 1397000 + ($excessWon * 0.98 * 0.38));
|
||||
}
|
||||
if ($salaryThousand <= 30000) {
|
||||
$excessWon = ($salaryThousand - 28000) * 1000;
|
||||
|
||||
return (int) ($baseTax + 6610600 + ($excessWon * 0.98 * 0.40));
|
||||
}
|
||||
if ($salaryThousand <= 45000) {
|
||||
$excessWon = ($salaryThousand - 30000) * 1000;
|
||||
|
||||
return (int) ($baseTax + 7394600 + ($excessWon * 0.40));
|
||||
}
|
||||
if ($salaryThousand <= 87000) {
|
||||
$excessWon = ($salaryThousand - 45000) * 1000;
|
||||
|
||||
return (int) ($baseTax + 13394600 + ($excessWon * 0.42));
|
||||
}
|
||||
|
||||
// 87,000천원 초과
|
||||
$excessWon = ($salaryThousand - 87000) * 1000;
|
||||
|
||||
return (int) ($baseTax + 31034600 + ($excessWon * 0.45));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,6 +607,29 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 수정
|
||||
*/
|
||||
|
||||
@@ -155,7 +155,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
|
||||
<option value="">사원 선택</option>
|
||||
@foreach($employees as $emp)
|
||||
<option value="{{ $emp->user_id }}"
|
||||
data-salary="{{ $emp->getJsonExtraValue('salary', 0) }}">
|
||||
data-salary="{{ $emp->getJsonExtraValue('salary', 0) }}"
|
||||
data-dependents="{{ 1 + collect($emp->dependents)->where('is_dependent', true)->count() }}">
|
||||
{{ $emp->display_name ?? $emp->user?->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
@@ -282,6 +283,10 @@ class="money-input w-28 px-2 py-1 border border-gray-200 rounded text-right text
|
||||
<span class="text-gray-400">과세표준 (식대 제외)</span>
|
||||
<span id="calcTaxableBase" class="text-gray-500">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-gray-400">공제대상가족수</span>
|
||||
<span id="calcFamilyCount" class="text-gray-500">1명</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 font-medium">총 공제액</span>
|
||||
<span id="calcTotalDeductions" class="text-red-600 font-bold">0</span>
|
||||
@@ -500,10 +505,14 @@ function closePayrollModal() {
|
||||
document.getElementById('payrollUserId').disabled = false;
|
||||
}
|
||||
|
||||
// ===== 사원 선택 시 기본급 자동 입력 =====
|
||||
// ===== 사원 선택 시 기본급 자동 입력 + 가족수 표시 =====
|
||||
document.getElementById('payrollUserId').addEventListener('change', function() {
|
||||
if (editingPayrollId) return;
|
||||
const selected = this.options[this.selectedIndex];
|
||||
const dependents = parseInt(selected.dataset.dependents || 1);
|
||||
const fcEl = document.getElementById('calcFamilyCount');
|
||||
if (fcEl) fcEl.textContent = dependents + '명';
|
||||
|
||||
if (editingPayrollId) return;
|
||||
const salary = parseInt(selected.dataset.salary || 0);
|
||||
if (salary > 0) {
|
||||
setMoneyValue(document.getElementById('payrollBaseSalary'), Math.round(salary / 12));
|
||||
@@ -570,6 +579,7 @@ function doRecalculate() {
|
||||
bonus: parseMoneyValue(document.getElementById('payrollBonus')),
|
||||
allowances: allowances,
|
||||
deductions: deductions,
|
||||
user_id: parseInt(document.getElementById('payrollUserId').value) || null,
|
||||
};
|
||||
|
||||
fetch('{{ route("api.admin.hr.payrolls.calculate") }}', {
|
||||
@@ -599,6 +609,10 @@ function doRecalculate() {
|
||||
});
|
||||
document.getElementById('calcGross').textContent = numberFormat(d.gross_salary);
|
||||
document.getElementById('calcTaxableBase').textContent = numberFormat(d.taxable_base);
|
||||
if (d.family_count) {
|
||||
const fcEl = document.getElementById('calcFamilyCount');
|
||||
if (fcEl) fcEl.textContent = d.family_count + '명';
|
||||
}
|
||||
updateDeductionTotals();
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user