with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)]) ->forTenant($tenantId); if (! empty($filters['q'])) { $search = $filters['q']; $query->whereHas('user', function ($q) use ($search) { $q->where('name', 'like', "%{$search}%"); }); } if (! empty($filters['department_id'])) { $deptId = $filters['department_id']; $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { $q->where('tenant_id', $tenantId)->where('department_id', $deptId); }); } if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { $query->betweenDates($filters['date_from'], $filters['date_to']); } elseif (! empty($filters['date_from'])) { $query->whereDate('base_date', '>=', $filters['date_from']); } elseif (! empty($filters['date_to'])) { $query->whereDate('base_date', '<=', $filters['date_to']); } return $query->orderBy('base_date', 'desc')->orderBy('created_at', 'desc'); } /** * 근태 목록 조회 (페이지네이션) */ public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator { return $this->buildFilteredQuery($filters)->paginate($perPage); } /** * 엑셀 내보내기용 데이터 (전체) */ public function getExportData(array $filters = []): Collection { return $this->buildFilteredQuery($filters)->get(); } /** * 월간 통계 (상태별 카운트) */ public function getMonthlyStats(?int $year = null, ?int $month = null): array { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; $month = $month ?? now()->month; $startDate = sprintf('%04d-%02d-01', $year, $month); $endDate = now()->year == $year && now()->month == $month ? now()->toDateString() : sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); $counts = Attendance::query() ->forTenant($tenantId) ->betweenDates($startDate, $endDate) ->select('status', DB::raw('COUNT(*) as cnt')) ->groupBy('status') ->pluck('cnt', 'status') ->toArray(); return [ 'onTime' => $counts['onTime'] ?? 0, 'late' => $counts['late'] ?? 0, 'absent' => $counts['absent'] ?? 0, 'vacation' => $counts['vacation'] ?? 0, 'etc' => ($counts['businessTrip'] ?? 0) + ($counts['fieldWork'] ?? 0) + ($counts['overtime'] ?? 0) + ($counts['remote'] ?? 0), 'year' => $year, 'month' => $month, ]; } /** * 근태 등록 (Upsert: tenant_id + user_id + base_date) */ public function storeAttendance(array $data): Attendance { $tenantId = session('selected_tenant_id'); return DB::transaction(function () use ($data, $tenantId) { $jsonDetails = []; if (! empty($data['check_in'])) { $jsonDetails['check_in'] = $data['check_in']; } if (! empty($data['check_out'])) { $jsonDetails['check_out'] = $data['check_out']; } // 근무 시간 자동 계산 if (! empty($data['check_in']) && ! empty($data['check_out'])) { $in = Carbon::createFromFormat('H:i', $data['check_in']); $out = Carbon::createFromFormat('H:i', $data['check_out']); if ($out->gt($in)) { $jsonDetails['work_minutes'] = $out->diffInMinutes($in); } } $attendance = Attendance::updateOrCreate( [ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], 'base_date' => $data['base_date'], ], [ 'status' => $data['status'] ?? 'onTime', 'json_details' => ! empty($jsonDetails) ? $jsonDetails : null, 'remarks' => $data['remarks'] ?? null, 'updated_by' => auth()->id(), ] ); if ($attendance->wasRecentlyCreated) { $attendance->update(['created_by' => auth()->id()]); } // 휴가 상태이면 연차 차감 if (($data['status'] ?? '') === 'vacation') { $this->deductLeaveBalance($tenantId, $data['user_id']); } return $attendance->load('user'); }); } /** * 일괄 등록 */ public function bulkStore(array $data): array { $tenantId = session('selected_tenant_id'); $created = 0; $updated = 0; DB::transaction(function () use ($data, $tenantId, &$created, &$updated) { $jsonDetails = []; if (! empty($data['check_in'])) { $jsonDetails['check_in'] = $data['check_in']; } if (! empty($data['check_out'])) { $jsonDetails['check_out'] = $data['check_out']; } if (! empty($data['check_in']) && ! empty($data['check_out'])) { $in = Carbon::createFromFormat('H:i', $data['check_in']); $out = Carbon::createFromFormat('H:i', $data['check_out']); if ($out->gt($in)) { $jsonDetails['work_minutes'] = $out->diffInMinutes($in); } } foreach ($data['user_ids'] as $userId) { $attendance = Attendance::updateOrCreate( [ 'tenant_id' => $tenantId, 'user_id' => $userId, 'base_date' => $data['base_date'], ], [ 'status' => $data['status'] ?? 'onTime', 'json_details' => ! empty($jsonDetails) ? $jsonDetails : null, 'remarks' => $data['remarks'] ?? null, 'updated_by' => auth()->id(), ] ); if ($attendance->wasRecentlyCreated) { $attendance->update(['created_by' => auth()->id()]); $created++; } else { $updated++; } if (($data['status'] ?? '') === 'vacation') { $this->deductLeaveBalance($tenantId, $userId); } } }); return ['created' => $created, 'updated' => $updated]; } /** * 근태 수정 */ public function updateAttendance(int $id, array $data): ?Attendance { $tenantId = session('selected_tenant_id'); $attendance = Attendance::query() ->forTenant($tenantId) ->find($id); if (! $attendance) { return null; } $updateData = []; if (array_key_exists('status', $data)) { $updateData['status'] = $data['status']; } if (array_key_exists('remarks', $data)) { $updateData['remarks'] = $data['remarks']; } // json_details 업데이트 $jsonDetails = $attendance->json_details ?? []; if (array_key_exists('check_in', $data)) { if ($data['check_in']) { $jsonDetails['check_in'] = $data['check_in']; } else { unset($jsonDetails['check_in']); } } if (array_key_exists('check_out', $data)) { if ($data['check_out']) { $jsonDetails['check_out'] = $data['check_out']; } else { unset($jsonDetails['check_out']); } } // 근무 시간 재계산 $checkIn = $jsonDetails['check_in'] ?? null; $checkOut = $jsonDetails['check_out'] ?? null; if ($checkIn && $checkOut) { $in = Carbon::createFromFormat('H:i', $checkIn); $out = Carbon::createFromFormat('H:i', $checkOut); if ($out->gt($in)) { $jsonDetails['work_minutes'] = $out->diffInMinutes($in); } } else { unset($jsonDetails['work_minutes']); } $updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null; $updateData['updated_by'] = auth()->id(); $attendance->update($updateData); return $attendance->fresh('user'); } /** * 근태 삭제 */ public function deleteAttendance(int $id): bool { $tenantId = session('selected_tenant_id'); $attendance = Attendance::query() ->forTenant($tenantId) ->find($id); if (! $attendance) { return false; } $attendance->update(['deleted_by' => auth()->id()]); $attendance->delete(); return true; } /** * 일괄 삭제 */ public function bulkDelete(array $ids): int { $tenantId = session('selected_tenant_id'); $attendances = Attendance::query() ->forTenant($tenantId) ->whereIn('id', $ids) ->get(); $count = 0; foreach ($attendances as $attendance) { $attendance->update(['deleted_by' => auth()->id()]); $attendance->delete(); $count++; } return $count; } /** * 월간 캘린더 데이터 (base_date 기준 그룹화) */ public function getMonthlyCalendarData(int $year, int $month, ?int $userId = null): Collection { $tenantId = session('selected_tenant_id'); $startDate = sprintf('%04d-%02d-01', $year, $month); $endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); $query = Attendance::query() ->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)]) ->forTenant($tenantId) ->betweenDates($startDate, $endDate) ->orderBy('base_date') ->orderBy('user_id'); if ($userId) { $query->where('user_id', $userId); } return $query->get(); } /** * 사원별 월간 요약 */ public function getEmployeeMonthlySummary(int $year, int $month): array { $tenantId = session('selected_tenant_id'); $startDate = sprintf('%04d-%02d-01', $year, $month); $endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year)); $raw = Attendance::query() ->forTenant($tenantId) ->betweenDates($startDate, $endDate) ->select( 'user_id', 'status', DB::raw('COUNT(*) as cnt'), DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as total_minutes") ) ->groupBy('user_id', 'status') ->get(); $summary = []; foreach ($raw as $row) { if (! isset($summary[$row->user_id])) { $summary[$row->user_id] = [ 'user_id' => $row->user_id, 'total_days' => 0, 'total_minutes' => 0, 'statuses' => [], ]; } $summary[$row->user_id]['total_days'] += $row->cnt; $summary[$row->user_id]['total_minutes'] += (int) $row->total_minutes; $summary[$row->user_id]['statuses'][$row->status] = $row->cnt; } // 사원 정보 가져오기 $employees = Employee::query() ->with(['user:id,name', 'department:id,name']) ->forTenant($tenantId) ->activeEmployees() ->get() ->keyBy('user_id'); foreach ($summary as &$item) { $emp = $employees[$item['user_id']] ?? null; $item['name'] = $emp?->display_name ?? $emp?->user?->name ?? '-'; $item['department'] = $emp?->department?->name ?? '-'; } return array_values($summary); } /** * 초과근무 알림 (이번 주 기준) */ public function getOvertimeAlerts(): array { $tenantId = session('selected_tenant_id'); $weekStart = now()->startOfWeek(Carbon::MONDAY)->toDateString(); $weekEnd = now()->endOfWeek(Carbon::SUNDAY)->toDateString(); $results = Attendance::query() ->forTenant($tenantId) ->betweenDates($weekStart, $weekEnd) ->select( 'user_id', DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as week_minutes") ) ->groupBy('user_id') ->having('week_minutes', '>=', 2880) // 48시간 = 2880분 ->get(); $alerts = []; if ($results->isNotEmpty()) { $employees = Employee::query() ->with(['user:id,name']) ->forTenant($tenantId) ->activeEmployees() ->get() ->keyBy('user_id'); foreach ($results as $row) { $emp = $employees[$row->user_id] ?? null; $hours = round($row->week_minutes / 60, 1); $alerts[] = [ 'user_id' => $row->user_id, 'name' => $emp?->display_name ?? $emp?->user?->name ?? '-', 'hours' => $hours, 'level' => $row->week_minutes >= 3120 ? 'danger' : 'warning', // 52h = 3120분 ]; } } return $alerts; } /** * 자동 결근 처리 (영업일에 출근 기록 없는 사원) */ public function markAbsentees(?string $date = null): int { $date = $date ?? now()->toDateString(); $carbonDate = Carbon::parse($date); // 주말이면 스킵 if ($carbonDate->isWeekend()) { return 0; } $count = 0; // 모든 테넌트의 활성 사원 조회 $tenantIds = DB::table('tenants')->pluck('id'); foreach ($tenantIds as $tenantId) { $activeUserIds = Employee::query() ->where('tenant_id', $tenantId) ->activeEmployees() ->pluck('user_id') ->toArray(); if (empty($activeUserIds)) { continue; } // 이미 기록이 있는 사원 제외 $existingUserIds = Attendance::query() ->where('tenant_id', $tenantId) ->whereDate('base_date', $date) ->pluck('user_id') ->toArray(); $absentUserIds = array_diff($activeUserIds, $existingUserIds); foreach ($absentUserIds as $userId) { Attendance::create([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'base_date' => $date, 'status' => 'absent', 'remarks' => '자동 결근 처리', 'created_by' => null, ]); $count++; } } return $count; } /** * 연차 잔여 조회 */ public function getLeaveBalance(int $userId): ?LeaveBalance { $tenantId = session('selected_tenant_id'); $year = now()->year; return LeaveBalance::query() ->where('tenant_id', $tenantId) ->where('user_id', $userId) ->where('year', $year) ->first(); } /** * 연차 차감 (remaining_days는 stored generated이므로 used_days만 업데이트) */ private function deductLeaveBalance(int $tenantId, int $userId): void { $year = now()->year; $balance = LeaveBalance::query() ->where('tenant_id', $tenantId) ->where('user_id', $userId) ->where('year', $year) ->first(); if ($balance && $balance->remaining_days > 0) { $balance->update([ 'used_days' => $balance->used_days + 1, ]); } } /** * 부서 목록 (드롭다운용) */ public function getDepartments(): \Illuminate\Database\Eloquent\Collection { $tenantId = session('selected_tenant_id'); return Department::query() ->where('is_active', true) ->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->orderBy('sort_order') ->orderBy('name') ->get(['id', 'name', 'code']); } /** * 활성 사원 목록 (드롭다운용) */ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection { $tenantId = session('selected_tenant_id'); return Employee::query() ->with('user:id,name') ->forTenant($tenantId) ->activeEmployees() ->orderBy('display_name') ->get(['id', 'user_id', 'display_name', 'department_id']); } }