2025-12-09 20:27:44 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Models\Members\User;
|
|
|
|
|
use App\Models\Tenants\TenantUserProfile;
|
|
|
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
|
|
|
|
|
|
class EmployeeService extends Service
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* 사원 목록 조회 (tenant_user_profiles + users 조인)
|
|
|
|
|
*/
|
|
|
|
|
public function index(array $params): LengthAwarePaginator
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$query = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
2025-12-30 17:25:13 +09:00
|
|
|
->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
|
2025-12-09 20:27:44 +09:00
|
|
|
|
|
|
|
|
// 검색 (이름, 이메일, 사원코드)
|
|
|
|
|
if (! empty($params['q'])) {
|
|
|
|
|
$search = $params['q'];
|
|
|
|
|
$query->where(function ($q) use ($search) {
|
|
|
|
|
$q->whereHas('user', function ($uq) use ($search) {
|
|
|
|
|
$uq->where('name', 'like', "%{$search}%")
|
|
|
|
|
->orWhere('email', 'like', "%{$search}%");
|
|
|
|
|
})
|
|
|
|
|
// json_extra에서 employee_code 검색
|
|
|
|
|
->orWhereJsonContains('json_extra->employee_code', $search);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 고용 상태 필터 (employee_status)
|
|
|
|
|
if (! empty($params['status'])) {
|
|
|
|
|
$query->where('employee_status', $params['status']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 부서 필터
|
|
|
|
|
if (! empty($params['department_id'])) {
|
|
|
|
|
$query->where('department_id', $params['department_id']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 시스템 계정 보유 여부 필터
|
|
|
|
|
if (isset($params['has_account'])) {
|
|
|
|
|
$hasAccount = filter_var($params['has_account'], FILTER_VALIDATE_BOOLEAN);
|
|
|
|
|
$query->whereHas('user', function ($q) use ($hasAccount) {
|
|
|
|
|
if ($hasAccount) {
|
|
|
|
|
$q->whereNotNull('password');
|
|
|
|
|
} else {
|
|
|
|
|
$q->whereNull('password');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 정렬
|
|
|
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
|
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
|
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
|
|
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
|
|
|
|
|
|
return $query->paginate($perPage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원 상세 조회
|
|
|
|
|
*/
|
|
|
|
|
public function show(int $id): TenantUserProfile
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$profile = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
2025-12-30 17:25:13 +09:00
|
|
|
->with(['user', 'department', 'manager', 'rankPosition', 'titlePosition'])
|
2025-12-09 20:27:44 +09:00
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (! $profile) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $profile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원 등록 (users 테이블에 사용자 생성 + tenant_user_profiles 생성)
|
2025-12-26 00:34:21 +09:00
|
|
|
*
|
|
|
|
|
* @param array $data 사원 데이터
|
|
|
|
|
* - create_account: bool (true=시스템 계정 생성, false=사원 전용)
|
|
|
|
|
* - password: string (create_account=true일 때 필수)
|
2025-12-09 20:27:44 +09:00
|
|
|
*/
|
|
|
|
|
public function store(array $data): TenantUserProfile
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$userId = $this->apiUserId();
|
|
|
|
|
|
2026-01-14 20:29:00 +09:00
|
|
|
// 1. 이메일 중복 체크 (전역 유니크)
|
|
|
|
|
$existingUser = User::withTrashed()->where('email', $data['email'])->first();
|
|
|
|
|
if ($existingUser) {
|
|
|
|
|
throw new \InvalidArgumentException(__('error.email_already_exists'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. user_id 중복 체크 및 유니크 ID 생성
|
|
|
|
|
$userIdValue = $data['user_id'] ?? $this->generateUniqueUserId($data['email']);
|
|
|
|
|
|
|
|
|
|
return DB::transaction(function () use ($data, $tenantId, $userId, $userIdValue) {
|
|
|
|
|
// 3. 비밀번호 결정: password가 있으면 시스템 계정 생성
|
2026-01-14 17:32:27 +09:00
|
|
|
// User 모델에 'password' => 'hashed' 캐스트가 있으므로 Hash::make() 불필요
|
|
|
|
|
$password = ! empty($data['password']) ? $data['password'] : null;
|
2025-12-26 00:34:21 +09:00
|
|
|
|
2026-01-14 20:29:00 +09:00
|
|
|
// 4. users 테이블에 사용자 생성
|
2025-12-09 20:27:44 +09:00
|
|
|
$user = User::create([
|
2026-01-14 20:29:00 +09:00
|
|
|
'user_id' => $userIdValue,
|
2025-12-09 20:27:44 +09:00
|
|
|
'name' => $data['name'],
|
|
|
|
|
'email' => $data['email'],
|
|
|
|
|
'phone' => $data['phone'] ?? null,
|
2025-12-26 00:34:21 +09:00
|
|
|
'password' => $password,
|
2025-12-09 20:27:44 +09:00
|
|
|
'is_active' => $data['is_active'] ?? true,
|
|
|
|
|
'created_by' => $userId,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-12-26 00:34:21 +09:00
|
|
|
// 3. user_tenants pivot에 관계 추가
|
2025-12-09 20:27:44 +09:00
|
|
|
$user->tenantsMembership()->attach($tenantId, [
|
|
|
|
|
'is_active' => true,
|
|
|
|
|
'is_default' => true,
|
|
|
|
|
'joined_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
2025-12-26 00:34:21 +09:00
|
|
|
// 4. tenant_user_profiles 생성
|
2025-12-09 20:27:44 +09:00
|
|
|
$profile = TenantUserProfile::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,
|
|
|
|
|
'profile_photo_path' => $data['profile_photo_path'] ?? null,
|
|
|
|
|
'display_name' => $data['display_name'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-12-26 00:34:21 +09:00
|
|
|
// 5. json_extra 사원 정보 설정
|
2025-12-09 20:27:44 +09:00
|
|
|
$profile->updateEmployeeInfo([
|
|
|
|
|
'employee_code' => $data['employee_code'] ?? null,
|
|
|
|
|
'resident_number' => $data['resident_number'] ?? null,
|
|
|
|
|
'gender' => $data['gender'] ?? null,
|
|
|
|
|
'address' => $data['address'] ?? null,
|
|
|
|
|
'salary' => $data['salary'] ?? null,
|
|
|
|
|
'hire_date' => $data['hire_date'] ?? null,
|
|
|
|
|
'rank' => $data['rank'] ?? null,
|
|
|
|
|
'bank_account' => $data['bank_account'] ?? null,
|
|
|
|
|
'work_type' => $data['work_type'] ?? 'regular',
|
|
|
|
|
'contract_info' => $data['contract_info'] ?? null,
|
|
|
|
|
]);
|
|
|
|
|
$profile->save();
|
|
|
|
|
|
2025-12-30 17:25:13 +09:00
|
|
|
return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
|
2025-12-09 20:27:44 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원 수정
|
|
|
|
|
*/
|
|
|
|
|
public function update(int $id, array $data): TenantUserProfile
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$userId = $this->apiUserId();
|
|
|
|
|
|
|
|
|
|
$profile = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (! $profile) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DB::transaction(function () use ($profile, $data, $userId) {
|
|
|
|
|
// 1. users 테이블 업데이트 (기본 정보)
|
|
|
|
|
$user = $profile->user;
|
|
|
|
|
$userUpdates = [];
|
|
|
|
|
|
|
|
|
|
if (isset($data['name'])) {
|
|
|
|
|
$userUpdates['name'] = $data['name'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($data['email'])) {
|
|
|
|
|
$userUpdates['email'] = $data['email'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($data['phone'])) {
|
|
|
|
|
$userUpdates['phone'] = $data['phone'];
|
|
|
|
|
}
|
|
|
|
|
if (isset($data['is_active'])) {
|
|
|
|
|
$userUpdates['is_active'] = $data['is_active'];
|
|
|
|
|
}
|
|
|
|
|
if (! empty($userUpdates)) {
|
|
|
|
|
$userUpdates['updated_by'] = $userId;
|
|
|
|
|
$user->update($userUpdates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. tenant_user_profiles 업데이트
|
|
|
|
|
$profileUpdates = [];
|
|
|
|
|
$profileFields = [
|
|
|
|
|
'department_id',
|
|
|
|
|
'position_key',
|
|
|
|
|
'job_title_key',
|
|
|
|
|
'work_location_key',
|
|
|
|
|
'employment_type_key',
|
|
|
|
|
'employee_status',
|
|
|
|
|
'manager_user_id',
|
|
|
|
|
'profile_photo_path',
|
|
|
|
|
'display_name',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($profileFields as $field) {
|
|
|
|
|
if (array_key_exists($field, $data)) {
|
|
|
|
|
$profileUpdates[$field] = $data[$field];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($profileUpdates)) {
|
|
|
|
|
$profile->update($profileUpdates);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. json_extra 사원 정보 업데이트
|
|
|
|
|
$jsonExtraFields = [
|
|
|
|
|
'employee_code',
|
|
|
|
|
'resident_number',
|
|
|
|
|
'gender',
|
|
|
|
|
'address',
|
|
|
|
|
'salary',
|
|
|
|
|
'hire_date',
|
|
|
|
|
'rank',
|
|
|
|
|
'bank_account',
|
|
|
|
|
'work_type',
|
|
|
|
|
'contract_info',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$jsonExtraUpdates = [];
|
|
|
|
|
foreach ($jsonExtraFields as $field) {
|
|
|
|
|
if (array_key_exists($field, $data)) {
|
|
|
|
|
$jsonExtraUpdates[$field] = $data[$field];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! empty($jsonExtraUpdates)) {
|
|
|
|
|
$profile->updateEmployeeInfo($jsonExtraUpdates);
|
|
|
|
|
$profile->save();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 17:25:13 +09:00
|
|
|
return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
|
2025-12-09 20:27:44 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원 삭제 (soft delete)
|
|
|
|
|
*/
|
|
|
|
|
public function destroy(int $id): array
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$profile = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (! $profile) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// tenant_user_profiles에는 SoftDeletes가 없으므로 hard delete
|
|
|
|
|
// 또는 employee_status를 resigned로 변경
|
|
|
|
|
$profile->update(['employee_status' => 'resigned']);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'id' => $id,
|
|
|
|
|
'deleted_at' => now()->toDateTimeString(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일괄 삭제
|
|
|
|
|
*/
|
|
|
|
|
public function bulkDelete(array $ids): array
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$updated = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
->whereIn('id', $ids)
|
|
|
|
|
->update(['employee_status' => 'resigned']);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'processed' => count($ids),
|
|
|
|
|
'updated' => $updated,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사원 통계
|
|
|
|
|
*/
|
|
|
|
|
public function stats(): array
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
$baseQuery = TenantUserProfile::query()->where('tenant_id', $tenantId);
|
|
|
|
|
|
|
|
|
|
$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();
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'total' => $total,
|
|
|
|
|
'active' => $active,
|
|
|
|
|
'leave' => $leave,
|
|
|
|
|
'resigned' => $resigned,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 시스템 계정 생성 (비밀번호 설정)
|
|
|
|
|
*/
|
|
|
|
|
public function createAccount(int $id, string $password): TenantUserProfile
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$userId = $this->apiUserId();
|
|
|
|
|
|
|
|
|
|
$profile = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
->with('user')
|
|
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (! $profile) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 17:32:27 +09:00
|
|
|
// User 모델에 'password' => 'hashed' 캐스트가 있으므로 Hash::make() 불필요
|
2025-12-09 20:27:44 +09:00
|
|
|
$profile->user->update([
|
2026-01-14 17:32:27 +09:00
|
|
|
'password' => $password,
|
2025-12-09 20:27:44 +09:00
|
|
|
'must_change_password' => true,
|
|
|
|
|
'updated_by' => $userId,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-12-30 17:25:13 +09:00
|
|
|
return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
|
2025-12-09 20:27:44 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-26 00:34:21 +09:00
|
|
|
/**
|
|
|
|
|
* 시스템 계정 해제 (비밀번호 제거 → 로그인 불가, 사원 정보 유지)
|
|
|
|
|
*/
|
|
|
|
|
public function revokeAccount(int $id): TenantUserProfile
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
$userId = $this->apiUserId();
|
|
|
|
|
|
|
|
|
|
$profile = TenantUserProfile::query()
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
->with('user')
|
|
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (! $profile) {
|
|
|
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$user = $profile->user;
|
|
|
|
|
|
|
|
|
|
// 이미 계정이 없는 경우
|
|
|
|
|
if (empty($user->password)) {
|
|
|
|
|
throw new \InvalidArgumentException(__('employee.no_account'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 1. 비밀번호 제거 (로그인 불가)
|
|
|
|
|
$user->update([
|
|
|
|
|
'password' => null,
|
|
|
|
|
'must_change_password' => false,
|
|
|
|
|
'updated_by' => $userId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 2. 기존 토큰 무효화 (로그아웃 처리)
|
|
|
|
|
$user->tokens()->delete();
|
|
|
|
|
|
2025-12-30 17:25:13 +09:00
|
|
|
return $profile->fresh(['user', 'department', 'manager', 'rankPosition', 'titlePosition']);
|
2025-12-26 00:34:21 +09:00
|
|
|
}
|
|
|
|
|
|
2025-12-09 20:27:44 +09:00
|
|
|
/**
|
2026-01-14 20:29:00 +09:00
|
|
|
* 유니크한 사용자 ID 자동 생성
|
|
|
|
|
* 중복 시 최대 10회까지 재시도
|
2025-12-09 20:27:44 +09:00
|
|
|
*/
|
2026-01-14 20:29:00 +09:00
|
|
|
private function generateUniqueUserId(string $email): string
|
2025-12-09 20:27:44 +09:00
|
|
|
{
|
|
|
|
|
$prefix = explode('@', $email)[0];
|
2026-01-14 20:29:00 +09:00
|
|
|
$prefix = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $prefix));
|
|
|
|
|
|
|
|
|
|
$maxAttempts = 10;
|
|
|
|
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
|
|
|
|
$suffix = Str::random(6); // 4자리 → 6자리로 증가
|
|
|
|
|
$userId = $prefix.'_'.$suffix;
|
|
|
|
|
|
|
|
|
|
// 중복 체크 (삭제된 사용자 포함)
|
|
|
|
|
if (! User::withTrashed()->where('user_id', $userId)->exists()) {
|
|
|
|
|
return $userId;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-09 20:27:44 +09:00
|
|
|
|
2026-01-14 20:29:00 +09:00
|
|
|
// 최대 시도 후에도 실패 시 타임스탬프 추가
|
|
|
|
|
return $prefix.'_'.time().'_'.Str::random(4);
|
2025-12-09 20:27:44 +09:00
|
|
|
}
|
|
|
|
|
}
|