yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -38,6 +38,7 @@
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserSyncReqDTO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
@@ -107,10 +108,8 @@
        deptApi.validateDeptList(Collections.singleton(createReqVO.getDeptId()));
        // 2. 校验岗位
        postApi.validPostList(Collections.singleton(createReqVO.getPostId()));
        // 3. 校验手机号是否已存在
        if (employeeMapper.selectByPhone(createReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        }
        // 3. 校验手机号是否已存在(并释放软删除历史记录占用的手机号)
        validatePhoneUniqueAndReleaseStale(null, createReqVO.getPhone());
        // 4. 同步创建系统用户
        AdminUserCreateReqDTO userCreateReqDTO = new AdminUserCreateReqDTO();
        userCreateReqDTO.setUsername(createReqVO.getPhone());
@@ -167,6 +166,42 @@
        return employeeId;
    }
    /**
     * 统一手机号唯一性校验
     *
     * <p>背景:hrm_employee.uk_phone 是建在 phone 列上的唯一索引(不含 deleted),
     * 因此「已软删除但手机号未清空」的历史档案会长期占着这个手机号。
     * 此时 MyBatis-Plus 的 selectByPhone 因逻辑删除过滤查不到它,预校验通过,
     * 但写库时数据库仍会抛 DuplicateKeyException,接口表现为 500「系统异常」。</p>
     *
     * <p>处理策略(不改库结构):
     * 1. 若手机号被「已软删除」的历史记录占用 → 自动清空该历史记录的手机号,释放唯一索引;
     * 2. 若被「未删除」的其他员工占用 → 抛业务异常「手机号已存在」,不再返回 500。</p>
     *
     * @param selfId 当前正在新增/修改的员工编号(新增传 null);命中自身时跳过
     * @param phone  待校验的手机号
     */
    private void validatePhoneUniqueAndReleaseStale(Long selfId, String phone) {
        if (StrUtil.isBlank(phone)) {
            return;
        }
        // 1. 释放被软删除历史记录占用的手机号
        HrmEmployeeDO staleHolder = employeeMapper.selectByPhoneIncludeDeleted(phone);
        if (staleHolder != null && !Objects.equals(staleHolder.getId(), selfId)
                && Boolean.TRUE.equals(staleHolder.getDeleted())) {
            employeeMapper.update(null, new LambdaUpdateWrapper<HrmEmployeeDO>()
                    .eq(HrmEmployeeDO::getId, staleHolder.getId())
                    .set(HrmEmployeeDO::getPhone, null));
            log.info("[validatePhoneUniqueAndReleaseStale][释放历史软删除员工({})占用的手机号]",
                    staleHolder.getId());
        }
        // 2. 校验是否仍被活跃员工占用
        HrmEmployeeDO exist = employeeMapper.selectByPhone(phone);
        if (exist != null && !Objects.equals(exist.getId(), selfId)) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        }
    }
    private String generateUniqueEmployeeNo() {
        String employeeNo = noRedisDAO.generateEmployeeNo();
        if (employeeMapper.selectByEmployeeNo(employeeNo) != null) {
@@ -184,10 +219,9 @@
        deptApi.validateDeptList(Collections.singleton(updateReqVO.getDeptId()));
        // 校验岗位
        postApi.validPostList(Collections.singleton(updateReqVO.getPostId()));
        // 校验手机号是否已存在(如果手机号有变更)
        if (!Objects.equals(employee.getPhone(), updateReqVO.getPhone())
                && employeeMapper.selectByPhone(updateReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
        // 校验手机号是否已存在(如果手机号有变更;并释放软删除历史记录占用的手机号)
        if (!Objects.equals(employee.getPhone(), updateReqVO.getPhone())) {
            validatePhoneUniqueAndReleaseStale(updateReqVO.getId(), updateReqVO.getPhone());
        }
        // 更新员工主表
        HrmEmployeeDO updateObj = BeanUtils.toBean(updateReqVO, HrmEmployeeDO.class);
@@ -286,7 +320,15 @@
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // 3. 删除员工
        // 3. 软删除前释放唯一约束字段,避免历史数据长期占用 uk_phone / uk_user_id,
        //    导致后续新档案使用同一手机号或绑定同一账号时撞数据库唯一索引(表现为 500)
        //    注:employee_no 为 NOT NULL 且 uk_employee_no 未含 deleted,这里不置空,
        //    但把 user_id 置空可释放 uk_user_id;手机号置空可释放 uk_phone。
        employeeMapper.update(null, new LambdaUpdateWrapper<HrmEmployeeDO>()
                .eq(HrmEmployeeDO::getId, id)
                .set(HrmEmployeeDO::getPhone, null)
                .set(HrmEmployeeDO::getUserId, null));
        // 4. 删除员工
        employeeMapper.deleteById(id);
    }
@@ -474,6 +516,8 @@
                    }
                }
                HrmEmployeeSaveReqVO save = buildImportSaveReqVO(row, deptMatches.get(0).getId(), postMatches.get(0).getId(), roleIds);
                // 导入前先释放被软删除历史记录占用的手机号,避免整行导入因唯一索引冲突失败
                validatePhoneUniqueAndReleaseStale(null, row.getPhone());
                HrmEmployeeDO existing = employeeMapper.selectByPhone(row.getPhone());
                if (existing == null) {
                    if (row.getGender() == null) throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "性别");