Spring Boot + MyBatis Plus 考勤日报统计报表:打卡记录聚合、异常分类和明细下钻 Demo

很多企业做考勤系统时,一开始只盯着"员工能不能打卡"。真正上线以后,HR 最痛苦的往往不是当天那一下打卡,而是月底统计。

一个人一天有上班卡、下班卡,复杂一点还有前班、后班、外勤、请假、补卡、迟到、早退、缺卡、旷工。主管问"这个人本月出勤率为什么只有 86%",HR 不能只甩一个 Excel 汇总数,还要能点开解释:哪几天缺卡,哪几天请假,哪些请假扣薪,哪些不扣薪,哪些异常已经补卡通过。

所以日报统计报表的关键不是"写一个 group by",而是把事实层、统计层、明细层拆清楚。事实层保存原始打卡和审批记录;统计层沉淀一人一天一条日报;报表层按时间范围和组织维度聚合;明细层负责解释每个可点击数字。

这篇文章基于智慧考勤项目真实代码脱敏整理,重点讲考勤日报统计如何从后端表结构、Mapper XML、Service 计算、Controller 下钻接口和 Vue PC 页面串成闭环。本文不是内部代码原样复制,而是按真实工程结构补齐成一个可公开阅读、可迁移的 Demo。

本文参考的真实工程文件包括:

  • 后端实体:`KqAttendanceDayStats.java`
  • 查询入参:`KqCountIn.java`
  • 报表返回:`KqCountVO.java`
  • 明细返回:`KqCountDetailsVO.java`
  • 管理接口:`KqAttendanceDayStatsController.java`
  • 业务服务:`KqAttendanceDayStatsServiceImpl.java`
  • Mapper:`KqAttendanceDayStatsMapper.java`
  • SQL 映射:`KqAttendanceDayStatsMapper.xml`
  • PC 报表接口:`attendance.statistics.api.ts`
  • PC 报表列配置:`attendance.statistics.data.ts`
  • PC 报表页面:`attendanceStatistics/index.vue`
  • PC 明细弹窗:`attendanceStatistics/DetailModal.vue`

一、日报统计为什么要单独建表

如果每次打开月报都直接扫描原始打卡表,系统很快会遇到三个问题。

第一,原始记录粒度太细。一个员工一天可能有多条打卡、外勤、补卡和审批记录。报表需要的是"这一天最终怎么算",不是把所有流水都摊给 HR 自己判断。

第二,业务状态会变化。缺卡可能经过申诉变成补卡,请假可能从审核中变成已完成,外勤也可能被驳回。报表必须按最终有效状态统计,不能把中间状态混进去。

第三,月度报表必须能解释。HR 看到"缺卡 3 次"时,要能点开看到具体日期、班次、规定时间、实际时间和审批状态。只有汇总没有明细,最后还是回到人工翻记录。

智慧考勤项目采用的思路是:先把一人一天的最终考勤事实沉淀到 `kq_attendance_day_stats`,再由 `getKqCount` 按时间范围聚合。这样报表查询更稳定,统计口径也更清楚。

二、核心字段怎么拆

日报统计表的字段不追求"什么都放",而是围绕报表最常问的问题设计。

|---------------------------------------|------------|-------------------|
| 字段 | 含义 | 为什么存在 |
| `personId/personName` | 员工信息 | 报表按人聚合,明细按人下钻 |
| `unitId/unitName` | 考勤单位 | 组织筛选、权限过滤、导出分组 |
| `departmentId/departmentName` | 部门 | 主管和 HR 常按部门核对 |
| `attendanceRule/attendanceRuleName` | 考勤规则 | 规则变更后仍保留当时口径 |
| `clockInTime1/clockOutTime1` | 班次 1 上下班时间 | 支持一天一班或两班统计 |
| `clockInType1/clockOutType1` | 班次 1 状态 | 正常、迟到、缺卡、早退、旷工、补卡 |
| `clockInTime2/clockOutTime2` | 班次 2 上下班时间 | 支持中午分段、两段班次 |
| `clockInType2/clockOutType2` | 班次 2 状态 | 避免把不同班段混成一个状态 |
| `workDay` | 是否工作日 | 应出勤天数只统计工作日 |
| `createTime` | 统计日期 | 报表时间范围和索引优化依赖它 |

