| | |
| | | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| | | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| | | import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceSummaryPageReqVO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.approval.HrmLeaveApplicationDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO; |
| | | import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceSummaryDO; |
| | | 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.HrmAttendanceRecordMapper; |
| | | import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceSummaryMapper; |
| | | import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum; |
| | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDate; |
| | | import java.time.YearMonth; |
| | | import java.time.temporal.TemporalAdjusters; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.Set; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | | * 考勤统计汇总 Service 实现类 |
| | |
| | | @Slf4j |
| | | public class HrmAttendanceSummaryServiceImpl implements HrmAttendanceSummaryService { |
| | | |
| | | /** 员工状态:离职(1-在职,2-试用,3-离职) */ |
| | | private static final Integer EMPLOYEE_STATUS_RESIGNED = 3; |
| | | |
| | | @Resource |
| | | private HrmAttendanceSummaryMapper attendanceSummaryMapper; |
| | | @Resource |
| | |
| | | private HrmEmployeeService employeeService; |
| | | @Resource |
| | | private HrmAttendanceHolidayService attendanceHolidayService; |
| | | @Resource |
| | | private HrmLeaveApplicationMapper leaveApplicationMapper; |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | |
| | | int earlyCount = 0; |
| | | int absentCount = 0; |
| | | BigDecimal overtimeHours = BigDecimal.ZERO; |
| | | int actualDays = 0; |
| | | // 实际出勤天数按「日期」去重:同一天可能有多条打卡记录(补卡、重复打卡), |
| | | // 不能每条记录都 +1,否则实出天数会翻倍,直接影响薪酬的缺勤扣款 |
| | | Set<LocalDate> actualDates = new LinkedHashSet<>(); |
| | | |
| | | for (HrmAttendanceRecordDO record : records) { |
| | | // 统计迟到 |
| | |
| | | if (record.getOvertimeHours() != null) { |
| | | overtimeHours = overtimeHours.add(record.getOvertimeHours()); |
| | | } |
| | | // 统计实际出勤天数(有正常打卡记录) |
| | | // 统计实际出勤天数(按日期去重) |
| | | if (record.getClockInTime() != null || record.getClockOutTime() != null) { |
| | | actualDays++; |
| | | LocalDate attendDate = record.getDate(); |
| | | if (attendDate == null && record.getClockInTime() != null) { |
| | | attendDate = record.getClockInTime().toLocalDate(); |
| | | } |
| | | if (attendDate != null) { |
| | | actualDates.add(attendDate); |
| | | } |
| | | } |
| | | } |
| | | // 实出天数不应超过应出天数 |
| | | int actualDays = Math.min(actualDates.size(), workDays); |
| | | |
| | | // 6. 保存或更新汇总记录 |
| | | HrmAttendanceSummaryDO summary = attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth); |
| | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void generateDeptMonthlySummary(Long deptId, String yearMonth) { |
| | | // 获取部门所有在职员工 |
| | | List<HrmEmployeeDO> employees = employeeService.getEmployeeList(null); |
| | | if (CollUtil.isEmpty(employees)) { |
| | | return; |
| | | } |
| | | // 筛选部门员工 |
| | | List<HrmEmployeeDO> deptEmployees = employees.stream() |
| | | List<HrmEmployeeDO> deptEmployees = getActiveEmployeeList().stream() |
| | | .filter(e -> deptId.equals(e.getDeptId())) |
| | | .toList(); |
| | | for (HrmEmployeeDO employee : deptEmployees) { |
| | |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public void generateAllMonthlySummary(String yearMonth) { |
| | | // 获取所有在职员工 |
| | | List<HrmEmployeeDO> employees = employeeService.getEmployeeList(null); |
| | | if (CollUtil.isEmpty(employees)) { |
| | | return; |
| | | } |
| | | for (HrmEmployeeDO employee : employees) { |
| | | for (HrmEmployeeDO employee : getActiveEmployeeList()) { |
| | | generateMonthlySummary(employee.getId(), yearMonth); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 获取参与考勤统计的员工(在职 + 试用,排除已离职) |
| | | * |
| | | * <p>注意:不能用 {@code getEmployeeList(null)},MyBatis-Plus 的 selectByIds(null) |
| | | * 会抛 "The expression 'coll' evaluated to a null value",导致「生成统计」报系统异常。 |
| | | */ |
| | | private List<HrmEmployeeDO> getActiveEmployeeList() { |
| | | List<HrmEmployeeDO> employees = employeeService.getEmployeeListByStatus(null); |
| | | if (CollUtil.isEmpty(employees)) { |
| | | return Collections.emptyList(); |
| | | } |
| | | return employees.stream() |
| | | .filter(e -> !Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)) |
| | | .toList(); |
| | | } |
| | | |
| | | /** |
| | |
| | | |
| | | @Override |
| | | public PageResult<HrmAttendanceSummaryDO> getAttendanceSummaryPage(HrmAttendanceSummaryPageReqVO pageReqVO) { |
| | | // 传了开始日期 → 按日区间实时聚合打卡记录(客户要求「按日筛选,不是固定本月」) |
| | | if (pageReqVO.getStartDate() != null) { |
| | | return getAttendanceSummaryByDateRange(pageReqVO); |
| | | } |
| | | return attendanceSummaryMapper.selectPage(pageReqVO, new LambdaQueryWrapperX<HrmAttendanceSummaryDO>() |
| | | .eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId()) |
| | | .eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId()) |
| | |
| | | .orderByAsc(HrmAttendanceSummaryDO::getDeptId)); |
| | | } |
| | | |
| | | /** |
| | | * 按日区间实时统计考勤(不落库,仅用于列表查询) |
| | | * |
| | | * <p>口径与月度汇总保持一致:应出勤天数取区间内工作日数,实出勤按打卡日期去重, |
| | | * 迟到/早退/缺卡按打卡记录计数,加班时长累加,请假时长按每天 8 小时折算。 |
| | | */ |
| | | private PageResult<HrmAttendanceSummaryDO> getAttendanceSummaryByDateRange(HrmAttendanceSummaryPageReqVO reqVO) { |
| | | LocalDate startDate = reqVO.getStartDate(); |
| | | LocalDate endDate = reqVO.getEndDate() != null |
| | | ? reqVO.getEndDate() : startDate.with(TemporalAdjusters.lastDayOfMonth()); |
| | | // 起止日期填反时自动纠正,避免客户误操作查不到数据 |
| | | if (endDate.isBefore(startDate)) { |
| | | LocalDate tmp = startDate; |
| | | startDate = endDate; |
| | | endDate = tmp; |
| | | } |
| | | |
| | | // 1. 确定员工范围(在职 + 试用),支持部门 / 员工筛选 |
| | | List<HrmEmployeeDO> employees = getActiveEmployeeList(); |
| | | if (reqVO.getDeptId() != null) { |
| | | employees = employees.stream() |
| | | .filter(e -> reqVO.getDeptId().equals(e.getDeptId())) |
| | | .toList(); |
| | | } |
| | | if (reqVO.getUserId() != null) { |
| | | employees = employees.stream() |
| | | .filter(e -> reqVO.getUserId().equals(e.getId())) |
| | | .toList(); |
| | | } |
| | | if (CollUtil.isEmpty(employees)) { |
| | | return PageResult.empty(); |
| | | } |
| | | |
| | | // 2. 应出勤天数(区间内工作日) |
| | | int workDays = calculateWorkDays(startDate, endDate); |
| | | |
| | | // 3. 批量查询区间内打卡记录,按员工分组 |
| | | Set<Long> employeeIds = employees.stream().map(HrmEmployeeDO::getId).collect(Collectors.toSet()); |
| | | List<HrmAttendanceRecordDO> records = attendanceRecordMapper |
| | | .selectListByUserIdsAndDateBetween(employeeIds, startDate, endDate); |
| | | Map<Long, List<HrmAttendanceRecordDO>> recordMap = records.stream() |
| | | .collect(Collectors.groupingBy(HrmAttendanceRecordDO::getUserId)); |
| | | |
| | | // 4. 批量查询区间内已审批通过的请假单(请假单的 user_id 是 system_users.id) |
| | | Set<Long> systemUserIds = employees.stream().map(HrmEmployeeDO::getUserId) |
| | | .filter(Objects::nonNull).collect(Collectors.toSet()); |
| | | List<HrmLeaveApplicationDO> leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange( |
| | | systemUserIds, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); |
| | | Map<Long, BigDecimal> leaveDaysMap = new HashMap<>(); |
| | | for (HrmLeaveApplicationDO leave : leaves) { |
| | | if (leave.getUserId() == null || leave.getDuration() == null) { |
| | | continue; |
| | | } |
| | | leaveDaysMap.merge(leave.getUserId(), leave.getDuration(), BigDecimal::add); |
| | | } |
| | | |
| | | // 5. 逐员工组装统计结果 |
| | | List<HrmAttendanceSummaryDO> list = new ArrayList<>(employees.size()); |
| | | for (HrmEmployeeDO employee : employees) { |
| | | List<HrmAttendanceRecordDO> empRecords = recordMap.getOrDefault(employee.getId(), Collections.emptyList()); |
| | | int lateCount = 0; |
| | | int earlyCount = 0; |
| | | int absentCount = 0; |
| | | BigDecimal overtimeHours = BigDecimal.ZERO; |
| | | Set<LocalDate> actualDates = new LinkedHashSet<>(); |
| | | for (HrmAttendanceRecordDO record : empRecords) { |
| | | if (HrmAttendanceClockTypeEnum.LATE.getType().equals(record.getClockInType())) { |
| | | lateCount++; |
| | | } |
| | | if (HrmAttendanceClockTypeEnum.EARLY.getType().equals(record.getClockOutType())) { |
| | | earlyCount++; |
| | | } |
| | | if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockInType())) { |
| | | absentCount++; |
| | | } |
| | | if (HrmAttendanceClockTypeEnum.MISSING.getType().equals(record.getClockOutType())) { |
| | | absentCount++; |
| | | } |
| | | if (record.getOvertimeHours() != null) { |
| | | overtimeHours = overtimeHours.add(record.getOvertimeHours()); |
| | | } |
| | | if (record.getClockInTime() != null || record.getClockOutTime() != null) { |
| | | LocalDate attendDate = record.getDate(); |
| | | if (attendDate == null && record.getClockInTime() != null) { |
| | | attendDate = record.getClockInTime().toLocalDate(); |
| | | } |
| | | if (attendDate != null) { |
| | | actualDates.add(attendDate); |
| | | } |
| | | } |
| | | } |
| | | // 请假天数折算为小时(按每天 8 小时),与月度汇总的 leave_hours 口径一致 |
| | | BigDecimal leaveDays = employee.getUserId() == null ? BigDecimal.ZERO |
| | | : leaveDaysMap.getOrDefault(employee.getUserId(), BigDecimal.ZERO); |
| | | list.add(HrmAttendanceSummaryDO.builder() |
| | | // 实时统计没有落库主键,用员工ID充当列表行 key,避免前端行 key 冲突 |
| | | .id(employee.getId()) |
| | | .userId(employee.getId()) |
| | | .deptId(employee.getDeptId()) |
| | | // 区间统计时,年月列展示统计区间 |
| | | .yearMonth(startDate + " ~ " + endDate) |
| | | .workDays(workDays) |
| | | .actualDays(Math.min(actualDates.size(), workDays)) |
| | | .lateCount(lateCount) |
| | | .earlyCount(earlyCount) |
| | | .absentCount(absentCount) |
| | | .overtimeHours(overtimeHours) |
| | | .leaveHours(leaveDays.multiply(BigDecimal.valueOf(8))) |
| | | .build()); |
| | | } |
| | | |
| | | // 6. 内存分页(区间统计不落库,无法走数据库分页) |
| | | int total = list.size(); |
| | | int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo(); |
| | | int pageSize = reqVO.getPageSize() == null || reqVO.getPageSize() < 1 ? 10 : reqVO.getPageSize(); |
| | | int fromIndex = Math.min((pageNo - 1) * pageSize, total); |
| | | int toIndex = Math.min(fromIndex + pageSize, total); |
| | | return new PageResult<>(list.subList(fromIndex, toIndex), (long) total); |
| | | } |
| | | |
| | | @Override |
| | | public HrmAttendanceSummaryDO getMonthlySummary(Long userId, String yearMonth) { |
| | | return attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth); |