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` 区分系统记录和人工记录,用日统计表承接最终结果。这样系统不只是"记录员工点了什么",而是能持续回答"这一天到底应该怎么算"。