这里最重要的设计是"一人一天一条"。真实项目里日统计 ID 使用 `personId + yyyyMMdd`,不是每次打卡都新增一条统计记录。这个设计能天然避免重复统计,也方便补卡、请假、异常申诉后按日期回写。

三、完整脱敏源码 Demo

1. Entity:一人一天的考勤事实

复制代码
package com.example.attendance.report.entity;



import lombok.Data;

import java.time.LocalDateTime;



/**

 * 每日考勤统计实体。

 * 该表负责保存"一人一天"的最终考勤事实,不直接承担月度报表展示职责。

 */

@Data

public class AttendanceDayStats {

    /** 主键,建议使用 personId + yyyyMMdd,保证同一员工同一天只有一条统计记录。 */

    private String id;

    /** 考勤单位 ID。 */

    private String unitId;

    /** 考勤单位名称。 */

    private String unitName;

    /** 部门 ID。 */

    private String departmentId;

    /** 部门名称。 */

    private String departmentName;

    /** 员工 ID。 */

    private String personId;

    /** 员工姓名。 */

    private String personName;

    /** 考勤规则 ID。 */

    private String attendanceRule;

    /** 考勤规则名称快照。 */

    private String attendanceRuleName;

    /** 班次 1 上班打卡时间。 */

    private LocalDateTime clockInTime1;

    /** 班次 1 上班状态。0正常、1迟到、2缺卡、3早退、4旷工、5请假、6补卡。 */

    private String clockInType1;

    /** 班次 1 下班打卡时间。 */

    private LocalDateTime clockOutTime1;

    /** 班次 1 下班状态。 */

    private String clockOutType1;

    /** 班次 2 上班打卡时间。 */

    private LocalDateTime clockInTime2;

    /** 班次 2 上班状态。 */

    private String clockInType2;

    /** 班次 2 下班打卡时间。 */

    private LocalDateTime clockOutTime2;

    /** 班次 2 下班状态。 */

    private String clockOutType2;

    /** 是否工作日:1 是,2 否。 */

    private Integer workDay;

    /** 统计日期。 */

    private LocalDateTime createTime;

    /** 逻辑删除标记。 */

    private Integer delFlag;

}

真实项目里还有一个 `setClockTimeAndTypeByTimeTypeAndUpDownWokClock` 方法,它负责根据"前班/后班"和"上班/下班"把打卡结果放进对应字段。这个方法看起来小,但能保证补卡回写时不会把班次 1 下班写到班次 2 上班。

2. 查询入参:报表只接受清晰时间范围

复制代码
package com.example.attendance.report.dto;



import lombok.Data;

import javax.validation.constraints.NotBlank;



/**

 * 考勤统计报表查询入参。

 * 时间范围必须显式传入,避免后台默认查全量数据。

 */

@Data

public class AttendanceCountQuery {

    /** 考勤单位或部门 ID。 */

    private String unitId;

    /** 员工姓名,支持模糊查询。 */

    private String personName;

    /** 开始日期,格式 yyyy-MM-dd。 */

    @NotBlank(message = "开始时间不能为空")

    private String startTime;

    /** 结束日期,格式 yyyy-MM-dd。 */

    @NotBlank(message = "结束时间不能为空")

    private String endTime;

}

报表接口不建议默认查全部历史。真实项目里 `startTime` 和 `endTime` 是必填项,PC 页面默认给最近 7 天。企业系统很多慢查询不是 SQL 写得多复杂,而是页面上少了时间边界。

3. VO:把 HR 要看的指标一次返回

复制代码
package com.example.attendance.report.vo;



import lombok.Data;



/**

 * 考勤统计报表返回对象。

 * 该对象面向 PC 报表展示和 Excel 导出,字段要稳定、命名要贴近业务。

 */

@Data

public class AttendanceCountVO {

    private String personId;

    private String realname;

    private String workNo;

    private String unitName;

    private String departmentName;

    private String ruleName;

    private Integer needWorkDays;

    private Double realWorkDays;

    private Double lackWorkDaysKx;

    private Double lackWorkDaysDx;

    private String attendanceRate;

    private Double workTime;

    private Double absenteeismDays;

    private Integer beLateCount;

    private Integer leaveEarlyCount;

    private Integer noClockCount;

    private Integer appealCount;

