'array', 'base_date' => 'date', ]; protected $fillable = [ 'tenant_id', 'user_id', 'base_date', 'status', 'json_details', 'remarks', 'created_by', 'updated_by', 'deleted_by', ]; /** * JSON 응답에 포함할 accessor */ protected $appends = [ 'check_in', 'check_out', 'break_minutes', ]; /** * 기본값 설정 */ protected $attributes = [ 'status' => 'onTime', ]; // ========================================================================= // 관계 정의 // ========================================================================= /** * 사용자 관계 */ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } /** * 생성자 관계 */ public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } /** * 수정자 관계 */ public function updater(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); } // ========================================================================= // json_details 헬퍼 메서드 (Accessor) // ========================================================================= /** * 출근 시간 (가장 빠른 출근) * 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; } /** * GPS 데이터 */ public function getGpsDataAttribute(): ?array { return $this->json_details['gps_data'] ?? null; } /** * 외근 정보 */ public function getExternalWorkAttribute(): ?array { return $this->json_details['external_work'] ?? null; } /** * 다중 출퇴근 기록 (여러 번 출퇴근) */ public function getMultipleEntriesAttribute(): ?array { return $this->json_details['multiple_entries'] ?? null; } /** * 근무 시간 (분 단위) */ public function getWorkMinutesAttribute(): ?int { return isset($this->json_details['work_minutes']) ? (int) $this->json_details['work_minutes'] : 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; } /** * 초과 근무 시간 (분 단위) */ public function getOvertimeMinutesAttribute(): ?int { return isset($this->json_details['overtime_minutes']) ? (int) $this->json_details['overtime_minutes'] : null; } /** * 지각 시간 (분 단위) */ public function getLateMinutesAttribute(): ?int { return isset($this->json_details['late_minutes']) ? (int) $this->json_details['late_minutes'] : null; } /** * 조퇴 시간 (분 단위) */ public function getEarlyLeaveMinutesAttribute(): ?int { return isset($this->json_details['early_leave_minutes']) ? (int) $this->json_details['early_leave_minutes'] : null; } /** * 휴가 유형 (vacation 상태일 때) */ public function getVacationTypeAttribute(): ?string { return $this->json_details['vacation_type'] ?? null; } // ========================================================================= // json_details 업데이트 메서드 // ========================================================================= /** * json_details에서 특정 키 값 설정 */ public function setJsonDetailsValue(string $key, mixed $value): void { $jsonDetails = $this->json_details ?? []; if ($value === null) { unset($jsonDetails[$key]); } else { $jsonDetails[$key] = $value; } $this->json_details = $jsonDetails; } /** * json_details에서 특정 키 값 가져오기 */ public function getJsonDetailsValue(string $key, mixed $default = null): mixed { return $this->json_details[$key] ?? $default; } /** * 출퇴근 정보 일괄 업데이트 */ public function updateAttendanceDetails(array $data): void { $jsonDetails = $this->json_details ?? []; $allowedKeys = [ 'check_in', 'check_out', 'gps_data', 'external_work', 'multiple_entries', 'work_minutes', 'overtime_minutes', 'late_minutes', 'early_leave_minutes', 'vacation_type', ]; foreach ($allowedKeys as $key) { if (array_key_exists($key, $data)) { if ($data[$key] === null) { unset($jsonDetails[$key]); } else { $jsonDetails[$key] = $data[$key]; } } } $this->json_details = $jsonDetails; } // ========================================================================= // 스코프 // ========================================================================= /** * 특정 날짜의 근태 조회 */ public function scopeOnDate($query, string $date) { return $query->whereDate('base_date', $date); } /** * 특정 기간의 근태 조회 */ public function scopeBetweenDates($query, string $startDate, string $endDate) { return $query->whereBetween('base_date', [$startDate, $endDate]); } /** * 특정 사용자의 근태 조회 */ public function scopeForUser($query, int $userId) { return $query->where('user_id', $userId); } /** * 특정 상태의 근태 조회 */ public function scopeWithStatus($query, string $status) { return $query->where('status', $status); } }