| | |
| | | package cn.iocoder.yudao.module.hrm.service.attendance; |
| | | |
| | | import cn.hutool.core.collection.CollUtil; |
| | | import cn.hutool.core.util.StrUtil; |
| | | import cn.hutool.json.JSONUtil; |
| | | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| | | import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignReqVO; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleBatchAssignRespVO; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleLeaveRespVO; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleListReqVO; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleRespVO; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmEmployeeScheduleSaveReqVO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmEmployeeScheduleDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmShiftDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.approval.HrmLeaveApplicationMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmEmployeeScheduleMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmShiftMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.employee.HrmEmployeeMapper; |
| | | import cn.iocoder.yudao.module.hrm.enums.HrmEmployeeStatusEnum; |
| | | import cn.iocoder.yudao.module.hrm.enums.HrmLeaveTypeEnum; |
| | | import cn.iocoder.yudao.module.system.api.dept.DeptApi; |
| | | import cn.iocoder.yudao.module.system.api.dept.PostApi; |
| | | import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; |
| | | import cn.iocoder.yudao.module.system.api.dept.dto.PostRespDTO; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | 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.util.*; |
| | | import java.time.temporal.ChronoUnit; |
| | | import java.util.ArrayList; |
| | | import java.util.Collection; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | | import java.util.HashSet; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.Set; |
| | | import java.util.function.Function; |
| | | import java.util.stream.Collectors; |
| | | |
| | |
| | | */ |
| | | @Service |
| | | @Validated |
| | | @Slf4j |
| | | public class HrmEmployeeScheduleServiceImpl implements HrmEmployeeScheduleService { |
| | | |
| | | /** 批量排班单次最多覆盖的天数,避免一次生成过多行拖垮请求 */ |
| | | private static final int BATCH_MAX_DAYS = 62; |
| | | |
| | | @Resource |
| | | private HrmEmployeeScheduleMapper employeeScheduleMapper; |
| | |
| | | private HrmEmployeeMapper employeeMapper; |
| | | @Resource |
| | | private HrmShiftMapper shiftMapper; |
| | | @Resource |
| | | private HrmLeaveApplicationMapper leaveApplicationMapper; |
| | | @Resource |
| | | private HrmAttendanceHolidayService attendanceHolidayService; |
| | | @Resource |
| | | private DeptApi deptApi; |
| | | @Resource |
| | | private PostApi postApi; |
| | | |
| | | @Override |
| | | public List<HrmEmployeeScheduleRespVO> getScheduleList(HrmEmployeeScheduleListReqVO reqVO) { |
| | |
| | | throw exception(SCHEDULE_SHIFT_NOT_EXISTS); |
| | | } |
| | | } |
| | | // 3. 逐条 upsert:有班次则保存,无班次则删除 |
| | | // 3. 逐条 upsert:有班次则保存,无班次则清空 |
| | | for (HrmEmployeeScheduleSaveReqVO reqVO : saveReqVOList) { |
| | | HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDate( |
| | | reqVO.getEmployeeId(), reqVO.getDate()); |
| | | if (reqVO.getShiftId() == null) { |
| | | if (existing != null) { |
| | | employeeScheduleMapper.deleteById(existing.getId()); |
| | | } |
| | | clearShift(reqVO.getEmployeeId(), reqVO.getDate()); |
| | | } else { |
| | | if (existing == null) { |
| | | employeeScheduleMapper.insert(BeanUtils.toBean(reqVO, HrmEmployeeScheduleDO.class)); |
| | | } else { |
| | | existing.setShiftId(reqVO.getShiftId()); |
| | | existing.setRemark(reqVO.getRemark()); |
| | | employeeScheduleMapper.updateById(existing); |
| | | upsertShift(reqVO.getEmployeeId(), reqVO.getDate(), reqVO.getShiftId(), reqVO.getRemark()); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public HrmEmployeeScheduleBatchAssignRespVO batchAssign(HrmEmployeeScheduleBatchAssignReqVO reqVO) { |
| | | // 1. 校验日期区间 |
| | | LocalDate startDate = reqVO.getStartDate(); |
| | | LocalDate endDate = reqVO.getEndDate(); |
| | | if (endDate.isBefore(startDate)) { |
| | | throw exception(SCHEDULE_BATCH_DATE_RANGE_INVALID, endDate, startDate); |
| | | } |
| | | long days = ChronoUnit.DAYS.between(startDate, endDate) + 1; |
| | | if (days > BATCH_MAX_DAYS) { |
| | | throw exception(SCHEDULE_BATCH_RANGE_TOO_LONG, startDate, endDate, days); |
| | | } |
| | | // 2. 校验班次存在 |
| | | HrmShiftDO shift = shiftMapper.selectById(reqVO.getShiftId()); |
| | | if (shift == null) { |
| | | throw exception(SCHEDULE_SHIFT_NOT_EXISTS); |
| | | } |
| | | // 3. 筛选员工:部门(含下级)+ 岗位 + 员工状态 |
| | | Set<Long> deptIds = new HashSet<>(); |
| | | deptIds.add(reqVO.getDeptId()); |
| | | List<DeptRespDTO> childDepts = deptApi.getChildDeptList(reqVO.getDeptId()); |
| | | if (CollUtil.isNotEmpty(childDepts)) { |
| | | childDepts.forEach(dept -> deptIds.add(dept.getId())); |
| | | } |
| | | List<HrmEmployeeDO> employees = employeeMapper.selectListByDeptIds(deptIds).stream() |
| | | .filter(employee -> reqVO.getEmployeeStatus() == null |
| | | || Objects.equals(reqVO.getEmployeeStatus(), employee.getEmployeeStatus())) |
| | | .filter(employee -> matchesPost(employee, reqVO.getPostId())) |
| | | .collect(Collectors.toList()); |
| | | if (CollUtil.isEmpty(employees)) { |
| | | throw exception(SCHEDULE_BATCH_NO_EMPLOYEE, |
| | | resolveDeptName(reqVO.getDeptId()), |
| | | resolvePostName(reqVO.getPostId()), |
| | | resolveStatusName(reqVO.getEmployeeStatus())); |
| | | } |
| | | // 4. 预加载已审批请假,按“员工 + 日期”标记需要跳过的单元格 |
| | | Set<String> leaveKeys = buildApprovedLeaveKeys(employees, startDate, endDate); |
| | | // 5. 按日期逐天排班:工作日写班次、休息日清空、已审批请假跳过 |
| | | int scheduledCount = 0; |
| | | int skippedLeaveCount = 0; |
| | | int clearedRestDayCount = 0; |
| | | for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { |
| | | boolean workDay = attendanceHolidayService.isWorkDay(date); |
| | | for (HrmEmployeeDO employee : employees) { |
| | | if (leaveKeys.contains(buildKey(employee.getId(), date))) { |
| | | skippedLeaveCount++; |
| | | continue; |
| | | } |
| | | if (workDay) { |
| | | upsertShift(employee.getId(), date, reqVO.getShiftId(), reqVO.getRemark()); |
| | | scheduledCount++; |
| | | } else if (clearShift(employee.getId(), date)) { |
| | | clearedRestDayCount++; |
| | | } |
| | | } |
| | | } |
| | | log.info("[batchAssign][部门({}) 岗位({}) 状态({}) {}~{} 班次({}) 命中员工 {} 人,写入 {} 条,跳过请假 {} 条,清空休息日 {} 条]", |
| | | reqVO.getDeptId(), reqVO.getPostId(), reqVO.getEmployeeStatus(), startDate, endDate, |
| | | reqVO.getShiftId(), employees.size(), scheduledCount, skippedLeaveCount, clearedRestDayCount); |
| | | return HrmEmployeeScheduleBatchAssignRespVO.builder() |
| | | .matchedEmployeeCount(employees.size()) |
| | | .scheduledCount(scheduledCount) |
| | | .skippedLeaveCount(skippedLeaveCount) |
| | | .clearedRestDayCount(clearedRestDayCount) |
| | | .build(); |
| | | } |
| | | |
| | | @Override |
| | | public List<HrmEmployeeScheduleLeaveRespVO> getScheduleLeaveList(HrmEmployeeScheduleListReqVO reqVO) { |
| | | YearMonth yearMonth = YearMonth.parse(reqVO.getYearMonth()); |
| | | LocalDate startDate = yearMonth.atDay(1); |
| | | LocalDate endDate = yearMonth.atEndOfMonth(); |
| | | // 1. 确定需要标记的员工 |
| | | List<HrmEmployeeDO> employees; |
| | | if (reqVO.getEmployeeId() != null) { |
| | | HrmEmployeeDO employee = employeeMapper.selectById(reqVO.getEmployeeId()); |
| | | employees = employee == null ? Collections.emptyList() : List.of(employee); |
| | | } else if (reqVO.getDeptId() != null) { |
| | | employees = employeeMapper.selectByDeptId(reqVO.getDeptId()); |
| | | } else { |
| | | employees = employeeMapper.selectList(); |
| | | } |
| | | if (CollUtil.isEmpty(employees)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | // 2. 查询已审批通过的请假(按系统用户ID) |
| | | Map<Long, List<HrmEmployeeDO>> employeeByUserId = employees.stream() |
| | | .filter(employee -> employee.getUserId() != null) |
| | | .collect(Collectors.groupingBy(HrmEmployeeDO::getUserId)); |
| | | if (employeeByUserId.isEmpty()) { |
| | | return Collections.emptyList(); |
| | | } |
| | | List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange( |
| | | employeeByUserId.keySet(), startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); |
| | | if (CollUtil.isEmpty(leaves)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | // 3. 按天展开为排班页标记 |
| | | List<HrmEmployeeScheduleLeaveRespVO> result = new ArrayList<>(); |
| | | for (HrmLeaveApplicationDO leave : leaves) { |
| | | List<HrmEmployeeDO> matchedEmployees = employeeByUserId.get(leave.getUserId()); |
| | | if (CollUtil.isEmpty(matchedEmployees)) { |
| | | continue; |
| | | } |
| | | LocalDate from = leave.getStartTime().toLocalDate().isBefore(startDate) |
| | | ? startDate : leave.getStartTime().toLocalDate(); |
| | | LocalDate to = leave.getEndTime().toLocalDate().isAfter(endDate) |
| | | ? endDate : leave.getEndTime().toLocalDate(); |
| | | String leaveTypeName = HrmLeaveTypeEnum.getNameByStatus(leave.getLeaveType()); |
| | | for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { |
| | | for (HrmEmployeeDO employee : matchedEmployees) { |
| | | result.add(new HrmEmployeeScheduleLeaveRespVO(employee.getId(), employee.getName(), date, |
| | | leave.getLeaveType(), leaveTypeName)); |
| | | } |
| | | } |
| | | } |
| | | result.sort((a, b) -> { |
| | | int compare = a.getDate().compareTo(b.getDate()); |
| | | return compare != 0 ? compare : a.getEmployeeId().compareTo(b.getEmployeeId()); |
| | | }); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 写入/覆盖班次;若该员工该日期存在逻辑删除的旧行,则先复活,规避唯一键冲突。 |
| | | */ |
| | | private void upsertShift(Long employeeId, LocalDate date, Long shiftId, String remark) { |
| | | HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDateIncludeDeleted(employeeId, date); |
| | | if (existing == null) { |
| | | HrmEmployeeScheduleDO insert = new HrmEmployeeScheduleDO(); |
| | | insert.setEmployeeId(employeeId); |
| | | insert.setDate(date); |
| | | insert.setShiftId(shiftId); |
| | | insert.setRemark(remark); |
| | | employeeScheduleMapper.insert(insert); |
| | | return; |
| | | } |
| | | if (Boolean.TRUE.equals(existing.getDeleted())) { |
| | | employeeScheduleMapper.reviveById(existing.getId(), shiftId, remark, currentUpdater()); |
| | | return; |
| | | } |
| | | existing.setShiftId(shiftId); |
| | | existing.setRemark(remark); |
| | | employeeScheduleMapper.updateById(existing); |
| | | } |
| | | |
| | | /** |
| | | * 清空班次(逻辑删除);返回本次是否真的清掉了一条非空班次。 |
| | | */ |
| | | private boolean clearShift(Long employeeId, LocalDate date) { |
| | | HrmEmployeeScheduleDO existing = employeeScheduleMapper.selectByEmployeeIdAndDateIncludeDeleted(employeeId, date); |
| | | if (existing == null || Boolean.TRUE.equals(existing.getDeleted()) || existing.getShiftId() == null) { |
| | | return false; |
| | | } |
| | | employeeScheduleMapper.deleteById(existing.getId()); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 构造“员工 + 日期”的已审批请假集合 |
| | | */ |
| | | private Set<String> buildApprovedLeaveKeys(List<HrmEmployeeDO> employees, LocalDate startDate, LocalDate endDate) { |
| | | Map<Long, List<HrmEmployeeDO>> employeeByUserId = employees.stream() |
| | | .filter(employee -> employee.getUserId() != null) |
| | | .collect(Collectors.groupingBy(HrmEmployeeDO::getUserId)); |
| | | if (employeeByUserId.isEmpty()) { |
| | | return Collections.emptySet(); |
| | | } |
| | | List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange( |
| | | employeeByUserId.keySet(), startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); |
| | | if (CollUtil.isEmpty(leaves)) { |
| | | return Collections.emptySet(); |
| | | } |
| | | Set<String> keys = new HashSet<>(); |
| | | for (HrmLeaveApplicationDO leave : leaves) { |
| | | List<HrmEmployeeDO> matchedEmployees = employeeByUserId.get(leave.getUserId()); |
| | | if (CollUtil.isEmpty(matchedEmployees)) { |
| | | continue; |
| | | } |
| | | LocalDate from = leave.getStartTime().toLocalDate().isBefore(startDate) |
| | | ? startDate : leave.getStartTime().toLocalDate(); |
| | | LocalDate to = leave.getEndTime().toLocalDate().isAfter(endDate) |
| | | ? endDate : leave.getEndTime().toLocalDate(); |
| | | for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { |
| | | for (HrmEmployeeDO employee : matchedEmployees) { |
| | | keys.add(buildKey(employee.getId(), date)); |
| | | } |
| | | } |
| | | } |
| | | return keys; |
| | | } |
| | | |
| | | private static String buildKey(Long employeeId, LocalDate date) { |
| | | return employeeId + "_" + date; |
| | | } |
| | | |
| | | private boolean matchesPost(HrmEmployeeDO employee, Long postId) { |
| | | if (postId == null) { |
| | | return true; |
| | | } |
| | | if (StrUtil.isBlank(employee.getPostIds())) { |
| | | return false; |
| | | } |
| | | try { |
| | | return JSONUtil.toList(employee.getPostIds(), Long.class).contains(postId); |
| | | } catch (Exception e) { |
| | | log.warn("[matchesPost][员工({}) 的 postIds({}) 不是合法 JSON,跳过岗位匹配]", employee.getId(), employee.getPostIds()); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private String resolveDeptName(Long deptId) { |
| | | DeptRespDTO dept = deptId == null ? null : deptApi.getDept(deptId); |
| | | return dept == null ? "ID=" + deptId : dept.getName(); |
| | | } |
| | | |
| | | private String resolvePostName(Long postId) { |
| | | if (postId == null) { |
| | | return "不限"; |
| | | } |
| | | List<PostRespDTO> posts = postApi.getPostList(Collections.singleton(postId)); |
| | | return CollUtil.isEmpty(posts) ? "ID=" + postId : posts.get(0).getName(); |
| | | } |
| | | |
| | | private String resolveStatusName(Integer employeeStatus) { |
| | | if (employeeStatus == null) { |
| | | return "不限"; |
| | | } |
| | | for (HrmEmployeeStatusEnum status : HrmEmployeeStatusEnum.values()) { |
| | | if (Objects.equals(status.getStatus(), employeeStatus)) { |
| | | return status.getName(); |
| | | } |
| | | } |
| | | return "ID=" + employeeStatus; |
| | | } |
| | | |
| | | private static String currentUpdater() { |
| | | Long userId = SecurityFrameworkUtils.getLoginUserId(); |
| | | return userId == null ? "" : String.valueOf(userId); |
| | | } |
| | | |
| | | } |