
做考勤系统时,很多团队一开始会把注意力放在"员工怎么打卡"上:移动端定位、拍照、外勤说明、WiFi、蓝牙、围栏、打卡按钮,这些当然重要。但系统真正上线一段时间后,最容易把 HR 和主管拖垮的,往往不是打卡入口,而是"应该有记录却没有记录"的那一批数据。
员工忘打卡、手机没网、排班跨天、规则切换、请假审批后补回、人员换部门、节假日补班,都会让考勤数据在月底变成一堆待解释的问题。如果系统只保存员工主动提交的打卡记录,那么月底统计一定会退回 Excel:HR 一条条看聊天记录,一条条问主管,一条条补状态。智慧考勤项目里把这一块拆成了后台自动补账能力,核心是用 XXL-Job 定时驱动,结合考勤规则、班次时间、日统计结果,自动生成缺卡或系统补卡记录。
本文结合智慧考勤后端真实代码,梳理一套可复用的设计:常规考勤缺卡生成、排班/值班缺卡生成、昨天记录补偿、规则变化后的历史回算、日统计同步,以及这类任务最容易踩的幂等和边界问题。

一、为什么考勤不能只靠"员工主动打卡"
一个完整的考勤系统至少有两类数据。
第一类是事实数据:员工在什么时间、什么地点、用什么方式打了卡。比如移动端上传的经纬度、打卡图片、打卡类型、上下班类型、外勤说明等。
第二类是应算数据:员工在某一天本来应该出现哪些上班卡、下班卡,哪些没有出现,哪些因为请假被抵扣,哪些因为排班跨天需要隐藏中间记录,哪些最终要进入月度统计。
很多系统的问题,是只做了第一类数据。员工点了按钮,系统就有记录;员工没点按钮,系统就什么都没有。到了统计时,系统无法区分三种完全不同的情况:
- 员工应该上班但没打卡;
- 员工不应该上班,所以没有打卡;
- 员工已经请假或审批通过,所以缺卡不应算异常。
所以后台必须有"补账任务"。它不是替员工伪造打卡,而是根据规则把"应有记录"和"缺失记录"补齐,让统计口径有依据。
二、项目里的任务入口
智慧考勤后端任务位于:
`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/xxljob/`
第 6 篇涉及的核心任务包括:
|-----------------------------------|--------------------|
| 任务类 | 职责 |
| `AddAttendanceRecordJob` | 生成常规考勤缺卡记录 |
| `AddLastdayAttendanceRecordJob` | 补偿昨天常规和排班缺卡记录 |
| `AddPbAttendanceRecordJob` | 生成排班/值班旷工记录 |
| `BackTrackRuleJob` | 排班结束后自动回到上一个常规考勤规则 |
常规缺卡任务入口很薄,只负责日志和调用服务层:
@XxlJob(value = "addAttendanceRecordJob")
public void execute() {
log.info("----------定时任务:生成缺卡记录执行了----------");
try {
iKqAttendanceRecordService.addAttendanceRecordJob(null, null);
} catch (Exception e) {
log.info("----------定时任务:生成缺卡记录出错了----------");
e.printStackTrace();
log.info(e.getMessage());
}
}
这一段代码的设计重点不是复杂,而是边界清晰:调度层只触发任务,真正的业务判断全部沉到 `IKqAttendanceRecordService`。后续如果要把日志规范化、接入任务报警、加分布式锁,也应该在任务层和服务层之间补,而不是把业务条件堆在 Job 入口。
昨天补偿任务则更像"兜底账务任务"。它把日期固定到昨天,同时跑常规考勤和排班考勤:
@XxlJob(value = "AddLastdayAttendanceRecordJob")
public void execute() {
LocalDate yesterday = LocalDate.now().minusDays(1);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String yesterdayString = yesterday.format(formatter);
iKqAttendanceRecordService.addAttendanceRecordJob(
yesterdayString,
yesterdayString + " 23:59:59"
);
iKqAttendanceRecordService.addPbAttendanceRecordJob(
yesterdayString,
yesterdayString + " 23:59:59"
);
}
这里有一个很实用的工程思想:实时任务可能因为服务重启、网络抖动、调度延迟漏掉某些时间点,所以需要"昨日补偿"。考勤、计费、库存、积分这类系统都类似,不能只靠一个实时任务赌全部准确,最好设计一个按天补偿的任务。
三、常规考勤缺卡如何判断
服务层代码位于:
`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/service/impl/KqAttendanceRecordServiceImpl.java`
常规考勤缺卡入口是 `addAttendanceRecordJob(String yyyymmdd, String yyyymmddhhmmss)`。它支持两种模式:
-
不传日期:按当前日期和当前时间生成;
-
传入日期范围:按指定日期补偿,常用于昨天任务或历史补账。
public void addAttendanceRecordJob(String yyyymmdd, String yyyymmddhhmmss) {
String curdate = "CURDATE()"; String now = "NOW()"; if (null != yyyymmdd && null != yyyymmddhhmmss) { curdate = "'" + yyyymmdd + "'"; now = "'" + yyyymmddhhmmss + "'"; } QueryWrapper<KqAttendanceRecord> wrapper = new QueryWrapper<>(); wrapper.eq("kq_af_rule.use_status", RuleUseStatusEnums.IN_USE.getValue()) .eq("kq_rule_person.locked_status", 0); List<KqAttendanceRecord> am = getCgAmEw(curdate, now, wrapper); QueryWrapper<KqAttendanceRecord> wrapper1 = new QueryWrapper<>(); wrapper1.eq("kq_af_rule.use_status", RuleUseStatusEnums.IN_USE.getValue()) .eq("kq_rule_person.locked_status", 0); List<KqAttendanceRecord> pm = getCgPmEw(curdate, now, wrapper1); addAttendanceRecordJobDetail(yyyymmdd, am, pm);}
这段代码里有几个关键条件:
- `kq_af_rule.use_status`:只处理正在使用的考勤规则;
- `kq_rule_person.locked_status`:排除已经冻结的人员规则关系;
- `curdate` 和 `now`:把当前任务和指定日期补偿统一到一个入口;
- `getCgAmEw` 和 `getCgPmEw`:上午卡、下午卡、整班卡拆开算,避免一条 SQL 把所有班次揉乱。
系统不是简单判断"今天有没有打卡",而是要结合规则、人员、班次和日统计表判断"这个时间点之前,某个应打卡位置是否还为空"。
四、上午卡、下午卡和夏令时处理
`getCgAmEw` 负责上午卡。它把班次时间、允许延迟时间和日统计字段组合起来判断:
String condition1 =
"(DATE_FORMAT(CONCAT(" + curdate + ", ' ', kq_af_time.work_time) " +
"+ INTERVAL kq_af_time.work_clock_delay_time+30 minute,'%Y-%m-%d %H:%i') " +
"<= DATE_FORMAT(now(), '%Y-%m-%d %H:%i') " +
"AND kq_attendance_day_stats.clock_in_type1 IS NULL)";
String condition2 =
"(DATE_FORMAT(CONCAT(" + curdate + ", ' ', kq_af_time.off_work_clock_time) " +
"+ INTERVAL 1 minute,'%Y-%m-%d %H:%i') " +
"<= DATE_FORMAT(now(), '%Y-%m-%d %H:%i') " +
"AND kq_attendance_day_stats.clock_out_type1 IS NULL)";
下午卡逻辑更复杂,因为项目里考虑了夏令时:
Calendar instance = Calendar.getInstance();
int month = instance.get(Calendar.MONDAY) + 1;
boolean isSummer = month >= 6 && month <= 8;
if (isSummer) {
condition3 = "work_time + work_clock_delay_time + delay_time + 30 minute";
condition4 = "off_work_clock_time + 1 minute";
} else {
condition3 = "work_time + work_clock_delay_time + 30 minute";
condition4 = "off_work_clock_time + 1 minute";
}
这里能看到真实业务系统和演示系统的区别。演示系统只会写"上班时间 09:00,下班时间 18:00"。真实系统要处理半班、整班、延迟时间、夏令时、补班、请假、跨天等情况。越是后期,这些边界越决定系统能不能用得住。
五、排班和值班不能套普通班逻辑
项目里排班/值班使用单独任务:
@XxlJob(value = "AddPbAttendanceRecordJob")
public void execute() {
log.info("----------定时任务:生成排班/值班的旷工记录----------");
try {
iKqAttendanceRecordService.addPbAttendanceRecordJob(null, null);
} catch (Exception e) {
log.info("----------定时任务:生成排班/值班的旷工记录出错了----------");
e.printStackTrace();
log.info(e.getMessage());
}
}
排班和值班不能完全复用固定班逻辑,主要有三个原因。
第一,排班经常跨天。比如夜班从晚上到第二天早上,中间可能需要生成多条系统记录,但并不都应该在 App 端展示。
第二,排班规则可能有有效期。一个人本周是临时排班,下周要回到原来的常规规则。如果不处理规则回退,后续考勤会一直挂在临时规则上。
第三,排班统计通常和日统计强绑定。系统补出的记录不只是写一条 `kq_attendance_record`,还要同步影响 `kq_attendance_day_stats`。
排班缺卡生成完成后,代码会批量保存记录和日统计:
this.saveBatch(kqAttendanceRecords);
iKqAttendanceDayStatsService.saveOrUpdateBatch(dayStatsList);
log.info("生成排班/值班旷工 成功");
这也是考勤系统里很关键的一点:记录表和统计表要一起考虑。只写记录不更新统计,月报会错;只更新统计不保留记录,争议时说不清。
六、历史回算:规则变了,旧账也要补
`fillKqRecord(String personId)` 用于人员或规则变化后的历史补齐。它先查人员最后一条考勤记录,如果最后一条已经是下班卡,就不处理;如果最后一条是上班卡,说明后续可能需要补下班记录或跨天记录。
public void fillKqRecord(String personId) {
log.info("进入fillKqRecord方法,personId={}", personId);
KqAttendanceRecord record = this.getLastOneByPersonId(personId);
if (null == record) {
return;
}
if (Objects.equals(EClockInOrOutType.OUT.getValue(), record.getUpDownWorkClock())) {
return;
}
KqAfRule kqAfRule = kqAfRuleService.getById(record.getAttendanceRule());
KqAfTime kqAfTime = iKqAfTimeService.getById(record.getAttendanceWork());
if (kqAfRule == null) {
return;
}
}
常规考勤只需要补当天下班卡:
if (EAttendanceType.CG.getValue().equals(kqAfRule.getAttendanceType())) {
String format = DateUtil.format(clockTime, "yyyy-MM-dd");
KqAttendanceRecord record1 = setRecordByJob(
format,
BeanUtil.copyProperties(record, KqAttendanceRecord.class),
EClockInOrOutType.OUT
);
record1.setClockTime(DateUtil.parse(
format + " " + kqAfTime.getOffWorkTime() + ":00",
"yyyy-MM-dd HH:mm:ss"
));
this.saveOrUpdateDay(record1, kqAttendanceDayStats);
return;
}
排班考勤则可能要从最后一次打卡日期补到当前日期,中间日期生成上班卡和下班卡,最后一天再把下班时间改回班次下班时间:
List<String> dateStrListBetween = DateUtil.getDateStrListBetween(
clockTime,
new Date(),
"yyyy-MM-dd"
);
for (int i = 0; i < dateStrListBetween.size(); i++) {
String date = dateStrListBetween.get(i);
if (i == 0) {
recordList.add(setRecordByJob(date, copyRecord, EClockInOrOutType.OUT));
continue;
}
recordList.add(setRecordByJob(date, copyRecord, EClockInOrOutType.IN));
recordList.add(setRecordByJob(date, copyRecord, EClockInOrOutType.OUT));
}
这类历史回算能力很容易被忽视,但它决定了系统能不能处理"人和规则变化之后的旧账"。企业系统不是一次性录入后永远不变,组织、岗位、规则、班次都会变,系统必须允许旧数据被合规地重新计算。
七、系统补卡记录应该长什么样
项目里 `setRecordByJob` 把系统生成的记录和员工真实打卡记录区分开:
private KqAttendanceRecord setRecordByJob(
String date,
KqAttendanceRecord record1,
EClockInOrOutType eClockInOrOutType) {
record1.setId(null);
record1.setLeaveRecordId(null);
record1.setClockAddress(null);
record1.setLongitude(null);
record1.setLatitude(null);
record1.setClockImg(null);
record1.setUpdateTime(null);
record1.setDelFlag(0);
record1.setCreateType(2);
record1.setUpDownWorkClock(eClockInOrOutType.getValue());
record1.setClockStatus(EClockStatus.NORMAL.getValue());
record1.setClockStatusName(EClockStatus.NORMAL.getName() + "(系统补卡)");
record1.setAfStatus(0);
record1.setCreateTime(new Date());
return record1;
}
这段代码非常值得借鉴。系统补卡不是员工真实打卡,所以必须清掉定位、图片、地址等人工打卡证据字段,同时用 `createType = 2` 和 `clockStatusName = 正常(系统补卡)` 标识来源。这样做有三个好处:
- 员工真实打卡和系统生成记录不会混淆;
- 主管和 HR 看到统计结果时能知道记录来源;
- 后续审计或申诉时可以追溯"这条记录为什么是系统补出来的"。
这里涉及的核心字段包括:
|---------------------|-------------------|
| 字段 | 含义 |
| `personId` | 考勤人员 |
| `attendanceRule` | 命中的考勤规则 |
| `attendanceWork` | 命中的班次/时间段 |
| `upDownWorkClock` | 上班卡或下班卡 |
| `clockStatus` | 正常、迟到、早退、旷工、请假等状态 |
| `clockStatusName` | 状态展示名,系统补卡会附加标识 |
| `createType` | 记录来源,区分人工打卡和系统生成 |
| `afStatus` | 审批状态或后续流程状态 |
| `clockTime` | 最终进入统计的考勤时间 |
八、规则回退:排班结束后要回到常规规则
自动补账不只在记录层。`BackTrackRuleJob` 负责排班考勤结束后回到上一个考勤规则:
@XxlJob(value = "backTrackRuleJob")
@Transactional(rollbackFor = Exception.class)
public void execute() {
List<DictModel> dictItems = sysDictService.getDictItems("back_track_rule");
if (null == dictItems || "false".equals(dictItems.get(0).getValue())) {
return;
}
List<KqRulePerson> kqRulePersonList = iKqRulePersonService.queryPbRuleCancel();
if (kqRulePersonList.size() == 0) {
return;
}
for (KqRulePerson kqRulePerson : kqRulePersonList) {
kqRulePerson.setLockedStatus(CommonConstant.IS_LOCKED);
}
iKqRulePersonService.updateBatchById(kqRulePersonList);
}
这个任务说明系统把"规则与人"的关系也纳入补偿范围。排班规则到期后,先冻结排班规则关联,再找到最近一条生效的常规考勤规则解除冻结。否则员工结束临时排班后,系统还按旧排班算,后面所有缺卡和统计都会错。
九、日统计不是简单查询,而是结果维护
项目里有 `kq_attendance_day_stats` 日统计表,月度统计 SQL 位于:
`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/mapper/xml/KqAttendanceDayStatsMapper.xml`
统计查询不是简单查 `kq_attendance_record`,而是围绕日统计结果继续关联人员、组织、外勤、请假等数据:
<select id="getKqCount" resultType="org.jeecg.modules.biz.vo.KqCountVO">
SELECT
sys_user.realname,
sys_user.work_no,
day_stats.*,
IFNULL(record.out_clock_count, 0) AS out_clock_count,
IFNULL(leave_record.visit_leave, 0) AS visit_leave
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
FROM kq_attendance_day_stats AS day_stats
FORCE INDEX (create_time_index)
WHERE day_stats.del_flag = 0
) day_stats
</select>
这类设计适合企业考勤系统。打卡、补卡、请假、申诉、规则回算都会影响当天统计,如果每次月报都临时从原始记录推导,SQL 会越来越复杂,性能也难控。提前维护日统计表,可以把"计算过程"放在业务动作和定时任务里,把"查询报表"变成稳定读取。
十、工程落地建议
这套自动补账能力落地时,我建议至少做 8 个检查。
第一,任务要能指定日期重跑。不要只写死 `NOW()`,否则补偿历史数据时会很被动。智慧考勤的 `yyyymmdd` 和 `yyyymmddhhmmss` 参数就是为这件事服务的。
第二,系统生成记录必须有来源标识。`createType = 2`、`clockStatusName = 正常(系统补卡)` 这种字段很重要,不然补出来的数据会和员工真实打卡混在一起。
第三,真实证据字段要清空。系统补卡不应该带经纬度、打卡图片、地址,否则会误导审计。
第四,补记录和日统计要一起维护。只补记录不改统计,月报还是错。
第五,排班和值班要单独处理。尤其是跨天排班、中间记录隐藏、规则回退,不适合硬套固定班。
第六,任务必须考虑幂等。重复调度、失败重试、昨日补偿都会导致任务多次执行,如果没有去重和状态判断,很容易重复生成记录。
第七,日志要规范。Job 里不建议使用 `e.printStackTrace()`,更好的方式是 `log.error("生成缺卡记录失败", e)`,方便后续统一采集和告警。
第八,补账能力要和申诉流程衔接。系统自动生成异常后,员工或主管应该能发起申诉,审批通过后再回写统计,否则"自动化"会变成新的争议来源。
总结
考勤系统越往后做,越不能只盯着打卡按钮。真正决定系统稳定性的,是后台能不能持续维护"应该有的数据":缺卡生成、排班缺卡、昨天补偿、规则回退、历史回算、日统计回写。
智慧考勤项目的这套实现给了一个清晰思路:用 XXL-Job 做调度,用服务层封装规则判断,用 `createType` 区分系统记录和人工记录,用日统计表承接最终结果。这样系统不只是"记录员工点了什么",而是能持续回答"这一天到底应该怎么算"。