
很多考勤系统最容易被忽略的,不是打卡按钮,而是"打错了以后怎么办"。
员工忘记打卡、手机定位漂移、外勤补传延迟、班次跨天、主管临时安排,这些情况最后都会落到同一个问题:这条异常记录能不能被解释、能不能被审批、审批通过后能不能自动回写考勤结果和日报统计。
如果系统只提供一个后台"修改状态"按钮,看起来处理很快,实际上风险很高。管理员把缺卡改成正常,员工看不到审批依据,主管没有审核意见,HR 月底导出报表时也不知道这条数据为什么变化。考勤牵涉工资、绩效和劳动争议,异常处理必须从一开始就按"申请、审核、回写、审计"建模。
这篇文章基于智慧考勤项目真实代码脱敏整理,重点讲异常申诉闭环怎么设计。不是泛泛介绍功能,而是把字段、接口、流程服务、日统计回算和前端调用拆开说明。
本文参考的真实工程文件包括:
- 后端实体:`KqAfAbnormalAppeal.java`
- 移动端入参:`KqAfAbnormalAppealIn.java`
- 移动端接口:`AppKqAfAbnormalAppealController.java`
- PC 管理接口:`KqAfAbnormalAppealController.java`
- 业务服务:`KqAfAbnormalAppealServiceImpl.java`
- 流程回调:`AfAbnormalAppealFlowService.java`
- 日统计服务:`KqAttendanceDayStatsServiceImpl.java`
- PC 异常申诉页:`exception/abnormalAppeal`
- PC 异常批处理页:`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` 是流程裁决状态。三者不能混在一个字段里。
三、完整脱敏源码 Demo
1. Entity
package 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. DTO
package 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. VO
package 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. Mapper
package com.example.attendance.appeal;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 异常申诉 Mapper。
* 业务边界:只负责异常申诉表访问,跨表聚合放到专门查询接口。
*/
@Mapper
public interface AttendanceAbnormalAppealMapper extends BaseMapper<AttendanceAbnormalAppeal> {
}
5. Service
package com.example.attendance.appeal;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* 异常申诉服务。
* 业务边界:负责申诉创建、重复提交校验、流程启动和审批回写。
*/
public interface AttendanceAbnormalAppealService extends IService<AttendanceAbnormalAppeal> {
/**
* 员工提交异常申诉。
* @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. ServiceImpl
package 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 ServiceImpl<AttendanceAbnormalAppealMapper, 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 + afStatus=1` 查询,命中后返回"已经提交过了"。否则员工连续点击、弱网重试、页面重复提交都会制造多条流程。
第三,审批通过后不是只改申诉表,而是回写考勤记录,再更新日统计。真实项目里通过 `setClockTimeAndTypeByTimeTypeAndUpDownWokClock` 把补卡结果同步到日统计,这一步决定了月底报表是否可信。
7. Controller
package 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 R<String> submit(@Validated @RequestBody AbnormalAppealCreateDTO dto) {
String userId = LoginContext.currentUserId();
return R.ok(appealService.submitAppeal(dto, userId));
}
/**
* 主管审批通过。
*/
@PostMapping("/{id}/approve")
public R<Void> approve(@PathVariable String id, @RequestParam String comment) {
appealService.approveAndRewrite(id, LoginContext.currentUserId(), comment);
return R.ok();
}
/**
* 主管审批驳回。
*/
@PostMapping("/{id}/reject")
public R<Void> 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) {
List<AttendanceRecord> 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 + afStatus=1` 做运行中校验 |
| 前端传人员和部门 | 可能被伪造 | 后端登录态补齐组织身份 |
| 补卡时间不校验班次 | 任意时间都能补 | 结合班次、排班和上下班类型校验 |
| 审批通过只改申诉表 | 打卡列表和统计不变 | 同步回写考勤记录和日统计 |
| 驳回没有意见 | 员工无法补充材料 | 必须保存 `examineIdea` |
| 跨天班次按自然日处理 | 夜班统计错乱 | 根据排班开始结束日期重算 |
| 批量处理不留痕 | 后续无法解释 | 批处理也要关联申诉或审计记录 |
七、建表 SQL
CREATE 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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考勤异常申诉表';
八、这套设计可以迁移到哪些系统
异常申诉闭环不是考勤系统独有能力。只要业务里存在"原始记录被员工或一线人员申请修正"的场景,都可以复用。
例如:
- 巡检系统:巡检漏项后提交补检说明。
- 门店签到:店员到岗失败后提交现场证据。
- 护理记录:护理时间或服务项目异常后申请修正。
- 工单系统:处理时长异常后提交复核。
- 仓库系统:盘点差异提交复盘材料。
可复用的不是某个表名,而是这五条原则:
-
原始记录不能直接覆盖,必须保留申诉表。
-
员工端只提交事实和证据,身份组织由后端补齐。
-
同一原始记录同一时间只能有一个运行中流程。
-
审批通过后必须回写原始记录和统计结果。
-
驳回必须保留意见,并允许员工补充材料重新提交。
九、结语
考勤异常处理如果做得轻,系统上线后会变成"谁有权限谁改数据"。短期省事,长期会失去可信度。
真正稳定的做法,是把异常当成一条需要解释的业务记录。员工提交申诉,主管给出意见,系统自动回写打卡和日统计,后台保留审核记录。这样 HR 月底不需要翻聊天记录,主管审批不靠感觉,员工也知道自己为什么通过或没通过。
好的考勤系统,不是让所有人永远不出错,而是让每一次出错都有入口、有证据、有审批、有结果。