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 ->leftJoin('tenant_user_profiles as emp', function ($join) use ($tenantId) { $join->on('payrolls.user_id', '=', 'emp.user_id') ->where('emp.tenant_id', '=', $tenantId); }) ->select('payrolls.*') ->orderByRaw("COALESCE(emp.json_extra->>'$.hire_date', '9999-12-31') ASC"); } /** * 급여 목록 조회 (페이지네이션) */ 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, bool $force = false): ?Payroll { $tenantId = session('selected_tenant_id', 1); $payroll = Payroll::query() ->forTenant($tenantId) ->find($id); if (! $payroll || (! $force && ! $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']); } /** * 급여 지급 취소 (paid → draft, 슈퍼관리자 전용) */ public function unpayPayroll(int $id): ?Payroll { $tenantId = session('selected_tenant_id', 1); $payroll = Payroll::query() ->forTenant($tenantId) ->find($id); if (! $payroll || ! $payroll->isUnpayable()) { return null; } $payroll->update([ 'status' => Payroll::STATUS_DRAFT, 'confirmed_at' => null, 'confirmed_by' => null, 'paid_at' => null, '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() ->whereDoesntHave('department', fn ($q) => $q->where('name', '영업팀')) ->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 sendPayslip(int $id): ?array { $tenantId = session('selected_tenant_id', 1); $payroll = Payroll::query() ->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department']) ->forTenant($tenantId) ->find($id); if (! $payroll) { return null; } $user = $payroll->user; $profile = $user?->tenantProfiles?->first(); // 개인 이메일 우선, 없으면 업무용 이메일 $personalEmail = $profile?->json_extra['personal_email'] ?? null; $email = $personalEmail ?: $user?->email; if (! $email) { throw new \RuntimeException('해당 직원의 이메일 주소가 등록되어 있지 않습니다.'); } // 지급 항목 구성 $payments = []; $payments[] = ['name' => '기본급', 'amount' => (int) $payroll->base_salary]; if ((int) $payroll->bonus > 0) { $payments[] = ['name' => '식대', 'amount' => (int) $payroll->bonus]; } if ((int) $payroll->overtime_pay > 0) { $payments[] = ['name' => '고정연장근로수당', 'amount' => (int) $payroll->overtime_pay]; } foreach ($payroll->allowances ?? [] as $a) { if (($a['amount'] ?? 0) > 0) { $payments[] = ['name' => $a['name'] ?? '기타수당', 'amount' => (int) $a['amount']]; } } // 공제 항목 구성 $deductionItems = []; if ((int) $payroll->pension > 0) { $deductionItems[] = ['name' => '국민연금', 'amount' => (int) $payroll->pension]; } if ((int) $payroll->health_insurance > 0) { $deductionItems[] = ['name' => '건강보험', 'amount' => (int) $payroll->health_insurance]; } if ((int) $payroll->employment_insurance > 0) { $deductionItems[] = ['name' => '고용보험', 'amount' => (int) $payroll->employment_insurance]; } if ((int) $payroll->long_term_care > 0) { $deductionItems[] = ['name' => '장기요양보험료', 'amount' => (int) $payroll->long_term_care]; } if ((int) $payroll->income_tax > 0) { $deductionItems[] = ['name' => '소득세', 'amount' => (int) $payroll->income_tax]; } if ((int) $payroll->resident_tax > 0) { $deductionItems[] = ['name' => '지방소득세', 'amount' => (int) $payroll->resident_tax]; } foreach ($payroll->deductions ?? [] as $d) { if (($d['amount'] ?? 0) > 0) { $deductionItems[] = ['name' => $d['name'] ?? '기타공제', 'amount' => (int) $d['amount']]; } } $payslipData = [ 'pay_year' => $payroll->pay_year, 'pay_month' => $payroll->pay_month, 'employee_code' => $user->id, 'employee_name' => $profile?->display_name ?? $user->name ?? '-', 'hire_date' => $profile?->hire_date ?? '-', 'department' => $profile?->department?->name ?? '-', 'position' => $profile?->position_label ?? '-', 'grade' => '', 'payments' => $payments, 'deduction_items' => $deductionItems, 'gross_salary' => (int) $payroll->gross_salary, 'total_deductions' => (int) $payroll->total_deductions, 'net_salary' => (int) $payroll->net_salary, ]; // PDF 생성 (한글 폰트를 동일 인스턴스에 등록) $month = str_pad($payslipData['pay_month'], 2, '0', STR_PAD_LEFT); $pdf = Pdf::loadView('emails.payslip', ['payslipData' => $payslipData]) ->setPaper('a4'); $this->registerKoreanFont($pdf); $pdfContent = $pdf->output(); $fileName = "{$payslipData['pay_year']}년{$month}월_급여명세서_{$payslipData['employee_name']}.pdf"; Mail::to($email)->send(new PayslipMail($payslipData, $pdfContent, $fileName)); // 발송 이력 저장 $options = $payroll->options ?? []; $options['email_sent_at'] = now()->format('Y-m-d H:i'); $options['email_sent_to'] = $email; $payroll->update(['options' => $options]); return [ 'email' => $email, 'message' => "{$payslipData['employee_name']}님({$email})에게 급여명세서를 발송했습니다.", ]; } /** * PDF 인스턴스에 한글(Pretendard) 폰트 등록 * * - render() 전에 동일 DomPDF 인스턴스에 등록해야 적용됨 * - storage/fonts/ (shared, 릴리스 불변) 경로 사용 → 배포 간 .ufm 캐시 재활용 * - 최초 1회만 resources/fonts/ → storage/fonts/로 복사 */ private function registerKoreanFont(\Barryvdh\DomPDF\PDF $pdf): void { $fontDir = storage_path('fonts'); $normalDst = $fontDir.'/Pretendard-Regular.ttf'; $boldDst = $fontDir.'/Pretendard-Bold.ttf'; // 최초 배포 시 resources → storage 복사 (이후 shared에 유지) if (! file_exists($normalDst)) { $src = resource_path('fonts/Pretendard-Regular.ttf'); if (! file_exists($src)) { return; } if (! is_dir($fontDir)) { mkdir($fontDir, 0755, true); } copy($src, $normalDst); } if (! file_exists($boldDst)) { $src = resource_path('fonts/Pretendard-Bold.ttf'); if (file_exists($src)) { copy($src, $boldDst); } } $dompdf = $pdf->getDomPDF(); $fm = $dompdf->getFontMetrics(); $fm->registerFont( ['family' => 'pretendard', 'style' => 'normal', 'weight' => 'normal'], $normalDst ); if (file_exists($boldDst)) { $fm->registerFont( ['family' => 'pretendard', 'style' => 'normal', 'weight' => 'bold'], $boldDst ); } $fm->saveFontFamilies(); } /** * 급여 설정 수정 */ 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(); } }