    private Integer outClockCount;

    private Double dockLeaveDays;

    private Double noDockLeaveDays;

    private String startTime;

    private String endTime;

}

这个 VO 的设计有一个细节:缺勤天数分为扣薪和带薪,请假天数也分为扣薪和带薪。很多公司报表纠纷就出在这里。把"请假"统一算成缺勤,会让工资核算、绩效统计和制度解释都变得很混乱。

4. 明细 VO:每个数字都能点开解释

复制代码
package com.example.attendance.report.vo;



import lombok.Data;

import java.time.LocalDateTime;



/**

 * 考勤统计明细返回对象。

 * 用于解释报表中的缺勤、迟到、早退、缺卡、补卡、外勤和请假数字。

 */

@Data

public class AttendanceCountDetailVO {

    private String id;

    private String personId;

    private String upDownWorkClockName;

    private String ruleTime;

    private String clockTime;

    private String repairClockTime;

    private String afDate;

    private Integer afStatus;

    private LocalDateTime updateTime;

    private String leaveTypeName;

    private String startDate;

    private String endDate;

    private String leaveDays;

}

这里不要只返回一个日期。迟到和早退要看规定时间与实际时间;补卡要看申请时间、补卡时间和审核状态;请假要看起止日期、天数和类型。明细字段越贴近业务,HR 越少回头找开发查库。

5. Mapper:报表聚合接口

复制代码
package com.example.attendance.report.mapper;



import com.baomidou.mybatisplus.core.metadata.IPage;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

import org.apache.ibatis.annotations.Mapper;

import org.apache.ibatis.annotations.Param;

import java.util.List;



/**

 * 考勤日报统计 Mapper。

 * 聚合查询放在 XML 中维护,便于处理复杂 SQL 和索引提示。

 */

@Mapper

public interface AttendanceDayStatsMapper {

    IPage<AttendanceCountVO> countReport(Page<?> page,

                                         @Param("startTime") String startTime,

                                         @Param("endTime") String endTime,

                                         @Param("unitId") String unitId,

                                         @Param("personName") String personName);



    List<AttendanceCountDetailVO> countDetails(@Param("personId") String personId,

                                               @Param("startTime") String startTime,

                                               @Param("endTime") String endTime,

                                               @Param("unitId") String unitId,

                                               @Param("type") Integer type);

}

真实项目使用 MyBatis XML 写聚合 SQL,而不是把大量 `case when` 拼进 Java 代码。这是更适合报表的做法:SQL 负责聚合,Service 负责口径补全和边界处理,Controller 负责参数校验和导出。

6. XML:把日报、外勤、请假聚成一张报表

