Files
sam-api/app/Models/Tenants/Attendance.php
kent d6e18fb54e 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>
2025-12-30 17:25:13 +09:00

348 lines
9.2 KiB
PHP

<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 근태 기록 모델
*
* @property int $id
* @property int $tenant_id
* @property int $user_id
* @property string $base_date
* @property string $status
* @property array|null $json_details
* @property string|null $remarks
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Attendance extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'attendances';
protected $casts = [
'json_details' => '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);
}
}