当前位置: 首页 > news >正文

Spring Boot + uni-app 智慧考勤闭环 Demo:打卡记录、异常状态和日统计如何复用到企业系统

这篇不讲“企业系统应该数字化”这种空话,直接从智慧考勤项目里抽一条可以复用的工程链路:移动端采集打卡数据,后端按规则落库,PC 后台查询和修正,最后沉淀成日统计。

真实项目里这一条链路分布在几个位置:

  • 后端实体:`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/entity/KqAttendanceRecord.java`
  • 移动端接口:`zhkq-uniapp/src/common/http/api.js`
  • 移动端打卡页:`zhkq-uniapp/src/pages/wqdk/wqgw.vue`
  • App 控制器:`AppKqAttendanceRecordController`
  • 后端服务:`IKqAttendanceRecordService`、`KqAttendanceRecordServiceImpl`
  • Mapper:`KqAttendanceRecordMapper`、`KqAttendanceRecordMapper.xml`
  • PC 后台接口:`zhkq-web/src/views/attendanceRecord/clockIn/clock.in.api.ts`

这套设计能复用到巡检、运维、门店拜访、工单签到、设备保养、上门服务等系统。原因很简单:这些业务都不是只要一条“提交记录”,而是要解决“现场数据可信、异常可解释、后台可追溯、统计可回算”。

下面给一个脱敏后的完整源码 Demo。代码不是内部项目原文件全文复制,而是参照现有工程的字段、分层、接口风格和业务边界整理出来的最小闭环。

一、为什么考勤记录不能只存一张流水表

智慧考勤里的 `KqAttendanceRecord` 不是简单的“某人几点打了卡”。真实字段至少要覆盖五类信息:

字段类型核心字段作用
人员组织`personId`、`personName`、`unitId`、`departmentId`支持租户、单位、部门维度查询
打卡类型`clockType`、`upDownWorkClock`区分单位打卡、外勤打卡、紧急外勤、上班、下班
证据数据`clockTime`、`longitude`、`latitude`、`clockAddress`、`clockImg`形成位置、时间、图片证据链
规则关联`attendanceRule`、`attendanceWork`保留当时命中的规则和班次,后续规则变更不影响历史解释
流程状态`clockStatus`、`afStatus`、`leaveRecordId`、`createType`支持异常、申诉、请假、定时任务补记录

如果只存 `person_id + clock_time`,后面所有问题都会变成查聊天记录:员工说定位不准,主管说超出范围,HR 说月底统计对不上,老板说报表不可信。

二、完整源码 Demo:考勤打卡闭环

1. Entity:考勤记录表

package com.demo.attendance.entity; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; /** * 考勤记录实体。 * 负责保存员工一次打卡的结果和证据,业务边界是“事件记录”,不直接承担规则配置和月度统计职责。 */ @Data @TableName("demo_attendance_record") public class AttendanceRecord { /** 主键 */ private String id; /** 员工ID */ private String personId; /** 员工姓名,脱敏 Demo 中只保存展示名 */ private String personName; /** 单位ID */ private String unitId; /** 部门ID */ private String departmentId; /** 打卡类型:1单位打卡,2外勤打卡,3紧急外勤 */ private String clockType; /** 上下班类型:1上班,2下班 */ private String upDownWorkClock; /** 打卡状态:0正常,1迟到,2缺卡,3早退,4旷工,5请假,6补卡 */ private Integer clockStatus; /** 申诉状态:0正常,1申诉中,3驳回 */ private Integer appealStatus; /** 打卡时间 */ private LocalDateTime clockTime; /** 打卡地址 */ private String clockAddress; /** 经度 */ private String longitude; /** 纬度 */ private String latitude; /** 打卡照片,多个文件用逗号分隔 */ private String clockImg; /** 外勤或异常说明 */ private String attendanceContent; /** 命中的考勤规则ID */ private String attendanceRule; /** 命中的班次ID */ private String attendanceWork; /** 创建方式:1员工打卡,2定时任务补记录 */ private Integer createType; /** 软删除标识:0正常,1删除 */ private Integer delFlag; }

2. DTO:移动端打卡入参

