From 0fef26f42ae06209ea0814bd29d09d58c99a5459 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 26 Dec 2025 01:32:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20HR=20=EB=AA=A8=EB=8D=B8=20userProfile?= =?UTF-8?q?=20=EA=B4=80=EA=B3=84=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 모델 개선 - Leave: userProfile relation 추가 - Salary: userProfile relation 추가 - TenantUserProfile: department, position 관계 및 label accessor 추가 ## 서비스 개선 - LeaveService: userProfile eager loading 추가 - SalaryService: 사원 정보 조회 개선 - CardService: 관계 정리 및 개선 - AttendanceService: 조회 기능 개선 ## 시더 - DummySalarySeeder 추가 - DummyCardSeeder 멀티테넌트 지원 개선 - DummyDataSeeder에 급여 시더 등록 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Http/Requests/Employee/IndexRequest.php | 2 +- app/Models/Tenants/Leave.php | 11 ++ app/Models/Tenants/Salary.php | 11 ++ app/Models/Tenants/TenantUserProfile.php | 47 +++++ app/Services/AttendanceService.php | 16 +- app/Services/CardService.php | 26 ++- app/Services/LeaveService.php | 14 +- app/Services/SalaryService.php | 24 ++- database/seeders/Dummy/DummyCardSeeder.php | 15 +- database/seeders/Dummy/DummySalarySeeder.php | 174 +++++++++++++++++++ database/seeders/DummyDataSeeder.php | 2 + 11 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 database/seeders/Dummy/DummySalarySeeder.php diff --git a/app/Http/Requests/Employee/IndexRequest.php b/app/Http/Requests/Employee/IndexRequest.php index 2459c5a..e4fbeb5 100644 --- a/app/Http/Requests/Employee/IndexRequest.php +++ b/app/Http/Requests/Employee/IndexRequest.php @@ -21,7 +21,7 @@ public function rules(): array 'sort_by' => 'nullable|in:created_at,name,employee_status,department_id', 'sort_dir' => 'nullable|in:asc,desc', 'page' => 'nullable|integer|min:1', - 'per_page' => 'nullable|integer|min:1|max:100', + 'per_page' => 'nullable|integer|min:1|max:500', // 드롭다운 등 관리용 확장 ]; } } diff --git a/app/Models/Tenants/Leave.php b/app/Models/Tenants/Leave.php index 21550fb..44cb75e 100644 --- a/app/Models/Tenants/Leave.php +++ b/app/Models/Tenants/Leave.php @@ -6,6 +6,7 @@ use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -140,6 +141,16 @@ public function updater(): BelongsTo return $this->belongsTo(User::class, 'updated_by'); } + /** + * 신청자 프로필 (테넌트별) + * 주의: eager loading 시 constrain 필요 + * ->with(['userProfile' => fn($q) => $q->where('tenant_id', $tenantId)]) + */ + public function userProfile(): HasOne + { + return $this->hasOne(TenantUserProfile::class, 'user_id', 'user_id'); + } + // ========================================================================= // 스코프 // ========================================================================= diff --git a/app/Models/Tenants/Salary.php b/app/Models/Tenants/Salary.php index 3abb1fb..2114ec2 100644 --- a/app/Models/Tenants/Salary.php +++ b/app/Models/Tenants/Salary.php @@ -7,6 +7,7 @@ use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -107,6 +108,16 @@ public function updater(): BelongsTo return $this->belongsTo(User::class, 'updated_by'); } + /** + * 직원 프로필 (테넌트별) + * 주의: eager loading 시 constrain 필요 + * ->with(['employeeProfile' => fn($q) => $q->where('tenant_id', $tenantId)]) + */ + public function employeeProfile(): HasOne + { + return $this->hasOne(TenantUserProfile::class, 'user_id', 'employee_id'); + } + // ========================================================================= // 헬퍼 메서드 // ========================================================================= diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php index 35c0ee6..f68b6e0 100644 --- a/app/Models/Tenants/TenantUserProfile.php +++ b/app/Models/Tenants/TenantUserProfile.php @@ -24,6 +24,12 @@ class TenantUserProfile extends Model 'json_extra' => 'array', ]; + protected $appends = [ + 'position_label', + 'job_title_label', + 'rank', + ]; + protected $fillable = [ 'tenant_id', 'user_id', @@ -143,6 +149,47 @@ public function getEmergencyContactAttribute(): ?string return $this->json_extra['emergency_contact'] ?? null; } + /** + * 직책 레이블 (position_key → 한글) + */ + public function getPositionLabelAttribute(): ?string + { + $labels = [ + 'EXECUTIVE' => '임원', + 'DIRECTOR' => '부장', + 'MANAGER' => '과장', + 'SENIOR' => '대리', + 'STAFF' => '사원', + 'INTERN' => '인턴', + ]; + + return $labels[$this->position_key] ?? $this->position_key; + } + + /** + * 직급 레이블 (job_title_key → 한글) + */ + public function getJobTitleLabelAttribute(): ?string + { + $labels = [ + 'CEO' => '대표이사', + 'CTO' => '기술이사', + 'CFO' => '재무이사', + 'TEAM_LEAD' => '팀장', + 'TEAM_MEMBER' => '팀원', + ]; + + return $labels[$this->job_title_key] ?? $this->job_title_key; + } + + /** + * json_extra 내 직급 정보 (rank) + */ + public function getRankAttribute(): ?string + { + return $this->json_extra['rank'] ?? null; + } + // ========================================================================= // 스코프 // ========================================================================= diff --git a/app/Services/AttendanceService.php b/app/Services/AttendanceService.php index a4ef740..3cfa9d9 100644 --- a/app/Services/AttendanceService.php +++ b/app/Services/AttendanceService.php @@ -17,7 +17,13 @@ public function index(array $params): LengthAwarePaginator $query = Attendance::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email']); + ->with([ + 'user:id,name,email', + 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + ]); // 사용자 필터 if (! empty($params['user_id'])) { @@ -69,7 +75,13 @@ public function show(int $id): Attendance $attendance = Attendance::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email']) + ->with([ + 'user:id,name,email', + 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + ]) ->findOrFail($id); return $attendance; diff --git a/app/Services/CardService.php b/app/Services/CardService.php index 9e1243d..4558c8f 100644 --- a/app/Services/CardService.php +++ b/app/Services/CardService.php @@ -17,10 +17,16 @@ public function index(array $params): LengthAwarePaginator $query = Card::query() ->where('tenant_id', $tenantId) - ->with(['assignedUser:id,name,email']); + ->with([ + 'assignedUser:id,name,email', + 'assignedUser.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + ]); // 검색 필터 - if (!empty($params['search'])) { + if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('card_name', 'like', "%{$search}%") @@ -30,12 +36,12 @@ public function index(array $params): LengthAwarePaginator } // 상태 필터 - if (!empty($params['status'])) { + if (! empty($params['status'])) { $query->where('status', $params['status']); } // 담당자 필터 - if (!empty($params['assigned_user_id'])) { + if (! empty($params['assigned_user_id'])) { $query->where('assigned_user_id', $params['assigned_user_id']); } @@ -59,7 +65,13 @@ public function show(int $id): Card return Card::query() ->where('tenant_id', $tenantId) - ->with(['assignedUser:id,name,email', 'assignedUser.department', 'assignedUser.position']) + ->with([ + 'assignedUser:id,name,email', + 'assignedUser.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + ]) ->findOrFail($id); } @@ -83,7 +95,7 @@ public function store(array $data): Card $card->created_by = $userId; $card->updated_by = $userId; - if (!empty($data['card_password'])) { + if (! empty($data['card_password'])) { $card->setCardPassword($data['card_password']); } @@ -194,7 +206,7 @@ public function getActiveCards(): array 'id' => $card->id, 'card_name' => $card->card_name, 'card_company' => $card->card_company, - 'display_number' => '****-' . $card->card_number_last4, + 'display_number' => '****-'.$card->card_number_last4, ]; }) ->toArray(); diff --git a/app/Services/LeaveService.php b/app/Services/LeaveService.php index 86119c7..1919062 100644 --- a/app/Services/LeaveService.php +++ b/app/Services/LeaveService.php @@ -21,7 +21,12 @@ public function index(array $params): LengthAwarePaginator $query = Leave::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email', 'approver:id,name']); + ->with([ + 'user:id,name,email', + 'userProfile' => fn ($q) => $q->where('tenant_id', $tenantId), + 'userProfile.department:id,name', + 'approver:id,name', + ]); // 사용자 필터 if (! empty($params['user_id'])) { @@ -78,7 +83,12 @@ public function show(int $id): Leave return Leave::query() ->where('tenant_id', $tenantId) - ->with(['user:id,name,email', 'approver:id,name']) + ->with([ + 'user:id,name,email', + 'userProfile' => fn ($q) => $q->where('tenant_id', $tenantId), + 'userProfile.department:id,name', + 'approver:id,name', + ]) ->findOrFail($id); } diff --git a/app/Services/SalaryService.php b/app/Services/SalaryService.php index 6747582..c3fa0e2 100644 --- a/app/Services/SalaryService.php +++ b/app/Services/SalaryService.php @@ -17,7 +17,11 @@ public function index(array $params): LengthAwarePaginator $query = Salary::query() ->where('tenant_id', $tenantId) - ->with(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + ->with([ + 'employee:id,name,user_id,email', + 'employeeProfile' => fn($q) => $q->where('tenant_id', $tenantId), + 'employeeProfile.department:id,name', + ]); // 검색 필터 (직원명) if (!empty($params['search'])) { @@ -78,7 +82,11 @@ public function show(int $id): Salary return Salary::query() ->where('tenant_id', $tenantId) - ->with(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']) + ->with([ + 'employee:id,name,user_id,email', + 'employeeProfile' => fn($q) => $q->where('tenant_id', $tenantId), + 'employeeProfile.department:id,name', + ]) ->findOrFail($id); } @@ -173,7 +181,11 @@ public function update(int $id, array $data): Salary $salary->updated_by = $userId; $salary->save(); - return $salary->fresh()->load(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + return $salary->fresh()->load([ + 'employee:id,name,user_id,email', + 'employeeProfile' => fn($q) => $q->where('tenant_id', $tenantId), + 'employeeProfile.department:id,name', + ]); }); } @@ -215,7 +227,11 @@ public function updateStatus(int $id, string $status): Salary $salary->updated_by = $userId; $salary->save(); - return $salary->load(['employee:id,name,employee_number', 'employee.department', 'employee.position', 'employee.rank']); + return $salary->load([ + 'employee:id,name,user_id,email', + 'employeeProfile' => fn($q) => $q->where('tenant_id', $tenantId), + 'employeeProfile.department:id,name', + ]); }); } diff --git a/database/seeders/Dummy/DummyCardSeeder.php b/database/seeders/Dummy/DummyCardSeeder.php index a7f8a24..762fa38 100644 --- a/database/seeders/Dummy/DummyCardSeeder.php +++ b/database/seeders/Dummy/DummyCardSeeder.php @@ -12,7 +12,15 @@ class DummyCardSeeder extends Seeder public function run(): void { $tenantIds = [1, 287]; // 두 테넌트에 생성 - $userId = User::first()?->id ?? 1; + + // 각 테넌트별로 TenantUserProfile이 있는 사용자 찾기 + $usersByTenant = []; + foreach ($tenantIds as $tenantId) { + $profile = \App\Models\Tenants\TenantUserProfile::where('tenant_id', $tenantId) + ->whereNotNull('department_id') + ->first(); + $usersByTenant[$tenantId] = $profile?->user_id ?? User::first()?->id ?? 1; + } $cards = [ [ @@ -53,8 +61,9 @@ public function run(): void ]; foreach ($tenantIds as $tenantId) { + $userId = $usersByTenant[$tenantId]; foreach ($cards as $cardData) { - $card = new Card(); + $card = new Card; $card->tenant_id = $tenantId; $card->card_company = $cardData['card_company']; $card->card_number_encrypted = Crypt::encryptString($cardData['card_number']); @@ -69,6 +78,6 @@ public function run(): void } } - $this->command->info('DummyCardSeeder: ' . (count($cards) * count($tenantIds)) . ' cards created'); + $this->command->info('DummyCardSeeder: '.(count($cards) * count($tenantIds)).' cards created'); } } diff --git a/database/seeders/Dummy/DummySalarySeeder.php b/database/seeders/Dummy/DummySalarySeeder.php new file mode 100644 index 0000000..7da06dc --- /dev/null +++ b/database/seeders/Dummy/DummySalarySeeder.php @@ -0,0 +1,174 @@ +where('tenant_id', $tenantId)->count(); + if ($existing > 0) { + $this->command->info(' ⚠ salaries: 이미 ' . $existing . '개 존재 (스킵)'); + return; + } + + // 테넌트 소속 사용자 조회 + $userIds = DB::table('user_tenants') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->pluck('user_id') + ->toArray(); + + if (empty($userIds)) { + $this->command->warn(' ⚠ salaries: 테넌트에 연결된 사용자가 없습니다'); + return; + } + + // 사용자 정보 조회 + $users = User::whereIn('id', $userIds)->get(); + + // 부서별 사용자 매핑 (급여 차등용) + $userDepartments = DB::table('department_user') + ->join('departments', 'departments.id', '=', 'department_user.department_id') + ->whereIn('department_user.user_id', $userIds) + ->where('department_user.tenant_id', $tenantId) + ->pluck('departments.name', 'department_user.user_id') + ->toArray(); + + // 직급별 기본급 설정 + $rankSalaries = [ + '사원' => ['base' => 3000000, 'position_allowance' => 0], + '대리' => ['base' => 3500000, 'position_allowance' => 100000], + '과장' => ['base' => 4200000, 'position_allowance' => 200000], + '차장' => ['base' => 5000000, 'position_allowance' => 300000], + '부장' => ['base' => 6000000, 'position_allowance' => 500000], + '이사' => ['base' => 7500000, 'position_allowance' => 800000], + ]; + + // 직급 배정 (인덱스 기반으로 분배) + $ranks = array_keys($rankSalaries); + + $count = 0; + $year = 2025; + $month = 12; + + foreach ($users as $index => $user) { + // 직급 결정 (순환 분배, 사원이 가장 많도록 가중치) + $rankIndex = $this->getRankIndex($index, count($users)); + $rank = $ranks[$rankIndex]; + $salaryConfig = $rankSalaries[$rank]; + + // 기본급 + $baseSalary = $salaryConfig['base']; + + // 수당 계산 + $positionAllowance = $salaryConfig['position_allowance']; + $overtimeHours = rand(0, 30); + $overtimeAllowance = $overtimeHours * 15000; // 시간당 15,000원 + $mealAllowance = 200000; // 식대 (비과세) + $transportAllowance = 100000; // 교통비 + $otherAllowance = rand(0, 5) * 50000; // 기타수당 + + $totalAllowance = $positionAllowance + $mealAllowance + $transportAllowance + $otherAllowance; + $totalOvertime = $overtimeAllowance; + $totalBonus = ($index % 5 === 0) ? $baseSalary * 0.5 : 0; // 5번째마다 상여 + + // 공제 계산 (과세 대상 급여 기준) + $taxableIncome = $baseSalary + $positionAllowance + $overtimeAllowance + $totalBonus; + $nationalPension = round($taxableIncome * 0.045); // 국민연금 4.5% + $healthInsurance = round($taxableIncome * 0.03545); // 건강보험 3.545% + $longTermCare = round($healthInsurance * 0.1281); // 장기요양 12.81% + $employmentInsurance = round($taxableIncome * 0.009); // 고용보험 0.9% + $incomeTax = $this->calculateIncomeTax($taxableIncome); + $localIncomeTax = round($incomeTax * 0.1); // 지방소득세 10% + $otherDeduction = 0; + + $totalDeduction = $nationalPension + $healthInsurance + $longTermCare + + $employmentInsurance + $incomeTax + $localIncomeTax + $otherDeduction; + + // 실지급액 + $netPayment = $baseSalary + $totalAllowance + $totalOvertime + $totalBonus - $totalDeduction; + + // 지급 상태 결정 (80%는 완료, 20%는 예정) + $status = (rand(1, 10) <= 8) ? 'completed' : 'scheduled'; + $paymentDate = ($status === 'completed') ? '2025-12-25' : '2025-12-31'; + + Salary::create([ + 'tenant_id' => $tenantId, + 'employee_id' => $user->id, + 'year' => $year, + 'month' => $month, + 'base_salary' => $baseSalary, + 'total_allowance' => $totalAllowance, + 'total_overtime' => $totalOvertime, + 'total_bonus' => $totalBonus, + 'total_deduction' => $totalDeduction, + 'net_payment' => $netPayment, + 'allowance_details' => [ + 'position_allowance' => $positionAllowance, + 'overtime_allowance' => $overtimeAllowance, + 'meal_allowance' => $mealAllowance, + 'transport_allowance' => $transportAllowance, + 'other_allowance' => $otherAllowance, + ], + 'deduction_details' => [ + 'national_pension' => $nationalPension, + 'health_insurance' => $healthInsurance, + 'long_term_care' => $longTermCare, + 'employment_insurance' => $employmentInsurance, + 'income_tax' => $incomeTax, + 'local_income_tax' => $localIncomeTax, + 'other_deduction' => $otherDeduction, + ], + 'payment_date' => $paymentDate, + 'status' => $status, + 'created_by' => $userId, + ]); + + $count++; + } + + $this->command->info(' ✓ salaries: ' . $count . '개 생성 (2025년 12월)'); + } + + /** + * 인덱스 기반 직급 결정 (사원이 가장 많도록) + */ + private function getRankIndex(int $index, int $total): int + { + // 배분: 사원 40%, 대리 25%, 과장 15%, 차장 10%, 부장 7%, 이사 3% + $ratio = $index / $total; + + if ($ratio < 0.40) return 0; // 사원 + if ($ratio < 0.65) return 1; // 대리 + if ($ratio < 0.80) return 2; // 과장 + if ($ratio < 0.90) return 3; // 차장 + if ($ratio < 0.97) return 4; // 부장 + return 5; // 이사 + } + + /** + * 간이세액표 기반 소득세 계산 (간략화) + */ + private function calculateIncomeTax(float $taxableIncome): int + { + // 월 급여 기준 간이세액 (부양가족 1인 기준, 간략화) + if ($taxableIncome <= 1500000) return 0; + if ($taxableIncome <= 2000000) return round($taxableIncome * 0.02); + if ($taxableIncome <= 3000000) return round($taxableIncome * 0.03); + if ($taxableIncome <= 4500000) return round($taxableIncome * 0.05); + if ($taxableIncome <= 6000000) return round($taxableIncome * 0.08); + if ($taxableIncome <= 8000000) return round($taxableIncome * 0.12); + return round($taxableIncome * 0.15); + } +} diff --git a/database/seeders/DummyDataSeeder.php b/database/seeders/DummyDataSeeder.php index 2937e33..8d06b47 100644 --- a/database/seeders/DummyDataSeeder.php +++ b/database/seeders/DummyDataSeeder.php @@ -52,6 +52,7 @@ public function run(): void Dummy\DummyLeaveGrantSeeder::class, // 휴가 부여 Dummy\DummyLeaveSeeder::class, // 휴가 Dummy\DummyCardSeeder::class, // 법인카드 + Dummy\DummySalarySeeder::class, // 급여 ]); // 4. 기타 데이터 @@ -84,6 +85,7 @@ public function run(): void ['HR', 'leave_grants', '~200'], ['HR', 'leaves', '~50'], ['HR', 'cards', '5'], + ['HR', 'salaries', '15'], ['기타', 'popups', '8'], ['기타', 'payments', '13'], ['', '총계', '~950'],