yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -37,7 +37,10 @@
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
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;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -55,7 +58,6 @@
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
/**
@@ -65,6 +67,7 @@
 */
@Service
@Validated
@Slf4j
public class HrmEmployeeServiceImpl implements HrmEmployeeService {
    @Resource
@@ -105,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());
@@ -165,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) {
@@ -182,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);
@@ -201,12 +237,40 @@
            adminUserApi.updateUserPassword(employee.getUserId(), updateReqVO.getPassword());
        }
        // 同步系统账号可对应字段(姓名 / 手机号 / 部门 / 性别 / 邮箱)
        if (employee.getUserId() != null) {
            syncSystemUserForEmployee(employee, updateReqVO);
        }
        // 同步教育经历
        syncEducationList(employeeId, updateReqVO.getEducations());
        if (updateReqVO.getEducations() != null) syncEducationList(employeeId, updateReqVO.getEducations());
        // 同步工作经历
        syncWorkHistoryList(employeeId, updateReqVO.getWorkHistories());
        if (updateReqVO.getWorkHistories() != null) syncWorkHistoryList(employeeId, updateReqVO.getWorkHistories());
        // 同步紧急联系人
        syncEmergencyContactList(employeeId, updateReqVO.getEmergencyContacts());
        if (updateReqVO.getEmergencyContacts() != null) syncEmergencyContactList(employeeId, updateReqVO.getEmergencyContacts());
    }
    /**
     * 员工修改后,把可一一对应的字段同步到系统账号。
     *
     * 只同步:姓名↔昵称、手机号↔mobile、部门、性别、邮箱。
     * 登录名 username、岗位、角色、状态不同步。
     * 同步失败时回滚员工更新,避免员工和系统账号不一致。
     */
    private void syncSystemUserForEmployee(HrmEmployeeDO employee, HrmEmployeeSaveReqVO reqVO) {
        try {
            AdminUserSyncReqDTO dto = new AdminUserSyncReqDTO();
            dto.setUserId(employee.getUserId());
            dto.setNickname(reqVO.getName() != null ? reqVO.getName() : employee.getName());
            dto.setMobile(reqVO.getPhone() != null ? reqVO.getPhone() : employee.getPhone());
            dto.setDeptId(reqVO.getDeptId() != null ? reqVO.getDeptId() : employee.getDeptId());
            dto.setSex(reqVO.getGender() != null ? reqVO.getGender() : employee.getGender());
            dto.setEmail(reqVO.getEmail() != null ? reqVO.getEmail() : employee.getEmail());
            adminUserApi.updateUserForEmployee(dto);
        } catch (Exception ex) {
            log.warn("[updateEmployee] 员工账号同步失败,员工编号={}", employee.getId());
            throw exception(EMPLOYEE_USER_SYNC_FAILED);
        }
    }
    private void syncEducationList(Long employeeId, List<HrmEmployeeEducationSaveReqVO> newList) {
@@ -249,13 +313,48 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteEmployee(Long id) {
        // 校验存在
        validateEmployeeExists(id);
        // 级联删除子表
        HrmEmployeeDO employee = validateEmployeeExists(id);
        // 1. 联动删除系统账号(不发布事件,避免回环)
        deleteLinkedSystemUser(employee);
        // 2. 级联删除子表
        educationMapper.deleteByEmployeeId(id);
        workHistoryMapper.deleteByEmployeeId(id);
        emergencyContactMapper.deleteByEmployeeId(id);
        // 删除员工
        // 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);
    }
    /**
     * 删除员工时联动删除其系统账号。
     *
     * 守卫:
     * 1. 未绑定账号 -> 跳过
     * 2. 内置 admin(id=1) -> 跳过
     * 3. 同一账号被多条员工档案引用 -> 跳过(一员工一账号约定下不应出现)
     */
    private void deleteLinkedSystemUser(HrmEmployeeDO employee) {
        Long userId = employee.getUserId();
        if (userId == null) {
            return;
        }
        if (Long.valueOf(1L).equals(userId)) {
            log.warn("[deleteEmployee][员工({}) 绑定的是内置管理员账号({}),不自动删除账号]", employee.getId(), userId);
            return;
        }
        Long count = employeeMapper.selectCount(HrmEmployeeDO::getUserId, userId);
        if (count != null && count > 1) {
            log.warn("[deleteEmployee][账号({}) 关联了 {} 条员工档案,不自动删除账号]", userId, count);
            return;
        }
        adminUserApi.deleteUserSilently(userId);
    }
    private HrmEmployeeDO validateEmployeeExists(Long id) {
@@ -375,57 +474,77 @@
    }
    @Override
    public HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                                      HrmEmployeeImportReqVO importReqVO) {
        // 1. 校验非空
        importEmployees = filterList(importEmployees, item -> Objects.nonNull(item.getName()));
        if (CollUtil.isEmpty(importEmployees)) {
            throw exception(EMPLOYEE_IMPORT_LIST_IS_EMPTY);
        }
        // 2. 预加载部门/岗位/角色 名称 -> 实体 映射
        Map<String, DeptRespDTO> deptMap = deptApi.getDeptList().stream()
                .collect(Collectors.toMap(DeptRespDTO::getName, dept -> dept, (v1, v2) -> v1));
        Map<String, PostRespDTO> postMap = postApi.getPostList().stream()
                .collect(Collectors.toMap(PostRespDTO::getName, post -> post, (v1, v2) -> v1));
        Map<String, RoleRespDTO> roleMap = roleApi.getRoleList().stream()
                .collect(Collectors.toMap(RoleRespDTO::getName, role -> role, (v1, v2) -> v1));
        // 3. 逐条处理
        HrmEmployeeImportRespVO respVO = HrmEmployeeImportRespVO.builder()
                .createNames(new ArrayList<>())
                .updateNames(new ArrayList<>())
                .failureNames(new LinkedHashMap<>())
                .build();
        for (HrmEmployeeImportExcelVO importEmployee : importEmployees) {
            String name = importEmployee.getName();
    public HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> rows, HrmEmployeeImportReqVO req) {
        if (CollUtil.isEmpty(rows)) throw exception(EMPLOYEE_IMPORT_LIST_IS_EMPTY);
        Map<String, List<DeptRespDTO>> depts = deptApi.getDeptList().stream()
                .filter(v -> StrUtil.isNotBlank(v.getName())).collect(Collectors.groupingBy(v -> v.getName().trim()));
        Map<String, List<PostRespDTO>> posts = postApi.getPostList().stream()
                .filter(v -> StrUtil.isNotBlank(v.getName())).collect(Collectors.groupingBy(v -> v.getName().trim()));
        Map<String, List<RoleRespDTO>> roles = roleApi.getRoleList().stream()
                .filter(v -> StrUtil.isNotBlank(v.getName())).collect(Collectors.groupingBy(v -> v.getName().trim()));
        HrmEmployeeImportRespVO result = HrmEmployeeImportRespVO.builder()
                .createNames(new ArrayList<>()).updateNames(new ArrayList<>()).failureNames(new LinkedHashMap<>()).build();
        Set<String> phones = new LinkedHashSet<>();
        boolean hasData = false;
        for (int i = 0; i < rows.size(); i++) {
            HrmEmployeeImportExcelVO row = rows.get(i);
            if (row == null || cn.hutool.core.bean.BeanUtil.beanToMap(row).values().stream()
                    .noneMatch(v -> v != null && (!(v instanceof String) || StrUtil.isNotBlank((String) v)))) continue;
            hasData = true;
            String label = "第" + (i + 2) + "行" + (StrUtil.isBlank(row.getName()) ? "" : "(" + row.getName().trim() + ")");
            try {
                // 3.1 名称转 ID
                Long deptId = parseDeptId(importEmployee.getDeptName(), deptMap);
                Long postId = parsePostId(importEmployee.getPostName(), postMap);
                Set<Long> roleIds = parseRoleIds(importEmployee.getRoleNames(), roleMap);
                // 3.2 构建保存请求
                HrmEmployeeSaveReqVO saveReqVO = buildImportSaveReqVO(importEmployee, deptId, postId, roleIds);
                // 3.3 判断存在性:按手机号
                HrmEmployeeDO existEmployee = employeeMapper.selectByPhone(importEmployee.getPhone());
                if (existEmployee == null) {
                    self.createEmployee(saveReqVO);
                    respVO.getCreateNames().add(name);
                } else {
                    if (!Boolean.TRUE.equals(importReqVO.getUpdateSupport())) {
                        respVO.getFailureNames().put(name, EMPLOYEE_PHONE_EXISTS.getMsg());
                        continue;
                if (StrUtil.isBlank(row.getName()) || StrUtil.isBlank(row.getPhone()) || row.getHireDate() == null) {
                    throw exception(EMPLOYEE_IMPORT_INVALID, "员工姓名、手机号和入职日期不能为空");
                }
                row.setName(row.getName().trim()); row.setPhone(row.getPhone().trim());
                if (!phones.add(row.getPhone())) throw exception(EMPLOYEE_IMPORT_INVALID, "文件内手机号重复");
                if (StrUtil.isBlank(row.getDeptName()) || StrUtil.isBlank(row.getPostName())) {
                    throw exception(EMPLOYEE_IMPORT_INVALID, "部门和岗位不能为空");
                }
                List<DeptRespDTO> deptMatches = depts.get(row.getDeptName().trim());
                List<PostRespDTO> postMatches = posts.get(row.getPostName().trim());
                if (deptMatches == null || deptMatches.size() != 1) throw exception(EMPLOYEE_IMPORT_INVALID, "部门名称不存在或不唯一");
                if (postMatches == null || postMatches.size() != 1) throw exception(EMPLOYEE_IMPORT_INVALID, "岗位名称不存在或不唯一");
                Set<Long> roleIds = new LinkedHashSet<>();
                if (StrUtil.isNotBlank(row.getRoleNames())) {
                    for (String value : row.getRoleNames().split("[,,]")) {
                        String name = value.trim();
                        if (name.isEmpty()) continue;
                        List<RoleRespDTO> matches = roles.get(name);
                        if (matches == null || matches.size() != 1) throw exception(EMPLOYEE_IMPORT_INVALID, "角色名称不存在或不唯一:" + name);
                        roleIds.add(matches.get(0).getId());
                    }
                    saveReqVO.setId(existEmployee.getId());
                    self.updateEmployee(saveReqVO);
                    respVO.getUpdateNames().add(name);
                }
                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, "性别");
                    self.createEmployee(save);
                    result.getCreateNames().add(label);
                } else {
                    if (!Boolean.TRUE.equals(req.getUpdateSupport())) throw exception(EMPLOYEE_PHONE_EXISTS);
                    if (StrUtil.isNotBlank(row.getPassword())) throw exception(EMPLOYEE_IMPORT_INVALID, "覆盖已有员工时初始密码须留空,密码请在账号管理中修改");
                    if (!roleIds.isEmpty()) throw exception(EMPLOYEE_IMPORT_INVALID, "覆盖员工时不支持修改用户角色,请留空并在账号管理中维护");
                    if (!Objects.equals(existing.getPostId(), save.getPostId())) throw exception(EMPLOYEE_IMPORT_INVALID, "覆盖员工时暂不支持变更岗位,请先处理账号岗位配置");
                    save.setId(existing.getId());
                    if (row.getGender() == null) save.setGender(existing.getGender());
                    if (row.getEmployeeStatus() == null) save.setEmployeeStatus(existing.getEmployeeStatus());
                    self.updateEmployee(save);
                    result.getUpdateNames().add(label);
                }
            } catch (ServiceException ex) {
                respVO.getFailureNames().put(name, ex.getMessage());
            } catch (Exception ex) {
                respVO.getFailureNames().put(name, ex.getMessage());
                result.getFailureNames().put(label, ex.getMessage());
            } catch (RuntimeException ex) {
                log.warn("[importEmployeeList] 员工导入失败,行号={}", i + 2);
                result.getFailureNames().put(label, "保存或账号同步失败,请检查该行数据及账号配置");
            }
        }
        return respVO;
        if (!hasData) throw exception(EMPLOYEE_IMPORT_LIST_IS_EMPTY);
        return result;
    }
    /**
     * 解析员工初始密码:
@@ -478,45 +597,5 @@
        return reqVO;
    }
    private Long parseDeptId(String deptName, Map<String, DeptRespDTO> deptMap) {
        if (StrUtil.isBlank(deptName)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "部门名称");
        }
        DeptRespDTO dept = deptMap.get(deptName.trim());
        if (dept == null) {
            throw exception(EMPLOYEE_DEPT_NAME_NOT_EXISTS, deptName.trim());
        }
        return dept.getId();
    }
    private Long parsePostId(String postName, Map<String, PostRespDTO> postMap) {
        if (StrUtil.isBlank(postName)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "岗位名称");
        }
        PostRespDTO post = postMap.get(postName.trim());
        if (post == null) {
            throw exception(EMPLOYEE_POST_NAME_NOT_EXISTS, postName.trim());
        }
        return post.getId();
    }
    private Set<Long> parseRoleIds(String roleNames, Map<String, RoleRespDTO> roleMap) {
        if (StrUtil.isBlank(roleNames)) {
            return Collections.emptySet();
        }
        Set<Long> roleIds = new LinkedHashSet<>();
        for (String roleName : roleNames.split("[,,]")) {
            String trimmed = roleName.trim();
            if (StrUtil.isBlank(trimmed)) {
                continue;
            }
            RoleRespDTO role = roleMap.get(trimmed);
            if (role == null) {
                throw exception(EMPLOYEE_ROLE_NAME_NOT_EXISTS, trimmed);
            }
            roleIds.add(role.getId());
        }
        return roleIds;
    }
}