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

很多考勤系统最容易被忽略的,不是打卡按钮,而是"打错了以后怎么办"。

员工忘记打卡、手机定位漂移、外勤补传延迟、班次跨天、主管临时安排,这些情况最后都会落到同一个问题:这条异常记录能不能被解释、能不能被审批、审批通过后能不能自动回写考勤结果和日报统计。

如果系统只提供一个后台"修改状态"按钮,看起来处理很快,实际上风险很高。管理员把缺卡改成正常,员工看不到审批依据,主管没有审核意见,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='考勤异常申诉表';

八、这套设计可以迁移到哪些系统

异常申诉闭环不是考勤系统独有能力。只要业务里存在"原始记录被员工或一线人员申请修正"的场景,都可以复用。

例如:

  • 巡检系统:巡检漏项后提交补检说明。
  • 门店签到:店员到岗失败后提交现场证据。
  • 护理记录:护理时间或服务项目异常后申请修正。
  • 工单系统:处理时长异常后提交复核。
  • 仓库系统:盘点差异提交复盘材料。

可复用的不是某个表名,而是这五条原则:

  1. 原始记录不能直接覆盖,必须保留申诉表。

  2. 员工端只提交事实和证据,身份组织由后端补齐。

  3. 同一原始记录同一时间只能有一个运行中流程。

  4. 审批通过后必须回写原始记录和统计结果。

  5. 驳回必须保留意见,并允许员工补充材料重新提交。

九、结语

考勤异常处理如果做得轻,系统上线后会变成"谁有权限谁改数据"。短期省事,长期会失去可信度。

真正稳定的做法,是把异常当成一条需要解释的业务记录。员工提交申诉,主管给出意见,系统自动回写打卡和日统计,后台保留审核记录。这样 HR 月底不需要翻聊天记录,主管审批不靠感觉,员工也知道自己为什么通过或没通过。

好的考勤系统,不是让所有人永远不出错,而是让每一次出错都有入口、有证据、有审批、有结果。

相关推荐
paopaokaka_luck9 小时前
基于springboot3+vue3的智能文库平台(AI智能搜索、AI智能汇总、实时在线状态展示、多格式文档预览与富文本编辑、Echarts图形化分析)
前端·网络·spring boot·网络协议·echarts
热心市民lcj14 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
天丁o15 小时前
Spring Boot + MyBatis Plus 考勤日报统计报表:打卡记录聚合、异常分类和明细下钻 Demo
spring boot·mybatis plus·企业数字化·考勤系统·报表统计
智_永无止境15 小时前
Spring Boot 集成 OnlyOffice
java·spring boot·后端
Devin~Y16 小时前
从本地生活电商到 AI RAG:互联网大厂 Java 面试场景完整实战
java·spring boot·redis·elasticsearch·spring cloud·kafka·rag
米码收割机16 小时前
SA508-3钢回火焊道焊接温度场数值模拟(模拟图+论文)
spring boot·express·宠物
米码收割机18 小时前
【项目】spring boot+vue3 宠物领养系统(源码+文档)【独一无二】
java·spring boot·宠物
momo1 天前
Java+WebAI黑马知识点
java·spring boot·mybatis
就改了1 天前
Mybatis快速入门大全(详细版)
java·spring boot·mybatis
Listen·Rain1 天前
Spring Boot 整合 JWT:从入门到源码原理深度解析
java·spring boot·后端