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']}%"); }); } if (! empty($params['department_id'])) { $deptId = $params['department_id']; $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { $q->where('tenant_id', $tenantId)->where('department_id', $deptId); }); } $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); } return $query->paginate($params['per_page'] ?? 20); } 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' => (int) $stats->total_gross, 'total_deductions' => (int) $stats->total_deductions, 'total_net' => (int) $stats->total_net, ]; } public function show(int $id): Payroll { return Payroll::query() ->where('tenant_id', $this->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(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 중복 확인 (soft-deleted 포함) $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']) ->first(); if ($existing && ! $existing->trashed()) { throw new BadRequestHttpException(__('error.payroll.already_exists')); } if ($existing && $existing->trashed()) { $existing->forceDelete(); } // 자동 계산 $settings = PayrollSetting::getOrCreate($tenantId); $familyCount = $data['family_count'] ?? $this->resolveFamilyCount($data['user_id']); $calculated = $this->calculateAmounts($data, $settings, $familyCount); $this->applyDeductionOverrides($calculated, $data['deduction_overrides'] ?? null); 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' => $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' => 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); $isSuperAdmin = $data['_is_super_admin'] ?? false; if (! $payroll->isEditable($isSuperAdmin)) { 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')); } } // 지급 항목 (신규 입력값 또는 기존값) $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; $deductions = array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions; $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'] : (int) $payroll->income_tax; $residentTax = isset($overrides['resident_tax']) ? (int) $overrides['resident_tax'] : (int) $payroll->resident_tax; $healthInsurance = isset($overrides['health_insurance']) ? (int) $overrides['health_insurance'] : (int) $payroll->health_insurance; $longTermCare = isset($overrides['long_term_care']) ? (int) $overrides['long_term_care'] : (int) $payroll->long_term_care; $pension = isset($overrides['pension']) ? (int) $overrides['pension'] : (int) $payroll->pension; $employmentInsurance = isset($overrides['employment_insurance']) ? (int) $overrides['employment_insurance'] : (int) $payroll->employment_insurance; $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([ 'user_id' => $newUserId, 'pay_year' => $newYear, 'pay_month' => $newMonth, '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' => $userId, ]); 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->update([ 'status' => Payroll::STATUS_CONFIRMED, 'confirmed_at' => now(), 'confirmed_by' => $userId, 'updated_by' => $userId, ]); return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); } public function unconfirm(int $id): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isUnconfirmable()) { throw new BadRequestHttpException(__('error.payroll.not_unconfirmable')); } $payroll->update([ 'status' => Payroll::STATUS_DRAFT, 'confirmed_at' => null, 'confirmed_by' => null, 'updated_by' => $userId, ]); return $payroll->fresh(['user:id,name,email']); } 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->update([ 'status' => Payroll::STATUS_PAID, 'paid_at' => now(), 'withdrawal_id' => $withdrawalId, 'updated_by' => $userId, ]); return $payroll->fresh(['user:id,name,email', 'withdrawal']); }); } public function unpay(int $id): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isUnpayable()) { throw new BadRequestHttpException(__('error.payroll.not_unpayable')); } $payroll->update([ 'status' => Payroll::STATUS_DRAFT, 'confirmed_at' => null, 'confirmed_by' => null, 'paid_at' => null, 'withdrawal_id' => null, 'updated_by' => $userId, ]); return $payroll->fresh(['user:id,name,email']); } 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 bulkGenerate(int $year, int $month): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $settings = PayrollSetting::getOrCreate($tenantId); $created = 0; $skipped = 0; $employees = TenantUserProfile::query() ->with('user:id,name') ->where('tenant_id', $tenantId) ->where('employee_status', 'active') ->get(); DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, $userId, &$created, &$skipped) { foreach ($employees as $employee) { $existing = Payroll::withTrashed() ->where('tenant_id', $tenantId) ->where('user_id', $employee->user_id) ->forPeriod($year, $month) ->first(); if ($existing && ! $existing->trashed()) { $skipped++; continue; } if ($existing && $existing->trashed()) { $existing->forceDelete(); } // 연봉에서 월급 산출 $salaryInfo = $employee->json_extra['salary_info'] ?? $employee->json_extra ?? []; $annualSalary = $salaryInfo['annual_salary'] ?? ($employee->json_extra['salary'] ?? 0); $baseSalary = $annualSalary > 0 ? (int) round($annualSalary / 12) : 0; $data = [ 'base_salary' => $baseSalary, 'overtime_pay' => 0, 'bonus' => 0, 'allowances' => null, 'deductions' => null, ]; // 피부양자 기반 가족수 산출 $dependents = $employee->json_extra['dependents'] ?? []; $familyCount = 1 + collect($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' => Payroll::STATUS_DRAFT, 'created_by' => $userId, 'updated_by' => $userId, ]); $created++; } }); return ['created' => $created, 'skipped' => $skipped]; } /** * 전월 급여 복사 */ public function copyFromPreviousMonth(int $year, int $month): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $prevYear = $month === 1 ? $year - 1 : $year; $prevMonth = $month === 1 ? 12 : $month - 1; $previousPayrolls = Payroll::query() ->where('tenant_id', $tenantId) ->forPeriod($prevYear, $prevMonth) ->get(); if ($previousPayrolls->isEmpty()) { throw new BadRequestHttpException(__('error.payroll.no_previous_month')); } $created = 0; $skipped = 0; DB::transaction(function () use ($previousPayrolls, $tenantId, $year, $month, $userId, &$created, &$skipped) { foreach ($previousPayrolls as $prev) { $existing = Payroll::withTrashed() ->where('tenant_id', $tenantId) ->where('user_id', $prev->user_id) ->forPeriod($year, $month) ->first(); if ($existing && ! $existing->trashed()) { $skipped++; continue; } 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' => Payroll::STATUS_DRAFT, 'created_by' => $userId, 'updated_by' => $userId, ]); $created++; } }); return ['created' => $created, 'skipped' => $skipped]; } /** * 급여 일괄 계산 (기존 draft 급여 재계산) */ public function calculate(int $year, int $month, ?array $userIds = null): Collection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $settings = PayrollSetting::getOrCreate($tenantId); 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) { $familyCount = $this->resolveFamilyCount($payroll->user_id); $data = [ 'base_salary' => (float) $payroll->base_salary, 'overtime_pay' => (float) $payroll->overtime_pay, 'bonus' => (float) $payroll->bonus, 'allowances' => $payroll->allowances, 'deductions' => $payroll->deductions, ]; $calculated = $this->calculateAmounts($data, $settings, $familyCount); $payroll->update([ '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'], 'total_deductions' => $calculated['total_deductions'], 'net_salary' => $calculated['net_salary'], 'updated_by' => $userId, ]); } return $payrolls->fresh(['user:id,name,email']); }); } /** * 계산 미리보기 (저장하지 않음) */ public function calculatePreview(array $data): array { $tenantId = $this->tenantId(); $settings = PayrollSetting::getOrCreate($tenantId); $familyCount = 1; if (! empty($data['user_id'])) { $familyCount = $this->resolveFamilyCount((int) $data['user_id']); } $calculated = $this->calculateAmounts($data, $settings, $familyCount); return array_merge($calculated, ['family_count' => $familyCount]); } // ========================================================================= // 급여명세서 // ========================================================================= public function payslip(int $id): array { $payroll = $this->show($id); $allowances = collect($payroll->allowances ?? [])->map(fn ($item) => [ 'name' => $item['name'] ?? '', 'amount' => (int) ($item['amount'] ?? 0), ])->toArray(); $deductions = collect($payroll->deductions ?? [])->map(fn ($item) => [ 'name' => $item['name'] ?? '', 'amount' => (int) ($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' => (int) $payroll->base_salary, 'overtime_pay' => (int) $payroll->overtime_pay, 'bonus' => (int) $payroll->bonus, 'allowances' => $allowances, 'allowances_total' => (int) $payroll->allowances_total, 'gross_total' => (int) $payroll->gross_salary, ], 'deductions' => [ 'income_tax' => (int) $payroll->income_tax, 'resident_tax' => (int) $payroll->resident_tax, 'health_insurance' => (int) $payroll->health_insurance, 'long_term_care' => (int) $payroll->long_term_care, 'pension' => (int) $payroll->pension, 'employment_insurance' => (int) $payroll->employment_insurance, 'other_deductions' => $deductions, 'other_total' => (int) $payroll->deductions_total, 'total' => (int) $payroll->total_deductions, ], 'net_salary' => (int) $payroll->net_salary, 'status' => $payroll->status, 'status_label' => $payroll->status_label, 'paid_at' => $payroll->paid_at?->toIso8601String(), ]; } // ========================================================================= // 급여 설정 // ========================================================================= public function getSettings(): PayrollSetting { return PayrollSetting::getOrCreate($this->tenantId()); } public function updateSettings(array $data): PayrollSetting { $settings = PayrollSetting::getOrCreate($this->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; } // ========================================================================= // 계산 엔진 // ========================================================================= /** * 급여 금액 자동 계산 * * 식대(bonus)는 비과세 항목으로, 총 지급액에는 포함되지만 * 4대보험 및 세금 산출 기준(과세표준)에서는 제외된다. */ public function calculateAmounts(array $data, ?PayrollSetting $settings = null, int $familyCount = 1): array { $settings = $settings ?? PayrollSetting::getOrCreate($this->tenantId()); $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->calcHealthInsurance($taxableBase, $settings); $longTermCare = $this->calcLongTermCare($taxableBase, $settings); $pension = $this->calcPension($taxableBase, $settings); $employmentInsurance = $this->calcEmploymentInsurance($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 국세청 간이세액표 기반) */ 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)); if ($salaryThousand < 770) { return 0; } if ($salaryThousand > 10000) { return $this->calculateHighIncomeTax($salaryThousand, $familyCount); } return IncomeTaxBracket::lookupTax(self::TAX_TABLE_YEAR, $salaryThousand, $familyCount); } /** * 10,000천원 초과 구간 근로소득세 공식 계산 (소득세법 시행령 별표2) */ private function calculateHighIncomeTax(int $salaryThousand, int $familyCount): int { $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; if ($salaryThousand <= 14000) { $excessWon = ($salaryThousand - 10000) * 1000; $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 { $excessWon = ($salaryThousand - 87000) * 1000; $tax = $baseTax + 31034600 + ($excessWon * 0.45); } return (int) (floor($tax / 10) * 10); } private function calcHealthInsurance(float $taxableBase, PayrollSetting $settings): int { return (int) (floor($taxableBase * ($settings->health_insurance_rate / 100) / 10) * 10); } private function calcLongTermCare(float $taxableBase, PayrollSetting $settings): int { $healthInsurance = $taxableBase * ($settings->health_insurance_rate / 100); return (int) (floor($healthInsurance * ($settings->long_term_care_rate / 100) / 10) * 10); } private function calcPension(float $taxableBase, PayrollSetting $settings): int { $base = min(max($taxableBase, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary); return (int) (floor($base * ($settings->pension_rate / 100) / 10) * 10); } private function calcEmploymentInsurance(float $taxableBase, PayrollSetting $settings): int { return (int) (floor($taxableBase * ($settings->employment_insurance_rate / 100) / 10) * 10); } /** * user_id로 공제대상가족수 산출 (본인 1 + 피부양자) */ public function resolveFamilyCount(int $userId): int { $tenantId = $this->tenantId(); $profile = TenantUserProfile::query() ->where('tenant_id', $tenantId) ->where('user_id', $userId) ->first(['json_extra']); if (! $profile) { return 1; } $dependents = $profile->json_extra['dependents'] ?? []; $dependentCount = collect($dependents) ->where('is_dependent', true) ->count(); return max(1, min(11, 1 + $dependentCount)); } }