with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver', ]) ->forTenant($tenantId); if (! empty($filters['q'])) { $search = $filters['q']; $query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%")); } if (! empty($filters['user_id'])) { $query->where('user_id', $filters['user_id']); } 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['leave_type'])) { $query->where('leave_type', $filters['leave_type']); } 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->where('start_date', '>=', $filters['date_from']); } elseif (! empty($filters['date_to'])) { $query->where('end_date', '<=', $filters['date_to']); } return $query ->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')") ->orderBy('created_at', 'desc') ->paginate($perPage); } /** * 휴가 신청 등록 */ public function storeLeave(array $data): Leave { $tenantId = session('selected_tenant_id'); $days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']); // 연차 차감 대상이면 잔여일수 검증 if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($data['user_id']) ->forYear(now()->year) ->first(); if (! $balance || ! $balance->canUse($days)) { throw new \RuntimeException('잔여 연차가 부족합니다.'); } } return Leave::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], 'leave_type' => $data['leave_type'], 'start_date' => $data['start_date'], 'end_date' => $data['end_date'], 'days' => $days, 'reason' => $data['reason'] ?? null, 'status' => 'pending', 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); } /** * 승인 → LeaveBalance 차감 → Attendance 자동 생성 */ public function approve(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('pending') ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { $leave->update([ 'status' => 'approved', 'approved_by' => auth()->id(), 'approved_at' => now(), 'updated_by' => auth()->id(), ]); // 연차 차감 대상이면 LeaveBalance 차감 if ($leave->is_deductible) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); if ($balance) { $balance->useLeave($leave->days); } } // 기간 내 영업일마다 Attendance(vacation) 자동 생성 $this->createAttendanceRecords($leave, $tenantId); return $leave->fresh(['user', 'approver']); }); } /** * 반려 */ public function reject(int $id, ?string $reason = null): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('pending') ->find($id); if (! $leave) { return null; } $leave->update([ 'status' => 'rejected', 'approved_by' => auth()->id(), 'approved_at' => now(), 'reject_reason' => $reason, 'updated_by' => auth()->id(), ]); return $leave->fresh(['user', 'approver']); } /** * 취소 → LeaveBalance 복원 → Attendance 삭제 */ public function cancel(int $id): ?Leave { $tenantId = session('selected_tenant_id'); $leave = Leave::query() ->forTenant($tenantId) ->withStatus('approved') ->find($id); if (! $leave) { return null; } return DB::transaction(function () use ($leave, $tenantId) { $leave->update([ 'status' => 'cancelled', 'updated_by' => auth()->id(), ]); // 연차 차감 대상이면 LeaveBalance 복원 if ($leave->is_deductible) { $balance = LeaveBalance::query() ->forTenant($tenantId) ->forUser($leave->user_id) ->forYear($leave->start_date->year) ->first(); if ($balance) { $balance->restoreLeave($leave->days); } } // 해당 기간 vacation Attendance soft delete $this->deleteAttendanceRecords($leave, $tenantId); return $leave->fresh(['user']); }); } /** * 전체 사원 잔여연차 요약 */ public function getBalanceSummary(?int $year = null): Collection { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; return LeaveBalance::query() ->with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', ]) ->forTenant($tenantId) ->forYear($year) ->orderBy('user_id') ->get(); } /** * 개별 사원 잔여연차 */ public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; return LeaveBalance::query() ->forTenant($tenantId) ->forUser($userId) ->forYear($year) ->first(); } /** * 유형별/사원별 사용 통계 */ public function getUsageStats(?int $year = null): array { $tenantId = session('selected_tenant_id'); $year = $year ?? now()->year; // 유형별 집계 $byType = Leave::query() ->forTenant($tenantId) ->forYear($year) ->withStatus('approved') ->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days')) ->groupBy('leave_type') ->get() ->keyBy('leave_type'); // 사원별 유형 크로스 테이블 $byUser = Leave::query() ->forTenant($tenantId) ->forYear($year) ->withStatus('approved') ->with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', ]) ->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days')) ->groupBy('user_id', 'leave_type') ->get() ->groupBy('user_id'); return [ 'by_type' => $byType, 'by_user' => $byUser, 'year' => $year, ]; } /** * 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수) */ public function calculateDays(string $type, string $start, string $end): float { if (in_array($type, ['half_am', 'half_pm'])) { return 0.5; } $period = CarbonPeriod::create($start, $end); $businessDays = 0; foreach ($period as $date) { if (! $date->isWeekend()) { $businessDays++; } } return (float) $businessDays; } /** * CSV 내보내기용 데이터 */ public function getExportData(array $filters = []): Collection { $tenantId = session('selected_tenant_id'); $query = Leave::query() ->with([ 'user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department', 'approver', ]) ->forTenant($tenantId); if (! empty($filters['status'])) { $query->withStatus($filters['status']); } if (! empty($filters['leave_type'])) { $query->where('leave_type', $filters['leave_type']); } if (! empty($filters['date_from']) && ! empty($filters['date_to'])) { $query->betweenDates($filters['date_from'], $filters['date_to']); } return $query->orderBy('start_date', 'desc')->get(); } /** * 부서 목록 */ 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 \App\Models\HR\Employee::query() ->with('user:id,name') ->forTenant($tenantId) ->activeEmployees() ->orderBy('display_name') ->get(['id', 'user_id', 'display_name', 'department_id']); } // ========================================================================= // Private // ========================================================================= /** * 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성 */ private function createAttendanceRecords(Leave $leave, int $tenantId): void { $period = CarbonPeriod::create($leave->start_date, $leave->end_date); foreach ($period as $date) { if ($date->isWeekend()) { continue; } Attendance::updateOrCreate( [ 'tenant_id' => $tenantId, 'user_id' => $leave->user_id, 'base_date' => $date->toDateString(), ], [ 'status' => 'vacation', 'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null, 'updated_by' => auth()->id(), ] ); } } /** * 취소 시 해당 기간 vacation Attendance soft delete */ private function deleteAttendanceRecords(Leave $leave, int $tenantId): void { Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('status', 'vacation') ->whereBetween('base_date', [ $leave->start_date->toDateString(), $leave->end_date->toDateString(), ]) ->update(['deleted_by' => auth()->id()]); Attendance::query() ->where('tenant_id', $tenantId) ->where('user_id', $leave->user_id) ->where('status', 'vacation') ->whereBetween('base_date', [ $leave->start_date->toDateString(), $leave->end_date->toDateString(), ]) ->delete(); } }