复制代码
<select id="countReport" resultType="com.example.attendance.report.vo.AttendanceCountVO">

  SELECT

    u.realname,

    u.work_no AS workNo,

    ds.person_id AS personId,

    ds.rule_name AS ruleName,

    ds.unit_name AS unitName,

    ds.department_name AS departmentName,

    ds.need_work_days AS needWorkDays,

    ds.work_time AS workTime,

    ds.absenteeism_days AS absenteeismDays,

    ds.be_late_count AS beLateCount,

    ds.leave_early_count AS leaveEarlyCount,

    ds.no_clock_count AS noClockCount,

    ds.appeal_count AS appealCount,

    IFNULL(out_record.out_clock_count, 0) AS outClockCount,

    IFNULL(leave_record.dock_leave_days, 0) AS dockLeaveDays,

    IFNULL(leave_record.no_dock_leave_days, 0) AS noDockLeaveDays

  FROM (

    SELECT

      day_stats.person_id,

      GROUP_CONCAT(DISTINCT day_stats.attendance_rule_name) AS rule_name,

      GROUP_CONCAT(DISTINCT day_stats.unit_name) AS unit_name,

      GROUP_CONCAT(DISTINCT day_stats.department_name) AS department_name,

      SUM(CASE WHEN day_stats.work_day = 1 THEN 1 ELSE 0 END) AS need_work_days,

      SUM(

        CASE

          WHEN day_stats.clock_in_type1 IN (0,1,3,6)

           AND day_stats.clock_out_type1 IN (0,1,3,6)

          THEN IFNULL(ROUND(TIMESTAMPDIFF(SECOND, day_stats.clock_in_time1, day_stats.clock_out_time1) / 3600, 2), 0)

          ELSE 0

        END

      ) AS work_time,

      SUM(IFNULL(day_stats.clock_in_type1 = 1, 0) + IFNULL(day_stats.clock_in_type2 = 1, 0)) AS be_late_count,

      SUM(IFNULL(day_stats.clock_out_type1 = 3, 0) + IFNULL(day_stats.clock_out_type2 = 3, 0)) AS leave_early_count,

      SUM(IFNULL(day_stats.clock_in_type1 IN (2,4), 0) + IFNULL(day_stats.clock_out_type1 IN (2,4), 0)

        + IFNULL(day_stats.clock_in_type2 IN (2,4), 0) + IFNULL(day_stats.clock_out_type2 IN (2,4), 0)) AS no_clock_count,

      SUM(IFNULL(day_stats.clock_in_type1 = 6, 0) + IFNULL(day_stats.clock_out_type1 = 6, 0)

        + IFNULL(day_stats.clock_in_type2 = 6, 0) + IFNULL(day_stats.clock_out_type2 = 6, 0)) AS appeal_count,

      SUM(IFNULL((day_stats.clock_in_type1 IN (2,4) OR day_stats.clock_out_type1 IN (2,4)), 0)

        + IFNULL((day_stats.clock_in_type2 IN (2,4) OR day_stats.clock_out_type2 IN (2,4)), 0)) AS absenteeism_days

    FROM kq_attendance_day_stats day_stats FORCE INDEX (idx_day_stats_date)

    WHERE day_stats.del_flag = 0

      AND day_stats.create_time >= CONCAT(#{startTime}, ' 00:00:00')

      AND day_stats.create_time <= CONCAT(#{endTime}, ' 23:59:59')

      <if test="unitId != null and unitId != ''">

        AND (day_stats.unit_id = #{unitId} OR day_stats.department_id = #{unitId})

      </if>

      <if test="personName != null and personName != ''">

        AND day_stats.person_name LIKE CONCAT('%', #{personName}, '%')

      </if>

    GROUP BY day_stats.person_id

  ) ds

  INNER JOIN sys_user u ON ds.person_id = u.id AND u.del_flag = 0

  LEFT JOIN (

    SELECT person_id, COUNT(*) AS out_clock_count

    FROM kq_af_out_record

    WHERE del_flag = 0

      AND af_status != -1

      AND DATE_FORMAT(clock_time, '%Y-%m-%d') BETWEEN #{startTime} AND #{endTime}

    GROUP BY person_id

  ) out_record ON ds.person_id = out_record.person_id

  LEFT JOIN (

    SELECT

      person_id,

      SUM(CASE WHEN leave_type IN (2,3,4,5) THEN leave_days ELSE 0 END) AS dock_leave_days,

      SUM(CASE WHEN leave_type NOT IN (2,3,4,5) THEN leave_days ELSE 0 END) AS no_dock_leave_days

    FROM kq_af_leave_record

    WHERE del_flag = 0

      AND af_status = 2

      AND start_date <= #{endTime}

      AND end_date >= #{startTime}

    GROUP BY person_id

  ) leave_record ON ds.person_id = leave_record.person_id

</select>

这段 SQL 有几个值得注意的点。

第一,报表主表来自日统计表,而不是原始打卡流水。这样能保证报表口径稳定。

第二,工时只统计有效打卡状态。示例中把正常、迟到、早退、补卡视为可计算工时的状态,缺卡和旷工不参与工时计算。

第三,请假记录按时间交集统计,不是只查 `start_date between`。跨月请假经常横跨统计区间,如果只按开始时间筛,会漏掉上月开始、本月仍然有效的请假。

第四,外勤记录只统计非草稿、未删除数据。报表不能把员工草稿或已删除记录算进去。

7. Service:聚合后补齐出勤率

复制代码
package com.example.attendance.report.service.impl;



import com.baomidou.mybatisplus.core.metadata.IPage;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

import java.util.List;



/**

 * 考勤统计报表服务。

 * 负责组织聚合查询、缺勤明细补算和出勤率计算。

 */

@Service

@RequiredArgsConstructor

public class AttendanceReportService {

    private final AttendanceDayStatsMapper mapper;



    /**

     * 查询考勤统计报表。

     * SQL 负责聚合基础指标,Java 负责处理扣薪/带薪缺勤和出勤率口径。

     */

    public IPage<AttendanceCountVO> count(Page<?> page, AttendanceCountQuery query) {

        IPage<AttendanceCountVO> result = mapper.countReport(

            page, query.getStartTime(), query.getEndTime(), query.getUnitId(), query.getPersonName()

        );

        List<AttendanceCountVO> records = result.getRecords();

        for (AttendanceCountVO row : records) {

            double paidLack = safe(row.getLackWorkDaysDx());

            double salaryLack = safe(row.getLackWorkDaysKx());

            int needDays = row.getNeedWorkDays() == null ? 0 : row.getNeedWorkDays();

            double realDays = Math.max(0, needDays - paidLack - salaryLack);

            row.setRealWorkDays(realDays);

            row.setAttendanceRate(needDays == 0 ? "0%" : (int) (realDays / needDays * 100) + "%");

            row.setStartTime(query.getStartTime());

            row.setEndTime(query.getEndTime());

        }

        return result;

    }



    private double safe(Double value) {

        return value == null ? 0D : value;

    }

}

真实项目里 `getKqCount` 先拿到聚合结果,再针对本页人员批量查询扣薪缺勤和带薪缺勤明细,最后计算 `realWorkDays` 和 `attendanceRate`。这样做的好处是报表口径集中,前端不需要自己算。

这里也有一个需要优化的点:如果 `needWorkDays` 为 0,直接除法会有风险。公开 Demo 里补了 `needDays == 0 ? "0%"` 的保护,实际工程也建议补上类似边界。

8. Controller:列表、导出、明细下钻分开

复制代码
package com.example.attendance.report.controller;



import com.baomidou.mybatisplus.core.metadata.IPage;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;

import lombok.RequiredArgsConstructor;

import org.springframework.validation.annotation.Validated;

import org.springframework.web.bind.annotation.*;

import java.util.List;



/**

 * 考勤统计报表接口。

 * 列表负责汇总,详情负责解释,导出负责交付给 HR。

 */

@RestController

@RequestMapping("/api/attendance/report")

@RequiredArgsConstructor

public class AttendanceReportController {

    private final AttendanceReportService reportService;

    private final AttendanceDetailService detailService;



    /** 查询考勤统计报表。 */

    @GetMapping("/count")

    public IPage<AttendanceCountVO> count(@Validated AttendanceCountQuery query,

                                          @RequestParam(defaultValue = "1") long pageNo,

                                          @RequestParam(defaultValue = "10") long pageSize) {

        return reportService.count(new Page<>(pageNo, pageSize), query);

    }



    /** 查询某个统计数字对应的明细。 */

    @GetMapping("/details")

    public List<AttendanceCountDetailVO> details(@RequestParam String personId,

                                                 @RequestParam String unitId,

                                                 @RequestParam String startTime,

                                                 @RequestParam String endTime,

                                                 @RequestParam Integer type) {

        return detailService.details(personId, unitId, startTime, endTime, type);

    }

}

真实项目中的详情类型有 22 类,覆盖缺勤扣薪、缺勤带薪、旷工、迟到、早退、缺卡、补卡、外勤打卡、各类请假。这个设计对前端很友好:统计表里的数字都可以点开,前端只需要传 `personId + unitId + startTime + endTime + type`。

9. 详情服务:把"数字"翻译成"原因"

复制代码
package com.example.attendance.report.service;



import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

import java.util.List;



/**

 * 考勤统计明细服务。

 * 负责根据报表数字类型查询不同来源的解释数据。

 */

@Service

@RequiredArgsConstructor

public class AttendanceDetailService {

    private final AttendanceRecordMapper recordMapper;

    private final LeaveRecordMapper leaveMapper;

    private final OutRecordMapper outMapper;



    /** 按统计类型查询明细,保证每个汇总数字都能被追溯。 */

    public List<AttendanceCountDetailVO> details(String personId, String unitId,

                                                 String startTime, String endTime, Integer type) {

        if (type == 4) {

            return recordMapper.findLateDetails(personId, startTime, endTime);

        }

        if (type == 5) {

            return recordMapper.findLeaveEarlyDetails(personId, unitId, startTime, endTime);

        }

        if (type == 7) {

            return recordMapper.findRepairCardDetails(personId, unitId, startTime, endTime);

        }

        if (type == 8) {

            return outMapper.findOutClockDetails(personId, unitId, startTime, endTime);

        }

        if (type >= 9) {

            return leaveMapper.findLeaveDetails(personId, startTime, endTime, type);

        }

        return recordMapper.findNoClockDetails(personId, unitId, startTime, endTime);

    }

}

不要把所有明细都塞进一条万能 SQL。迟到早退来自打卡记录,外勤来自外勤审批,请假来自请假记录,补卡还要带审批状态。按来源拆开查询,更容易维护,也更容易解释。

10. 前端:统计列和详情弹窗

复制代码
import { defHttp } from '/@/utils/http/axios';



enum Api {

  list = '/biz/kqAttendanceDayStats/getKqCount',

  exportXls = '/biz/kqAttendanceDayStats/getKqCountExportXls',

  handleDetail = '/biz/kqAttendanceDayStats/getKqCountDetails',

}



export const list = (params) => defHttp.get({ url: Api.list, params });



export const handleDetail = (params) => defHttp.get({ url: Api.handleDetail, params });



export const columns = [

  { title: '姓名', dataIndex: 'realname' },

  { title: '工号', dataIndex: 'workNo' },

  { title: '应出勤天数', dataIndex: 'needWorkDays' },

  { title: '实际出勤天数', dataIndex: 'realWorkDays' },

  { title: '个人出勤率', dataIndex: 'attendanceRate' },

  { title: '迟到次数', dataIndex: 'beLateCount', slots: { customRender: 'beLateCount' } },

  { title: '早退次数', dataIndex: 'leaveEarlyCount', slots: { customRender: 'leaveEarlyCount' } },

  { title: '缺卡次数', dataIndex: 'noClockCount', slots: { customRender: 'noClockCount' } },

  { title: '补卡次数', dataIndex: 'appealCount', slots: { customRender: 'appealCount' } },

  { title: '外勤打卡次数', dataIndex: 'outClockCount', slots: { customRender: 'outClockCount' } },

];

真实页面里很多列都带 `customRender` 插槽,点击数字打开详情弹窗。这个交互很实用:HR 不需要先导出 Excel 再查库,页面上就能解释"为什么这个人迟到 2 次、缺卡 1 次、请假 3 天"。

四、建表和索引建议

复制代码
CREATE TABLE kq_attendance_day_stats (

  id varchar(64) NOT NULL COMMENT '主键,建议 personId + yyyyMMdd',

  unit_id varchar(64) NOT NULL DEFAULT '' COMMENT '考勤单位ID',

  unit_name varchar(100) NOT NULL DEFAULT '' COMMENT '考勤单位名称',

  department_id varchar(64) NOT NULL DEFAULT '' COMMENT '部门ID',

  department_name varchar(100) NOT NULL DEFAULT '' COMMENT '部门名称',

  person_id varchar(64) NOT NULL DEFAULT '' COMMENT '员工ID',

  person_name varchar(100) NOT NULL DEFAULT '' COMMENT '员工姓名',

  attendance_rule varchar(64) NOT NULL DEFAULT '' COMMENT '考勤规则ID',

  attendance_rule_name varchar(100) NOT NULL DEFAULT '' COMMENT '考勤规则名称',

  clock_in_time1 datetime DEFAULT NULL COMMENT '班次1上班打卡时间',

  clock_in_type1 varchar(8) NOT NULL DEFAULT '' COMMENT '班次1上班状态',

  clock_out_time1 datetime DEFAULT NULL COMMENT '班次1下班打卡时间',

  clock_out_type1 varchar(8) NOT NULL DEFAULT '' COMMENT '班次1下班状态',

  clock_in_time2 datetime DEFAULT NULL COMMENT '班次2上班打卡时间',

  clock_in_type2 varchar(8) NOT NULL DEFAULT '' COMMENT '班次2上班状态',

  clock_out_time2 datetime DEFAULT NULL COMMENT '班次2下班打卡时间',

  clock_out_type2 varchar(8) NOT NULL DEFAULT '' COMMENT '班次2下班状态',

  work_day tinyint NOT NULL DEFAULT 1 COMMENT '是否工作日:1是,2否',

  create_time datetime NOT NULL COMMENT '统计日期',

  del_flag tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',

  PRIMARY KEY (id),

  KEY idx_day_stats_date (create_time),

  KEY idx_day_stats_person_date (person_id, create_time),

  KEY idx_day_stats_unit_date (unit_id, create_time),

  KEY idx_day_stats_dept_date (department_id, create_time)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='每日考勤统计';

索引的核心是时间、人员、组织。报表一定要带时间范围,人员详情一定要走 `person_id + create_time`,组织报表则走 `unit_id + create_time` 或 `department_id + create_time`。如果页面允许无时间查询,这些索引再好也救不了全量扫描。

五、几个真实踩坑点

第一,不要用 `DATE_FORMAT(create_time)` 包住索引字段做主筛选。真实项目里有些地方用 `DATE_FORMAT` 做日期判断,读起来直观,但在数据量上来后可能影响索引使用。更推荐传入完整起止时间,用 `create_time >= startDateTime and create_time <= endDateTime`。

第二,出勤率要处理应出勤为 0 的情况。节假日、刚入职、刚离职或者组织筛选为空,都可能出现应出勤 0 天。没有保护会出现除零错误或奇怪百分比。

第三,跨区间请假要查交集。请假开始时间在上个月、结束时间在本月,这种数据必须计入本月报表。条件应是 `start_date <= endTime AND end_date >= startTime`。

第四,统计数字必须支持下钻。只给"迟到 3 次"没有价值,必须能点开看到 3 次分别是哪天、哪个班段、规定几点、实际几点。

第五,补卡通过后要同步日报。异常申诉如果只改原始打卡记录,不重算或回写日报,月报还是旧数据。考勤系统里最怕"列表看着正常,报表还是异常"。

第六,Excel 导出要有限制。真实项目里导出前有 `ExportCheck` 校验,避免一次导出全库。这个细节对企业系统很重要。

六、可以迁移到哪些系统

这套设计不只适合考勤。

巡检系统可以把"一人一天考勤"换成"一设备一天巡检",统计漏检、异常、复核和整改。

门店管理可以换成"一店一天经营日报",统计销售、客流、退款、异常订单和店长确认。

护理系统可以换成"一老人一天服务日报",统计护理项目、生命体征、异常记录和家属反馈。

工单系统可以换成"一人一天处理日报",统计响应时长、超时次数、返工次数和客户确认。

可复用的方法是:先沉淀事实,再聚合报表;汇总数字必须能解释;导出只是结果交付,不应该成为计算核心。

七、总结

考勤日报统计报表看起来是一个普通后台列表,实际上是企业管理系统里很典型的"事实到报表"链路。

好的设计应该满足四句话:

一人一天一条事实,避免月底重复拼表。

报表按时间和组织聚合,避免无边界全量扫描。

每个汇总数字都能下钻,避免 HR 解释不清。

补卡、请假、外勤等审批结果要及时回写,避免列表和报表不一致。

如果一个考勤系统只会记录打卡,不会解释统计,那它最后还是会把压力还给 HR。真正有价值的报表,不是把数字摆出来,而是让每个数字都有来源、有口径、有明细、有责任边界。

相关推荐
Devin~Y2 小时前
从本地生活电商到 AI RAG:互联网大厂 Java 面试场景完整实战
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag
米码收割机2 小时前
SA508-3钢回火焊道焊接温度场数值模拟(模拟图+论文)
spring boot·express·宠物
米码收割机3 小时前
【项目】spring boot+vue3 宠物领养系统(源码+文档)【独一无二】
java·spring boot·宠物
momo13 小时前
Java+WebAI黑马知识点
java·spring boot·mybatis
就改了20 小时前
Mybatis快速入门大全(详细版)
java·spring boot·mybatis
Listen·Rain20 小时前
Spring Boot 整合 JWT:从入门到源码原理深度解析
java·spring boot·后端
自强的小白20 小时前
重点Springboot原理(起步原理和自动配置的两个实现方案)!
spring boot·spring
罗超驿20 小时前
3.SpringBoot快速上手:从零搭建你的第一个Web应用
前端·spring boot·后端
万亿少女的梦16821 小时前
基于Spring Boot、Vue.js与MySQL的流浪猫狗救助系统设计与实现
vue.js·spring boot·mysql·javaweb·权限管理