tuqin
20 小时以前 ba100c651a81162a9a9a4533ff4d072f2889171a
fix(hrm): 考勤统计支持员工姓名搜索;缺卡统计补全(与考勤记录联动)

一、员工姓名搜索不生效
现象:列表上方搜索「任伟」,仍返回全部员工。
根因:前端搜索表单有 userName 字段,但后端 HrmAttendanceSummaryPageReqVO 没有该参数,
传了也被忽略,等同查全部。
修复:
- HrmAttendanceSummaryPageReqVO 增加 userName;
- HrmEmployeeMapper 新增 selectListByNameLike(原 selectListByName 是精确匹配),
Service 暴露 getEmployeeListByNameLike;
- 月度汇总查询:按姓名查出员工ID集合后用 in 过滤,查不到时直接返回空(避免退化成查全部);
- 按日区间实时统计:员工列表按姓名 contains 过滤。

二、导出「数据重复」
核查结论:不是重复。hrm_attendance_summary 里 (user_id, year_month) 唯一,无重复记录;
客户看到的是 8/9/10 三个月各 132 条共 396 条,同一员工出现 3 次(不同月份)。
导出本身跟随筛选条件:
yearMonth=2026-10 -> 133 行(132 数据 + 表头)
不带筛选 -> 397 行(396 数据 + 表头)
顺带把导出文件名带上筛选条件(年月或日期区间),减少误解。
注:实测响应头无 Content-Disposition,对照考勤记录导出同样如此,
文件名由前端 downloadFileFromBlobPart 指定,属框架统一行为。

三、缺卡统计恒为 0(与考勤记录模块对不上)
根因:HrmAttendanceRecordServiceImpl#fillClockTypes 只在「该侧有打卡时间」时才设置类型,
「只打上班卡、没打下班卡」的记录 clockOutType 一直为 NULL;
而统计只认 MISSING(4),所以缺卡次数永远统计不到。
修复:fillClockTypes 优先按「有无打卡时间」判定缺卡(MISSING),不依赖部门与考勤规则;
有打卡时间再按规则判定迟到/早退。打卡与 Excel 导入两处调用均生效。
数据:新增 sql/mysql/hrm_attendance_record_fill_missing_clock_type.sql 修复历史记录,
已在 mom-zyjc 执行(7 条 clock_out_type 由 NULL 补为 4),并重新生成 2026-10 统计。

实测(mom-zyjc):
- 姓名搜索:不传 total=396;userName=任伟 total=3(8/9/10 各一条);不存在的人 total=0
- 缺卡:员工72 有 4 条缺下班卡 -> 汇总 absent_count=4;
员工202(超级管理员离职档案)的 1 条已按在职人员范围排除,合计正确
- 迟到 4 / 早退 1 与打卡记录一致
已添加1个文件
已修改7个文件
123 ■■■■■ 文件已修改
sql/mysql/hrm_attendance_record_fill_missing_clock_type.sql 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceSummaryController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceSummaryPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_attendance_record_fill_missing_clock_type.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
-- ============================================================
-- è€ƒå‹¤è®°å½•:补齐「缺卡」标记(历史数据)
--
-- èƒŒæ™¯ï¼š
--   HrmAttendanceRecordServiceImpl#fillClockTypes åŽŸå®žçŽ°åªåœ¨ã€Œè¯¥ä¾§æœ‰æ‰“å¡æ—¶é—´ã€æ—¶
--   æ‰è®¾ç½® clockInType / clockOutType,导致「只打上班卡、没打下班卡」的记录
--   clockOutType ä¸€ç›´ä¸º NULL。
--   è€Œè€ƒå‹¤ç»Ÿè®¡ï¼ˆHrmAttendanceSummaryServiceImpl#generateMonthlySummary)只统计
--   clockInType/clockOutType = 4(MISSING)的记录,于是「缺卡次数」永远为 0,
--   ä¸Žè€ƒå‹¤è®°å½•模块对不上(客户反馈「缺卡、早退数据统计不对」)。
--
--   ä»£ç å·²ä¿®å¤ï¼šçŽ°åœ¨åªè¦æŸä¸€ä¾§æ²¡æœ‰æ‰“å¡æ—¶é—´å°±æ ‡è®°ä¸º 4(缺卡),且不依赖考勤规则。
--   æœ¬è„šæœ¬ç”¨äºŽä¿®å¤ä¿®å¤å‰å·²å…¥åº“çš„åŽ†å²æ‰“å¡è®°å½•ã€‚
--
-- æžšä¸¾ï¼šHrmAttendanceClockTypeEnum  NORMAL(1) / LATE(2) / EARLY(3) / MISSING(4)
-- ============================================================
-- 1)缺上班卡:有记录但 clock_in_time ä¸ºç©ºï¼Œä¸”类型还没标
UPDATE hrm_attendance_record
SET clock_in_type = 4
WHERE deleted = b'0'
  AND clock_in_time IS NULL
  AND clock_in_type IS NULL;
