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', 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); // 동일 대상/기간 중복 체크 (soft-deleted 포함 — DB 유니크 제약과 일치) $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) { if ($existing->trashed()) { $existing->forceDelete(); } else { throw new \InvalidArgumentException( "해당 직원의 {$data['pay_year']}년 {$data['pay_month']}월 급여가 이미 등록되어 있습니다." ); } } 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'], '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): ?Payroll { $tenantId = session('selected_tenant_id', 1); $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'], 'long_term_care' => $calculated['long_term_care'], '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', 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']); } /** * 급여 지급 처리 */ 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']); } /** * 일괄 생성 (재직 사원 전체) */ 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) { $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'], '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]; } /** * 급여 금액 자동 계산 */ 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); $longTermCare = $this->calculateLongTermCare($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 + $longTermCare + $pension + $employmentInsurance + $extraDeductions; // 실수령액 $netSalary = $grossSalary - $totalDeductions; return [ 'gross_salary' => (int) $grossSalary, '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), ]; } /** * 근로소득세 계산 (간이세액표 기준, 부양가족 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 { return (int) round($grossSalary * ($settings->health_insurance_rate / 100)); } /** * 장기요양보험료 계산 (건강보험료의 일정 비율) */ private function calculateLongTermCare(float $grossSalary, PayrollSetting $settings): int { $healthInsurance = $grossSalary * ($settings->health_insurance_rate / 100); return (int) round($healthInsurance * ($settings->long_term_care_rate / 100)); } /** * 국민연금 계산 */ 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', 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() ->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(); } }