feat: HR 모델 userProfile 관계 추가 및 서비스 개선
## 모델 개선 - 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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', // 드롭다운 등 관리용 확장
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
174
database/seeders/Dummy/DummySalarySeeder.php
Normal file
174
database/seeders/Dummy/DummySalarySeeder.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\Dummy;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Tenants\Salary;
|
||||
use Database\Seeders\DummyDataSeeder;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DummySalarySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tenantId = DummyDataSeeder::TENANT_ID;
|
||||
$userId = DummyDataSeeder::USER_ID;
|
||||
|
||||
// 기존 급여 데이터가 있으면 스킵
|
||||
$existing = Salary::withoutGlobalScopes()->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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user