gaoluyang
6 天以前 8e580aad93b43d5a310a3e35788ec2acb3791c30
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -3,19 +3,27 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeEducationSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeEmergencyContactSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeImportReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeImportRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeePageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSaveReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeSimpleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeStatisticsRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.employee.vo.HrmEmployeeWorkHistorySaveReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeContractDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEducationDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeEmergencyContactDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeWorkHistoryDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeContractMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEducationMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeEmergencyContactMapper;
import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper;
@@ -29,13 +37,24 @@
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.AdminUserUpdateReqDTO;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -43,21 +62,28 @@
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.*;
import static cn.iocoder.yudao.module.hrm.enums.LogRecordConstants.*;
/**
 * 员工信息 Service 实现类
 *
 * @author 芋道源码
 * @author 超级管理员
 */
@Service
@Validated
public class HrmEmployeeServiceImpl implements HrmEmployeeService {
    /** 员工状态:离职 */
    private static final Integer EMPLOYEE_STATUS_RESIGNED = 3;
    @Resource
    private HrmEmployeeMapper employeeMapper;
    @Resource
    private HrmEmployeeEducationMapper educationMapper;
    @Resource
    private HrmEmployeeContractMapper contractMapper;
    @Resource
    private HrmEmployeeWorkHistoryMapper workHistoryMapper;
    @Resource
@@ -72,6 +98,9 @@
    private AdminUserApi adminUserApi;
    @Resource
    private RoleApi roleApi;
    @Resource
    @Lazy // 自注入需延迟加载,否则 bean 被代理包装后注入原始版本导致启动失败
    private HrmEmployeeService self;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -79,7 +108,7 @@
        // 1. 校验部门
        deptApi.validateDeptList(Collections.singleton(createReqVO.getDeptId()));
        // 2. 校验岗位
        postApi.validPostList(Collections.singleton(createReqVO.getPostId()));
        postApi.validPostList(createReqVO.getPostIds());
        // 3. 校验手机号是否已存在
        if (employeeMapper.selectByPhone(createReqVO.getPhone()) != null) {
            throw exception(EMPLOYEE_PHONE_EXISTS);
@@ -89,7 +118,7 @@
        userCreateReqDTO.setUsername(createReqVO.getPhone());
        userCreateReqDTO.setNickname(createReqVO.getName());
        userCreateReqDTO.setDeptId(createReqVO.getDeptId());
        userCreateReqDTO.setPostIds(Collections.singleton(createReqVO.getPostId()));
        userCreateReqDTO.setPostIds(new LinkedHashSet<>(createReqVO.getPostIds()));
        userCreateReqDTO.setMobile(createReqVO.getPhone());
        userCreateReqDTO.setPassword(createReqVO.getPhone());
        userCreateReqDTO.setSex(createReqVO.getGender());
@@ -109,6 +138,10 @@
        // roleIds 存储为 JSON
        if (CollUtil.isNotEmpty(createReqVO.getRoleIds())) {
            employee.setRoleIds(JSONUtil.toJsonStr(createReqVO.getRoleIds()));
        }
        // postIds 存储为 JSON
        if (CollUtil.isNotEmpty(createReqVO.getPostIds())) {
            employee.setPostIds(JSONUtil.toJsonStr(createReqVO.getPostIds()));
        }
        employeeMapper.insert(employee);
        Long employeeId = employee.getId();
@@ -155,7 +188,7 @@
        // 校验部门
        deptApi.validateDeptList(Collections.singleton(updateReqVO.getDeptId()));
        // 校验岗位
        postApi.validPostList(Collections.singleton(updateReqVO.getPostId()));
        postApi.validPostList(updateReqVO.getPostIds());
        // 校验手机号是否已存在(如果手机号有变更)
        if (!Objects.equals(employee.getPhone(), updateReqVO.getPhone())
                && employeeMapper.selectByPhone(updateReqVO.getPhone()) != null) {
@@ -167,7 +200,24 @@
        if (CollUtil.isNotEmpty(updateReqVO.getRoleIds())) {
            updateObj.setRoleIds(JSONUtil.toJsonStr(updateReqVO.getRoleIds()));
        }
        // postIds 存储为 JSON
        if (CollUtil.isNotEmpty(updateReqVO.getPostIds())) {
            updateObj.setPostIds(JSONUtil.toJsonStr(updateReqVO.getPostIds()));
        }
        employeeMapper.updateById(updateObj);
        // 同步更新关联系统用户(岗位/部门/昵称/角色/邮箱/备注),保持与用户管理一致
        if (employee.getUserId() != null) {
            AdminUserUpdateReqDTO userUpdateReqDTO = new AdminUserUpdateReqDTO();
            userUpdateReqDTO.setId(employee.getUserId());
            userUpdateReqDTO.setNickname(updateReqVO.getName());
            userUpdateReqDTO.setDeptId(updateReqVO.getDeptId());
            userUpdateReqDTO.setPostIds(new LinkedHashSet<>(updateReqVO.getPostIds()));
            userUpdateReqDTO.setRoleIds(CollUtil.isEmpty(updateReqVO.getRoleIds()) ? null
                    : new LinkedHashSet<>(updateReqVO.getRoleIds()));
            userUpdateReqDTO.setEmail(updateReqVO.getEmail());
            userUpdateReqDTO.setRemark(updateReqVO.getRemark());
            adminUserApi.updateUser(userUpdateReqDTO);
        }
        Long employeeId = updateReqVO.getId();
        // 同步教育经历
@@ -258,12 +308,16 @@
                respVO.setDeptName(dept.getName());
            }
        }
        // 填充岗位名称
        if (employee.getPostId() != null) {
            Map<Long, PostRespDTO> postMap = postApi.getPostMap(Collections.singleton(employee.getPostId()));
            PostRespDTO post = postMap.get(employee.getPostId());
            if (post != null) {
                respVO.setPostName(post.getName());
        // 填充岗位信息
        if (StrUtil.isNotBlank(employee.getPostIds())) {
            List<Long> postIds = JSONUtil.toList(employee.getPostIds(), Long.class);
            respVO.setPostIds(postIds);
            if (CollUtil.isNotEmpty(postIds)) {
                Map<Long, PostRespDTO> postMap = postApi.getPostMap(postIds);
                respVO.setPostNames(postIds.stream()
                        .filter(postMap::containsKey)
                        .map(postId -> postMap.get(postId).getName())
                        .collect(Collectors.joining(",")));
            }
        }
        // 填充角色名称
@@ -287,12 +341,30 @@
    @Override
    public PageResult<HrmEmployeeDO> getEmployeePage(HrmEmployeePageReqVO pageReqVO) {
        if (pageReqVO.getDeptId() != null) {
            // 选中部门时,连带查询其所有子部门的员工(含自身)
            List<DeptRespDTO> childDepts = deptApi.getChildDeptList(pageReqVO.getDeptId());
            Set<Long> deptIds = new LinkedHashSet<>();
            deptIds.add(pageReqVO.getDeptId());
            if (CollUtil.isNotEmpty(childDepts)) {
                childDepts.forEach(dept -> deptIds.add(dept.getId()));
            }
            return employeeMapper.selectPageByDeptIds(pageReqVO, deptIds);
        }
        return employeeMapper.selectPage(pageReqVO);
    }
    @Override
    public List<HrmEmployeeDO> getEmployeeList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return Collections.emptyList();
        }
        return employeeMapper.selectByIds(ids);
    }
    @Override
    public List<HrmEmployeeDO> getEmployeeListByStatus(Integer employeeStatus) {
        return employeeMapper.selectListByStatus(employeeStatus);
    }
    @Override
@@ -322,4 +394,495 @@
        return employeeMapper.selectByUserId(userId);
    }
    @Override
    public boolean updateEmployeeStatusToResignedByUserId(Long userId, LocalDate leaveDate) {
        if (userId == null) {
            return false;
        }
        return employeeMapper.updateEmployeeStatusByUserId(userId, EMPLOYEE_STATUS_RESIGNED, leaveDate) > 0;
    }
    @Override
    public HrmEmployeeDO getEmployeeByName(String name) {
        return employeeMapper.selectByName(name);
    }
    @Override
    public List<HrmEmployeeDO> getEmployeeListByName(String name) {
        return employeeMapper.selectListByName(name);
    }
    @Override
    @LogRecord(type = HRM_EMPLOYEE_TYPE, subType = HRM_EMPLOYEE_IMPORT_SUB_TYPE, bizNo = "0",
            success = HRM_EMPLOYEE_IMPORT_SUCCESS)
    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();
            try {
                // 3.1 名称转 ID
                Long deptId = parseDeptId(importEmployee.getDeptName(), deptMap);
                Set<Long> postIds = parsePostIds(importEmployee.getPostName(), postMap);
                Set<Long> roleIds = parseRoleIds(importEmployee.getRoleNames(), roleMap);
                // 3.2 构建保存请求
                HrmEmployeeSaveReqVO saveReqVO = buildImportSaveReqVO(importEmployee, deptId, postIds, 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;
                    }
                    saveReqVO.setId(existEmployee.getId());
                    self.updateEmployee(saveReqVO);
                    respVO.getUpdateNames().add(name);
                }
            } catch (ServiceException ex) {
                respVO.getFailureNames().put(name, ex.getMessage());
            } catch (Exception ex) {
                respVO.getFailureNames().put(name, ex.getMessage());
            }
        }
        // 记录导入结果汇总,供 @LogRecord 的 success 文案引用
        OperateLogUtils.putImportSummary(respVO.getCreateNames(), respVO.getUpdateNames(),
                respVO.getFailureNames());
        return respVO;
    }
    private HrmEmployeeSaveReqVO buildImportSaveReqVO(HrmEmployeeImportExcelVO importEmployee,
                                                      Long deptId, Set<Long> postIds, Set<Long> roleIds) {
        if (StrUtil.isBlank(importEmployee.getName())) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "员工姓名");
        }
        if (StrUtil.isBlank(importEmployee.getPhone())) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "手机号");
        }
        if (importEmployee.getHireDate() == null) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "入职日期");
        }
        HrmEmployeeSaveReqVO reqVO = new HrmEmployeeSaveReqVO();
        reqVO.setName(importEmployee.getName());
        reqVO.setGender(importEmployee.getGender() != null ? importEmployee.getGender() : 1); // 默认男
        reqVO.setPhone(importEmployee.getPhone());
        reqVO.setDeptId(deptId);
        reqVO.setPostIds(CollUtil.isEmpty(postIds) ? null : new ArrayList<>(postIds));
        reqVO.setRoleIds(CollUtil.isEmpty(roleIds) ? null : new ArrayList<>(roleIds));
        reqVO.setHireDate(importEmployee.getHireDate());
        reqVO.setEmployeeStatus(importEmployee.getEmployeeStatus() != null
                ? importEmployee.getEmployeeStatus() : 1); // 默认在职
        reqVO.setEmail(importEmployee.getEmail());
        reqVO.setBirthday(importEmployee.getBirthday());
        reqVO.setIdCard(importEmployee.getIdCard());
        reqVO.setNation(importEmployee.getNation());
        reqVO.setMarriageStatus(importEmployee.getMarriageStatus());
        reqVO.setAge(importEmployee.getAge());
        reqVO.setEducation(importEmployee.getEducation());
        reqVO.setPoliticalStatus(importEmployee.getPoliticalStatus());
        reqVO.setRemark(importEmployee.getRemark());
        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 Set<Long> parsePostIds(String postNames, Map<String, PostRespDTO> postMap) {
        if (StrUtil.isBlank(postNames)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "岗位名称");
        }
        Set<Long> postIds = new LinkedHashSet<>();
        for (String postName : postNames.split("[,,]")) {
            String trimmed = postName.trim();
            if (StrUtil.isBlank(trimmed)) {
                continue;
            }
            PostRespDTO post = postMap.get(trimmed);
            if (post == null) {
                throw exception(EMPLOYEE_POST_NAME_NOT_EXISTS, trimmed);
            }
            postIds.add(post.getId());
        }
        if (CollUtil.isEmpty(postIds)) {
            throw exception(EMPLOYEE_IMPORT_FIELD_REQUIRED, "岗位名称");
        }
        return postIds;
    }
    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;
    }
    // ==================== 人力统计 ====================
    /** 员工状态:在职 */
    private static final Integer EMPLOYEE_STATUS_ACTIVE = 1;
    /** 员工状态:试用 */
    private static final Integer EMPLOYEE_STATUS_PROBATION = 2;
    /** 工龄区间名称,与下限(含)一一对应,最后一档无上限 */
    private static final String[] TENURE_BUCKET_NAMES = {"1年以内", "1-3年", "3-5年", "5-10年", "10年以上"};
    private static final double[] TENURE_BUCKET_MIN = {0, 1, 3, 5, 10};
    /** 年龄区间名称,与下限(含)一一对应,最后一档无上限 */
    private static final String[] AGE_BUCKET_NAMES = {"25岁以下", "25-30岁", "30-35岁", "35-40岁", "40-50岁", "50岁以上"};
    private static final int[] AGE_BUCKET_MIN = {0, 25, 30, 35, 40, 50};
    @Override
    public HrmEmployeeStatisticsRespVO getTenureStatistics() {
        List<HrmEmployeeDO> allEmployees = employeeMapper.selectList();
        LocalDate today = LocalDate.now();
        int currentYear = today.getYear();
        List<HrmEmployeeDO> activeEmployees = allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)
                        || Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION))
                .collect(Collectors.toList());
        HrmEmployeeStatisticsRespVO respVO = new HrmEmployeeStatisticsRespVO();
        respVO.setTotalCount(activeEmployees.size());
        respVO.setActiveCount((int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)).count());
        respVO.setProbationCount((int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION)).count());
        respVO.setResignedCount((int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)).count());
        // 本年入职:在岗员工中入职日期落在今年
        respVO.setThisYearHireCount((int) activeEmployees.stream()
                .filter(e -> e.getHireDate() != null && e.getHireDate().getYear() == currentYear).count());
        // 本年离职:离职员工中离职日期落在今年
        respVO.setThisYearResignCount((int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED))
                .filter(e -> e.getLeaveDate() != null && e.getLeaveDate().getYear() == currentYear).count());
        // 1. 平均工龄、平均年龄
        List<Double> tenureList = activeEmployees.stream()
                .map(e -> calculateTenureYears(e, today))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        respVO.setAverageTenureYears(averageOf(tenureList));
        List<Integer> ageList = activeEmployees.stream()
                .map(e -> resolveAge(e, today))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        respVO.setAverageAge(averageOfInt(ageList));
        // 2. 工龄分布
        int[] tenureCounts = new int[TENURE_BUCKET_NAMES.length];
        for (Double tenure : tenureList) {
            tenureCounts[resolveTenureBucket(tenure)]++;
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> tenureDistribution = new ArrayList<>();
        for (int i = 0; i < TENURE_BUCKET_NAMES.length; i++) {
            tenureDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO(TENURE_BUCKET_NAMES[i], tenureCounts[i]));
        }
        respVO.setTenureDistribution(tenureDistribution);
        // 3. 年龄分布
        int[] ageCounts = new int[AGE_BUCKET_NAMES.length];
        for (Integer age : ageList) {
            ageCounts[resolveAgeBucket(age)]++;
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> ageDistribution = new ArrayList<>();
        for (int i = 0; i < AGE_BUCKET_NAMES.length; i++) {
            ageDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO(AGE_BUCKET_NAMES[i], ageCounts[i]));
        }
        respVO.setAgeDistribution(ageDistribution);
        // 4. 学历分布(未填写的归入“未填写”)
        Map<String, Integer> educationMap = new LinkedHashMap<>();
        activeEmployees.stream()
                .map(e -> StrUtil.blankToDefault(StrUtil.trim(e.getEducation()), "未填写"))
                .forEach(name -> educationMap.merge(name, 1, Integer::sum));
        respVO.setEducationDistribution(toItemListSorted(educationMap));
        // 5. 性别分布(1 男 / 2 女 / 其它 未知)
        Map<String, Integer> genderMap = new LinkedHashMap<>();
        activeEmployees.forEach(e -> {
            String name = Objects.equals(e.getGender(), 1) ? "男" : Objects.equals(e.getGender(), 2) ? "女" : "未知";
            genderMap.merge(name, 1, Integer::sum);
        });
        respVO.setGenderDistribution(toItemListSorted(genderMap));
        // 6. 部门工龄统计
        Set<Long> deptIds = activeEmployees.stream()
                .map(HrmEmployeeDO::getDeptId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Long, DeptRespDTO> deptMap = CollUtil.isEmpty(deptIds) ? Collections.emptyMap() : deptApi.getDeptMap(deptIds);
        Map<Long, List<Double>> deptTenureMap = new LinkedHashMap<>();
        Map<Long, String> deptNameMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            Double tenure = calculateTenureYears(employee, today);
            if (employee.getDeptId() == null) {
                deptTenureMap.computeIfAbsent(0L, k -> new ArrayList<>()).add(tenure == null ? 0D : tenure);
                deptNameMap.putIfAbsent(0L, "未分配部门");
                continue;
            }
            deptTenureMap.computeIfAbsent(employee.getDeptId(), k -> new ArrayList<>())
                    .add(tenure == null ? 0D : tenure);
            DeptRespDTO dept = deptMap.get(employee.getDeptId());
            deptNameMap.put(employee.getDeptId(), dept != null ? dept.getName() : "部门ID=" + employee.getDeptId());
        }
        List<HrmEmployeeStatisticsRespVO.DeptTenureVO> deptTenureList = new ArrayList<>();
        deptTenureMap.forEach((deptId, tenures) -> {
            HrmEmployeeStatisticsRespVO.DeptTenureVO deptVO = new HrmEmployeeStatisticsRespVO.DeptTenureVO();
            deptVO.setDeptId(deptId == 0L ? null : deptId);
            deptVO.setDeptName(deptNameMap.get(deptId));
            deptVO.setCount(tenures.size());
            deptVO.setAverageTenureYears(averageOf(tenures));
            deptTenureList.add(deptVO);
        });
        deptTenureList.sort(Comparator.comparing(HrmEmployeeStatisticsRespVO.DeptTenureVO::getAverageTenureYears,
                Comparator.nullsLast(Comparator.reverseOrder())));
        respVO.setDeptTenureList(deptTenureList);
        // 6.1 部门人数分布(与部门工龄同一份分组,直接复用)
        Map<String, Integer> deptCountMap = new LinkedHashMap<>();
        deptTenureMap.forEach((deptId, tenures) -> deptCountMap.put(deptNameMap.get(deptId), tenures.size()));
        respVO.setDeptDistribution(toItemListSorted(deptCountMap));
        // 7. 工龄排行(前 10 名)
        respVO.setTopTenureEmployees(activeEmployees.stream()
                .filter(e -> e.getHireDate() != null)
                .sorted(Comparator.comparing(HrmEmployeeDO::getHireDate))
                .limit(10)
                .map(e -> {
                    HrmEmployeeStatisticsRespVO.TenureEmployeeVO employeeVO = new HrmEmployeeStatisticsRespVO.TenureEmployeeVO();
                    employeeVO.setId(e.getId());
                    employeeVO.setName(e.getName());
                    DeptRespDTO dept = e.getDeptId() == null ? null : deptMap.get(e.getDeptId());
                    employeeVO.setDeptName(dept != null ? dept.getName() : (e.getDeptId() == null ? "未分配部门" : "部门ID=" + e.getDeptId()));
                    employeeVO.setHireDate(e.getHireDate());
                    employeeVO.setTenureYears(calculateTenureYears(e, today));
                    return employeeVO;
                })
                .collect(Collectors.toList()));
        // 8. 人员状态构成(在职 / 试用 / 离职,其余状态归入“其它”)
        int activeStatusCount = (int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_ACTIVE)).count();
        int probationStatusCount = (int) activeEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_PROBATION)).count();
        int resignedStatusCount = (int) allEmployees.stream()
                .filter(e -> Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)).count();
        List<HrmEmployeeStatisticsRespVO.ItemVO> statusDistribution = new ArrayList<>();
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("在职", activeStatusCount));
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("试用", probationStatusCount));
        statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("离职", resignedStatusCount));
        int otherStatusCount = allEmployees.size() - activeStatusCount - probationStatusCount - resignedStatusCount;
        if (otherStatusCount > 0) {
            statusDistribution.add(new HrmEmployeeStatisticsRespVO.ItemVO("其它", otherStatusCount));
        }
        respVO.setStatusDistribution(statusDistribution);
        // 9. 岗位分布(postIds 为 JSON 数组,可一人多岗;无岗位归入“未分配岗位”)
        Set<Long> postIds = new HashSet<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            if (StrUtil.isNotBlank(employee.getPostIds())) {
                postIds.addAll(JSONUtil.toList(employee.getPostIds(), Long.class));
            }
        }
        Map<Long, PostRespDTO> postMap = CollUtil.isEmpty(postIds) ? Collections.emptyMap() : postApi.getPostMap(postIds);
        Map<String, Integer> postCountMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            List<Long> employeePostIds = StrUtil.isNotBlank(employee.getPostIds())
                    ? JSONUtil.toList(employee.getPostIds(), Long.class) : Collections.emptyList();
            if (CollUtil.isEmpty(employeePostIds)) {
                postCountMap.merge("未分配岗位", 1, Integer::sum);
                continue;
            }
            for (Long postId : employeePostIds) {
                PostRespDTO post = postMap.get(postId);
                postCountMap.merge(post != null ? post.getName() : "岗位ID=" + postId, 1, Integer::sum);
            }
        }
        respVO.setPostDistribution(toItemListSorted(postCountMap));
        // 10. 婚姻状况、政治面貌分布(档案自由文本,空值归入“未填写”)
        Map<String, Integer> marriageMap = new LinkedHashMap<>();
        Map<String, Integer> politicalMap = new LinkedHashMap<>();
        for (HrmEmployeeDO employee : activeEmployees) {
            marriageMap.merge(StrUtil.blankToDefault(StrUtil.trim(employee.getMarriageStatus()), "未填写"), 1, Integer::sum);
            politicalMap.merge(StrUtil.blankToDefault(StrUtil.trim(employee.getPoliticalStatus()), "未填写"), 1, Integer::sum);
        }
        respVO.setMarriageDistribution(toItemListSorted(marriageMap));
        respVO.setPoliticalDistribution(toItemListSorted(politicalMap));
        // 11. 近 12 个月入职 / 离职趋势(含本月)
        Map<YearMonth, Integer> hireMonthMap = new LinkedHashMap<>();
        Map<YearMonth, Integer> resignMonthMap = new LinkedHashMap<>();
        allEmployees.forEach(e -> {
            if (e.getHireDate() != null) {
                hireMonthMap.merge(YearMonth.from(e.getHireDate()), 1, Integer::sum);
            }
            if (e.getLeaveDate() != null) {
                resignMonthMap.merge(YearMonth.from(e.getLeaveDate()), 1, Integer::sum);
            }
        });
        List<HrmEmployeeStatisticsRespVO.TrendVO> trendList = new ArrayList<>();
        YearMonth startMonth = YearMonth.from(today).minusMonths(11);
        for (int i = 0; i < 12; i++) {
            YearMonth month = startMonth.plusMonths(i);
            HrmEmployeeStatisticsRespVO.TrendVO trendVO = new HrmEmployeeStatisticsRespVO.TrendVO();
            trendVO.setMonth(month.toString());
            trendVO.setHireCount(hireMonthMap.getOrDefault(month, 0));
            trendVO.setResignCount(resignMonthMap.getOrDefault(month, 0));
            trendList.add(trendVO);
        }
        respVO.setHireResignTrend(trendList);
        // 12. 合同到期预警(只看在岗员工的当前生效合同)
        Set<Long> activeEmployeeIds = activeEmployees.stream()
                .map(HrmEmployeeDO::getId)
                .collect(Collectors.toSet());
        Map<Long, HrmEmployeeContractDO> currentContractMap = contractMapper.selectList().stream()
                .filter(c -> Boolean.TRUE.equals(c.getIsCurrent()))
                .filter(c -> c.getTerminateStatus() == null || c.getTerminateStatus() == 0)
                .filter(c -> activeEmployeeIds.contains(c.getEmployeeId()))
                .collect(Collectors.toMap(HrmEmployeeContractDO::getEmployeeId, c -> c, (a, b) -> a));
        int expiredCount = 0;
        int expireIn30Count = 0;
        int expireIn90Count = 0;
        int expireOver90Count = 0;
        int unlimitedCount = 0;
        for (HrmEmployeeContractDO contract : currentContractMap.values()) {
            if (contract.getEndDate() == null) {
                unlimitedCount++;
                continue;
            }
            long days = ChronoUnit.DAYS.between(today, contract.getEndDate());
            if (days < 0) {
                expiredCount++;
            } else if (days <= 30) {
                expireIn30Count++;
            } else if (days <= 90) {
                expireIn90Count++;
            } else {
                expireOver90Count++;
            }
        }
        List<HrmEmployeeStatisticsRespVO.ItemVO> contractWarning = new ArrayList<>();
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("已到期", expiredCount));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("30天内到期", expireIn30Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("31-90天到期", expireIn90Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("90天以上", expireOver90Count));
        contractWarning.add(new HrmEmployeeStatisticsRespVO.ItemVO("无固定期限", unlimitedCount));
        respVO.setContractWarning(contractWarning);
        respVO.setNoContractCount(Math.max(0, activeEmployeeIds.size() - currentContractMap.size()));
        return respVO;
    }
    /**
     * 计算工龄(年,保留 1 位小数);离职员工按离职日期计算,无入职日期返回 null
     */
    private Double calculateTenureYears(HrmEmployeeDO employee, LocalDate today) {
        if (employee.getHireDate() == null) {
            return null;
        }
        LocalDate endDate = employee.getLeaveDate() != null ? employee.getLeaveDate() : today;
        if (endDate.isBefore(employee.getHireDate())) {
            return 0D;
        }
        double years = ChronoUnit.DAYS.between(employee.getHireDate(), endDate) / 365.25D;
        return Math.round(years * 10D) / 10D;
    }
    /**
     * 解析年龄:优先取档案年龄,缺失时用出生日期推算
     */
    private Integer resolveAge(HrmEmployeeDO employee, LocalDate today) {
        if (employee.getAge() != null && employee.getAge() > 0) {
            return employee.getAge();
        }
        if (employee.getBirthday() == null) {
            return null;
        }
        return (int) ChronoUnit.YEARS.between(employee.getBirthday(), today);
    }
    private int resolveTenureBucket(double tenure) {
        for (int i = TENURE_BUCKET_MIN.length - 1; i >= 0; i--) {
            if (tenure >= TENURE_BUCKET_MIN[i]) {
                return i;
            }
        }
        return 0;
    }
    private int resolveAgeBucket(int age) {
        for (int i = AGE_BUCKET_MIN.length - 1; i >= 0; i--) {
            if (age >= AGE_BUCKET_MIN[i]) {
                return i;
            }
        }
        return 0;
    }
    private List<HrmEmployeeStatisticsRespVO.ItemVO> toItemListSorted(Map<String, Integer> countMap) {
        return countMap.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
                .map(entry -> new HrmEmployeeStatisticsRespVO.ItemVO(entry.getKey(), entry.getValue()))
                .collect(Collectors.toList());
    }
    private Double averageOf(List<Double> values) {
        if (CollUtil.isEmpty(values)) {
            return 0D;
        }
        double sum = values.stream().mapToDouble(Double::doubleValue).sum();
        return Math.round(sum / values.size() * 10D) / 10D;
    }
    private Double averageOfInt(List<Integer> values) {
        if (CollUtil.isEmpty(values)) {
            return 0D;
        }
        double sum = values.stream().mapToInt(Integer::intValue).sum();
        return Math.round(sum / values.size() * 10D) / 10D;
    }
}