| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | @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 |
| | |
| | | } |
| | | |
| | | @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); |
| | | } |
| | |
| | | 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; |
| | | } |
| | | |
| | | } |