package cn.iocoder.yudao.module.hrm.service.attendance; import cn.hutool.core.collection.CollUtil; 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 cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService; 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.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 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class HrmAttendanceSummaryServiceImpl implements HrmAttendanceSummaryService { /** 员工状态:离职(1-在职,2-试用,3-离职) */ private static final Integer EMPLOYEE_STATUS_RESIGNED = 3; @Resource private HrmAttendanceSummaryMapper attendanceSummaryMapper; @Resource private HrmAttendanceRecordMapper attendanceRecordMapper; @Resource private HrmEmployeeService employeeService; @Resource private HrmAttendanceHolidayService attendanceHolidayService; @Resource private HrmLeaveApplicationMapper leaveApplicationMapper; @Override @Transactional(rollbackFor = Exception.class) public void generateMonthlySummary(Long userId, String yearMonth) { // 1. 解析年月,获取该月起始日期 YearMonth ym = YearMonth.parse(yearMonth); LocalDate startDate = ym.atDay(1); LocalDate endDate = ym.atEndOfMonth(); // 2. 获取员工信息 HrmEmployeeDO employee = employeeService.getEmployee(userId); if (employee == null) { log.warn("[generateMonthlySummary] 员工不存在: {}", userId); return; } // 3. 查询该月所有考勤记录 List records = attendanceRecordMapper.selectListByUserIdAndDateBetween(userId, startDate, endDate); // 4. 计算应出勤天数(工作日数量) int workDays = calculateWorkDays(startDate, endDate); // 5. 统计各项数据 int lateCount = 0; int earlyCount = 0; int absentCount = 0; BigDecimal overtimeHours = BigDecimal.ZERO; // 实际出勤天数按「日期」去重:同一天可能有多条打卡记录(补卡、重复打卡), // 不能每条记录都 +1,否则实出天数会翻倍,直接影响薪酬的缺勤扣款 Set actualDates = new LinkedHashSet<>(); for (HrmAttendanceRecordDO record : records) { // 统计迟到 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); } } } // 实出天数不应超过应出天数 int actualDays = Math.min(actualDates.size(), workDays); // 6. 保存或更新汇总记录 HrmAttendanceSummaryDO summary = attendanceSummaryMapper.selectByUserIdAndYearMonth(userId, yearMonth); if (summary == null) { summary = HrmAttendanceSummaryDO.builder() .userId(userId) .deptId(employee.getDeptId()) .yearMonth(yearMonth) .workDays(workDays) .actualDays(actualDays) .lateCount(lateCount) .earlyCount(earlyCount) .absentCount(absentCount) .overtimeHours(overtimeHours) .leaveHours(BigDecimal.ZERO) .build(); attendanceSummaryMapper.insert(summary); } else { summary.setWorkDays(workDays); summary.setActualDays(actualDays); summary.setLateCount(lateCount); summary.setEarlyCount(earlyCount); summary.setAbsentCount(absentCount); summary.setOvertimeHours(overtimeHours); attendanceSummaryMapper.updateById(summary); } } @Override @Transactional(rollbackFor = Exception.class) public void generateDeptMonthlySummary(Long deptId, String yearMonth) { // 获取部门所有在职员工 List deptEmployees = getActiveEmployeeList().stream() .filter(e -> deptId.equals(e.getDeptId())) .toList(); for (HrmEmployeeDO employee : deptEmployees) { generateMonthlySummary(employee.getId(), yearMonth); } } @Override @Transactional(rollbackFor = Exception.class) public void generateAllMonthlySummary(String yearMonth) { // 获取所有在职员工 for (HrmEmployeeDO employee : getActiveEmployeeList()) { generateMonthlySummary(employee.getId(), yearMonth); } } /** * 获取参与考勤统计的员工(在职 + 试用,排除已离职) * *

注意:不能用 {@code getEmployeeList(null)},MyBatis-Plus 的 selectByIds(null) * 会抛 "The expression 'coll' evaluated to a null value",导致「生成统计」报系统异常。 */ private List getActiveEmployeeList() { List employees = employeeService.getEmployeeListByStatus(null); if (CollUtil.isEmpty(employees)) { return Collections.emptyList(); } return employees.stream() .filter(e -> !Objects.equals(e.getEmployeeStatus(), EMPLOYEE_STATUS_RESIGNED)) .toList(); } /** * 计算应出勤天数(工作日数量) */ private int calculateWorkDays(LocalDate startDate, LocalDate endDate) { int workDays = 0; LocalDate current = startDate; while (!current.isAfter(endDate)) { if (attendanceHolidayService.isWorkDay(current)) { workDays++; } current = current.plusDays(1); } return workDays; } @Override public PageResult getAttendanceSummaryPage(HrmAttendanceSummaryPageReqVO pageReqVO) { // 传了开始日期 → 按日区间实时聚合打卡记录(客户要求「按日筛选,不是固定本月」) if (pageReqVO.getStartDate() != null) { return getAttendanceSummaryByDateRange(pageReqVO); } return attendanceSummaryMapper.selectPage(pageReqVO, new LambdaQueryWrapperX() .eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId()) .eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId()) .eqIfPresent(HrmAttendanceSummaryDO::getYearMonth, pageReqVO.getYearMonth()) .orderByDesc(HrmAttendanceSummaryDO::getYearMonth) .orderByAsc(HrmAttendanceSummaryDO::getDeptId)); } /** * 按日区间实时统计考勤(不落库,仅用于列表查询) * *

口径与月度汇总保持一致:应出勤天数取区间内工作日数,实出勤按打卡日期去重, * 迟到/早退/缺卡按打卡记录计数,加班时长累加,请假时长按每天 8 小时折算。 */ private PageResult 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 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 employeeIds = employees.stream().map(HrmEmployeeDO::getId).collect(Collectors.toSet()); List records = attendanceRecordMapper .selectListByUserIdsAndDateBetween(employeeIds, startDate, endDate); Map> recordMap = records.stream() .collect(Collectors.groupingBy(HrmAttendanceRecordDO::getUserId)); // 4. 批量查询区间内已审批通过的请假单(请假单的 user_id 是 system_users.id) Set systemUserIds = employees.stream().map(HrmEmployeeDO::getUserId) .filter(Objects::nonNull).collect(Collectors.toSet()); List leaves = leaveApplicationMapper.selectApprovedListByUserIdsAndTimeRange( systemUserIds, startDate.atStartOfDay(), endDate.atTime(23, 59, 59)); Map 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 list = new ArrayList<>(employees.size()); for (HrmEmployeeDO employee : employees) { List empRecords = recordMap.getOrDefault(employee.getId(), Collections.emptyList()); int lateCount = 0; int earlyCount = 0; int absentCount = 0; BigDecimal overtimeHours = BigDecimal.ZERO; Set 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); } @Override public List getDeptMonthlySummaryList(Long deptId, String yearMonth) { return attendanceSummaryMapper.selectListByDeptIdAndYearMonth(deptId, yearMonth); } }