
很多企业做考勤系统时,一开始只盯着"员工能不能打卡"。真正上线以后,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。真正有价值的报表,不是把数字摆出来,而是让每个数字都有来源、有口径、有明细、有责任边界。