tenantId(); $query = Attendance::query() ->where('tenant_id', $tenantId) ->with([ 'user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId) ->with('department:id,name'); }, ]); // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } // 날짜 필터 (단일) if (! empty($params['date'])) { $query->whereDate('base_date', $params['date']); } // 날짜 범위 필터 if (! empty($params['date_from'])) { $query->whereDate('base_date', '>=', $params['date_from']); } if (! empty($params['date_to'])) { $query->whereDate('base_date', '<=', $params['date_to']); } // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 부서 필터 (사용자의 부서) if (! empty($params['department_id'])) { $query->whereHas('user.tenantProfile', function ($q) use ($params) { $q->where('department_id', $params['department_id']); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'base_date'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); // 페이지네이션 $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 근태 상세 조회 */ public function show(int $id): Attendance { $tenantId = $this->tenantId(); $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->with([ 'user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId) ->with('department:id,name'); }, ]) ->findOrFail($id); return $attendance; } /** * 근태 등록 (Upsert) * - 같은 날 같은 사용자의 기록이 있으면 업데이트 * - 없으면 새로 생성 */ public function store(array $data): Attendance { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 기존 기록 확인 (같은 날 같은 사용자) $existing = Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $data['user_id']) ->whereDate('base_date', $data['base_date']) ->first(); // json_details 구성 $jsonDetails = isset($data['json_details']) && is_array($data['json_details']) ? $data['json_details'] : $this->buildJsonDetails($data); if ($existing) { // 기존 기록 업데이트 (Upsert) $existing->status = $data['status'] ?? $existing->status; $existing->json_details = array_merge($existing->json_details ?? [], $jsonDetails); if (array_key_exists('remarks', $data)) { $existing->remarks = $data['remarks']; } $existing->updated_by = $userId; $existing->save(); return $existing->fresh(['user:id,name,email']); } // 새 기록 생성 $attendance = Attendance::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], 'base_date' => $data['base_date'], 'status' => $data['status'] ?? 'onTime', 'json_details' => $jsonDetails, 'remarks' => $data['remarks'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); return $attendance->fresh(['user:id,name,email']); }); } /** * 근태 수정 */ public function update(int $id, array $data): Attendance { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 기본 필드 업데이트 if (isset($data['status'])) { $attendance->status = $data['status']; } if (array_key_exists('remarks', $data)) { $attendance->remarks = $data['remarks']; } // json_details 업데이트 $jsonDetails = $attendance->json_details ?? []; // json_details 객체가 직접 전달된 경우 그대로 병합, 아니면 개별 필드에서 구성 $detailsUpdate = isset($data['json_details']) && is_array($data['json_details']) ? $data['json_details'] : $this->buildJsonDetails($data); $attendance->json_details = array_merge($jsonDetails, $detailsUpdate); $attendance->updated_by = $userId; $attendance->save(); return $attendance->fresh(['user:id,name,email']); }); } /** * 근태 삭제 */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->findOrFail($id); $attendance->deleted_by = $userId; $attendance->save(); $attendance->delete(); return true; } /** * 일괄 삭제 */ public function bulkDelete(array $ids): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $deletedCount = 0; DB::transaction(function () use ($ids, $tenantId, $userId, &$deletedCount) { $attendances = Attendance::query() ->where('tenant_id', $tenantId) ->whereIn('id', $ids) ->get(); foreach ($attendances as $attendance) { $attendance->deleted_by = $userId; $attendance->save(); $attendance->delete(); $deletedCount++; } }); return ['deleted_count' => $deletedCount]; } /** * 출근 기록 (체크인) * - 모든 출근 기록을 check_ins 배열에 히스토리로 저장 * - check_in accessor는 가장 빠른 출근 시간 반환 */ public function checkIn(array $data): Attendance { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $targetUserId = $data['user_id'] ?? $userId; $today = now()->toDateString(); return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) { // 오늘 기록 확인 $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $targetUserId) ->whereDate('base_date', $today) ->first(); $checkInTime = $data['check_in'] ?? now()->format('H:i:s'); $gpsData = $data['gps_data'] ?? null; // 출근 엔트리 생성 $entry = [ 'time' => $checkInTime, 'recorded_at' => now()->toIso8601String(), ]; if ($gpsData) { $entry['gps'] = $gpsData; } if ($attendance) { // 기존 기록에 출근 추가 $jsonDetails = $attendance->json_details ?? []; $checkIns = $jsonDetails['check_ins'] ?? []; $checkIns[] = $entry; $jsonDetails['check_ins'] = $checkIns; $attendance->json_details = $jsonDetails; $attendance->status = $this->determineStatusFromEntries($jsonDetails); $attendance->updated_by = $userId; $attendance->save(); } else { // 새 기록 생성 $jsonDetails = [ 'check_ins' => [$entry], 'check_outs' => [], ]; $attendance = Attendance::create([ 'tenant_id' => $tenantId, 'user_id' => $targetUserId, 'base_date' => $today, 'status' => $this->determineStatus($checkInTime, null), 'json_details' => $jsonDetails, 'created_by' => $userId, 'updated_by' => $userId, ]); } return $attendance->fresh(['user:id,name,email']); }); } /** * 퇴근 기록 (체크아웃) * - 모든 퇴근 기록을 check_outs 배열에 히스토리로 저장 * - check_out accessor는 가장 늦은 퇴근 시간 반환 */ public function checkOut(array $data): Attendance { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $targetUserId = $data['user_id'] ?? $userId; $today = now()->toDateString(); return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) { $attendance = Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $targetUserId) ->whereDate('base_date', $today) ->first(); if (! $attendance) { throw new \Exception(__('error.attendance.no_check_in')); } $checkOutTime = $data['check_out'] ?? now()->format('H:i:s'); $gpsData = $data['gps_data'] ?? null; // 퇴근 엔트리 생성 $entry = [ 'time' => $checkOutTime, 'recorded_at' => now()->toIso8601String(), ]; if ($gpsData) { $entry['gps'] = $gpsData; } $jsonDetails = $attendance->json_details ?? []; $checkOuts = $jsonDetails['check_outs'] ?? []; $checkOuts[] = $entry; $jsonDetails['check_outs'] = $checkOuts; // 근무 시간 계산 (가장 빠른 출근 ~ 가장 늦은 퇴근) $checkIns = $jsonDetails['check_ins'] ?? []; if (! empty($checkIns)) { $earliestIn = $this->getEarliestTime($checkIns); $latestOut = $this->getLatestTime($checkOuts); if ($earliestIn && $latestOut) { $checkInCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $earliestIn); $checkOutCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $latestOut); $totalMinutes = $checkOutCarbon->diffInMinutes($checkInCarbon); // 휴게시간 계산 (근무 설정에서 조회) $workSettingService = app(WorkSettingService::class); $workSetting = $workSettingService->getWorkSetting(); $breakMinutes = 0; // 설정된 휴게시간이 있으면 적용 if ($workSetting->break_start && $workSetting->break_end) { $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)) { $breakMinutes = $breakEnd->diffInMinutes($breakStart); $jsonDetails['break_minutes'] = $breakMinutes; } } // 실제 근무시간 = 총 시간 - 휴게시간 $jsonDetails['work_minutes'] = $totalMinutes - $breakMinutes; } } $attendance->json_details = $jsonDetails; $attendance->status = $this->determineStatusFromEntries($jsonDetails); $attendance->updated_by = $userId; $attendance->save(); return $attendance->fresh(['user:id,name,email']); }); } /** * 엑셀 내보내기용 데이터 조회 * * @return array{data: array>, headings: array} */ public function getExportData(array $params): array { $tenantId = $this->tenantId(); $query = Attendance::query() ->where('tenant_id', $tenantId) ->with([ 'user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { $q->where('tenant_id', $tenantId) ->with('department:id,name'); }, ]); // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } // 날짜 필터 (단일) if (! empty($params['date'])) { $query->whereDate('base_date', $params['date']); } // 날짜 범위 필터 if (! empty($params['date_from'])) { $query->whereDate('base_date', '>=', $params['date_from']); } if (! empty($params['date_to'])) { $query->whereDate('base_date', '<=', $params['date_to']); } // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 부서 필터 if (! empty($params['department_id'])) { $query->whereHas('user.tenantProfile', function ($q) use ($params) { $q->where('department_id', $params['department_id']); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'base_date'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); $attendances = $query->get(); // 상태 레이블 매핑 $statusLabels = [ 'onTime' => '정상출근', 'late' => '지각', 'absent' => '결근', 'vacation' => '휴가', 'businessTrip' => '출장', 'fieldWork' => '외근', 'overtime' => '야근', 'remote' => '재택', ]; // 엑셀 데이터 변환 $data = $attendances->map(function ($attendance) use ($statusLabels) { $profile = $attendance->user?->tenantProfiles?->first(); $jsonDetails = $attendance->json_details ?? []; return [ $attendance->base_date, $attendance->user?->name ?? '-', $profile?->department?->name ?? '-', $statusLabels[$attendance->status] ?? $attendance->status, $attendance->check_in ?? '-', $attendance->check_out ?? '-', isset($jsonDetails['work_minutes']) ? round($jsonDetails['work_minutes'] / 60, 1) : '-', $attendance->remarks ?? '', ]; })->toArray(); $headings = [ '날짜', '직원명', '부서', '상태', '출근시간', '퇴근시간', '근무시간(h)', '비고', ]; return [ 'data' => $data, 'headings' => $headings, ]; } /** * 월간 통계 조회 */ public function monthlyStats(array $params): array { $tenantId = $this->tenantId(); $year = $params['year'] ?? now()->year; $month = $params['month'] ?? now()->month; $userId = $params['user_id'] ?? null; $startDate = \Carbon\Carbon::create($year, $month, 1)->startOfMonth(); $endDate = $startDate->copy()->endOfMonth(); $query = Attendance::query() ->where('tenant_id', $tenantId) ->whereBetween('base_date', [$startDate, $endDate]); if ($userId) { $query->where('user_id', $userId); } $attendances = $query->get(); $stats = [ 'year' => $year, 'month' => $month, 'total_days' => $attendances->count(), 'by_status' => [ 'onTime' => $attendances->where('status', 'onTime')->count(), 'late' => $attendances->where('status', 'late')->count(), 'absent' => $attendances->where('status', 'absent')->count(), 'vacation' => $attendances->where('status', 'vacation')->count(), 'businessTrip' => $attendances->where('status', 'businessTrip')->count(), 'fieldWork' => $attendances->where('status', 'fieldWork')->count(), 'overtime' => $attendances->where('status', 'overtime')->count(), 'remote' => $attendances->where('status', 'remote')->count(), ], 'total_work_minutes' => $attendances->sum(function ($a) { return $a->json_details['work_minutes'] ?? 0; }), 'total_overtime_minutes' => $attendances->sum(function ($a) { return $a->json_details['overtime_minutes'] ?? 0; }), ]; return $stats; } /** * json_details 구성 */ private function buildJsonDetails(array $data): array { $details = []; $detailKeys = [ 'check_in', 'check_out', 'gps_data', 'external_work', 'multiple_entries', 'work_minutes', 'overtime_minutes', 'late_minutes', 'early_leave_minutes', 'vacation_type', ]; foreach ($detailKeys as $key) { if (isset($data[$key])) { $details[$key] = $data[$key]; } } return $details; } /** * 상태 자동 결정 * 근무 설정의 출근 시간 기준으로 지각 여부 판단 * 출근 시간 설정이 없으면 지각 개념 없음 (항상 정시) */ private function determineStatus(?string $checkIn, ?string $checkOut): string { if (! $checkIn) { return 'absent'; } // 근무 설정에서 출근 시간 조회 $workSettingService = app(WorkSettingService::class); $workSetting = $workSettingService->getWorkSetting(); // 출근 시간 설정이 없으면 지각 판정 안함 if (! $workSetting->start_time) { return 'onTime'; } if ($checkIn > $workSetting->start_time) { return 'late'; } return 'onTime'; } /** * 엔트리 기반 상태 결정 * check_ins 배열에서 가장 빠른 시간을 기준으로 상태 판단 */ private function determineStatusFromEntries(array $jsonDetails): string { $checkIns = $jsonDetails['check_ins'] ?? []; $checkOuts = $jsonDetails['check_outs'] ?? []; if (empty($checkIns)) { return 'absent'; } $earliestIn = $this->getEarliestTime($checkIns); $latestOut = ! empty($checkOuts) ? $this->getLatestTime($checkOuts) : null; return $this->determineStatus($earliestIn, $latestOut); } /** * 엔트리 배열에서 가장 빠른 시간 추출 */ private function getEarliestTime(array $entries): ?string { if (empty($entries)) { return null; } $times = array_map(function ($entry) { return $entry['time'] ?? null; }, $entries); $times = array_filter($times); if (empty($times)) { return null; } sort($times); return $times[0]; } /** * 엔트리 배열에서 가장 늦은 시간 추출 */ private function getLatestTime(array $entries): ?string { if (empty($entries)) { return null; } $times = array_map(function ($entry) { return $entry['time'] ?? null; }, $entries); $times = array_filter($times); if (empty($times)) { return null; } rsort($times); return $times[0]; } }