package com.demo.attendance.dto; import lombok.Data; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; /** * 移动端打卡入参。 * 只接收客户端可以提供的现场证据,人员、组织和最终状态由后端根据登录态和规则补齐。 */ @Data public class ClockInDTO { /** 上下班类型:1上班,2下班 */ @NotBlank(message = "上下班类型不能为空") private String upDownWorkClock; /** 打卡类型:1单位打卡,2外勤打卡,3紧急外勤 */ @NotBlank(message = "打卡类型不能为空") private String clockType; /** 客户端采集的打卡时间 */ @NotNull(message = "打卡时间不能为空") private LocalDateTime clockTime; /** 经度 */ private String longitude; /** 纬度 */ private String latitude; /** 客户端解析到的地址,缺失时后端兜底解析 */ private String clockAddress; /** 打卡照片 */ private String clockImg; /** 外勤说明 */ private String attendanceContent; /** 移动端命中的规则ID */ private String ruleId; /** 移动端命中的班次ID */ private String workId; }

3. VO:PC 后台列表返回

package com.demo.attendance.vo; import lombok.Data; import java.time.LocalDateTime; /** * 考勤记录展示对象。 * 用于 PC 后台列表和移动端详情,避免把内部控制字段直接暴露给页面。 */ @Data public class AttendanceRecordVO { private String id; private String personName; private String departmentName; private String clockTypeName; private String upDownWorkClockName; private String clockStatusName; private Integer appealStatus; private LocalDateTime clockTime; private String clockAddress; private String clockImg; private String attendanceContent; }

4. Mapper:MyBatis Plus 基础查询 + 日记录查询

package com.demo.attendance.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.demo.attendance.entity.AttendanceRecord; import com.demo.attendance.vo.AttendanceRecordVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.time.LocalDate; import java.util.List; /** * 考勤记录 Mapper。 * 负责记录表的基础 CRUD 和面向页面的组合查询,不处理规则判断。 */ @Mapper public interface AttendanceRecordMapper extends BaseMapper<AttendanceRecord> { /** * 查询某员工某天的打卡记录。 * * @param personId 员工ID * @param date 日期 * @return 当日记录 */ List<AttendanceRecordVO> selectDayRecord(@Param("personId") String personId, @Param("date") LocalDate date); }

对应 XML:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.demo.attendance.mapper.AttendanceRecordMapper"> <select id="selectDayRecord" resultType="com.demo.attendance.vo.AttendanceRecordVO"> SELECT id, person_name AS personName, department_id AS departmentName, CASE clock_type WHEN '1' THEN '单位打卡' WHEN '2' THEN '外勤打卡' WHEN '3' THEN '紧急外勤' ELSE '未知' END AS clockTypeName, CASE up_down_work_clock WHEN '1' THEN '上班打卡' WHEN '2' THEN '下班打卡' ELSE '未知' END AS upDownWorkClockName, CASE clock_status WHEN 0 THEN '正常' WHEN 1 THEN '迟到' WHEN 2 THEN '缺卡' WHEN 3 THEN '早退' WHEN 4 THEN '旷工' WHEN 5 THEN '请假' WHEN 6 THEN '补卡' ELSE '异常' END AS clockStatusName, appeal_status AS appealStatus, clock_time AS clockTime, clock_address AS clockAddress, clock_img AS clockImg, attendance_content AS attendanceContent FROM demo_attendance_record WHERE person_id = #{personId} AND del_flag = 0 AND DATE(clock_time) = #{date} ORDER BY clock_time ASC </select> </mapper>

5. Service:接口定义

package com.demo.attendance.service; import com.baomidou.mybatisplus.extension.service.IService; import com.demo.attendance.dto.ClockInDTO; import com.demo.attendance.entity.AttendanceRecord; import com.demo.attendance.vo.AttendanceRecordVO; import java.time.LocalDate; import java.util.List; /** * 考勤记录业务服务。 * 负责打卡落库、重复打卡控制、状态判断和日统计触发。 */ public interface AttendanceRecordService extends IService<AttendanceRecord> { /** * 员工打卡。 * * @param dto 移动端打卡数据 * @param loginUserId 当前登录员工ID * @return 结果提示 */ String clock(ClockInDTO dto, String loginUserId); /** * 查询某天考勤记录。 * * @param personId 员工ID * @param date 日期 * @return 当日打卡记录 */ List<AttendanceRecordVO> dayRecord(String personId, LocalDate date); }

6. ServiceImpl:状态判断、时间防篡改和日统计更新

