SpringBoot3+Vue3 人力假期余额设计:预占、扣减、回滚与重复回调怎么保证一致
🌐 文档地址:https://ruoyioffice.com 👇 文章底部获取源码和演示地址 👇 💬 :17156169080(获取产品咨询)
员工看到“年假还剩 6 天”,背后其实不是一道减法。审批中的 3 天算不算可用?流程拒绝要不要自动退?请了 5 天只休 3 天怎么返还?同一条 Flowable 回调执行两次,会不会再扣一次?真正可靠的假期余额,需要按账户系统来设计。
▲ 规则负责算额度,账户保存当前值,流水解释每次变化,请假单只通过余额服务改变账户
引言:一个 remainingDays 字段为什么撑不住真实请假
最初版假勤系统往往只有员工、假种和剩余天数:
employee_id | leave_type | remaining_days
它能支持“提交后减 1 天”,却回答不了下面的问题:
-
两张审批中的请假单能否同时占用同一份余额; -
审批拒绝、员工撤回、管理员取消是不是同一种退回; -
流程通过后,预占怎样变成正式使用; -
销假实际天数变少,差额回到哪一个年度账户; -
工龄调整导致年假额度变化,为什么从 10 天变成 12 天; -
消息重试让回调重复执行时,怎样避免重复扣减。
所以假期余额的正确抽象是:一套带业务来源、状态迁移和审计流水的额度账户。
一、员工余额由三套配置合成
余额不从账户表凭空产生。系统先合成假种、部门方案和员工参数,再计算某员工某年度应得额度。
1. 假种决定是否管理余额
年假、调休假、福利假通常需要余额;事假、婚假等可能只校验条件,不做账户扣减。假种层应明确是否启用余额、默认额度、最小请假单位、是否允许透支和有效期。
2. 部门方案承载企业差异
总部、制造基地和项目团队可能适用不同方案。系统按员工部门查找生效方案,必要时向上继承,而不是在请假表单里写死规则。
3. 员工参数处理个体差异
员工入职日期、工龄起算日和奖励年假会改变最终额度。规则更新后,不应直接覆盖旧数字,而应生成一条调整流水。
▲ HR 配的是规则,员工拿到的是规则在某个年度计算后的账户结果
核心边界是:规则是计算依据,账户是计算结果。历史账户不能随着规则页面每次打开而漂移,否则系统无法解释余额变化。
二、账户看当前,流水看原因
年度账户建议至少保留五个口径:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public class LeaveAccountDO extends TenantBaseDO {private Long id;private Long employeeId;private Integer leaveType;private Integer grantYear;private BigDecimal grantedDays;private BigDecimal usedDays;private BigDecimal reservedDays;private BigDecimal availableDays;private BigDecimal carryForwardDays;private LocalDate expireDate;}
账户只回答“现在是多少”。每一次变化还要写入 hrm_leave_ledger,记录变动类型、前后余额、业务类型、业务 ID 和备注。这样 HR 可以从“剩 6 天”继续追到“年度发放 10 天、请假确认 3 天、审批中预占 1 天”。
三、提交时先预占,不要等通过后才扣
员工有 5 天年假,同时提交两张各 4 天的申请。如果系统等审批通过才扣余额,两张单都能通过提交校验,最后必然透支。
更稳妥的状态迁移是:
提交:available -= days,reserved += days通过:reserved -= days,used += days拒绝/撤回:reserved -= days,available += days销假:used -= returnedDays,available += returnedDays
▲ 预占解决并发申请透支;确认、释放和销假退回分别对应不同业务结果
预占动作必须和请假单进入审批状态处于同一事务边界。后端不能只相信前端展示的余额,而要重新读取账户并完成原子扣减:
public void reserveForBill(Long billId, Long employeeId,Integer leaveType, BigDecimal days) {LeaveAccountDO account = getOrCreateAccount(employeeId, leaveType);if (account.getAvailableDays().compareTo(days) < 0) {throw exception(LEAVE_BALANCE_NOT_ENOUGH);}BigDecimal before = account.getAvailableDays();account.setAvailableDays(before.subtract(days));account.setReservedDays(account.getReservedDays().add(days));leaveAccountMapper.updateById(account);createLedger(account, "RESERVE", days.negate(), before,account.getAvailableDays(), billId, "HRM_LEAVE_CANCEL_BILL");}
高并发场景还应增加乐观锁版本号或带余额条件的原子更新,让“余额足够”和“完成扣减”成为不可分割的数据库动作。
四、审批回调只做状态迁移,而且必须幂等
Flowable 审批通过后,不应再次从可用余额扣减,而是把预占转成已用:
public void confirmForBill(Long billId) {if (leaveLedgerMapper.existsByBizAndType("HRM_LEAVE_CANCEL_BILL", billId, "CONFIRM_USE")) {return;}LeaveBillDO bill = validateBillExists(billId);LeaveAccountDO account = getAccount(bill);BigDecimal days = bill.getExpectedDays();account.setReservedDays(account.getReservedDays().subtract(days));account.setUsedDays(account.getUsedDays().add(days));leaveAccountMapper.updateById(account);createLedger(account, "CONFIRM_USE", BigDecimal.ZERO,account.getAvailableDays(), account.getAvailableDays(),billId, "HRM_LEAVE_CANCEL_BILL");}
确认使用时可用余额变化为 0,因为额度在提交时已经预占。确认动作改变的是 reservedDays 和 usedDays 的归属。
幂等不能只靠“当前流程状态已经通过”。更可靠的做法是:
-
用 business_type + business_id + change_type定义业务幂等键; -
数据库增加唯一约束或在落流水前检查; -
账户更新与流水写入同事务; -
重复调用直接返回已有结果; -
异常补偿只追加新流水,不修改历史流水。
五、拒绝、撤回与取消都释放预占,但保留不同原因
从余额数学上看,审批拒绝和员工撤回都执行:
reserved -= daysavailable += days
但审计含义不同。流水变动类型可以统一为 RELEASE_RESERVE,备注和业务状态要保留“审批拒绝”“发起人撤回”“管理员取消”的真实原因。
释放时还要防止 reservedDays 变成负数。如果旧数据没有成功预占,系统应先核对流水,再决定拒绝补偿还是记录告警,不能盲目加回余额。
六、销假不是撤销原单,而是按实际使用退差额
员工原申请 5 天,实际休 3 天,销假通过后应退回 2 天:
-
原请假单仍保留“批准 5 天”的审批事实; -
销假单记录实际使用 3 天; -
年度账户 usedDays减 2; availableDays加 2; -
新增 CANCEL_RETURN流水并关联销假业务。
▲ 表单在提交前展示余额;审批后的实际天数由销假动作修正,不回头篡改历史审批事实
BigDecimal returnedDays = bill.getExpectedDays().subtract(bill.getActualDays());if (returnedDays.signum() <= 0) {return;}account.setUsedDays(account.getUsedDays().subtract(returnedDays));account.setAvailableDays(account.getAvailableDays().add(returnedDays));
如果实际天数大于预计天数,不能静默把余额扣成负数,应走补充申请、变更流程或明确的透支规则。
七、前端负责解释余额,不负责决定余额
Vue3 表单在员工选择假种和预计天数后,调用摘要接口展示本年度发放、已使用、审批中预占、当前可用、本次申请和提交后预计剩余。
这能把“余额不足”从提交后的报错,提前变成员工填表过程中的反馈。但前端数值只用于体验,最终校验仍由后端事务完成。
▲ PC 与移动端共享同一余额摘要接口,避免两端各算一套口径
▲ 移动端负责快速发起与查看状态,余额变动仍统一由后端服务和流程回调驱动
八、年度发放、工龄重算和到期清零都要走流水
自动任务最容易被写成直接 update available_days = 0,这会让系统失去解释能力。正确做法是把定时任务也当作业务来源:
-
年度发放: GRANT; -
工龄或奖励变化: ADJUST_ADD/ADJUST_SUB; -
上年度结转: CARRY_FORWARD; -
到期清零: EXPIRE_CLEAR。
任务应按员工、假种、年度建立幂等键。重复跑一次年度发放任务,不能再发一份年假;清零任务失败重试,也不能生成多条相同清零流水。
九、这套模型的边界在哪里
假期余额账户解决的是额度一致性,不会自动解决所有假勤问题:
-
工作日和请假时长仍要读取班次、节假日方案; -
跨年度请假需要按日期拆分到不同年度账户; -
调休额度可能来自加班审批,不一定由年度任务发放; -
小时假与天数假要统一换算精度; -
多租户环境下,账户、流水和规则都必须保留租户边界。
余额服务应保持单一职责:接收已经算好的变动额度,执行可靠的账户迁移与流水留痕;日历、班次和劳动政策由上游规则服务负责。
十、上线前建议验证的 10 个场景
-
余额 5 天,同时提交两张 4 天申请,第二张必须失败; -
提交 3 天后,可用减少 3 天、预占增加 3 天; -
审批通过后,可用不再减少,预占转为已用; -
同一通过回调执行两次,账户只变化一次; -
审批拒绝后,预占释放且生成原因清晰的流水; -
发起人撤回后,与拒绝使用相同数学动作但不同备注; -
请 5 天、销假实际 3 天,退回 2 天; -
工龄重算额度增加 2 天,保留调整前后余额; -
到期清零任务重复运行,不重复清零或重复写流水; -
PC 与 App 查询到的可用、预占、已用口径一致。
结语
企业假期余额的难点不在字段多,而在业务动作多、审批时间长、回调可能重复、员工权益必须能解释。
一套可靠设计可以归纳为四句话:规则算额度,账户存当前,流水讲原因,流程做迁移。 再补上事务、并发控制和业务幂等,假期余额才不会在流程重试、销假和年度任务中越算越乱。
这套账户模型也可以复用到调休、福利额度、培训学时、补贴余额和会员积分等场景。区别只在发放规则,可靠性骨架是相通的。
如果这篇对你有用,点个「在看」或收藏。
🌐 演示地址
https://ruoyioffice.com/web
📦 GitHub 源码
https://github.com/yuqing2026/ruoyi-office
📦 Gitee 源码
https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(获取产品咨询)
打开演示地址直接查看系统。

