Spring Boot + XXL-Job 实现考勤自动补账:缺卡生成、历史回算和幂等设计

Spring Boot + XXL-Job 实现考勤自动补账:缺卡生成、历史回算和幂等设计

做考勤系统时,很多团队一开始会把注意力放在“员工怎么打卡”上:移动端定位、拍照、外勤说明、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` 区分系统记录和人工记录,用日统计表承接最终结果。这样系统不只是“记录员工点了什么”,而是能持续回答“这一天到底应该怎么算”。