Spring Boot 考勤异常申诉审批闭环:缺卡、补卡、审核回写和日统计重算 Demo

发布时间:2026/7/29 8:27:57
Spring Boot 考勤异常申诉审批闭环:缺卡、补卡、审核回写和日统计重算 Demo 很多考勤系统最容易被忽略的不是打卡按钮而是“打错了以后怎么办”。员工忘记打卡、手机定位漂移、外勤补传延迟、班次跨天、主管临时安排这些情况最后都会落到同一个问题这条异常记录能不能被解释、能不能被审批、审批通过后能不能自动回写考勤结果和日报统计。如果系统只提供一个后台“修改状态”按钮看起来处理很快实际上风险很高。管理员把缺卡改成正常员工看不到审批依据主管没有审核意见HR 月底导出报表时也不知道这条数据为什么变化。考勤牵涉工资、绩效和劳动争议异常处理必须从一开始就按“申请、审核、回写、审计”建模。这篇文章基于智慧考勤项目真实代码脱敏整理重点讲异常申诉闭环怎么设计。不是泛泛介绍功能而是把字段、接口、流程服务、日统计回算和前端调用拆开说明。本文参考的真实工程文件包括后端实体KqAfAbnormalAppeal.java移动端入参KqAfAbnormalAppealIn.java移动端接口AppKqAfAbnormalAppealController.javaPC 管理接口KqAfAbnormalAppealController.java业务服务KqAfAbnormalAppealServiceImpl.java流程回调AfAbnormalAppealFlowService.java日统计服务KqAttendanceDayStatsServiceImpl.javaPC 异常申诉页exception/abnormalAppealPC 异常批处理页exception/batchProcessing移动端申诉详情页pages/abnormalAppealList/appeal-detail.vue审核记录组件components/AuditRecords下面的 Demo 不是内部代码原样复制而是按真实字段、接口风格和业务边界整理成一条可以公开阅读的完整链路。它适合迁移到请假补录、巡检异常、门店签到纠错、工单复核、护理记录修正等企业系统场景。一、为什么异常申诉不能做成“改字段”考勤异常至少有四类参与者。角色关心的问题系统要提供的能力员工我为什么被判异常怎么说明情况申诉入口、补卡时间、理由、图片证据主管这条异常该不该通过原始打卡记录、异常类型、审批意见HR月底统计是否可信补卡回写、日统计重算、导出依据老板制度是否公平责任是否清楚流程留痕、数据权限、异常趋势所以异常申诉的核心不是“让员工补一张卡”而是让系统保留一条完整证据链原始异常记录 - 员工发起申诉 - 工作流审核 - 审核通过/驳回 - 回写打卡记录 - 重算日统计 - 后台留审计记录真实项目里的 AfAbnormalAppealFlowService.after 就承担了这个关键职责流程状态变化后不能只更新申诉表还要同步 KqAttendanceRecord 的 clockStatus、clockTime、afStatus并在必要时更新 KqAttendanceDayStats。二、核心字段怎么拆kq_af_abnormal_appeal 不是一张简单的“补卡表”。它同时保存人员、组织、原始异常、补卡信息和流程状态。字段含义设计原因attendanceRecordId原始考勤记录 ID申诉必须绑定原记录不能凭空补卡personId/personName申诉人员后端根据登录态补齐避免前端伪造unitId/unitName考勤单位用于数据隔离和后台筛选departmentId/departmentName部门主管审核、权限过滤和导出需要attendanceRule考勤规则 ID判断申诉是否符合对应规则attendanceWork班次 ID补卡时间要落在合法班次内upDownWorkClock上班/下班/外出决定回写到日报哪个时间段abnormalStatus异常状态缺卡、迟到、早退、旷工等说明repairClockTime补卡时间审批通过后写回打卡记录appealArgument申诉理由主管判断依据appealImg图片证据保留现场截图、照片等辅助材料afStatus流程状态草稿、运行中、完成、驳回examineDate审核时间审计和追责需要examineIdea审核意见不能只有通过/驳回没有原因这里有一个很重要的边界attendanceRecordId 是申诉的锚点repairClockTime 是员工请求修正的结果afStatus 是流程裁决状态。三者不能混在一个字段里。三、完整脱敏源码 Demo1. Entitypackage com.example.attendance.appeal; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.time.LocalDateTime; /** * 考勤异常申诉实体。 * 业务边界保存员工对异常考勤记录的申诉证据和流程状态不直接代表最终考勤结果。 */ Data TableName(demo_attendance_abnormal_appeal) public class AttendanceAbnormalAppeal implements Serializable { /** 主键 */ private String id; /** 原始考勤记录ID */ private String attendanceRecordId; /** 员工ID由后端登录态补齐 */ private String personId; /** 员工姓名用于列表展示和导出 */ private String personName; /** 考勤单位ID用于数据隔离 */ private String unitId; /** 考勤单位名称 */ private String unitName; /** 部门ID */ private String departmentId; /** 部门名称 */ private String departmentName; /** 考勤规则ID */ private String attendanceRuleId; /** 班次ID */ private String attendanceWorkId; /** 上班/下班/外出打卡类型 */ private String upDownWorkClock; /** 原始异常状态例如缺卡、迟到、早退、旷工 */ private String abnormalStatus; /** 员工希望修正的补卡时间 */ private LocalDateTime repairClockTime; /** 申诉理由 */ private String appealArgument; /** 申诉图片多个图片用逗号或JSON保存 */ private String appealImg; /** 申请状态-1草稿1审核中2已完成3驳回 */ private Integer afStatus; /** 审核时间 */ private LocalDateTime examineDate; /** 审核意见 */ private String examineIdea; /** 创建时间 */ private LocalDateTime createTime; /** 更新时间 */ private LocalDateTime updateTime; }2. DTOpackage com.example.attendance.appeal; import lombok.Data; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; /** * 员工提交异常申诉入参。 * 业务边界只允许客户端提交申诉内容人员和组织信息必须由后端补齐。 */ Data public class AbnormalAppealCreateDTO { /** 原始考勤记录ID */ NotBlank(message 考勤记录ID不能为空) private String attendanceRecordId; /** 异常状态 */ NotBlank(message 异常状态不能为空) private String abnormalStatus; /** 补卡时间 */ NotNull(message 补卡时间不能为空) private LocalDateTime repairClockTime; /** 申诉理由 */ NotBlank(message 申诉理由不能为空) private String appealArgument; /** 申诉图片 */ private String appealImg; }3. VOpackage com.example.attendance.appeal; import lombok.Data; import java.time.LocalDateTime; /** * 异常申诉详情返回对象。 * 业务边界给员工端和管理端展示申诉结果不暴露内部流程变量。 */ Data public class AbnormalAppealVO { private String id; private String attendanceRecordId; private String personName; private String departmentName; private String abnormalStatus; private LocalDateTime repairClockTime; private String appealArgument; private String appealImg; private Integer afStatus; private LocalDateTime examineDate; private String examineIdea; }4. Mapperpackage com.example.attendance.appeal; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; /** * 异常申诉 Mapper。 * 业务边界只负责异常申诉表访问跨表聚合放到专门查询接口。 */ Mapper public interface AttendanceAbnormalAppealMapper extends BaseMapperAttendanceAbnormalAppeal { }5. Servicepackage com.example.attendance.appeal; import com.baomidou.mybatisplus.extension.service.IService; /** * 异常申诉服务。 * 业务边界负责申诉创建、重复提交校验、流程启动和审批回写。 */ public interface AttendanceAbnormalAppealService extends IServiceAttendanceAbnormalAppeal { /** * 员工提交异常申诉。 * param dto 申诉入参 * param loginUserId 当前登录员工ID * return 申诉ID */ String submitAppeal(AbnormalAppealCreateDTO dto, String loginUserId); /** * 审批通过后回写考勤记录和日统计。 * param appealId 申诉ID * param auditUserId 审核人ID * param auditComment 审核意见 */ void approveAndRewrite(String appealId, String auditUserId, String auditComment); /** * 审批驳回只更新申诉状态和考勤记录申诉状态。 * param appealId 申诉ID * param auditUserId 审核人ID * param auditComment 审核意见 */ void rejectAppeal(String appealId, String auditUserId, String auditComment); }6. ServiceImplpackage com.example.attendance.appeal; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; /** * 异常申诉服务实现。 * 业务边界用事务保证申诉状态、原始考勤记录、日统计三者一致。 */ Slf4j Service RequiredArgsConstructor public class AttendanceAbnormalAppealServiceImpl extends ServiceImplAttendanceAbnormalAppealMapper, AttendanceAbnormalAppeal implements AttendanceAbnormalAppealService { private final AttendanceRecordService attendanceRecordService; private final AttendanceDayStatsService attendanceDayStatsService; private final AttendanceWorkService attendanceWorkService; private final UserOrgService userOrgService; /** * 提交申诉。 * 关键约束同一条考勤记录不能存在运行中的重复申诉补卡时间必须落在班次可解释范围内。 */ Override Transactional(rollbackFor Exception.class) public String submitAppeal(AbnormalAppealCreateDTO dto, String loginUserId) { AttendanceRecord record attendanceRecordService.getById(dto.getAttendanceRecordId()); if (record null) { throw new IllegalArgumentException(未查询到考勤记录); } if (!loginUserId.equals(record.getPersonId())) { throw new IllegalArgumentException(只能申诉本人的考勤记录); } long runningCount lambdaQuery() .eq(AttendanceAbnormalAppeal::getAttendanceRecordId, dto.getAttendanceRecordId()) .eq(AttendanceAbnormalAppeal::getAfStatus, 1) .count(); if (runningCount 0) { throw new IllegalStateException(该考勤记录已有审核中的申诉); } if (!attendanceWorkService.isRepairTimeAllowed(record.getAttendanceWorkId(), dto.getRepairClockTime())) { throw new IllegalArgumentException(补卡时间不在当前班次允许范围内); } UserOrgInfo orgInfo userOrgService.getUserOrgInfo(loginUserId); AttendanceAbnormalAppeal appeal new AttendanceAbnormalAppeal(); appeal.setAttendanceRecordId(record.getId()); appeal.setPersonId(loginUserId); appeal.setPersonName(orgInfo.getRealName()); appeal.setUnitId(orgInfo.getUnitId()); appeal.setUnitName(orgInfo.getUnitName()); appeal.setDepartmentId(orgInfo.getDepartmentId()); appeal.setDepartmentName(orgInfo.getDepartmentName()); appeal.setAttendanceRuleId(record.getAttendanceRuleId()); appeal.setAttendanceWorkId(record.getAttendanceWorkId()); appeal.setUpDownWorkClock(record.getUpDownWorkClock()); appeal.setAbnormalStatus(dto.getAbnormalStatus()); appeal.setRepairClockTime(dto.getRepairClockTime()); appeal.setAppealArgument(dto.getAppealArgument()); appeal.setAppealImg(dto.getAppealImg()); appeal.setAfStatus(1); appeal.setCreateTime(LocalDateTime.now()); save(appeal); attendanceRecordService.markAppealing(record.getId()); log.info(异常申诉已提交, appealId:{}, recordId:{}, appeal.getId(), record.getId()); return appeal.getId(); } /** * 审批通过。 * 副作用原始考勤记录变成补卡日报统计按补卡时间重算。 */ Override Transactional(rollbackFor Exception.class) public void approveAndRewrite(String appealId, String auditUserId, String auditComment) { AttendanceAbnormalAppeal appeal getById(appealId); if (appeal null) { throw new IllegalArgumentException(未查询到申诉记录); } if (!Integer.valueOf(1).equals(appeal.getAfStatus())) { throw new IllegalStateException(只有审核中的申诉可以审批通过); } appeal.setAfStatus(2); appeal.setExamineDate(LocalDateTime.now()); appeal.setExamineIdea(auditComment); updateById(appeal); AttendanceRecord record attendanceRecordService.getById(appeal.getAttendanceRecordId()); record.setClockStatus(6); record.setClockStatusName(补卡); record.setClockTime(appeal.getRepairClockTime()); record.setAfStatus(0); attendanceRecordService.updateById(record); LocalDate statsDate appeal.getRepairClockTime().toLocalDate(); attendanceDayStatsService.rebuildOneDay(appeal.getPersonId(), statsDate); log.info(异常申诉审批通过并回写统计, appealId:{}, recordId:{}, appealId, record.getId()); } /** * 审批驳回。 * 副作用原始记录仍保持异常只把申诉状态标记为驳回员工端可展示重新申诉入口。 */ Override Transactional(rollbackFor Exception.class) public void rejectAppeal(String appealId, String auditUserId, String auditComment) { AttendanceAbnormalAppeal appeal getById(appealId); if (appeal null) { throw new IllegalArgumentException(未查询到申诉记录); } appeal.setAfStatus(3); appeal.setExamineDate(LocalDateTime.now()); appeal.setExamineIdea(auditComment); updateById(appeal); attendanceRecordService.markAppealRejected(appeal.getAttendanceRecordId()); } }这段实现有三个关键点。第一提交时不相信前端传来的人员和部门后端根据登录人补齐。真实项目里的 saveAbnormalAppeal 也是先通过 SecurityUtils 拿当前用户再通过组织服务补充单位、部门、岗位等信息。第二同一条考勤记录不能重复提交运行中的申诉。真实代码里通过 attendanceRecordId afStatus1 查询命中后返回“已经提交过了”。否则员工连续点击、弱网重试、页面重复提交都会制造多条流程。第三审批通过后不是只改申诉表而是回写考勤记录再更新日统计。真实项目里通过 setClockTimeAndTypeByTimeTypeAndUpDownWokClock 把补卡结果同步到日统计这一步决定了月底报表是否可信。7. Controllerpackage com.example.attendance.appeal; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; /** * 异常申诉接口。 * 业务边界员工端提交申诉管理端审批真实生产中还应叠加数据权限。 */ RestController RequiredArgsConstructor RequestMapping(/app/attendance/abnormal-appeal) public class AttendanceAbnormalAppealController { private final AttendanceAbnormalAppealService appealService; /** * 员工提交异常申诉。 */ PostMapping(/submit) public RString submit(Validated RequestBody AbnormalAppealCreateDTO dto) { String userId LoginContext.currentUserId(); return R.ok(appealService.submitAppeal(dto, userId)); } /** * 主管审批通过。 */ PostMapping(/{id}/approve) public RVoid approve(PathVariable String id, RequestParam String comment) { appealService.approveAndRewrite(id, LoginContext.currentUserId(), comment); return R.ok(); } /** * 主管审批驳回。 */ PostMapping(/{id}/reject) public RVoid reject(PathVariable String id, RequestParam String comment) { appealService.rejectAppeal(id, LoginContext.currentUserId(), comment); return R.ok(); } }8. 移动端提交片段/** * 员工端提交异常申诉。 * 业务边界只提交原始记录ID、补卡时间、理由和图片不提交人员组织字段。 */ export function submitAbnormalAppeal(form) { if (!form.attendanceRecordId) { throw new Error(缺少考勤记录); } if (!form.repairClockTime) { throw new Error(请选择补卡时间); } if (!form.appealArgument || form.appealArgument.length 5) { throw new Error(请填写更清楚的补卡事由); } return request.post(/app/attendance/abnormal-appeal/submit, { attendanceRecordId: form.attendanceRecordId, abnormalStatus: form.abnormalStatus, repairClockTime: form.repairClockTime, appealArgument: form.appealArgument, appealImg: form.appealImg, }); }真实移动端 appeal-detail.vue 里有一个很实用的交互如果 afStatus 3页面展示“重新申诉”。这说明驳回不是流程终点而是让员工根据审核意见补证据后再次提交。四、审批回写为什么要重算日统计很多系统会犯一个错审批通过后只把原始记录改成“补卡”但日报、月报、人员统计不动。页面上看起来通过了导出时还是异常。比较稳的做法是把考勤记录当“事件表”把日报当“结果表”。事件变化后结果表必须回算。/** * 日统计重算示意。 * 业务边界根据某人某天全部打卡事件重新生成统计结果避免局部字段修补导致报表不一致。 */ public void rebuildOneDay(String personId, LocalDate day) { ListAttendanceRecord records attendanceRecordService.listByPersonAndDay(personId, day); AttendanceDayStats stats getOrCreate(personId, day); stats.reset(); for (AttendanceRecord record : records) { if (1.equals(record.getUpDownWorkClock())) { stats.applyWorkClock(record.getClockTime(), record.getClockStatus()); } else if (2.equals(record.getUpDownWorkClock())) { stats.applyOffWorkClock(record.getClockTime(), record.getClockStatus()); } else { stats.applyFieldWork(record.getClockTime(), record.getClockStatus()); } } updateById(stats); }真实项目里还处理了排班跨天。比如夜班 22:00 上班次日 06:00 下班如果只按自然日更新就可能把上班卡和下班卡拆到两天导致工时和异常统计出错。AfAbnormalAppealFlowService.afterPb 专门判断排班考勤和跨天场景再决定补卡应该影响哪些日期和哪些记录。五、PC 后台为什么还要异常批处理员工端申诉解决的是“员工主动解释”。但真实管理里还会有另一类场景HR 或主管发现一批异常记录需要统一补卡或处理。真实项目里的 PC 后台提供了 queryPagePclList 查询异常打卡记录排除正常、补卡、请假等状态只把真正需要处理的异常列出来。列表返回时还会把已存在的申诉记录合并进去展示申诉时间、理由、图片、补卡时间和审核状态。这个设计比单纯查 kq_attendance_record 更好因为管理端需要看到“异常记录”和“申诉进度”的合并视图。否则主管只知道这个人缺卡却不知道员工是否已经提交了说明。六、容易踩坑的 7 个细节坑点后果处理建议允许重复提交申诉一条异常对应多条流程用 attendanceRecordId afStatus1 做运行中校验前端传人员和部门可能被伪造后端登录态补齐组织身份补卡时间不校验班次任意时间都能补结合班次、排班和上下班类型校验审批通过只改申诉表打卡列表和统计不变同步回写考勤记录和日统计驳回没有意见员工无法补充材料必须保存 examineIdea跨天班次按自然日处理夜班统计错乱根据排班开始结束日期重算批量处理不留痕后续无法解释批处理也要关联申诉或审计记录七、建表 SQLCREATE TABLE demo_attendance_abnormal_appeal ( id varchar(64) NOT NULL COMMENT 主键, attendance_record_id varchar(64) NOT NULL DEFAULT COMMENT 原始考勤记录ID, person_id varchar(64) NOT NULL DEFAULT COMMENT 人员ID, person_name varchar(64) NOT NULL DEFAULT COMMENT 人员姓名, unit_id varchar(64) NOT NULL DEFAULT COMMENT 考勤单位ID, unit_name varchar(128) NOT NULL DEFAULT COMMENT 考勤单位名称, department_id varchar(64) NOT NULL DEFAULT COMMENT 部门ID, department_name varchar(128) NOT NULL DEFAULT COMMENT 部门名称, attendance_rule_id varchar(64) NOT NULL DEFAULT COMMENT 考勤规则ID, attendance_work_id varchar(64) NOT NULL DEFAULT COMMENT 班次ID, up_down_work_clock varchar(8) NOT NULL DEFAULT COMMENT 上下班打卡类型, abnormal_status varchar(64) NOT NULL DEFAULT COMMENT 异常状态, repair_clock_time datetime NOT NULL COMMENT 补卡时间, appeal_argument varchar(500) NOT NULL DEFAULT COMMENT 申诉理由, appeal_img text NOT NULL COMMENT 申诉图片, af_status int(11) NOT NULL DEFAULT 1 COMMENT 流程状态-1草稿1运行中2完成3驳回, examine_date datetime DEFAULT NULL COMMENT 审核时间, examine_idea varchar(500) NOT NULL DEFAULT COMMENT 审核意见, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_record_status (attendance_record_id,af_status), KEY idx_person_time (person_id,repair_clock_time), KEY idx_dept_status_time (department_id,af_status,create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT考勤异常申诉表;八、这套设计可以迁移到哪些系统异常申诉闭环不是考勤系统独有能力。只要业务里存在“原始记录被员工或一线人员申请修正”的场景都可以复用。例如巡检系统巡检漏项后提交补检说明。门店签到店员到岗失败后提交现场证据。护理记录护理时间或服务项目异常后申请修正。工单系统处理时长异常后提交复核。仓库系统盘点差异提交复盘材料。可复用的不是某个表名而是这五条原则1. 原始记录不能直接覆盖必须保留申诉表。2. 员工端只提交事实和证据身份组织由后端补齐。3. 同一原始记录同一时间只能有一个运行中流程。4. 审批通过后必须回写原始记录和统计结果。5. 驳回必须保留意见并允许员工补充材料重新提交。九、结语考勤异常处理如果做得轻系统上线后会变成“谁有权限谁改数据”。短期省事长期会失去可信度。真正稳定的做法是把异常当成一条需要解释的业务记录。员工提交申诉主管给出意见系统自动回写打卡和日统计后台保留审核记录。这样 HR 月底不需要翻聊天记录主管审批不靠感觉员工也知道自己为什么通过或没通过。好的考勤系统不是让所有人永远不出错而是让每一次出错都有入口、有证据、有审批、有结果。