Files
sam-api/app/Services/PayrollService.php
권혁성 5448f0e57d deploy: 2026-03-12 배포
- feat: [barobill] 바로빌 카드/은행/홈택스 REST API 구현
- feat: [equipment] 설비관리 API 백엔드 구현
- feat: [payroll] 급여관리 계산 엔진 및 일괄 처리 API
- feat: [QMS] 점검표 템플릿 관리 + 로트심사 개선
- feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선
- feat: [receiving] 입고 성적서 파일 연결
- feat: [견적] 제어기 타입 체계 변경
- feat: [email] 테넌트 메일 설정 마이그레이션 및 모델
- feat: [pmis] 시공관리 테이블 마이그레이션
- feat: [R2] 파일 업로드 커맨드 + filesystems 설정
- feat: [배포] Jenkinsfile 롤백 기능 추가
- fix: [approval] SAM API 규칙 준수 코드 개선
- fix: [account-codes] 계정과목 중복 데이터 정리
- fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기
- fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 제거
- refactor: [barobill] 바로빌 연동 코드 전면 개선
2026-03-12 15:20:20 +09:00

1176 lines
44 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\IncomeTaxBracket;
use App\Models\Tenants\JournalEntry;
use App\Models\Tenants\Payroll;
use App\Models\Tenants\PayrollSetting;
use App\Models\Tenants\TenantUserProfile;
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
{
private const TAX_TABLE_YEAR = 2024;
// =========================================================================
// 급여 목록/상세
// =========================================================================
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']}%");
});
}
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')
->whereHas('user')
->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));
}
// =========================================================================
// 엑셀 내보내기
// =========================================================================
/**
* 급여 엑셀 내보내기용 데이터
*/
public function getExportData(array $params): array
{
$tenantId = $this->tenantId();
$query = Payroll::query()
->where('tenant_id', $tenantId)
->with(['user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId)->with('department: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['status'])) {
$query->where('status', $params['status']);
}
if (! empty($params['user_id'])) {
$query->where('user_id', $params['user_id']);
}
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);
});
}
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);
}
$payrolls = $query->get();
$statusLabels = [
Payroll::STATUS_DRAFT => '작성중',
Payroll::STATUS_CONFIRMED => '확정',
Payroll::STATUS_PAID => '지급완료',
];
$data = $payrolls->map(function ($payroll) use ($statusLabels) {
$profile = $payroll->user?->tenantProfiles?->first();
$department = $profile?->department?->name ?? '-';
return [
$payroll->pay_year.'년 '.$payroll->pay_month.'월',
$payroll->user?->name ?? '-',
$department,
number_format($payroll->base_salary),
number_format($payroll->overtime_pay),
number_format($payroll->bonus),
number_format($payroll->gross_salary),
number_format($payroll->income_tax),
number_format($payroll->resident_tax),
number_format($payroll->health_insurance),
number_format($payroll->long_term_care),
number_format($payroll->pension),
number_format($payroll->employment_insurance),
number_format($payroll->total_deductions),
number_format($payroll->net_salary),
$statusLabels[$payroll->status] ?? $payroll->status,
];
})->toArray();
$headings = [
'급여월',
'직원명',
'부서',
'기본급',
'야근수당',
'상여금',
'총지급액',
'소득세',
'주민세',
'건강보험',
'장기요양',
'국민연금',
'고용보험',
'공제합계',
'실지급액',
'상태',
];
return [
'data' => $data,
'headings' => $headings,
];
}
// =========================================================================
// 전표 생성
// =========================================================================
/**
* 급여 전표 일괄 생성
*
* 해당 연월의 확정/지급완료 급여를 합산하여 전표를 생성한다.
* - 차변: 급여 (총지급액)
* - 대변: 각 공제항목 + 미지급금(실지급액)
*/
public function createJournalEntries(int $year, int $month, ?string $entryDate = null): JournalEntry
{
$tenantId = $this->tenantId();
$payrolls = Payroll::query()
->where('tenant_id', $tenantId)
->forPeriod($year, $month)
->whereIn('status', [Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID])
->get();
if ($payrolls->isEmpty()) {
throw new BadRequestHttpException(__('error.payroll.no_confirmed_payrolls'));
}
// 합산
$totalGross = $payrolls->sum('gross_salary');
$totalIncomeTax = $payrolls->sum('income_tax');
$totalResidentTax = $payrolls->sum('resident_tax');
$totalHealthInsurance = $payrolls->sum('health_insurance');
$totalLongTermCare = $payrolls->sum('long_term_care');
$totalPension = $payrolls->sum('pension');
$totalEmploymentInsurance = $payrolls->sum('employment_insurance');
$totalNet = $payrolls->sum('net_salary');
// 전표일자: 지정값 또는 해당월 급여지급일
if (! $entryDate) {
$settings = PayrollSetting::getOrCreate($tenantId);
$payDay = min($settings->pay_day, 28);
$entryDate = sprintf('%04d-%02d-%02d', $year, $month, $payDay);
}
$sourceKey = "payroll_{$year}_{$month}";
$description = "{$year}{$month}월 급여";
// 분개 행 구성
$rows = [];
// 차변: 급여 (총지급액)
$rows[] = [
'side' => 'debit',
'account_code' => '51100',
'account_name' => '급여',
'debit_amount' => (int) $totalGross,
'credit_amount' => 0,
'memo' => $description,
];
// 대변: 소득세예수금
if ($totalIncomeTax > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25500',
'account_name' => '예수금-소득세',
'debit_amount' => 0,
'credit_amount' => (int) $totalIncomeTax,
];
}
// 대변: 주민세예수금
if ($totalResidentTax > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25501',
'account_name' => '예수금-주민세',
'debit_amount' => 0,
'credit_amount' => (int) $totalResidentTax,
];
}
// 대변: 건강보험예수금
if ($totalHealthInsurance > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25502',
'account_name' => '예수금-건강보험',
'debit_amount' => 0,
'credit_amount' => (int) $totalHealthInsurance,
];
}
// 대변: 장기요양보험예수금
if ($totalLongTermCare > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25503',
'account_name' => '예수금-장기요양',
'debit_amount' => 0,
'credit_amount' => (int) $totalLongTermCare,
];
}
// 대변: 국민연금예수금
if ($totalPension > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25504',
'account_name' => '예수금-국민연금',
'debit_amount' => 0,
'credit_amount' => (int) $totalPension,
];
}
// 대변: 고용보험예수금
if ($totalEmploymentInsurance > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25505',
'account_name' => '예수금-고용보험',
'debit_amount' => 0,
'credit_amount' => (int) $totalEmploymentInsurance,
];
}
// 대변: 미지급금 (실지급액)
if ($totalNet > 0) {
$rows[] = [
'side' => 'credit',
'account_code' => '25300',
'account_name' => '미지급금',
'debit_amount' => 0,
'credit_amount' => (int) $totalNet,
'memo' => "급여 실지급액 ({$payrolls->count()}명)",
];
}
$syncService = app(JournalSyncService::class);
return $syncService->saveForSource(
JournalEntry::SOURCE_PAYROLL,
$sourceKey,
$entryDate,
$description,
$rows
);
}
}