feat(API): 직책/직원/근태 관리 API 개선

- Position 모델: key 필드 추가 및 마이그레이션
- PositionSeeder: 기본 직책 시더 추가
- TenantUserProfile: 프로필 이미지 관련 필드 추가
- Employee Request: 직원 등록/수정 요청 검증 강화
- EmployeeService: 직원 관리 서비스 로직 개선
- AttendanceService: 근태 관리 서비스 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 17:25:13 +09:00
parent 08c9a74cbc
commit d6e18fb54e
9 changed files with 381 additions and 62 deletions

View File

@@ -45,6 +45,15 @@ class Attendance extends Model
'deleted_by',
];
/**
* JSON 응답에 포함할 accessor
*/
protected $appends = [
'check_in',
'check_out',
'break_minutes',
];
/**
* 기본값 설정
*/
@@ -85,18 +94,44 @@ public function updater(): BelongsTo
// =========================================================================
/**
* 출근 시간
* 출근 시간 (가장 빠른 출근)
* check_ins 배열이 있으면 가장 빠른 시간 반환, 없으면 레거시 check_in 사용
*/
public function getCheckInAttribute(): ?string
{
$checkIns = $this->json_details['check_ins'] ?? [];
if (! empty($checkIns)) {
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkIns));
if (! empty($times)) {
sort($times);
return $times[0];
}
}
// 레거시 호환: 기존 check_in 필드
return $this->json_details['check_in'] ?? null;
}
/**
* 퇴근 시간
* 퇴근 시간 (가장 늦은 퇴근)
* check_outs 배열이 있으면 가장 늦은 시간 반환, 없으면 레거시 check_out 사용
*/
public function getCheckOutAttribute(): ?string
{
$checkOuts = $this->json_details['check_outs'] ?? [];
if (! empty($checkOuts)) {
$times = array_filter(array_map(fn ($entry) => $entry['time'] ?? null, $checkOuts));
if (! empty($times)) {
rsort($times);
return $times[0];
}
}
// 레거시 호환: 기존 check_out 필드
return $this->json_details['check_out'] ?? null;
}
@@ -134,6 +169,50 @@ public function getWorkMinutesAttribute(): ?int
: null;
}
/**
* 휴게 시간 (분 단위)
ㅣ * 1. json_details에 저장된 값이 있으면 반환
* 2. 없으면 check_in/check_out 기준으로 WorkSetting에서 실시간 계산
*/
public function getBreakMinutesAttribute(): ?int
{
// 1. 이미 계산된 값이 있으면 사용
if (isset($this->json_details['break_minutes'])) {
return (int) $this->json_details['break_minutes'];
}
// 2. 레거시 데이터: check_in, check_out이 있으면 실시간 계산
$checkIn = $this->check_in;
$checkOut = $this->check_out;
if (! $checkIn || ! $checkOut) {
return null;
}
// WorkSetting에서 휴게시간 설정 조회
$workSetting = WorkSetting::where('tenant_id', $this->tenant_id)->first();
if (! $workSetting || ! $workSetting->break_start || ! $workSetting->break_end) {
return null;
}
try {
$checkInCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $checkIn);
$checkOutCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $checkOut);
$breakStart = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_start);
$breakEnd = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_end);
// 출근~퇴근 시간이 휴게시간을 포함하면 휴게시간 적용
if ($checkInCarbon->lte($breakStart) && $checkOutCarbon->gte($breakEnd)) {
return (int) $breakStart->diffInMinutes($breakEnd);
}
} catch (\Exception $e) {
return null;
}
return null;
}
/**
* 초과 근무 시간 (분 단위)
*/

View File

@@ -13,6 +13,7 @@
* @property int $id
* @property int $tenant_id
* @property string $type rank(직급) | title(직책)
* @property string|null $key 영문 키 (tenant_user_profiles 연동용)
* @property string $name 명칭
* @property int $sort_order 정렬 순서
* @property bool $is_active 활성화 여부
@@ -26,6 +27,7 @@ class Position extends Model
protected $fillable = [
'tenant_id',
'type',
'key',
'name',
'sort_order',
'is_active',

View File

@@ -65,6 +65,26 @@ public function manager(): BelongsTo
return $this->belongsTo(User::class, 'manager_user_id');
}
/**
* 직급 (positions 테이블 type=rank)
*/
public function rankPosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'position_key', 'key')
->where('type', Position::TYPE_RANK)
->where('tenant_id', $this->tenant_id);
}
/**
* 직책 (positions 테이블 type=title)
*/
public function titlePosition(): BelongsTo
{
return $this->belongsTo(Position::class, 'job_title_key', 'key')
->where('type', Position::TYPE_TITLE)
->where('tenant_id', $this->tenant_id);
}
// =========================================================================
// json_extra 헬퍼 메서드
// =========================================================================
@@ -151,36 +171,37 @@ public function getEmergencyContactAttribute(): ?string
}
/**
* 직 레이블 (position_key → 한글)
* 직 레이블 (position_key → positions 테이블에서 name 조회)
*/
public function getPositionLabelAttribute(): ?string
{
$labels = [
'EXECUTIVE' => '임원',
'DIRECTOR' => '부장',
'MANAGER' => '과장',
'SENIOR' => '대리',
'STAFF' => '사원',
'INTERN' => '인턴',
];
if (! $this->position_key || ! $this->tenant_id) {
return $this->position_key;
}
return $labels[$this->position_key] ?? $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;
}
/**
* 직 레이블 (job_title_key → 한글)
* 직 레이블 (job_title_key → positions 테이블에서 name 조회)
*/
public function getJobTitleLabelAttribute(): ?string
{
$labels = [
'CEO' => '대표이사',
'CTO' => '기술이사',
'CFO' => '재무이사',
'TEAM_LEAD' => '팀장',
'TEAM_MEMBER' => '팀원',
];
if (! $this->job_title_key || ! $this->tenant_id) {
return $this->job_title_key;
}
return $labels[$this->job_title_key] ?? $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;
}
/**