with(['user', 'department']) ->forTenant($tenantId); // 제외 사원 필터 (기본: 숨김) if (empty($filters['show_excluded'])) { // "영업팀" 포함 부서 사원 제외 $query->where(function ($q) { $q->whereDoesntHave('department', function ($dq) { $dq->where('name', 'like', '%영업팀%'); })->orWhereNull('department_id'); }); // 강제 제외된 사원 제외 $query->where(function ($q) { $q->whereNull('json_extra->is_excluded') ->orWhere('json_extra->is_excluded', false); }); } // 검색 필터 (이름, 이메일, 연락처) if (! empty($filters['q'])) { $search = $filters['q']; $query->where(function ($q) use ($search) { $q->where('display_name', 'like', "%{$search}%") ->orWhereHas('user', function ($uq) use ($search) { $uq->where('name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%") ->orWhere('phone', 'like', "%{$search}%"); }); }); } // 상태 필터 if (! empty($filters['status'])) { $query->where('employee_status', $filters['status']); } // 부서 필터 if (! empty($filters['department_id'])) { $query->where('department_id', $filters['department_id']); } // 정렬 $sortBy = $filters['sort_by'] ?? 'hire_date_asc'; switch ($sortBy) { case 'hire_date_asc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC"); break; case 'hire_date_desc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC"); break; case 'resign_date_asc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) ASC"); break; case 'resign_date_desc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) DESC"); break; default: $query->orderByRaw("FIELD(employee_status, 'active', 'leave', 'resigned')") ->orderBy('created_at', 'desc'); break; } return $query->paginate($perPage); } /** * 사원 상세 조회 */ public function getEmployeeById(int $id): ?Employee { $tenantId = session('selected_tenant_id'); return Employee::query() ->with(['user', 'department', 'manager']) ->forTenant($tenantId) ->find($id); } /** * 사원 통계 */ public function getStats(bool $showExcluded = false): array { $tenantId = session('selected_tenant_id'); $baseQuery = Employee::query()->forTenant($tenantId); if (! $showExcluded) { $baseQuery->where(function ($q) { $q->whereDoesntHave('department', function ($dq) { $dq->where('name', 'like', '%영업팀%'); })->orWhereNull('department_id'); })->where(function ($q) { $q->whereNull('json_extra->is_excluded') ->orWhere('json_extra->is_excluded', false); }); } return [ 'total' => (clone $baseQuery)->count(), 'active' => (clone $baseQuery)->where('employee_status', 'active')->count(), 'leave' => (clone $baseQuery)->where('employee_status', 'leave')->count(), 'resigned' => (clone $baseQuery)->where('employee_status', 'resigned')->count(), ]; } /** * 테넌트 소속이지만 사원 미등록인 사용자 검색 */ public function searchTenantUsers(string $query): array { $tenantId = session('selected_tenant_id'); $builder = User::query() ->select('users.id', 'users.name', 'users.email', 'users.phone') ->join('user_tenants as ut', function ($join) use ($tenantId) { $join->on('users.id', '=', 'ut.user_id') ->where('ut.tenant_id', $tenantId) ->whereNull('ut.deleted_at'); }) ->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) { $join->on('users.id', '=', 'tup.user_id') ->where('tup.tenant_id', $tenantId); }) ->whereNull('tup.id') ->whereNull('users.deleted_at'); if ($query !== '') { $like = "%{$query}%"; $builder->where(function ($q) use ($like) { $q->where('users.name', 'like', $like) ->orWhere('users.email', 'like', $like) ->orWhere('users.phone', 'like', $like); }); } return $builder->orderBy('users.name')->limit(20)->get()->toArray(); } /** * 사원 등록 (User + TenantUserProfile 동시 생성) */ public function createEmployee(array $data): Employee { $tenantId = session('selected_tenant_id'); return DB::transaction(function () use ($data, $tenantId) { // 기존 사용자 선택 분기 if (! empty($data['existing_user_id'])) { $user = User::findOrFail($data['existing_user_id']); // 테넌트 소속 검증 $isMember = $user->tenants() ->wherePivot('tenant_id', $tenantId) ->wherePivotNull('deleted_at') ->exists(); if (! $isMember) { throw new \RuntimeException('해당 사용자는 현재 테넌트에 소속되어 있지 않습니다.'); } // 이미 사원 등록 여부 확인 $alreadyEmployee = Employee::where('tenant_id', $tenantId) ->where('user_id', $user->id) ->exists(); if ($alreadyEmployee) { throw new \RuntimeException('이미 사원으로 등록된 사용자입니다.'); } } else { // 신규 사용자 생성 $loginId = ! empty($data['email']) ? Str::before($data['email'], '@') : 'EMP_'.strtolower(Str::random(6)); while (User::where('user_id', $loginId)->exists()) { $loginId = $loginId.'_'.Str::random(3); } $email = ! empty($data['email']) ? $data['email'] : $loginId.'@placeholder.local'; while (User::where('email', $email)->exists()) { $email = $loginId.'_'.Str::random(3).'@placeholder.local'; } $user = User::create([ 'user_id' => $loginId, 'name' => $data['name'], 'email' => $email, 'phone' => $data['phone'] ?? null, 'password' => Hash::make('sam1234!'), 'role' => 'ops', 'is_active' => true, 'must_change_password' => true, 'created_by' => auth()->id(), ]); if ($tenantId) { $user->tenants()->attach($tenantId, [ 'is_active' => true, 'is_default' => true, 'joined_at' => now(), ]); } } // json_extra 구성 $jsonExtra = []; foreach (['hire_date', 'resign_date', 'address', 'emergency_contact', 'resident_number', 'personal_email'] as $key) { if (! empty($data[$key])) { $jsonExtra[$key] = $data[$key]; } } // 급여이체정보 (bank_account 객체) if (! empty($data['bank_account']) && is_array($data['bank_account'])) { $bankAccount = array_filter($data['bank_account'], fn ($v) => $v !== null && $v !== ''); if (! empty($bankAccount)) { $jsonExtra['bank_account'] = $bankAccount; } } // 부양가족 정보 (dependents 배열) if (! empty($data['dependents']) && is_array($data['dependents'])) { $dependents = array_values(array_filter($data['dependents'], function ($dep) { return ! empty($dep['name']); })); $dependents = array_map(function ($dep) { $dep['is_disabled'] = filter_var($dep['is_disabled'] ?? false, FILTER_VALIDATE_BOOLEAN); $dep['is_dependent'] = filter_var($dep['is_dependent'] ?? false, FILTER_VALIDATE_BOOLEAN); return $dep; }, $dependents); if (! empty($dependents)) { $jsonExtra['dependents'] = $dependents; } } // Employee(TenantUserProfile) 생성 $employee = Employee::create([ 'tenant_id' => $tenantId, 'user_id' => $user->id, 'department_id' => $data['department_id'] ?? null, 'position_key' => $data['position_key'] ?? null, 'job_title_key' => $data['job_title_key'] ?? null, 'work_location_key' => $data['work_location_key'] ?? null, 'employment_type_key' => $data['employment_type_key'] ?? null, 'employee_status' => $data['employee_status'] ?? 'active', 'manager_user_id' => $data['manager_user_id'] ?? null, 'display_name' => $data['display_name'] ?? $data['name'], 'json_extra' => ! empty($jsonExtra) ? $jsonExtra : null, ]); return $employee->load(['user', 'department']); }); } /** * 사원 정보 수정 */ public function updateEmployee(int $id, array $data): ?Employee { $employee = $this->getEmployeeById($id); if (! $employee) { return null; } // 기본 필드 업데이트 $updateData = array_filter([ 'department_id' => $data['department_id'] ?? null, 'position_key' => $data['position_key'] ?? null, 'job_title_key' => $data['job_title_key'] ?? null, 'work_location_key' => $data['work_location_key'] ?? null, 'employment_type_key' => $data['employment_type_key'] ?? null, 'employee_status' => $data['employee_status'] ?? null, 'manager_user_id' => $data['manager_user_id'] ?? null, 'display_name' => $data['display_name'] ?? null, ], fn ($v) => $v !== null); // json_extra 업데이트 (스칼라 값) $jsonExtraKeys = ['hire_date', 'resign_date', 'address', 'emergency_contact', 'salary', 'resident_number', 'personal_email']; $extra = $employee->json_extra ?? []; foreach ($jsonExtraKeys as $key) { if (array_key_exists($key, $data)) { if ($data[$key] === null || $data[$key] === '') { unset($extra[$key]); } else { $extra[$key] = $data[$key]; } } } // 급여이체정보 (bank_account 객체) if (array_key_exists('bank_account', $data)) { if (! empty($data['bank_account']) && is_array($data['bank_account'])) { $bankAccount = array_filter($data['bank_account'], fn ($v) => $v !== null && $v !== ''); if (! empty($bankAccount)) { $extra['bank_account'] = $bankAccount; } else { unset($extra['bank_account']); } } else { unset($extra['bank_account']); } } // 부양가족 정보 (dependents 배열) if (array_key_exists('dependents', $data)) { if (! empty($data['dependents']) && is_array($data['dependents'])) { $dependents = array_values(array_filter($data['dependents'], function ($dep) { return ! empty($dep['name']); })); $dependents = array_map(function ($dep) { $dep['is_disabled'] = filter_var($dep['is_disabled'] ?? false, FILTER_VALIDATE_BOOLEAN); $dep['is_dependent'] = filter_var($dep['is_dependent'] ?? false, FILTER_VALIDATE_BOOLEAN); return $dep; }, $dependents); if (! empty($dependents)) { $extra['dependents'] = $dependents; } else { unset($extra['dependents']); } } else { unset($extra['dependents']); } } $updateData['json_extra'] = ! empty($extra) ? $extra : null; $employee->update($updateData); // User 기본정보 동기화 if ($employee->user) { $userUpdate = []; if (! empty($data['name'])) { $userUpdate['name'] = $data['name']; } if (! empty($data['email'])) { $userUpdate['email'] = $data['email']; } if (! empty($data['phone'])) { $userUpdate['phone'] = $data['phone']; } if (! empty($userUpdate)) { $employee->user->update($userUpdate); } } return $employee->fresh(['user', 'department']); } /** * 사원 삭제 (퇴직 처리) */ public function deleteEmployee(int $id): bool { $employee = $this->getEmployeeById($id); if (! $employee) { return false; } $employee->update(['employee_status' => 'resigned']); return true; } /** * 사원 영구 삭제 (퇴직자만, 슈퍼관리자 전용) */ public function forceDeleteEmployee(int $id): array { $employee = $this->getEmployeeById($id); if (! $employee) { return ['success' => false, 'message' => '사원 정보를 찾을 수 없습니다.']; } if ($employee->employee_status !== 'resigned') { return ['success' => false, 'message' => '퇴직 상태인 사원만 영구삭제할 수 있습니다.']; } $name = $employee->display_name ?? $employee->user?->name ?? '알 수 없음'; // 관련 첨부파일 삭제 \App\Models\Boards\File::where('document_type', 'employee_profile') ->where('document_id', $employee->id) ->where('tenant_id', session('selected_tenant_id')) ->delete(); // tenant_user_profiles 레코드 영구 삭제 $employee->delete(); return ['success' => true, 'message' => "사원 '{$name}'이(가) 영구삭제되었습니다."]; } /** * 사원 제외/복원 토글 */ public function toggleExclude(int $id): ?Employee { $employee = $this->getEmployeeById($id); if (! $employee) { return null; } $isExcluded = $employee->getJsonExtraValue('is_excluded', false); $employee->setJsonExtraValue('is_excluded', ! $isExcluded); $employee->save(); return $employee; } /** * 부서 목록 (드롭다운용) */ 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 getPositions(string $type = 'rank'): \Illuminate\Database\Eloquent\Collection { return Position::query() ->forTenant() ->where('type', $type) ->where('is_active', true) ->ordered() ->get(['id', 'key', 'name']); } /** * 입퇴사자 현황 조회 (페이지네이션) */ public function getEmployeeTenure(array $filters = [], int $perPage = 50): LengthAwarePaginator { $tenantId = session('selected_tenant_id'); $query = Employee::query() ->with(['user', 'department']) ->forTenant($tenantId) ->whereNotNull('json_extra->hire_date'); // 이름 검색 if (! empty($filters['q'])) { $search = $filters['q']; $query->where(function ($q) use ($search) { $q->where('display_name', 'like', "%{$search}%") ->orWhereHas('user', function ($uq) use ($search) { $uq->where('name', 'like', "%{$search}%"); }); }); } // 부서 필터 if (! empty($filters['department_id'])) { $query->where('department_id', $filters['department_id']); } // 상태 필터 if (! empty($filters['status'])) { if ($filters['status'] === 'active') { $query->where('employee_status', 'active'); } elseif ($filters['status'] === 'resigned') { $query->where('employee_status', 'resigned'); } } // 입사기간 범위 if (! empty($filters['hire_from'])) { $query->where('json_extra->hire_date', '>=', $filters['hire_from']); } if (! empty($filters['hire_to'])) { $query->where('json_extra->hire_date', '<=', $filters['hire_to']); } // 정렬 $sortBy = $filters['sort_by'] ?? 'hire_date_desc'; switch ($sortBy) { case 'hire_date_asc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC"); break; case 'tenure_desc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC"); break; case 'tenure_asc': $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC"); break; default: // hire_date_desc $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC"); break; } return $query->paginate($perPage); } /** * 입퇴사자 통계 */ public function getTenureStats(): array { $tenantId = session('selected_tenant_id'); $baseQuery = Employee::query() ->forTenant($tenantId) ->whereNotNull('json_extra->hire_date'); $total = (clone $baseQuery)->count(); $active = (clone $baseQuery)->where('employee_status', 'active')->count(); $resigned = (clone $baseQuery)->where('employee_status', 'resigned')->count(); // 올해 입사자 $thisYear = now()->year; $hiredThisYear = (clone $baseQuery) ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) LIKE ?", ["{$thisYear}%"]) ->count(); // 올해 퇴사자 $resignedThisYear = Employee::query() ->forTenant($tenantId) ->whereNotNull('json_extra->resign_date') ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) LIKE ?", ["{$thisYear}%"]) ->count(); // 평균 근속기간 (재직자 기준) $activeEmployees = (clone $baseQuery) ->where('employee_status', 'active') ->get(['json_extra']); $avgTenureDays = 0; if ($activeEmployees->isNotEmpty()) { $totalDays = 0; $count = 0; foreach ($activeEmployees as $emp) { $hireDate = $emp->json_extra['hire_date'] ?? null; if ($hireDate) { $totalDays += Carbon::parse($hireDate)->diffInDays(today()); $count++; } } $avgTenureDays = $count > 0 ? (int) round($totalDays / $count) : 0; } return [ 'total' => $total, 'active' => $active, 'resigned' => $resigned, 'hired_this_year' => $hiredThisYear, 'resigned_this_year' => $resignedThisYear, 'avg_tenure_days' => $avgTenureDays, 'avg_tenure_label' => $this->formatTenure($avgTenureDays), ]; } /** * CSV 내보내기용 전체 데이터 */ public function getTenureExportData(array $filters = []): Collection { $tenantId = session('selected_tenant_id'); $query = Employee::query() ->with(['user', 'department']) ->forTenant($tenantId) ->whereNotNull('json_extra->hire_date'); if (! empty($filters['department_id'])) { $query->where('department_id', $filters['department_id']); } if (! empty($filters['status'])) { if ($filters['status'] === 'active') { $query->where('employee_status', 'active'); } elseif ($filters['status'] === 'resigned') { $query->where('employee_status', 'resigned'); } } if (! empty($filters['hire_from'])) { $query->where('json_extra->hire_date', '>=', $filters['hire_from']); } if (! empty($filters['hire_to'])) { $query->where('json_extra->hire_date', '<=', $filters['hire_to']); } return $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC")->get(); } /** * 근속일수 → "N년 M개월 D일" 변환 */ public function formatTenure(int $days): string { if ($days <= 0) { return '0일'; } $years = intdiv($days, 365); $remaining = $days % 365; $months = intdiv($remaining, 30); $d = $remaining % 30; $parts = []; if ($years > 0) { $parts[] = "{$years}년"; } if ($months > 0) { $parts[] = "{$months}개월"; } if ($d > 0 || empty($parts)) { $parts[] = "{$d}일"; } return implode(' ', $parts); } /** * 직급/직책 추가 */ public function createPosition(string $type, string $name): Position { $tenantId = session('selected_tenant_id'); // key 생성: 이름을 소문자+언더스코어로 변환, 한글은 그대로 $key = str_replace(' ', '_', mb_strtolower(trim($name))); // 중복 체크 후 존재하면 기존 반환 $existing = Position::query() ->forTenant() ->where('type', $type) ->where('key', $key) ->first(); if ($existing) { return $existing; } // 다음 sort_order $maxSort = Position::query() ->forTenant() ->where('type', $type) ->max('sort_order') ?? 0; return Position::create([ 'tenant_id' => $tenantId, 'type' => $type, 'key' => $key, 'name' => trim($name), 'sort_order' => $maxSort + 1, 'is_active' => true, ]); } }