-- 2)缺下班卡:有记录但 clock_out_time ä¸ºç©ºï¼Œä¸”类型还没标
UPDATE hrm_attendance_record
SET clock_out_type = 4
WHERE deleted = b'0'
  AND clock_out_time IS NULL
  AND clock_out_type IS NULL;
-- 3)校验:缺卡标记数量
-- SELECT SUM(clock_in_type = 4)  AS ç¼ºä¸Šç­å¡æ¬¡æ•°,
--        SUM(clock_out_type = 4) AS ç¼ºä¸‹ç­å¡æ¬¡æ•°
-- FROM hrm_attendance_record WHERE deleted = b'0';
-- 4)注意:修完数据后需要重新生成考勤统计,缺卡次数才会刷进汇总表:
--    è€ƒå‹¤ç®¡ç† â†’ è€ƒå‹¤ç»Ÿè®¡ â†’ ç”Ÿæˆç»Ÿè®¡ï¼ˆé€‰å¯¹åº”年月),或调
--    POST /admin-api/hrm/attendance/summary/generate?yearMonth=YYYY-MM
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceSummaryController.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
@@ -149,7 +150,15 @@
        if (CollUtil.isNotEmpty(data)) {
            fillUserInfo(data, pageResult.getList());
        }
        ExcelUtils.write(response, "考勤统计汇总.xls", "数据", HrmAttendanceSummaryRespVO.class, data);
        // æ–‡ä»¶åå¸¦ä¸Šç­›é€‰æ¡ä»¶ï¼Œé¿å…å¯¼å‡ºå¤šä¸ªæœˆä»½æ•°æ®æ—¶è¢«è¯¯è®¤ä¸ºã€Œé‡å¤ã€
        String fileName = "考勤统计汇总";
        if (StrUtil.isNotBlank(pageReqVO.getYearMonth())) {
            fileName = fileName + "_" + pageReqVO.getYearMonth();
        } else if (pageReqVO.getStartDate() != null) {
            fileName = fileName + "_" + pageReqVO.getStartDate()
                    + "_" + (pageReqVO.getEndDate() != null ? pageReqVO.getEndDate() : "至今");
        }
        ExcelUtils.write(response, fileName + ".xls", "数据", HrmAttendanceSummaryRespVO.class, data);
    }
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceSummaryPageReqVO.java
@@ -18,6 +18,9 @@
    @Schema(description = "员工ID", example = "1")
    private Long userId;
    @Schema(description = "员工姓名(模糊匹配,与 userId åŒæ—¶ä¼ æ—¶å–交集)", example = "任伟")
    private String userName;
    @Schema(description = "部门ID", example = "1")
    private Long deptId;
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/mysql/employee/HrmEmployeeMapper.java
@@ -47,6 +47,14 @@
        return selectList(HrmEmployeeDO::getName, name);
    }
    /**
     * æŒ‰å§“名模糊查询员工(考勤统计等列表的姓名搜索用)
     */
    default List<HrmEmployeeDO> selectListByNameLike(String name) {
        return selectList(new LambdaQueryWrapperX<HrmEmployeeDO>()
                .like(HrmEmployeeDO::getName, name));
    }
    default HrmEmployeeDO selectByEmployeeNo(String employeeNo) {
        return selectOne(HrmEmployeeDO::getEmployeeNo, employeeNo);
    }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -94,9 +94,22 @@
    }
    /**
     * æŒ‰è€ƒå‹¤è§„则重算上下班打卡类型(正常/迟到/早退),与打卡逻辑保持一致
     * æŒ‰è€ƒå‹¤è§„则重算上下班打卡类型(正常/迟到/早退/缺卡),与打卡逻辑保持一致
     *
     * <p>缺卡标记不依赖考勤规则:只要某一侧没有打卡时间就是缺卡。
     * åŽŸå®žçŽ°åªåœ¨ã€Œè¯¥ä¾§æœ‰æ‰“å¡æ—¶é—´ã€æ—¶æ‰è®¾ç½®ç±»åž‹ï¼Œå¯¼è‡´ã€Œåªæ‰“ä¸Šç­å¡ã€æ²¡æ‰“ä¸‹ç­å¡ã€çš„è®°å½•
     * clockOutType ä¸€ç›´æ˜¯ null;而考勤统计只认 MISSING ç±»åž‹ï¼Œ
     * äºŽæ˜¯ç¼ºå¡æ¬¡æ•°æ°¸è¿œç»Ÿè®¡ä¸º 0(客户反馈「缺卡数据统计不对、没和考勤记录联动」)。
     */
    private void fillClockTypes(HrmAttendanceRecordDO record) {
        // 1. ç¼ºå¡ä¼˜å…ˆï¼šæŸä¸€ä¾§æ²¡æœ‰æ‰“卡时间即为缺卡,不依赖部门与考勤规则
        if (record.getClockInTime() == null) {
            record.setClockInType(HrmAttendanceClockTypeEnum.MISSING.getType());
        }
        if (record.getClockOutTime() == null) {
            record.setClockOutType(HrmAttendanceClockTypeEnum.MISSING.getType());
        }
        if (record.getDeptId() == null) {
            return;
        }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceSummaryServiceImpl.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
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;
@@ -206,12 +207,35 @@
        if (pageReqVO.getStartDate() != null) {
            return getAttendanceSummaryByDateRange(pageReqVO);
        }
        // å‘˜å·¥å§“名模糊搜索:先按姓名查出员工ID,再按 ID é›†åˆè¿‡æ»¤ï¼ˆæ±‡æ€»è¡¨çš„ user_id å­˜çš„æ˜¯å‘˜å·¥ID)
        Set<Long> nameMatchedIds = resolveEmployeeIdsByName(pageReqVO.getUserName());
        if (nameMatchedIds != null && nameMatchedIds.isEmpty()) {
            // å§“名搜不到任何员工时直接返回空,避免退化成查全部
            return PageResult.empty();
        }
        return attendanceSummaryMapper.selectPage(pageReqVO, new LambdaQueryWrapperX<HrmAttendanceSummaryDO>()
                .eqIfPresent(HrmAttendanceSummaryDO::getUserId, pageReqVO.getUserId())
                .inIfPresent(HrmAttendanceSummaryDO::getUserId, nameMatchedIds)
                .eqIfPresent(HrmAttendanceSummaryDO::getDeptId, pageReqVO.getDeptId())
                .eqIfPresent(HrmAttendanceSummaryDO::getYearMonth, pageReqVO.getYearMonth())
                .orderByDesc(HrmAttendanceSummaryDO::getYearMonth)
                .orderByAsc(HrmAttendanceSummaryDO::getDeptId));
    }
    /**
     * æŒ‰å‘˜å·¥å§“名模糊匹配,返回员工ID集合
     *
     * @return null è¡¨ç¤ºæœªä¼ å§“名(不参与过滤);空集合表示传了姓名但没有任何员工匹配
     */
    private Set<Long> resolveEmployeeIdsByName(String userName) {
        if (StrUtil.isBlank(userName)) {
            return null;
        }
        List<HrmEmployeeDO> matched = employeeService.getEmployeeListByNameLike(userName.trim());
        if (CollUtil.isEmpty(matched)) {
            return Collections.emptySet();
        }
        return matched.stream().map(HrmEmployeeDO::getId).collect(Collectors.toSet());
    }
    /**
@@ -243,6 +267,13 @@
                    .filter(e -> reqVO.getUserId().equals(e.getId()))
                    .toList();
        }
        // å‘˜å·¥å§“名模糊过滤
        if (StrUtil.isNotBlank(reqVO.getUserName())) {
            String keyword = reqVO.getUserName().trim();
            employees = employees.stream()
                    .filter(e -> e.getName() != null && e.getName().contains(keyword))
                    .toList();
        }
        if (CollUtil.isEmpty(employees)) {
            return PageResult.empty();
        }
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeService.java
@@ -130,6 +130,14 @@
    /** æ ¹æ®å‘˜å·¥å§“名获取员工 */
    HrmEmployeeDO getEmployeeByName(String name);
    /**
     * æŒ‰å§“名模糊查询员工列表(列表页姓名搜索用)
     *
     * @param name å§“名关键字
     * @return å‘˜å·¥åˆ—表
     */
    List<HrmEmployeeDO> getEmployeeListByNameLike(String name);
    /** æ ¹æ®å‘˜å·¥å§“名获取所有同名员工 */
    List<HrmEmployeeDO> getEmployeeListByName(String name);
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/employee/HrmEmployeeServiceImpl.java
@@ -520,6 +520,14 @@
    }
    @Override
    public List<HrmEmployeeDO> getEmployeeListByNameLike(String name) {
        if (StrUtil.isBlank(name)) {
            return new ArrayList<>();
        }
        return employeeMapper.selectListByNameLike(name.trim());
    }
    @Override
    public HrmEmployeeImportRespVO importEmployeeList(List<HrmEmployeeImportExcelVO> importEmployees,
                                                      HrmEmployeeImportReqVO importReqVO) {
        // 1. æ ¡éªŒéžç©º