- 초과근무수당 → 고정연장근로수당 명칭 변경 - 소득세 → 근로소득세, 주민세 → 지방소득세 명칭 변경 - 공제항목 순서: 국민연금-건강보험-고용보험-근로소득세-지방소득세 - CSV 내보내기 헤더 및 데이터 순서 동일 적용
525 lines
18 KiB
PHP
525 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\HR;
|
|
|
|
use App\Models\HR\Employee;
|
|
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
|
|
{
|
|
// =========================================================================
|
|
// 간이세액표 (국세청 근로소득 간이세액표 기준, 부양가족 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 function buildFilteredQuery(array $filters = [])
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$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');
|
|
$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');
|
|
|
|
return DB::transaction(function () use ($data, $tenantId) {
|
|
$calculated = $this->calculateAmounts($data);
|
|
|
|
$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'],
|
|
'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');
|
|
|
|
$payroll = Payroll::query()
|
|
->forTenant($tenantId)
|
|
->find($id);
|
|
|
|
if (! $payroll || ! $payroll->isEditable()) {
|
|
return null;
|
|
}
|
|
|
|
$mergedData = array_merge($payroll->toArray(), $data);
|
|
$calculated = $this->calculateAmounts($mergedData);
|
|
|
|
$payroll->update([
|
|
'base_salary' => $data['base_salary'] ?? $payroll->base_salary,
|
|
'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay,
|
|
'bonus' => $data['bonus'] ?? $payroll->bonus,
|
|
'allowances' => array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances,
|
|
'gross_salary' => $calculated['gross_salary'],
|
|
'income_tax' => $calculated['income_tax'],
|
|
'resident_tax' => $calculated['resident_tax'],
|
|
'health_insurance' => $calculated['health_insurance'],
|
|
'pension' => $calculated['pension'],
|
|
'employment_insurance' => $calculated['employment_insurance'],
|
|
'deductions' => array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions,
|
|
'total_deductions' => $calculated['total_deductions'],
|
|
'net_salary' => $calculated['net_salary'],
|
|
'note' => $data['note'] ?? $payroll->note,
|
|
'updated_by' => auth()->id(),
|
|
]);
|
|
|
|
return $payroll->fresh(['user']);
|
|
}
|
|
|
|
/**
|
|
* 급여 삭제
|
|
*/
|
|
public function deletePayroll(int $id): bool
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$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');
|
|
|
|
$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']);
|
|
}
|
|
|
|
/**
|
|
* 급여 지급 처리
|
|
*/
|
|
public function payPayroll(int $id): ?Payroll
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
$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 bulkGenerate(int $year, int $month): array
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
$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) {
|
|
$exists = Payroll::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('user_id', $employee->user_id)
|
|
->forPeriod($year, $month)
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$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,
|
|
];
|
|
|
|
$calculated = $this->calculateAmounts($data, $settings);
|
|
|
|
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'],
|
|
'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];
|
|
}
|
|
|
|
/**
|
|
* 급여 금액 자동 계산
|
|
*/
|
|
public function calculateAmounts(array $data, ?PayrollSetting $settings = null): 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;
|
|
|
|
// 4대보험 계산
|
|
$healthInsurance = $this->calculateHealthInsurance($grossSalary, $settings);
|
|
$pension = $this->calculatePension($grossSalary, $settings);
|
|
$employmentInsurance = $this->calculateEmploymentInsurance($grossSalary, $settings);
|
|
|
|
// 근로소득세 (간이세액표)
|
|
$incomeTax = $this->calculateIncomeTax($grossSalary);
|
|
// 지방소득세 (근로소득세의 10%)
|
|
$residentTax = (int) floor($incomeTax * ($settings->resident_tax_rate / 100));
|
|
|
|
// 추가 공제 합계
|
|
$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 + $pension + $employmentInsurance + $extraDeductions;
|
|
|
|
// 실수령액
|
|
$netSalary = $grossSalary - $totalDeductions;
|
|
|
|
return [
|
|
'gross_salary' => (int) $grossSalary,
|
|
'income_tax' => $incomeTax,
|
|
'resident_tax' => $residentTax,
|
|
'health_insurance' => $healthInsurance,
|
|
'pension' => $pension,
|
|
'employment_insurance' => $employmentInsurance,
|
|
'total_deductions' => (int) $totalDeductions,
|
|
'net_salary' => (int) max(0, $netSalary),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 근로소득세 계산 (간이세액표 기준, 부양가족 1인)
|
|
*/
|
|
public function calculateIncomeTax(float $grossSalary, int $dependents = 1): int
|
|
{
|
|
if ($grossSalary <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$tax = 0;
|
|
foreach (self::INCOME_TAX_TABLE as [$min, $max, $amount]) {
|
|
if ($grossSalary > $min && $grossSalary <= $max) {
|
|
$tax = $amount;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 마지막 구간 초과
|
|
if ($grossSalary > 87000000) {
|
|
$tax = self::INCOME_TAX_TABLE[count(self::INCOME_TAX_TABLE) - 1][2];
|
|
}
|
|
|
|
return (int) $tax;
|
|
}
|
|
|
|
/**
|
|
* 건강보험료 계산
|
|
*/
|
|
private function calculateHealthInsurance(float $grossSalary, PayrollSetting $settings): int
|
|
{
|
|
$healthInsurance = $grossSalary * ($settings->health_insurance_rate / 100);
|
|
$longTermCare = $healthInsurance * ($settings->long_term_care_rate / 100);
|
|
|
|
return (int) round($healthInsurance + $longTermCare);
|
|
}
|
|
|
|
/**
|
|
* 국민연금 계산
|
|
*/
|
|
private function calculatePension(float $grossSalary, PayrollSetting $settings): int
|
|
{
|
|
$base = min(max($grossSalary, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary);
|
|
|
|
return (int) round($base * ($settings->pension_rate / 100));
|
|
}
|
|
|
|
/**
|
|
* 고용보험료 계산
|
|
*/
|
|
private function calculateEmploymentInsurance(float $grossSalary, PayrollSetting $settings): int
|
|
{
|
|
return (int) round($grossSalary * ($settings->employment_insurance_rate / 100));
|
|
}
|
|
|
|
/**
|
|
* 부서 목록 (드롭다운용)
|
|
*/
|
|
public function getDepartments(): Collection
|
|
{
|
|
$tenantId = session('selected_tenant_id');
|
|
|
|
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');
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 급여 설정 수정
|
|
*/
|
|
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();
|
|
}
|
|
}
|