diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php new file mode 100644 index 00000000..d75c95da --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -0,0 +1,398 @@ +payrollService->getPayrolls( + $request->all(), + $request->integer('per_page', 20) + ); + + if ($request->header('HX-Request')) { + return response(view('hr.payrolls.partials.table', compact('payrolls'))); + } + + return response()->json([ + 'success' => true, + 'data' => $payrolls->items(), + 'meta' => [ + 'current_page' => $payrolls->currentPage(), + 'last_page' => $payrolls->lastPage(), + 'per_page' => $payrolls->perPage(), + 'total' => $payrolls->total(), + ], + ]); + } + + /** + * 월간 통계 (HTMX → HTML / 일반 → JSON) + */ + public function stats(Request $request): JsonResponse|Response + { + $stats = $this->payrollService->getMonthlyStats( + $request->integer('year') ?: null, + $request->integer('month') ?: null + ); + + if ($request->header('HX-Request')) { + return response(view('hr.payrolls.partials.stats', compact('stats'))); + } + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 급여 등록 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'user_id' => 'required|integer|exists:users,id', + 'pay_year' => 'required|integer|min:2020|max:2100', + 'pay_month' => 'required|integer|min:1|max:12', + 'base_salary' => 'required|numeric|min:0', + 'overtime_pay' => 'nullable|numeric|min:0', + 'bonus' => 'nullable|numeric|min:0', + 'allowances' => 'nullable|array', + 'allowances.*.name' => 'required_with:allowances|string', + 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', + 'deductions' => 'nullable|array', + 'deductions.*.name' => 'required_with:deductions|string', + 'deductions.*.amount' => 'required_with:deductions|numeric|min:0', + 'note' => 'nullable|string|max:500', + ]); + + try { + $payroll = $this->payrollService->storePayroll($validated); + + return response()->json([ + 'success' => true, + 'message' => '급여가 등록되었습니다.', + 'data' => $payroll, + ], 201); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '급여 등록 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 급여 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'base_salary' => 'sometimes|required|numeric|min:0', + 'overtime_pay' => 'nullable|numeric|min:0', + 'bonus' => 'nullable|numeric|min:0', + 'allowances' => 'nullable|array', + 'allowances.*.name' => 'required_with:allowances|string', + 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', + 'deductions' => 'nullable|array', + 'deductions.*.name' => 'required_with:deductions|string', + 'deductions.*.amount' => 'required_with:deductions|numeric|min:0', + 'note' => 'nullable|string|max:500', + ]); + + try { + $payroll = $this->payrollService->updatePayroll($id, $validated); + + if (! $payroll) { + return response()->json([ + 'success' => false, + 'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '급여가 수정되었습니다.', + 'data' => $payroll, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '급여 수정 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 급여 삭제 + */ + public function destroy(Request $request, int $id): JsonResponse|Response + { + try { + $result = $this->payrollService->deletePayroll($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.', + ], 404); + } + + if ($request->header('HX-Request')) { + $payrolls = $this->payrollService->getPayrolls( + $request->all(), + $request->integer('per_page', 20) + ); + + return response(view('hr.payrolls.partials.table', compact('payrolls'))); + } + + return response()->json([ + 'success' => true, + 'message' => '급여가 삭제되었습니다.', + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '급여 삭제 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 급여 확정 + */ + public function confirm(Request $request, int $id): JsonResponse + { + try { + $payroll = $this->payrollService->confirmPayroll($id); + + if (! $payroll) { + return response()->json([ + 'success' => false, + 'message' => '급여를 확정할 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '급여가 확정되었습니다.', + 'data' => $payroll, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '급여 확정 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 급여 지급 처리 + */ + public function pay(Request $request, int $id): JsonResponse + { + try { + $payroll = $this->payrollService->payPayroll($id); + + if (! $payroll) { + return response()->json([ + 'success' => false, + 'message' => '급여를 지급 처리할 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '급여가 지급 처리되었습니다.', + 'data' => $payroll, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '급여 지급 처리 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 일괄 생성 + */ + public function bulkGenerate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'year' => 'required|integer|min:2020|max:2100', + 'month' => 'required|integer|min:1|max:12', + ]); + + try { + $result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']); + + return response()->json([ + 'success' => true, + 'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).", + 'data' => $result, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '일괄 생성 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 엑셀(CSV) 내보내기 + */ + public function export(Request $request): StreamedResponse + { + $payrolls = $this->payrollService->getExportData($request->all()); + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $filename = "급여관리_{$year}년{$month}월_".now()->format('Ymd').'.csv'; + + return response()->streamDownload(function () use ($payrolls) { + $file = fopen('php://output', 'w'); + fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM + + fputcsv($file, ['사원명', '부서', '기본급', '초과근무수당', '상여금', '총지급액', '소득세', '주민세', '건강보험', '국민연금', '고용보험', '총공제액', '실수령액', '상태']); + + foreach ($payrolls as $payroll) { + $profile = $payroll->user?->tenantProfiles?->first(); + $displayName = $profile?->display_name ?? $payroll->user?->name ?? '-'; + $department = $profile?->department?->name ?? '-'; + $statusLabel = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status; + + fputcsv($file, [ + $displayName, + $department, + $payroll->base_salary, + $payroll->overtime_pay, + $payroll->bonus, + $payroll->gross_salary, + $payroll->income_tax, + $payroll->resident_tax, + $payroll->health_insurance, + $payroll->pension, + $payroll->employment_insurance, + $payroll->total_deductions, + $payroll->net_salary, + $statusLabel, + ]); + } + + fclose($file); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** + * 급여 설정 조회 (HTMX → HTML / 일반 → JSON) + */ + public function settingsIndex(Request $request): JsonResponse|Response + { + $settings = $this->payrollService->getSettings(); + + if ($request->header('HX-Request')) { + return response(view('hr.payrolls.partials.settings', compact('settings'))); + } + + return response()->json([ + 'success' => true, + 'data' => $settings, + ]); + } + + /** + * 급여 설정 수정 + */ + public function settingsUpdate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'health_insurance_rate' => 'nullable|numeric|min:0|max:100', + 'long_term_care_rate' => 'nullable|numeric|min:0|max:100', + 'pension_rate' => 'nullable|numeric|min:0|max:100', + 'employment_insurance_rate' => 'nullable|numeric|min:0|max:100', + 'pension_max_salary' => 'nullable|numeric|min:0', + 'pension_min_salary' => 'nullable|numeric|min:0', + 'pay_day' => 'nullable|integer|min:1|max:31', + 'auto_calculate' => 'nullable|boolean', + ]); + + try { + $settings = $this->payrollService->updateSettings($validated); + + return response()->json([ + 'success' => true, + 'message' => '급여 설정이 저장되었습니다.', + 'data' => $settings, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '설정 저장 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + + /** + * 급여 계산 미리보기 (AJAX) + */ + public function calculate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'base_salary' => 'required|numeric|min:0', + 'overtime_pay' => 'nullable|numeric|min:0', + 'bonus' => 'nullable|numeric|min:0', + 'allowances' => 'nullable|array', + 'deductions' => 'nullable|array', + ]); + + $result = $this->payrollService->calculateAmounts($validated); + + return response()->json([ + 'success' => true, + 'data' => $result, + ]); + } +} diff --git a/app/Http/Controllers/HR/PayrollController.php b/app/Http/Controllers/HR/PayrollController.php new file mode 100644 index 00000000..87e7a7fa --- /dev/null +++ b/app/Http/Controllers/HR/PayrollController.php @@ -0,0 +1,41 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('hr.payrolls.index')); + } + + $stats = $this->payrollService->getMonthlyStats(); + $departments = $this->payrollService->getDepartments(); + $employees = $this->payrollService->getActiveEmployees(); + $settings = $this->payrollService->getSettings(); + $statusMap = Payroll::STATUS_MAP; + + return view('hr.payrolls.index', [ + 'stats' => $stats, + 'departments' => $departments, + 'employees' => $employees, + 'settings' => $settings, + 'statusMap' => $statusMap, + ]); + } +} diff --git a/app/Models/HR/Payroll.php b/app/Models/HR/Payroll.php new file mode 100644 index 00000000..13865469 --- /dev/null +++ b/app/Models/HR/Payroll.php @@ -0,0 +1,175 @@ + 'int', + 'user_id' => 'int', + 'pay_year' => 'int', + 'pay_month' => 'int', + 'base_salary' => 'decimal:0', + 'overtime_pay' => 'decimal:0', + 'bonus' => 'decimal:0', + 'gross_salary' => 'decimal:0', + 'income_tax' => 'decimal:0', + 'resident_tax' => 'decimal:0', + 'health_insurance' => 'decimal:0', + 'pension' => 'decimal:0', + 'employment_insurance' => 'decimal:0', + 'total_deductions' => 'decimal:0', + 'net_salary' => 'decimal:0', + 'allowances' => 'array', + 'deductions' => 'array', + 'confirmed_at' => 'datetime', + 'paid_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => 'draft', + ]; + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_CONFIRMED = 'confirmed'; + + public const STATUS_PAID = 'paid'; + + public const STATUS_MAP = [ + 'draft' => '작성중', + 'confirmed' => '확정', + 'paid' => '지급완료', + ]; + + public const STATUS_COLORS = [ + 'draft' => 'amber', + 'confirmed' => 'blue', + 'paid' => 'emerald', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function confirmer(): BelongsTo + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getStatusLabelAttribute(): string + { + return self::STATUS_MAP[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return self::STATUS_COLORS[$this->status] ?? 'gray'; + } + + public function getPeriodLabelAttribute(): string + { + return sprintf('%d년 %d월', $this->pay_year, $this->pay_month); + } + + // ========================================================================= + // 상태 헬퍼 + // ========================================================================= + + public function isEditable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isConfirmable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isPayable(): bool + { + return $this->status === self::STATUS_CONFIRMED; + } + + public function isDeletable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where($this->table.'.tenant_id', $tenantId); + } + + return $query; + } + + public function scopeForPeriod($query, int $year, int $month) + { + return $query->where('pay_year', $year)->where('pay_month', $month); + } + + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/app/Models/HR/PayrollSetting.php b/app/Models/HR/PayrollSetting.php new file mode 100644 index 00000000..6e0f4a06 --- /dev/null +++ b/app/Models/HR/PayrollSetting.php @@ -0,0 +1,107 @@ + 'int', + 'income_tax_rate' => 'decimal:2', + 'resident_tax_rate' => 'decimal:2', + 'health_insurance_rate' => 'decimal:3', + 'long_term_care_rate' => 'decimal:4', + 'pension_rate' => 'decimal:3', + 'employment_insurance_rate' => 'decimal:3', + 'pension_max_salary' => 'decimal:0', + 'pension_min_salary' => 'decimal:0', + 'pay_day' => 'int', + 'auto_calculate' => 'boolean', + 'allowance_types' => 'array', + 'deduction_types' => 'array', + ]; + + public const DEFAULT_ALLOWANCE_TYPES = [ + ['code' => 'meal', 'name' => '식대', 'is_taxable' => false], + ['code' => 'transport', 'name' => '교통비', 'is_taxable' => false], + ['code' => 'position', 'name' => '직책수당', 'is_taxable' => true], + ['code' => 'tech', 'name' => '기술수당', 'is_taxable' => true], + ['code' => 'family', 'name' => '가족수당', 'is_taxable' => true], + ['code' => 'housing', 'name' => '주거수당', 'is_taxable' => true], + ]; + + public const DEFAULT_DEDUCTION_TYPES = [ + ['code' => 'loan', 'name' => '대출상환'], + ['code' => 'union', 'name' => '조합비'], + ['code' => 'savings', 'name' => '저축'], + ['code' => 'etc', 'name' => '기타공제'], + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + if ($tenantId) { + return $query->where('tenant_id', $tenantId); + } + + return $query; + } + + // ========================================================================= + // 헬퍼 + // ========================================================================= + + public static function getOrCreate(?int $tenantId = null): self + { + $tenantId = $tenantId ?? session('selected_tenant_id'); + + return self::firstOrCreate( + ['tenant_id' => $tenantId], + [ + 'income_tax_rate' => 0, + 'resident_tax_rate' => 10, + 'health_insurance_rate' => 3.545, + 'long_term_care_rate' => 0.9082, + 'pension_rate' => 4.5, + 'employment_insurance_rate' => 0.9, + 'pension_max_salary' => 5900000, + 'pension_min_salary' => 370000, + 'pay_day' => 25, + 'auto_calculate' => false, + ] + ); + } + + public function getAllowanceTypesWithDefault(): array + { + return $this->allowance_types ?? self::DEFAULT_ALLOWANCE_TYPES; + } + + public function getDeductionTypesWithDefault(): array + { + return $this->deduction_types ?? self::DEFAULT_DEDUCTION_TYPES; + } +} diff --git a/app/Services/HR/PayrollService.php b/app/Services/HR/PayrollService.php new file mode 100644 index 00000000..e8f3ab06 --- /dev/null +++ b/app/Services/HR/PayrollService.php @@ -0,0 +1,524 @@ +with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department']) + ->forTenant($tenantId); + + if (! empty($filters['q'])) { + $search = $filters['q']; + $query->whereHas('user', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + if (! empty($filters['department_id'])) { + $deptId = $filters['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + $year = $filters['year'] ?? now()->year; + $month = $filters['month'] ?? now()->month; + $query->forPeriod((int) $year, (int) $month); + + return $query->orderBy('created_at', 'desc'); + } + + /** + * 급여 목록 조회 (페이지네이션) + */ + public function getPayrolls(array $filters = [], int $perPage = 20): LengthAwarePaginator + { + return $this->buildFilteredQuery($filters)->paginate($perPage); + } + + /** + * 엑셀 내보내기용 데이터 (전체) + */ + public function getExportData(array $filters = []): Collection + { + return $this->buildFilteredQuery($filters)->get(); + } + + /** + * 월간 통계 (통계 카드용) + */ + public function getMonthlyStats(?int $year = null, ?int $month = null): array + { + $tenantId = session('selected_tenant_id'); + $year = $year ?? now()->year; + $month = $month ?? now()->month; + + $result = Payroll::query() + ->forTenant($tenantId) + ->forPeriod($year, $month) + ->select( + DB::raw('COUNT(*) as total_count'), + DB::raw('SUM(gross_salary) as total_gross'), + DB::raw('SUM(total_deductions) as total_deductions'), + DB::raw('SUM(net_salary) as total_net'), + DB::raw("SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count"), + DB::raw("SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed_count"), + DB::raw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count"), + ) + ->first(); + + return [ + 'total_gross' => (int) ($result->total_gross ?? 0), + 'total_deductions' => (int) ($result->total_deductions ?? 0), + 'total_net' => (int) ($result->total_net ?? 0), + 'total_count' => (int) ($result->total_count ?? 0), + 'draft_count' => (int) ($result->draft_count ?? 0), + 'confirmed_count' => (int) ($result->confirmed_count ?? 0), + 'paid_count' => (int) ($result->paid_count ?? 0), + 'year' => $year, + 'month' => $month, + ]; + } + + /** + * 급여 등록 + */ + public function storePayroll(array $data): Payroll + { + $tenantId = session('selected_tenant_id'); + + return DB::transaction(function () use ($data, $tenantId) { + $calculated = $this->calculateAmounts($data); + + $payroll = Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'pay_year' => $data['pay_year'], + 'pay_month' => $data['pay_month'], + 'base_salary' => $data['base_salary'] ?? 0, + 'overtime_pay' => $data['overtime_pay'] ?? 0, + 'bonus' => $data['bonus'] ?? 0, + 'allowances' => $data['allowances'] ?? null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => $data['deductions'] ?? null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => 'draft', + 'note' => $data['note'] ?? null, + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + return $payroll->load('user'); + }); + } + + /** + * 급여 수정 + */ + public function updatePayroll(int $id, array $data): ?Payroll + { + $tenantId = session('selected_tenant_id'); + + $payroll = Payroll::query() + ->forTenant($tenantId) + ->find($id); + + if (! $payroll || ! $payroll->isEditable()) { + return null; + } + + $mergedData = array_merge($payroll->toArray(), $data); + $calculated = $this->calculateAmounts($mergedData); + + $payroll->update([ + 'base_salary' => $data['base_salary'] ?? $payroll->base_salary, + 'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay, + 'bonus' => $data['bonus'] ?? $payroll->bonus, + 'allowances' => array_key_exists('allowances', $data) ? $data['allowances'] : $payroll->allowances, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => array_key_exists('deductions', $data) ? $data['deductions'] : $payroll->deductions, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'note' => $data['note'] ?? $payroll->note, + 'updated_by' => auth()->id(), + ]); + + return $payroll->fresh(['user']); + } + + /** + * 급여 삭제 + */ + public function deletePayroll(int $id): bool + { + $tenantId = session('selected_tenant_id'); + + $payroll = Payroll::query() + ->forTenant($tenantId) + ->find($id); + + if (! $payroll || ! $payroll->isDeletable()) { + return false; + } + + $payroll->update(['deleted_by' => auth()->id()]); + $payroll->delete(); + + return true; + } + + /** + * 급여 확정 + */ + public function confirmPayroll(int $id): ?Payroll + { + $tenantId = session('selected_tenant_id'); + + $payroll = Payroll::query() + ->forTenant($tenantId) + ->find($id); + + if (! $payroll || ! $payroll->isConfirmable()) { + return null; + } + + $payroll->update([ + 'status' => Payroll::STATUS_CONFIRMED, + 'confirmed_at' => now(), + 'confirmed_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + return $payroll->fresh(['user']); + } + + /** + * 급여 지급 처리 + */ + public function payPayroll(int $id): ?Payroll + { + $tenantId = session('selected_tenant_id'); + + $payroll = Payroll::query() + ->forTenant($tenantId) + ->find($id); + + if (! $payroll || ! $payroll->isPayable()) { + return null; + } + + $payroll->update([ + 'status' => Payroll::STATUS_PAID, + 'paid_at' => now(), + 'updated_by' => auth()->id(), + ]); + + return $payroll->fresh(['user']); + } + + /** + * 일괄 생성 (재직 사원 전체) + */ + public function bulkGenerate(int $year, int $month): array + { + $tenantId = session('selected_tenant_id'); + $settings = PayrollSetting::getOrCreate($tenantId); + $created = 0; + $skipped = 0; + + $employees = Employee::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->activeEmployees() + ->get(); + + DB::transaction(function () use ($employees, $tenantId, $year, $month, $settings, &$created, &$skipped) { + foreach ($employees as $employee) { + $exists = Payroll::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $employee->user_id) + ->forPeriod($year, $month) + ->exists(); + + if ($exists) { + $skipped++; + + continue; + } + + $annualSalary = $employee->getJsonExtraValue('salary', 0); + $baseSalary = $annualSalary > 0 ? round($annualSalary / 12) : 0; + + $data = [ + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'deductions' => null, + ]; + + $calculated = $this->calculateAmounts($data, $settings); + + Payroll::create([ + 'tenant_id' => $tenantId, + 'user_id' => $employee->user_id, + 'pay_year' => $year, + 'pay_month' => $month, + 'base_salary' => $baseSalary, + 'overtime_pay' => 0, + 'bonus' => 0, + 'allowances' => null, + 'gross_salary' => $calculated['gross_salary'], + 'income_tax' => $calculated['income_tax'], + 'resident_tax' => $calculated['resident_tax'], + 'health_insurance' => $calculated['health_insurance'], + 'pension' => $calculated['pension'], + 'employment_insurance' => $calculated['employment_insurance'], + 'deductions' => null, + 'total_deductions' => $calculated['total_deductions'], + 'net_salary' => $calculated['net_salary'], + 'status' => 'draft', + 'created_by' => auth()->id(), + 'updated_by' => auth()->id(), + ]); + + $created++; + } + }); + + return ['created' => $created, 'skipped' => $skipped]; + } + + /** + * 급여 금액 자동 계산 + */ + public function calculateAmounts(array $data, ?PayrollSetting $settings = null): array + { + $settings = $settings ?? PayrollSetting::getOrCreate(); + + $baseSalary = (float) ($data['base_salary'] ?? 0); + $overtimePay = (float) ($data['overtime_pay'] ?? 0); + $bonus = (float) ($data['bonus'] ?? 0); + + // 수당 합계 + $allowancesTotal = 0; + if (! empty($data['allowances'])) { + $allowances = is_string($data['allowances']) ? json_decode($data['allowances'], true) : $data['allowances']; + foreach ($allowances ?? [] as $allowance) { + $allowancesTotal += (float) ($allowance['amount'] ?? 0); + } + } + + // 총 지급액 + $grossSalary = $baseSalary + $overtimePay + $bonus + $allowancesTotal; + + // 4대보험 계산 + $healthInsurance = $this->calculateHealthInsurance($grossSalary, $settings); + $pension = $this->calculatePension($grossSalary, $settings); + $employmentInsurance = $this->calculateEmploymentInsurance($grossSalary, $settings); + + // 소득세 (간이세액표) + $incomeTax = $this->calculateIncomeTax($grossSalary); + // 주민세 (소득세의 10%) + $residentTax = (int) floor($incomeTax * ($settings->resident_tax_rate / 100)); + + // 추가 공제 합계 + $extraDeductions = 0; + if (! empty($data['deductions'])) { + $deductions = is_string($data['deductions']) ? json_decode($data['deductions'], true) : $data['deductions']; + foreach ($deductions ?? [] as $deduction) { + $extraDeductions += (float) ($deduction['amount'] ?? 0); + } + } + + // 총 공제액 + $totalDeductions = $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $extraDeductions; + + // 실수령액 + $netSalary = $grossSalary - $totalDeductions; + + return [ + 'gross_salary' => (int) $grossSalary, + 'income_tax' => $incomeTax, + 'resident_tax' => $residentTax, + 'health_insurance' => $healthInsurance, + 'pension' => $pension, + 'employment_insurance' => $employmentInsurance, + 'total_deductions' => (int) $totalDeductions, + 'net_salary' => (int) max(0, $netSalary), + ]; + } + + /** + * 소득세 계산 (간이세액표 기준, 부양가족 1인) + */ + public function calculateIncomeTax(float $grossSalary, int $dependents = 1): int + { + if ($grossSalary <= 0) { + return 0; + } + + $tax = 0; + foreach (self::INCOME_TAX_TABLE as [$min, $max, $amount]) { + if ($grossSalary > $min && $grossSalary <= $max) { + $tax = $amount; + break; + } + } + + // 마지막 구간 초과 + if ($grossSalary > 87000000) { + $tax = self::INCOME_TAX_TABLE[count(self::INCOME_TAX_TABLE) - 1][2]; + } + + return (int) $tax; + } + + /** + * 건강보험료 계산 + */ + private function calculateHealthInsurance(float $grossSalary, PayrollSetting $settings): int + { + $healthInsurance = $grossSalary * ($settings->health_insurance_rate / 100); + $longTermCare = $healthInsurance * ($settings->long_term_care_rate / 100); + + return (int) round($healthInsurance + $longTermCare); + } + + /** + * 국민연금 계산 + */ + private function calculatePension(float $grossSalary, PayrollSetting $settings): int + { + $base = min(max($grossSalary, (float) $settings->pension_min_salary), (float) $settings->pension_max_salary); + + return (int) round($base * ($settings->pension_rate / 100)); + } + + /** + * 고용보험료 계산 + */ + private function calculateEmploymentInsurance(float $grossSalary, PayrollSetting $settings): int + { + return (int) round($grossSalary * ($settings->employment_insurance_rate / 100)); + } + + /** + * 부서 목록 (드롭다운용) + */ + public function getDepartments(): Collection + { + $tenantId = session('selected_tenant_id'); + + return Department::query() + ->where('is_active', true) + ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'name', 'code']); + } + + /** + * 활성 사원 목록 (드롭다운용) + */ + public function getActiveEmployees(): Collection + { + $tenantId = session('selected_tenant_id'); + + return Employee::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->activeEmployees() + ->orderBy('display_name') + ->get(['id', 'user_id', 'display_name', 'department_id', 'json_extra']); + } + + /** + * 급여 설정 조회/생성 + */ + public function getSettings(): PayrollSetting + { + return PayrollSetting::getOrCreate(); + } + + /** + * 급여 설정 수정 + */ + public function updateSettings(array $data): PayrollSetting + { + $settings = PayrollSetting::getOrCreate(); + + $settings->update([ + 'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate, + 'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate, + 'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate, + 'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate, + 'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary, + 'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary, + 'pay_day' => $data['pay_day'] ?? $settings->pay_day, + 'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate, + ]); + + return $settings->fresh(); + } +} diff --git a/resources/views/hr/payrolls/index.blade.php b/resources/views/hr/payrolls/index.blade.php new file mode 100644 index 00000000..03ccb898 --- /dev/null +++ b/resources/views/hr/payrolls/index.blade.php @@ -0,0 +1,793 @@ +@extends('layouts.app') + +@section('title', '급여관리') + +@section('content') +
4대보험 요율 및 급여 지급일 설정
+| 사원 | +부서 | +기본급 | +수당 | +총지급액 | +공제액 | +실수령액 | +상태 | +작업 | +
|---|---|---|---|---|---|---|---|---|
|
+
+
+
+ {{ mb_substr($displayName, 0, 1) }}
+
+ {{ $displayName }}
+ |
+
+ {{-- 부서 --}}
+ + {{ $department?->name ?? '-' }} + | + + {{-- 기본급 --}} ++ {{ number_format($payroll->base_salary) }} + | + + {{-- 수당 (초과근무+상여+기타) --}} ++ {{ $overtimeBonus > 0 ? number_format($overtimeBonus) : '-' }} + | + + {{-- 총지급액 --}} ++ {{ number_format($payroll->gross_salary) }} + | + + {{-- 공제액 --}} ++ {{ number_format($payroll->total_deductions) }} + | + + {{-- 실수령액 --}} ++ {{ number_format($payroll->net_salary) }} + | + + {{-- 상태 --}} ++ + {{ $label }} + + | + + {{-- 작업 --}} +
+
+ {{-- 수정 (draft만) --}}
+ @if($payroll->isEditable())
+
+
+ @endif
+
+ {{-- 확정 (draft만) --}}
+ @if($payroll->isConfirmable())
+
+ @endif
+
+ {{-- 지급 (confirmed만) --}}
+ @if($payroll->isPayable())
+
+ @endif
+
+ {{-- 상세보기 (paid) --}}
+ @if($payroll->status === 'paid')
+
+ @endif
+
+ |
+
|
+
+
+
+ 급여 기록이 없습니다. + |
+ ||||||||