做考勤系统时很多团队一开始会把注意力放在“员工怎么打卡”上移动端定位、拍照、外勤说明、WiFi、蓝牙、围栏、打卡按钮这些当然重要。但系统真正上线一段时间后最容易把 HR 和主管拖垮的往往不是打卡入口而是“应该有记录却没有记录”的那一批数据。员工忘打卡、手机没网、排班跨天、规则切换、请假审批后补回、人员换部门、节假日补班都会让考勤数据在月底变成一堆待解释的问题。如果系统只保存员工主动提交的打卡记录那么月底统计一定会退回 ExcelHR 一条条看聊天记录一条条问主管一条条补状态。智慧考勤项目里把这一块拆成了后台自动补账能力核心是用 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 ; } QueryWrapperKqAttendanceRecord wrapper new QueryWrapper(); wrapper.eq(kq_af_rule.use_status, RuleUseStatusEnums.IN_USE.getValue()) .eq(kq_rule_person.locked_status, 0); ListKqAttendanceRecord am getCgAmEw(curdate, now, wrapper); QueryWrapperKqAttendanceRecord wrapper1 new QueryWrapper(); wrapper1.eq(kq_af_rule.use_status, RuleUseStatusEnums.IN_USE.getValue()) .eq(kq_rule_person.locked_status, 0); ListKqAttendanceRecord 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_time30 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; }排班考勤则可能要从最后一次打卡日期补到当前日期中间日期生成上班卡和下班卡最后一天再把下班时间改回班次下班时间ListString 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() { ListDictModel dictItems sysDictService.getDictItems(back_track_rule); if (null dictItems || false.equals(dictItems.get(0).getValue())) { return; } ListKqRulePerson 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 idgetKqCount resultTypeorg.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 区分系统记录和人工记录用日统计表承接最终结果。这样系统不只是“记录员工点了什么”而是能持续回答“这一天到底应该怎么算”。
Spring Boot + XXL-Job 实现考勤自动补账:缺卡生成、历史回算和幂等设计
做考勤系统时很多团队一开始会把注意力放在“员工怎么打卡”上移动端定位、拍照、外勤说明、WiFi、蓝牙、围栏、打卡按钮这些当然重要。但系统真正上线一段时间后最容易把 HR 和主管拖垮的往往不是打卡入口而是“应该有记录却没有记录”的那一批数据。员工忘打卡、手机没网、排班跨天、规则切换、请假审批后补回、人员换部门、节假日补班都会让考勤数据在月底变成一堆待解释的问题。如果系统只保存员工主动提交的打卡记录那么月底统计一定会退回 ExcelHR 一条条看聊天记录一条条问主管一条条补状态。智慧考勤项目里把这一块拆成了后台自动补账能力核心是用 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 ; } QueryWrapperKqAttendanceRecord wrapper new QueryWrapper(); wrapper.eq(kq_af_rule.use_status, RuleUseStatusEnums.IN_USE.getValue()) .eq(kq_rule_person.locked_status, 0); ListKqAttendanceRecord am getCgAmEw(curdate, now, wrapper); QueryWrapperKqAttendanceRecord wrapper1 new QueryWrapper(); wrapper1.eq(kq_af_rule.use_status, RuleUseStatusEnums.IN_USE.getValue()) .eq(kq_rule_person.locked_status, 0); ListKqAttendanceRecord 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_time30 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; }排班考勤则可能要从最后一次打卡日期补到当前日期中间日期生成上班卡和下班卡最后一天再把下班时间改回班次下班时间ListString 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() { ListDictModel dictItems sysDictService.getDictItems(back_track_rule); if (null dictItems || false.equals(dictItems.get(0).getValue())) { return; } ListKqRulePerson 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 idgetKqCount resultTypeorg.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 区分系统记录和人工记录用日统计表承接最终结果。这样系统不只是“记录员工点了什么”而是能持续回答“这一天到底应该怎么算”。