- 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>
348 lines
9.2 KiB
PHP
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);
|
|
}
|
|
}
|