yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java
@@ -4,9 +4,11 @@
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;
@@ -20,7 +22,16 @@
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 实现类
@@ -32,6 +43,9 @@
@Slf4j
public class HrmAttendanceSummaryServiceImpl implements HrmAttendanceSummaryService {
    /** 员工状态:离职(1-在职,2-试用,3-离职) */
    private static final Integer EMPLOYEE_STATUS_RESIGNED = 3;
    @Resource
    private HrmAttendanceSummaryMapper attendanceSummaryMapper;
    @Resource
@@ -40,6 +54,8 @@
    private HrmEmployeeService employeeService;
    @Resource
    private HrmAttendanceHolidayService attendanceHolidayService;
    @Resource
    private HrmLeaveApplicationMapper leaveApplicationMapper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -67,7 +83,9 @@
        int earlyCount = 0;
        int absentCount = 0;
        BigDecimal overtimeHours = BigDecimal.ZERO;
        int actualDays = 0;
        // 实际出勤天数按「日期」去重:同一天可能有多条打卡记录(补卡、重复打卡),
        // 不能每条记录都 +1,否则实出天数会翻倍,直接影响薪酬的缺勤扣款
        Set<LocalDate> actualDates = new LinkedHashSet<>();
        for (HrmAttendanceRecordDO record : records) {
            // 统计迟到
@@ -89,11 +107,19 @@
            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);
@@ -126,12 +152,7 @@
    @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) {
@@ -143,13 +164,25 @@
    @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();
    }
    /**
@@ -169,6 +202,10 @@
    @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())
@@ -177,6 +214,126 @@
                .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);