package com.demo.attendance.service.impl; import cn.hutool.core.util.IdUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.demo.attendance.dto.ClockInDTO; import com.demo.attendance.entity.AttendanceRecord; import com.demo.attendance.mapper.AttendanceRecordMapper; import com.demo.attendance.service.AttendanceRecordService; import com.demo.attendance.service.AttendanceStatsService; import com.demo.attendance.vo.AttendanceRecordVO; 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; import java.util.List; /** * 考勤记录业务实现。 * 从真实项目抽取了三个关键约束:客户端时间不可信、同一班次不能重复覆盖、记录落库后要同步日统计。 */ @Slf4j @Service @RequiredArgsConstructor public class AttendanceRecordServiceImpl extends ServiceImpl<AttendanceRecordMapper, AttendanceRecord> implements AttendanceRecordService { private final AttendanceStatsService attendanceStatsService; @Override @Transactional(rollbackFor = Exception.class) public String clock(ClockInDTO dto, String loginUserId) { LocalDateTime now = LocalDateTime.now(); // 防止手机系统时间被调到未来。真实项目中超过当前时间 10 分钟会拒绝。 if (dto.getClockTime().isAfter(now.plusMinutes(10))) { throw new IllegalArgumentException("打卡失败,请先校准手机系统时间"); } // 防止补很久以前的卡。真实项目中超过 90 天会重置为当前时间。 if (dto.getClockTime().isBefore(now.minusDays(90))) { dto.setClockTime(now); } LocalDate clockDate = dto.getClockTime().toLocalDate(); boolean duplicated = this.count(new LambdaQueryWrapper<AttendanceRecord>() .eq(AttendanceRecord::getPersonId, loginUserId) .eq(AttendanceRecord::getAttendanceWork, dto.getWorkId()) .eq(AttendanceRecord::getUpDownWorkClock, dto.getUpDownWorkClock()) .apply("DATE(clock_time) = {0}", clockDate)) > 0; if (duplicated) { throw new IllegalStateException("当前班次已存在打卡记录,请勿重复提交"); } AttendanceRecord record = new AttendanceRecord(); record.setId(IdUtil.fastSimpleUUID()); record.setPersonId(loginUserId); record.setPersonName("演示员工"); record.setUnitId("demo-unit"); record.setDepartmentId("demo-dept"); record.setClockType(dto.getClockType()); record.setUpDownWorkClock(dto.getUpDownWorkClock()); record.setClockTime(dto.getClockTime()); record.setLongitude(dto.getLongitude()); record.setLatitude(dto.getLatitude()); record.setClockAddress(resolveAddress(dto)); record.setClockImg(dto.getClockImg()); record.setAttendanceContent(dto.getAttendanceContent()); record.setAttendanceRule(dto.getRuleId()); record.setAttendanceWork(dto.getWorkId()); record.setClockStatus(matchClockStatus(dto)); record.setAppealStatus(0); record.setCreateType(1); record.setDelFlag(0); this.save(record); attendanceStatsService.rebuildDayStats(loginUserId, clockDate); return "打卡成功"; } @Override public List<AttendanceRecordVO> dayRecord(String personId, LocalDate date) { return baseMapper.selectDayRecord(personId, date); } /** * 地址兜底。 * 真实项目中如果前端未传地址,会用经纬度反查地址;Demo 中只保留结构。 */ private String resolveAddress(ClockInDTO dto) { if (dto.getClockAddress() != null && !dto.getClockAddress().trim().isEmpty()) { return dto.getClockAddress(); } if (dto.getLongitude() != null && dto.getLatitude() != null) { return "经纬度解析地址"; } return "未知位置"; } /** * 判断打卡状态。 * 真实项目会结合班次时间、延迟分钟、请假、外勤审批等规则;Demo 只保留可复用骨架。 */ private Integer matchClockStatus(ClockInDTO dto) { if ("2".equals(dto.getClockType()) && dto.getAttendanceContent() == null) { return 4; } return 0; } }

7. 日统计 Service:记录和结果分层

package com.demo.attendance.service; import java.time.LocalDate; /** * 考勤日统计服务。 * 负责把多条打卡记录汇总为当天结果,避免列表页每次重新计算。 */ public interface AttendanceStatsService { /** * 重算某员工某天统计。 * * @param personId 员工ID * @param date 日期 */ void rebuildDayStats(String personId, LocalDate date); }
package com.demo.attendance.service.impl; import com.demo.attendance.service.AttendanceStatsService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDate; /** * 考勤日统计实现。 * Demo 只展示触发位置,真实项目可在这里统计迟到、早退、缺卡、外勤、请假和补卡次数。 */ @Slf4j @Service public class AttendanceStatsServiceImpl implements AttendanceStatsService { @Override public void rebuildDayStats(String personId, LocalDate date) { log.info("重算考勤日统计 personId={}, date={}", personId, date); } }

8. Controller:移动端提交和查询

package com.demo.attendance.controller; import com.demo.attendance.dto.ClockInDTO; import com.demo.attendance.service.AttendanceRecordService; import com.demo.attendance.vo.AttendanceRecordVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.time.LocalDate; import java.util.List; /** * 移动端考勤记录接口。 * 与真实项目的 `/app/kqAttendanceRecord` 职责一致:接收现场打卡、查询日记录。 */ @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/app/attendanceRecord") public class AppAttendanceRecordController { private final AttendanceRecordService attendanceRecordService; /** * 员工打卡。 * * @param dto 移动端采集的现场数据 * @return 结果提示 */ @PostMapping("/clock") public String clock(@RequestBody @Valid ClockInDTO dto) { String loginUserId = "demo-user"; log.info("收到移动端打卡数据 userId={}, clockType={}, time={}", loginUserId, dto.getClockType(), dto.getClockTime()); return attendanceRecordService.clock(dto, loginUserId); } /** * 查询某天打卡记录。 * * @param date yyyy-MM-dd * @return 当天记录 */ @GetMapping("/dayRecord") public List<AttendanceRecordVO> dayRecord(@RequestParam String date) { return attendanceRecordService.dayRecord("demo-user", LocalDate.parse(date)); } }

9. PC 后台 API:列表、详情和编辑

import { defHttp } from '/@/utils/http/axios'; enum Api { list = '/biz/kqAttendanceRecord/list', detail = '/biz/kqAttendanceRecord/queryById', update = '/biz/kqAttendanceRecord/edit', exportXls = '/biz/kqAttendanceRecord/exportXls', } /** * PC 后台考勤记录列表。 * 用于管理员按人员、部门、打卡状态和时间范围查询。 */ export const listAttendanceRecord = (params) => { return defHttp.get({ url: Api.list, params }); }; /** * 查询单条考勤详情。 */ export const getAttendanceRecordDetail = (params) => { return defHttp.get({ url: Api.detail, params }); }; /** * 管理端修正记录。 * 实际项目里建议所有修正都走审批或留审计日志,避免直接改结果。 */ export const updateAttendanceRecord = (params) => { return defHttp.put({ url: Api.update, params }); }; export const exportAttendanceRecordUrl = Api.exportXls;

10. uni-app 移动端调用

真实项目的 `api.js` 里有:

const API = { clock: "/zhkq-api/app/kqAttendanceRecord/clock", dayRecord: "/zhkq-api/app/kqAttendanceRecord/dayRecord", monthRecord: "/zhkq-api/app/kqAttendanceRecord/monthRecord", abnormalList: "/zhkq-api/app/kqAttendanceRecord/abnormalList" }

脱敏后的页面调用可以这样写:

function submitClock(clockInfo, location, fileList) { const formData = { ruleId: clockInfo.ruleId, workId: clockInfo.workId, clockType: clockInfo.clockType, upDownWorkClock: clockInfo.upDownWorkClock, clockTime: new Date().toISOString().slice(0, 19).replace('T', ' '), longitude: location.longitude, latitude: location.latitude, clockAddress: location.address, clockImg: fileList.map(file => file.url).join(','), attendanceContent: clockInfo.remark }; return http.post('clock', formData).then(() => { uni.showToast({ title: '打卡成功' }); }); }

三、这套 Demo 对应真实项目里的技术特色

第一,移动端只负责采集,不负责最终裁判。

uni-app 页面会采集时间、经纬度、图片、外勤说明、规则 ID 和班次 ID,但最终是否迟到、早退、旷工、缺卡,应该由后端统一判断。真实项目中还会校验手机系统时间,避免员工把时间调到未来。

第二,记录必须保留规则快照。

`attendanceRule` 和 `attendanceWork` 看起来只是两个 ID,但非常关键。员工 7 月 1 日打卡时命中的规则,和 7 月 10 日管理员修改后的规则可能不是一回事。历史记录必须能解释当时为什么判定正常或异常。

第三,异常不是后台直接改字段。

真实项目里 `afStatus` 用来表示申诉状态。一个异常考勤记录如果直接被管理员改成正常,短期省事,长期会失去可信度。更好的方式是:员工发起申诉,主管审批,审批通过后再回写记录和日统计。

第四,记录和统计要分层。

`AttendanceRecord` 是事件,`AttendanceStats` 是结果。一个员工一天可能有上班卡、下班卡、外勤卡、补卡、请假记录。如果列表页每次都实时计算,查询会越来越重。更稳的方式是记录变动后重算当天统计。

第五,后台任务负责兜底。

智慧考勤项目里有 XXL-Job 生成缺卡、旷工、历史回算等任务。移动端没有提交,不代表当天没有考勤结果。系统必须在关键时间点自动补偿,否则月底就会变成 HR 手工补账。

四、迁移到其他企业系统时怎么复用

这条链路可以抽象成一句话:

现场采集事件,后端验证规则,流程处理异常,统计沉淀结果,后台任务补偿缺口。

巡检系统可以这样迁移:

  • `AttendanceRecord` 换成 `InspectionRecord`
  • `clockType` 换成 `inspectionType`
  • `attendanceRule` 换成 `inspectionPlan`
  • `clockImg` 保留为现场照片
  • `AttendanceStats` 换成巡检日报
  • 缺卡任务换成漏检任务

设备保养系统也可以这样迁移:

  • 规则表定义保养周期
  • 移动端提交保养位置、照片和说明
  • 后端判断是否超期
  • 异常进入审批
  • 定时任务生成逾期保养记录
  • PC 后台按设备、人员、区域做统计

五、落地时最容易踩的坑

1. 不要让移动端直接传“正常/迟到/旷工”结果。移动端可以给建议状态,最终状态必须由后端判断。

2. 不要只存当前组织名称。人员调岗后,历史统计还要能按当时部门解释。

3. 不要把异常处理做成管理员后台手工改字段。必须留申请、审批、回写和审计。

4. 不要只做打卡流水,不做日统计。数据量一大,报表会越来越慢。

5. 不要忽略定时任务。缺卡、旷工、超时、漏检这类结果,很多时候不是用户主动提交出来的,而是系统补偿生成的。

六、结论

智慧考勤的可复用价值不在“打卡”两个字,而在它把企业系统里最难处理的几件事串起来了:

  • 规则配置
  • 现场采集
  • 证据留存
  • 后端裁判
  • 异常流程
  • 统计回算
  • 定时补偿

如果你在做巡检、外勤、工单、门店、设备保养或现场服务系统,可以直接复用这套工程骨架。页面可以变,业务名可以变,但“事件、规则、流程、统计、补偿”这五层不要省。省掉哪一层,后面都会在对账、争议和报表里补回来。

http://www.jsqmd.com/news/1125239/

相关文章:

  • AI 生成帮助文档:先回答用户任务,再补接口细节
  • Agent记忆系统设计与实现
  • 环路补偿(二) Bode 图:环路分析的“频率地图”
  • Android随笔-启动Zygote的rc文件是什么?
  • Resisting Bruteforce
  • 用EasyX库写一个按钮函数(Button)
  • Cadence 17.2 焊盘设计:从Flash Symbol创建到通孔焊盘集成的5步流程
  • DeepSeek-V2与V2.5技术对比:数学推理与代码生成能力实测
  • 基于PQ功率控制的三相并网逆变器仿真、锁相环PWM控制,附参考文献
  • Linux 服务器访问控制:组合使用 PAM wheel 组与 iptables 限制 SSH 来源
  • WIN10任务栏日期隐藏年显示星期几
  • uos-network-exporter与Grafana集成:打造可视化网络监控仪表板
  • PCA主成分分析法:数据降维与特征提取实战指南
  • AI 写作版本对比:别只问哪版更好,要问哪里变了
  • 重复视频清理工具 MD5+关键帧双重识别 智能查重去重 下载
  • 数据集切分策略:随机划分不一定适合时间序列任务
  • 天伟生物专利涉及圈养匹配与选址,养猪户了解技术方案要点
  • web安全代码基础-PHP(防护过滤操作)
  • 2026年联发科嵌入式岗位高频面试题带参考答案
  • OCamCalib 工具箱 v1.0:鱼眼相机标定 8 步实操,平均重投影误差 < 0.5 像素
  • Behat API测试实战:从配置陷阱到复杂场景编排的避坑指南
  • 一次OTA固件签名绕过事件的排查复盘
  • 电脑错误dll修复工具 运行库工具修复dll 缺失找不到dll丢失问题
  • 3D医学影像分割:基于TotalSegmentator等5个公开数据集的模型训练实战
  • 当“遇见小面”商标遇见“渝见小面”!
  • 图数据库与知识图谱构建实战
  • Linux /etc/fstab 配置详解:5个关键参数避免重启后挂载回退只读
  • 3个关键步骤让AirPods在Windows上重获完整功能:AirPodsDesktop终极解决方案
  • TwinCAT3实战:台达A2伺服扭矩读取与参数优化指南
  • 高清图像数据集 DIV2K 与 Flickr2K 超分实战:1900张图像预处理与数据增强3种策略