From 8f6911121c8c070bb0db667d9870829f27cd5f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 26 Feb 2026 22:49:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[payroll]=20=EA=B8=89=EC=97=AC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Payroll, PayrollSetting 모델 생성 - PayrollService 구현 (CRUD, 자동계산, 간이세액표, 일괄생성) - Web/API 컨트롤러 생성 (HTMX/JSON 이중 응답) - 급여 목록, 통계 카드, 급여 설정 뷰 생성 - 라우트 추가 (web.php, api.php) - 상태 흐름: draft → confirmed → paid --- .../Api/Admin/HR/PayrollController.php | 398 +++++++++ app/Http/Controllers/HR/PayrollController.php | 41 + app/Models/HR/Payroll.php | 175 ++++ app/Models/HR/PayrollSetting.php | 107 +++ app/Services/HR/PayrollService.php | 524 ++++++++++++ resources/views/hr/payrolls/index.blade.php | 793 ++++++++++++++++++ .../hr/payrolls/partials/settings.blade.php | 88 ++ .../hr/payrolls/partials/stats.blade.php | 23 + .../hr/payrolls/partials/table.blade.php | 180 ++++ routes/api.php | 20 + routes/web.php | 5 + 11 files changed, 2354 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/HR/PayrollController.php create mode 100644 app/Http/Controllers/HR/PayrollController.php create mode 100644 app/Models/HR/Payroll.php create mode 100644 app/Models/HR/PayrollSetting.php create mode 100644 app/Services/HR/PayrollService.php create mode 100644 resources/views/hr/payrolls/index.blade.php create mode 100644 resources/views/hr/payrolls/partials/settings.blade.php create mode 100644 resources/views/hr/payrolls/partials/stats.blade.php create mode 100644 resources/views/hr/payrolls/partials/table.blade.php 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') +
+ {{-- 페이지 헤더 --}} +
+
+

급여관리

+
+ + +
+
+
+ + + +
+
+ + {{-- 탭 네비게이션 --}} +
+ + +
+ + {{-- 통계 카드 (HTMX 갱신 대상) --}} +
+ @include('hr.payrolls.partials.stats', ['stats' => $stats]) +
+ + {{-- 탭 콘텐츠 영역 --}} +
+ {{-- 급여 목록 탭 --}} +
+
+ {{-- 필터 --}} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {{-- HTMX 테이블 영역 --}} +
+
+
+
+
+
+
+ + {{-- 급여 설정 탭 --}} + +
+
+ +{{-- 급여 등록/수정 모달 --}} + + +{{-- 급여 상세 모달 --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/payrolls/partials/settings.blade.php b/resources/views/hr/payrolls/partials/settings.blade.php new file mode 100644 index 00000000..fd2e600b --- /dev/null +++ b/resources/views/hr/payrolls/partials/settings.blade.php @@ -0,0 +1,88 @@ +{{-- 급여 설정 폼 (HTMX로 로드) --}} +
+
+

급여 설정

+

4대보험 요율 및 급여 지급일 설정

+
+ +
+ {{-- 4대보험 요율 --}} +
+

4대보험 요율 (%)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {{-- 국민연금 기준금액 --}} +
+

국민연금 기준소득월액

+
+
+ + +
+
+ + +
+
+
+ + {{-- 기타 설정 --}} +
+

기타 설정

+
+
+ + +
+
+ +
+
+
+ + {{-- 저장 버튼 --}} +
+ +
+
+
diff --git a/resources/views/hr/payrolls/partials/stats.blade.php b/resources/views/hr/payrolls/partials/stats.blade.php new file mode 100644 index 00000000..766ceb93 --- /dev/null +++ b/resources/views/hr/payrolls/partials/stats.blade.php @@ -0,0 +1,23 @@ +{{-- 급여 월간 통계 카드 (HTMX로 갱신) --}} +
+
+
총 지급액
+
{{ number_format($stats['total_gross']) }}
+
+
+
총 공제액
+
{{ number_format($stats['total_deductions']) }}
+
+
+
실수령 총액
+
{{ number_format($stats['total_net']) }}
+
+
+
대상 인원
+
{{ $stats['total_count'] }}명
+
+
+
미확정
+
{{ $stats['draft_count'] }}건
+
+
diff --git a/resources/views/hr/payrolls/partials/table.blade.php b/resources/views/hr/payrolls/partials/table.blade.php new file mode 100644 index 00000000..dde8edc1 --- /dev/null +++ b/resources/views/hr/payrolls/partials/table.blade.php @@ -0,0 +1,180 @@ +{{-- 급여 목록 테이블 (HTMX로 로드) --}} +@php + use App\Models\HR\Payroll; +@endphp + + + + + + + + + + + + + + + + + + @forelse($payrolls as $payroll) + @php + $profile = $payroll->user?->tenantProfiles?->first(); + $department = $profile?->department; + $displayName = $profile?->display_name ?? $payroll->user?->name ?? '-'; + $color = Payroll::STATUS_COLORS[$payroll->status] ?? 'gray'; + $label = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status; + $allowancesTotal = 0; + if ($payroll->allowances) { + foreach ($payroll->allowances as $a) { $allowancesTotal += ($a['amount'] ?? 0); } + } + $overtimeBonus = ($payroll->overtime_pay ?? 0) + ($payroll->bonus ?? 0) + $allowancesTotal; + @endphp + + {{-- 사원 --}} + + + {{-- 부서 --}} + + + {{-- 기본급 --}} + + + {{-- 수당 (초과근무+상여+기타) --}} + + + {{-- 총지급액 --}} + + + {{-- 공제액 --}} + + + {{-- 실수령액 --}} + + + {{-- 상태 --}} + + + {{-- 작업 --}} + + + @empty + + + + @endforelse + +
사원부서기본급수당총지급액공제액실수령액상태작업
+
+
+ {{ 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 +
+
+
+ + + +

급여 기록이 없습니다.

+
+
+
+ +{{-- 페이지네이션 --}} +@if($payrolls->hasPages()) +
+ {{ $payrolls->links() }} +
+@endif diff --git a/routes/api.php b/routes/api.php index e00728fd..a8bc3ddb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1098,3 +1098,23 @@ Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'reject'])->name('reject'); Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'cancel'])->name('cancel'); }); + +// 급여관리 API +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/payrolls')->name('api.admin.hr.payrolls.')->group(function () { + Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'stats'])->name('stats'); + Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'export'])->name('export'); + Route::post('/bulk-generate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'bulkGenerate'])->name('bulk-generate'); + Route::post('/calculate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'calculate'])->name('calculate'); + Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'destroy'])->name('destroy'); + Route::post('/{id}/confirm', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'confirm'])->name('confirm'); + Route::post('/{id}/pay', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'pay'])->name('pay'); +}); + +// 급여 설정 API +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/payroll-settings')->name('api.admin.hr.payroll-settings.')->group(function () { + Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsIndex'])->name('index'); + Route::put('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsUpdate'])->name('update'); +}); diff --git a/routes/web.php b/routes/web.php index 356e480e..97cd6a2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -910,6 +910,11 @@ Route::prefix('leaves')->name('leaves.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index'); }); + + // 급여관리 + Route::prefix('payrolls')->name('payrolls.')->group(function () { + Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index'); + }); }); /*