Files
sam-manage/app/Models/HR/Employee.php
김보곤 de6ef7472a feat: [hr] 사원 연봉 등록 시 급여 산정 테이블 추가
- 고정연장근로수당 산정 계산 로직 구현 (기본급, 통상시급, 고정OT 자동 계산)
- 수정 모드에서 실시간 급여 산정 미리보기 테이블
- 조회 모드에서 요약/상세 급여 산정 내역 표시
- 식대, 월 고정연장근로시간 입력 필드 추가
- 계산 결과를 salary_info에 저장하여 급여관리에서 활용 가능
2026-03-12 14:34:21 +09:00

318 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models\HR;
use App\Models\Tenants\Department;
use App\Models\User;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Employee extends Model
{
use ModelTrait;
const EMPLOYMENT_TYPES = [
'regular' => '정규직',
'contract' => '계약직',
'daily' => '일용직',
'freelancer' => '프리랜서',
];
protected $table = 'tenant_user_profiles';
protected $fillable = [
'tenant_id',
'user_id',
'department_id',
'position_key',
'job_title_key',
'work_location_key',
'employment_type_key',
'employee_status',
'worker_type',
'manager_user_id',
'json_extra',
'profile_photo_path',
'display_name',
];
protected static function booted(): void
{
static::addGlobalScope('employee', function (Builder $builder) {
$builder->where(function ($q) {
$q->where('worker_type', 'employee')
->orWhereNull('worker_type');
});
});
}
protected $casts = [
'json_extra' => 'array',
'tenant_id' => 'int',
'user_id' => 'int',
'department_id' => 'int',
'manager_user_id' => 'int',
];
protected $appends = [
'hire_date',
'resign_date',
'position_label',
'job_title_label',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function manager(): BelongsTo
{
return $this->belongsTo(User::class, 'manager_user_id');
}
// =========================================================================
// json_extra Accessor
// =========================================================================
public function getHireDateAttribute(): ?string
{
return $this->json_extra['hire_date'] ?? null;
}
public function getResignDateAttribute(): ?string
{
return $this->json_extra['resign_date'] ?? null;
}
public function getAddressAttribute(): ?string
{
return $this->json_extra['address'] ?? null;
}
public function getEmergencyContactAttribute(): ?string
{
return $this->json_extra['emergency_contact'] ?? null;
}
public function getResidentNumberAttribute(): ?string
{
return $this->json_extra['resident_number'] ?? null;
}
public function getPersonalEmailAttribute(): ?string
{
return $this->json_extra['personal_email'] ?? null;
}
public function getBankAccountAttribute(): ?array
{
return $this->json_extra['bank_account'] ?? null;
}
public function getDependentsAttribute(): array
{
return $this->json_extra['dependents'] ?? [];
}
public function getPositionLabelAttribute(): ?string
{
if (! $this->position_key || ! $this->tenant_id) {
return $this->position_key;
}
$position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_RANK)
->where('key', $this->position_key)
->first();
return $position?->name ?? $this->position_key;
}
public function getJobTitleLabelAttribute(): ?string
{
if (! $this->job_title_key || ! $this->tenant_id) {
return $this->job_title_key;
}
$position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_TITLE)
->where('key', $this->job_title_key)
->first();
return $position?->name ?? $this->job_title_key;
}
// =========================================================================
// json_extra 헬퍼
// =========================================================================
public function getJsonExtraValue(string $key, mixed $default = null): mixed
{
return $this->json_extra[$key] ?? $default;
}
public function setJsonExtraValue(string $key, mixed $value): void
{
$extra = $this->json_extra ?? [];
if ($value === null) {
unset($extra[$key]);
} else {
$extra[$key] = $value;
}
$this->json_extra = $extra;
}
// =========================================================================
// 연봉 정보 (salary_info) — 민감 데이터, 별도 접근 제어
// =========================================================================
public function getSalaryInfo(): array
{
$defaults = [
'annual_salary' => null,
'effective_date' => null,
'notes' => null,
'fixed_overtime_hours' => null,
'meal_allowance' => 200000,
'monthly_salary' => null,
'base_salary' => null,
'fixed_overtime_pay' => null,
'hourly_wage' => null,
'monthly_work_hours' => 209,
'overtime_multiplier' => 1.5,
'history' => [],
];
$data = $this->json_extra['salary_info'] ?? [];
return array_merge($defaults, $data);
}
public function setSalaryInfo(array $data): void
{
$current = $this->getSalaryInfo();
$history = $current['history'] ?? [];
// 기존 연봉이 있으면 이력에 추가
if ($current['annual_salary'] !== null) {
$history[] = [
'annual_salary' => $current['annual_salary'],
'fixed_overtime_hours' => $current['fixed_overtime_hours'],
'meal_allowance' => $current['meal_allowance'],
'base_salary' => $current['base_salary'],
'fixed_overtime_pay' => $current['fixed_overtime_pay'],
'effective_date' => $current['effective_date'],
'notes' => $current['notes'],
'recorded_at' => now()->format('Y-m-d H:i:s'),
'recorded_by' => auth()->user()?->name ?? '-',
];
}
$annualSalary = $data['annual_salary'] ?? null;
$mealAllowance = $data['meal_allowance'] ?? 200000;
$fixedOvertimeHours = $data['fixed_overtime_hours'] ?? null;
$breakdown = $this->calculateSalaryBreakdown($annualSalary, $mealAllowance, $fixedOvertimeHours);
$this->setJsonExtraValue('salary_info', array_merge([
'annual_salary' => $annualSalary,
'effective_date' => $data['effective_date'] ?? null,
'notes' => $data['notes'] ?? null,
'fixed_overtime_hours' => $fixedOvertimeHours,
'meal_allowance' => $mealAllowance,
], $breakdown, ['history' => $history]));
}
/**
* 급여 산정 계산
*
* 공식: (기본급 + 식대) = 월급여 × 209 / (209 + 고정연장근로시간 × 1.5)
*/
private function calculateSalaryBreakdown(?int $annualSalary, int $mealAllowance, ?int $fixedOvertimeHours): array
{
$monthlyWorkHours = 209;
$overtimeMultiplier = 1.5;
if (! $annualSalary) {
return [
'monthly_salary' => null,
'base_salary' => null,
'fixed_overtime_pay' => null,
'hourly_wage' => null,
'monthly_work_hours' => $monthlyWorkHours,
'overtime_multiplier' => $overtimeMultiplier,
];
}
$monthlySalary = (int) round($annualSalary / 12);
$otFactor = ($fixedOvertimeHours ?? 0) * $overtimeMultiplier;
$basePlusMeal = (int) round($monthlySalary * $monthlyWorkHours / ($monthlyWorkHours + $otFactor));
$baseSalary = $basePlusMeal - $mealAllowance;
$hourlyWage = (int) floor($basePlusMeal / $monthlyWorkHours);
$fixedOvertimePay = $monthlySalary - $baseSalary - $mealAllowance;
return [
'monthly_salary' => $monthlySalary,
'base_salary' => $baseSalary,
'fixed_overtime_pay' => $fixedOvertimePay,
'hourly_wage' => $hourlyWage,
'monthly_work_hours' => $monthlyWorkHours,
'overtime_multiplier' => $overtimeMultiplier,
];
}
/**
* toArray 시 salary_info 제거 (일반 API 응답에서 연봉 정보 노출 방지)
*/
public function toArray(): array
{
$array = parent::toArray();
if (isset($array['json_extra']['salary_info'])) {
unset($array['json_extra']['salary_info']);
}
return $array;
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeForTenant($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? session('selected_tenant_id');
if ($tenantId) {
return $query->where('tenant_id', $tenantId);
}
return $query;
}
public function scopeActiveEmployees($query)
{
return $query->where('employee_status', 'active');
}
public function scopeOnLeave($query)
{
return $query->where('employee_status', 'leave');
}
public function scopeResigned($query)
{
return $query->where('employee_status', 'resigned');
}
}