SpringBoot+Vue3 企业假期余额系统设计:账户、流水、预占、销假退回与到期清零全链路拆解
🌐 演示地址 :http://ruoyioffice.com | 源码1:ruoyi-office-vben | 源码2:ruoyi-office | 源码3:ruoyi-office
RuoYi Office,一个平台,管好整个企业。
假期余额最怕被做成一个简单的remainingDays字段。员工提交请假后审批还没结束,余额要不要先扣?审批拒绝、流程撤回后怎么退?请了 5 天实际只休 3 天,销假时多出来的 2 天要不要回到账户?年底清零、工龄变化、个人奖励年假又该如何留痕?RuoYi Office 的设计是:假期账户记录当前状态,余额流水记录每次变化,请假销假单只通过余额服务完成预占、确认、释放和退回。
图 1:假期余额账户与流水架构。业务单据不直接修改余额字段,而是通过 LeaveBalanceService 同时落账户与流水。
引言:为什么一个剩余天数字段迟早会出问题?
很多企业最开始做假勤系统时,会把余额设计成这样:
text
employee_id | leave_type | remaining_days
看起来很简单,但真实业务一跑起来,就会不断出现口径问题。
| 业务场景 | 只存剩余天数的问题 | 企业侧后果 |
|---|---|---|
| 请假审批中 | 余额还没扣,员工可连续提交多张超额请假单 | 审批通过后余额透支 |
| 审批拒绝 | 如果提交时已经扣了余额,拒绝后缺少退回依据 | HR 只能手工修正 |
| 流程撤回 | 员工撤回申请,预占余额应释放 | 余额长期被占用 |
| 销假提前返岗 | 申请 5 天,实际休 3 天,差额需要退回 | 员工权益无法解释 |
| 工龄变化 | 年假额度按工龄变化后需要重算 | 旧余额与新规则对不上 |
| 到期清零 | 年底清零必须能解释清楚 | 审计时缺少流水证据 |
所以假期余额不是一个普通字段,而是一套小型账户系统。它至少要回答两个问题:
| 问题 | 查询对象 |
|---|---|
| 员工现在还有多少假? | 假期账户 |
| 为什么只剩这么多? | 余额流水 |
一、业务设计:账户看当前,流水看过程
1.1 假期规则先决定"该不该管余额"
RuoYi Office 没有把所有假期都强行纳入余额扣减。例如事假可能不需要余额,年假、调休假、福利假则需要余额控制。这个开关来自假期规则体系:
| 层级 | 作用 | 典型字段 |
|---|---|---|
| 假种基础层 | 定义年假、病假、事假、调休假等基础假种 | code、dictValue、defaultDays、balanceEnabled |
| 部门方案层 | 不同部门可绑定不同假期规则 | deptId、leaveRules、annualRules |
| 员工个性层 | 支持个人工龄起算日、奖励天数 | workAgeStartDate、annualBonusDays |
| 余额账户层 | 规则计算后的年度账户结果 | grantedDays、usedDays、availableDays |

图 2:企业请假配置四层模型。假种基础数据不直接决定最终额度,最终规则由部门方案与员工参数合成。
这一层设计非常关键。因为企业的假期规则通常不是"全公司一套":
- 总部和分公司可能有不同福利假。
- 研发中心和生产基地可能有不同调休规则。
- 员工年假可能叠加工龄、入职日期和个人奖励天数。
- 某些假种需要附件、某些假种不走余额。
1.2 账户字段承载当前状态
后端实体 LeaveAccountDO 对应表 hrm_leave_account,用于记录某个员工、某个假种、某个年度的当前余额状态。
java
@TableName("hrm_leave_account")
public class LeaveAccountDO extends TenantBaseDO {
@TableId
private Long id;
private Long employeeId;
private Long userId;
private Integer leaveType;
private Integer grantYear;
private BigDecimal grantedDays;
private BigDecimal usedDays;
private BigDecimal reservedDays;
private BigDecimal availableDays;
private BigDecimal carryForwardDays;
private LocalDate expireDate;
private LocalDateTime lastGrantTime;
}
这张表回答的是"现在还剩多少":
| 字段 | 含义 | 示例 |
|---|---|---|
grantedDays |
年度累计发放天数 | 年假发放 12 天 |
usedDays |
已确认使用天数 | 审批通过后累加 |
reservedDays |
审批中预占天数 | 请假提交后锁定 |
availableDays |
当前可用天数 | 员工还能申请多少 |
carryForwardDays |
结转天数 | 上年度结转额度 |
expireDate |
到期日期 | 年假到 12 月 31 日 |
1.3 流水字段解释每一次变化
后端实体 LeaveLedgerDO 对应表 hrm_leave_ledger,用于记录账户每一次变化。
java
@TableName("hrm_leave_ledger")
public class LeaveLedgerDO extends TenantBaseDO {
@TableId
private Long id;
private Long employeeId;
private Long userId;
private Integer leaveType;
private Integer grantYear;
private String changeType;
private BigDecimal changeDays;
private BigDecimal beforeBalance;
private BigDecimal afterBalance;
private String businessType;
private Long businessId;
private String remark;
}
这张表回答的是"为什么变成这个数":
| 字段 | 含义 |
|---|---|
changeType |
变动类型,如 GRANT、RESERVE、CONFIRM_USE |
changeDays |
本次变动天数,正数增加、负数减少 |
beforeBalance |
变动前可用余额 |
afterBalance |
变动后可用余额 |
businessType / businessId |
关联业务来源,如请假销假单 |
remark |
人能看懂的变动原因 |
账户和流水的组合,让 HR 可以同时看到当前余额和历史依据。
二、系统设计:余额服务是唯一入口
2.1 模块协作关系
假期余额不应该散落在请假单、员工档案、定时任务、审批回调里分别处理。RuoYi Office 把余额动作集中在 LeaveBalanceService。

图 3:假期余额账户与流水闭环。年度发放生成账户,请假提交先预占,审批通过确认使用,拒绝撤回释放预占,销假按实际天数退回。
核心服务职责如下:
| 方法 | 触发时机 | 作用 |
|---|---|---|
getLeaveSummary |
前端选择假种、填写天数时 | 返回可用余额、预占天数、是否充足 |
reserveForBill |
请假销假单提交审批时 | 扣减可用余额,增加预占余额 |
confirmForBill |
BPM 审批通过时 | 将预占转为已使用 |
releaseReserveForBill |
审批拒绝、撤回、取消时 | 释放预占,回补可用余额 |
cancelReturnForBill |
销假实际天数小于预计天数时 | 退回差额余额 |
grantAnnualLeaveForCurrentYear |
年度发放任务 | 初始化年度账户 |
refreshEmployeeAnnualLeaveForCurrentYear |
员工假期参数变化时 | 重算年假额度并写调整流水 |
clearExpiredBalance |
到期清零任务 | 清空过期可用余额并写流水 |
2.2 变动类型要可读、可追踪、可幂等
服务里定义了一组清晰的变动类型:
java
public static final String CHANGE_TYPE_GRANT = "GRANT";
public static final String CHANGE_TYPE_RESERVE = "RESERVE";
public static final String CHANGE_TYPE_CONFIRM_USE = "CONFIRM_USE";
public static final String CHANGE_TYPE_RELEASE_RESERVE = "RELEASE_RESERVE";
public static final String CHANGE_TYPE_CANCEL_RETURN = "CANCEL_RETURN";
public static final String CHANGE_TYPE_ADJUST_ADD = "ADJUST_ADD";
public static final String CHANGE_TYPE_ADJUST_SUB = "ADJUST_SUB";
public static final String BUSINESS_TYPE_LEAVE_CANCEL = "HRM_LEAVE_CANCEL_BILL";
public static final String BUSINESS_TYPE_LEAVE_ACCOUNT = "HRM_LEAVE_ACCOUNT";
这些字符串看起来普通,但对系统长期维护很重要:
| 设计点 | 价值 |
|---|---|
| 统一变动类型 | 前端可直接展示、筛选、统计 |
| 业务来源绑定 | 流水可追溯到具体请假单或账户调整 |
| 预占与确认分离 | 审批中和已使用口径清楚 |
| 调整增减分离 | 员工参数变化时能解释额度变动 |
三、PC 端功能实现:提交前先看余额,档案里查流水
3.1 请假销假表单中的余额摘要
员工在请假销假单里选择假期类型、填写预计时间和预计天数后,前端会实时刷新余额摘要。

图 4:请假销假详情页。表单中展示"假期余额(天)",员工提交前即可看到当前可用额度。
前端 API 位于 apps/web-antd/src/api/hrm/leave-balance/index.ts:
typescript
export function getLeaveBalanceSummary(params: {
employeeId?: number;
expectedDays?: number;
expectedEndTime?: number;
expectedStartTime?: number;
leaveType: number;
}) {
return requestClient.get<LeaveBalanceApi.LeaveBalanceSummary>(
'/hrm/leave-balance/summary',
{ params },
);
}
表单页通过 refreshLeaveBalance 拉取余额,并把可用天数回填到 leaveBalance 字段:
typescript
async function refreshLeaveBalance(showMessage = true) {
const formValues = await basicFormRef.value.getFormValues(false);
const leaveType = formValues.leaveType ?? formData.value.leaveType;
if (!leaveType) {
balanceSummary.value = null;
return null;
}
const summary = await getLeaveBalanceSummary({
leaveType,
expectedDays: formValues.expectedDays ?? formData.value.expectedDays,
expectedStartTime: formValues.expectedStartTime ?? formData.value.expectedStartTime,
expectedEndTime: formValues.expectedEndTime ?? formData.value.expectedEndTime,
});
balanceSummary.value = summary;
formData.value.leaveBalance = summary.availableDays;
await basicFormRef.value.setFormValues({ leaveBalance: summary.availableDays });
return summary;
}
提交时还有一次强校验,避免前端展示和最终提交之间发生变化:
typescript
const summary = await refreshLeaveBalance(false);
if (
isSubmit &&
summary &&
summary.balanceEnabled === 1 &&
summary.sufficient === false
) {
message.error('请假天数超出可用余额,无法提交');
return;
}
3.2 请假销假列表保留业务上下文

图 5:请假销假列表。列表保留单据编号、流程状态、假期类型、预计天数和实际天数,便于从流水追溯回具体单据。
请假销假单是余额流水最重要的业务来源。流水中保存 businessType = HRM_LEAVE_CANCEL_BILL 和 businessId = 请假单ID,后端返回流水时会反查单据编号:
java
if (BUSINESS_TYPE_LEAVE_CANCEL.equals(ledger.getBusinessType())
&& ledger.getBusinessId() != null) {
LeaveCancelBillDO leaveCancelBill = leaveCancelBillMapper.selectById(ledger.getBusinessId());
respVO.setBillCode(leaveCancelBill != null ? leaveCancelBill.getBillCode() : null);
}
这样 HR 在看流水时,不只看到"扣了 1 天",还能看到"是哪一张请假单导致扣减"。
3.3 员工档案里查看余额账户与流水
员工档案详情页接入了两个接口:
typescript
export function getEmployeeLeaveBalancePage(
employeeId: number,
params: PageParam & { leaveType?: number },
) {
return requestClient.get<PageResult<LeaveBalanceApi.LeaveAccount>>(
'/hrm/leave-balance/employee-page',
{ params: { employeeId, ...params } },
);
}
export function getEmployeeLeaveLedgerPage(
employeeId: number,
params: PageParam & { leaveType?: number },
) {
return requestClient.get<PageResult<LeaveBalanceApi.LeaveLedger>>(
'/hrm/leave-balance/ledger-page',
{ params: { employeeId, ...params } },
);
}
前端表格列设计也很直接:
| 账户列 | 作用 |
|---|---|
| 假期类型 | 区分年假、调休、福利假 |
| 发放年度 | 区分不同年度账户 |
| 发放天数 | 年度额度来源 |
| 已用天数 | 审批通过后确认使用 |
| 预占天数 | 正在审批中的占用 |
| 可用天数 | 员工当前可申请额度 |
| 到期日期 | 年底清零或结转依据 |
| 余额流水 | 打开该假种的流水明细 |
流水弹窗列包含:
| 流水列 | 作用 |
|---|---|
| 变动类型 | 展示 GRANT、RESERVE、CONFIRM_USE 等动作 |
| 请假单号 | 关联具体业务单据 |
| 变动天数 | 本次增加或减少多少 |
| 变动前 / 变动后 | 审计口径 |
| 操作时间 | 变动发生时间 |
| 备注 | 人能看懂的原因 |
四、移动端体验:员工在手机上也能完成请假销假闭环
RuoYi Office 的请假销假不仅有 PC 端,也有 UniApp 移动端页面。员工可以在移动端查看单据、发起申请,并在流程到达销假节点时补充实际休假信息。

图 6:移动端请假销假列表。卡片式展示单据编号、请假类型、预计时间、申请人和流程状态。

图 7:移动端请假销假创建页。移动端表单延续 PC 端字段口径,保证同一张业务单据在多端一致。
移动端和 PC 端共享同一套后端余额服务,所以余额扣减口径不会因为端不同而分裂。
| 场景 | PC 端 | 移动端 | 后端口径 |
|---|---|---|---|
| 创建请假 | 表单页填写预计时间和天数 | 移动端表单填写 | saveLeaveCancelBill |
| 提交审批 | 校验余额是否充足 | 校验同一接口 | reserveForBill |
| 审批通过 | 待办中心操作 | 移动待办操作 | confirmForBill |
| 销假退回 | 销假节点填写实际天数 | 移动端填写实际天数 | cancelReturnForBill |
五、后端核心实现:提交预占,审批回调确认
5.1 查询余额摘要
LeaveBalanceController 提供了三个接口:
| 接口 | 作用 |
|---|---|
GET /hrm/leave-balance/summary |
当前请假表单的余额摘要 |
GET /hrm/leave-balance/employee-page |
员工假期账户分页 |
GET /hrm/leave-balance/ledger-page |
员工假期流水分页 |
摘要接口会把前端传入的时间戳转成 LocalDateTime,再交给余额服务:
java
@GetMapping("/summary")
public CommonResult<LeaveBalanceSummaryRespVO> getLeaveSummary(
@RequestParam("leaveType") Integer leaveType,
@RequestParam(value = "expectedDays", required = false) BigDecimal expectedDays,
@RequestParam(value = "expectedStartTime", required = false) Long expectedStartTime,
@RequestParam(value = "expectedEndTime", required = false) Long expectedEndTime,
@RequestParam(value = "employeeId", required = false) Long employeeId) {
Long realEmployeeId = employeeId != null ? employeeId : currentEmployeeId();
return success(leaveBalanceService.getLeaveSummary(realEmployeeId, leaveType, expectedDays,
toLocalDateTime(expectedStartTime), toLocalDateTime(expectedEndTime)));
}
getLeaveSummary 的核心逻辑是:先判断该假种是否启用余额,再返回账户字段和本次预计占用天数。
java
public LeaveBalanceSummaryRespVO getLeaveSummary(Long employeeId, Integer leaveType,
BigDecimal expectedDays,
LocalDateTime expectedStartTime,
LocalDateTime expectedEndTime) {
EmployeeDO employee = employeeMapper.selectById(employeeId);
LeaveTypeDO leaveTypeDO = leaveRuleService.getEffectiveLeaveTypeByDeptId(employee.getDeptId(), leaveType);
BigDecimal applyDays = normalizeDays(expectedDays, expectedStartTime, expectedEndTime);
if (!Integer.valueOf(1).equals(leaveTypeDO.getBalanceEnabled())) {
LeaveBalanceSummaryRespVO respVO = new LeaveBalanceSummaryRespVO();
respVO.setBalanceEnabled(0);
respVO.setExpectedOccupyDays(applyDays);
return respVO;
}
LeaveAccountDO account = initOrGetAccount(employee, leaveTypeDO, LocalDate.now().getYear());
LeaveBalanceSummaryRespVO respVO = toSummary(account, applyDays);
respVO.setSufficient(respVO.getAvailableDays().compareTo(applyDays) >= 0);
return respVO;
}
5.2 年度发放:没有账户时初始化
假期账户可以通过年度任务批量发放,也可以在查询时惰性初始化。核心是 initOrGetAccount。
java
private LeaveAccountDO initOrGetAccount(EmployeeDO employee, LeaveTypeDO leaveType, Integer year) {
LeaveAccountDO account = leaveAccountMapper
.selectByEmployeeAndTypeAndYear(employee.getId(), leaveType.getDictValue(), year);
if (account != null) {
return account;
}
BigDecimal grantedDays = calculateGrantDays(employee, leaveType, year);
account = LeaveAccountDO.builder()
.employeeId(employee.getId())
.userId(employee.getUserId())
.leaveType(leaveType.getDictValue())
.grantYear(year)
.grantedDays(grantedDays)
.usedDays(BigDecimal.ZERO)
.reservedDays(BigDecimal.ZERO)
.availableDays(grantedDays)
.expireDate(LocalDate.of(year, 12, 31))
.lastGrantTime(LocalDateTime.now())
.build();
leaveAccountMapper.insert(account);
createLedger(account, CHANGE_TYPE_GRANT, grantedDays, BigDecimal.ZERO, grantedDays, account.getId(), "系统发放年度额度");
return account;
}
年假额度不是固定写死,而是综合部门规则、工龄和个人奖励天数:
java
private BigDecimal calculateGrantDays(EmployeeDO employee, LeaveTypeDO leaveType, Integer year) {
LeaveRuleService.LeaveSchemeRespVO scheme = leaveRuleService.requireLeaveScheme(employee.getDeptId());
LeaveRuleService.LeaveRuleItem leaveRuleItem = leaveRuleService.getLeaveRuleItem(scheme, leaveType.getDictValue());
BigDecimal totalDays = resolveQuotaDays(leaveType, leaveRuleItem);
if (!isAnnualLeave(leaveType)) {
return totalDays;
}
BigDecimal serviceYears = calculateServiceYears(employee);
for (LeaveRuleService.AnnualRuleItem item : scheme.getAnnualRules()) {
if (matchRule(serviceYears, item.getSeniorityMin(), item.getSeniorityMax())) {
totalDays = totalDays.add(defaultZero(item.getExtraDays()));
}
}
EmployeeLeaveSettingDO setting = leaveRuleService.getEmployeeLeaveSettingDO(employee.getId());
if (setting != null) {
totalDays = totalDays.add(defaultZero(setting.getAnnualBonusDays()));
}
return maxZero(totalDays);
}
5.3 提交请假:先预占,不直接变成已用
员工提交请假后,流程还没有审批结束。此时最合理的动作不是计入 usedDays,而是预占。
java
@Transactional(rollbackFor = Exception.class)
public void reserveForBill(LeaveCancelBillDO bill) {
if (leaveLedgerMapper.selectByBusiness(BUSINESS_TYPE_LEAVE_CANCEL, bill.getId(), CHANGE_TYPE_RESERVE) != null) {
return;
}
EmployeeDO employee = employeeMapper.selectById(resolveEmployeeId(bill));
LeaveTypeDO leaveTypeDO = leaveRuleService.getEffectiveLeaveTypeByDeptId(employee.getDeptId(), bill.getLeaveType());
if (!Integer.valueOf(1).equals(leaveTypeDO.getBalanceEnabled())) {
return;
}
LeaveAccountDO account = initOrGetAccount(employee, leaveTypeDO, LocalDate.now().getYear());
BigDecimal applyDays = normalizeDays(bill.getExpectedDays(), bill.getExpectedStartTime(), bill.getExpectedEndTime());
if (defaultZero(account.getAvailableDays()).compareTo(applyDays) < 0) {
throw exception(LEAVE_BALANCE_NOT_ENOUGH);
}
BigDecimal before = defaultZero(account.getAvailableDays());
BigDecimal after = before.subtract(applyDays);
account.setAvailableDays(after);
account.setReservedDays(defaultZero(account.getReservedDays()).add(applyDays));
leaveAccountMapper.updateById(account);
createLedger(account, CHANGE_TYPE_RESERVE, applyDays.negate(), before, after, bill.getId(), "提交请假预占");
}
这一步有两个设计亮点:
| 设计 | 价值 |
|---|---|
| 提交即预占 | 防止审批中重复提交导致余额透支 |
selectByBusiness 幂等判断 |
避免重复提交或回调导致重复扣减 |
5.4 审批通过:预占转已用
请假流程通过后,账户不再扣减 availableDays,因为提交时已经扣过。此时只需要把 reservedDays 转成 usedDays。
java
@Transactional(rollbackFor = Exception.class)
public void confirmForBill(LeaveCancelBillDO bill) {
if (leaveLedgerMapper.selectByBusiness(BUSINESS_TYPE_LEAVE_CANCEL, bill.getId(), CHANGE_TYPE_CONFIRM_USE) != null) {
return;
}
EmployeeDO employee = employeeMapper.selectById(resolveEmployeeId(bill));
LeaveTypeDO leaveTypeDO = leaveRuleService.getEffectiveLeaveTypeByDeptId(employee.getDeptId(), bill.getLeaveType());
LeaveAccountDO account = initOrGetAccount(employee, leaveTypeDO, LocalDate.now().getYear());
BigDecimal applyDays = normalizeDays(bill.getExpectedDays(), bill.getExpectedStartTime(), bill.getExpectedEndTime());
account.setReservedDays(maxZero(defaultZero(account.getReservedDays()).subtract(applyDays)));
account.setUsedDays(defaultZero(account.getUsedDays()).add(applyDays));
leaveAccountMapper.updateById(account);
BigDecimal after = defaultZero(account.getAvailableDays());
createLedger(account, CHANGE_TYPE_CONFIRM_USE, applyDays.negate(), after, after, bill.getId(), "审批通过,确认扣减");
}
流水里仍然记录 CONFIRM_USE,即使 beforeBalance 和 afterBalance 一样,也能解释"预占已经正式确认使用"。
5.5 审批拒绝、撤回、取消:释放预占
拒绝、撤回、流程回到发起人时,预占余额必须释放。
java
@Transactional(rollbackFor = Exception.class)
public void releaseReserveForBill(LeaveCancelBillDO bill, String reason) {
EmployeeDO employee = employeeMapper.selectById(resolveEmployeeId(bill));
LeaveTypeDO leaveTypeDO = leaveRuleService.getEffectiveLeaveTypeByDeptId(employee.getDeptId(), bill.getLeaveType());
LeaveAccountDO account = initOrGetAccount(employee, leaveTypeDO, LocalDate.now().getYear());
BigDecimal applyDays = normalizeDays(bill.getExpectedDays(), bill.getExpectedStartTime(), bill.getExpectedEndTime());
if (defaultZero(account.getReservedDays()).compareTo(BigDecimal.ZERO) <= 0) {
return;
}
BigDecimal before = defaultZero(account.getAvailableDays());
BigDecimal after = before.add(applyDays);
account.setAvailableDays(after);
account.setReservedDays(maxZero(defaultZero(account.getReservedDays()).subtract(applyDays)));
leaveAccountMapper.updateById(account);
createLedger(account, CHANGE_TYPE_RELEASE_RESERVE, applyDays, before, after, bill.getId(), reason);
}
5.6 销假退回:按实际天数修正已用
请假销假模块的特殊点在于:请假审批通过后,员工可能提前返岗。比如预计请假 5 天,实际只休 3 天,差额 2 天应该回到可用余额。
java
@Transactional(rollbackFor = Exception.class)
public void cancelReturnForBill(LeaveCancelBillDO bill) {
if (bill.getActualDays().compareTo(bill.getExpectedDays()) > 0) {
throw exception(LEAVE_CANCEL_DAYS_INVALID);
}
BigDecimal returnDays = bill.getExpectedDays().subtract(bill.getActualDays());
if (returnDays.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
EmployeeDO employee = employeeMapper.selectById(resolveEmployeeId(bill));
LeaveTypeDO leaveTypeDO = leaveRuleService.getEffectiveLeaveTypeByDeptId(employee.getDeptId(), bill.getLeaveType());
LeaveAccountDO account = initOrGetAccount(employee, leaveTypeDO, LocalDate.now().getYear());
BigDecimal before = defaultZero(account.getAvailableDays());
BigDecimal after = before.add(returnDays);
account.setAvailableDays(after);
account.setUsedDays(maxZero(defaultZero(account.getUsedDays()).subtract(returnDays)));
leaveAccountMapper.updateById(account);
createLedger(account, CHANGE_TYPE_CANCEL_RETURN, returnDays, before, after, bill.getId(), "销假退回余额");
}
六、流程回调设计:BPM 不算余额,只触发余额服务
请假销假单服务实现了 FlowBillService<HrmBillTypeEnum>。它的职责不是自己计算余额,而是在流程状态变化时调用余额服务。
6.1 提交时预占
java
@Transactional(rollbackFor = Exception.class)
public Long submitLeaveCancelBill(LeaveCancelBillSaveReqVO saveReqVO) {
LeaveCancelBillDO leaveCancelBill = BeanUtils.toBean(saveReqVO, LeaveCancelBillDO.class)
.setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus());
leaveCancelBill.setLeaveBalance(leaveBalanceService.syncBillBalance(leaveCancelBill));
leaveCancelBillMapper.insertOrUpdate(leaveCancelBill);
leaveBalanceService.reserveForBill(leaveCancelBill);
Map<String, Object> variables = BpmProcessVariableUtils.buildBillVariables(saveReqVO);
String processInstanceId = processInstanceApi.submitProcessInstance(
Long.valueOf(saveReqVO.getCreator()),
new BpmProcessInstanceCreateReqDTO()
.setProcessDefinitionKey(HrmBillTypeEnum.HRM_LEAVE_CANCEL_BILL.getProcessDefinitionKey())
.setVariables(variables)
.setBusinessKey(String.valueOf(leaveCancelBill.getId()))
).getCheckedData();
leaveCancelBillMapper.updateById(new LeaveCancelBillDO()
.setId(leaveCancelBill.getId()).setProcessInstanceId(processInstanceId));
return leaveCancelBill.getId();
}
6.2 审批通过、拒绝、取消分别处理
java
@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessApproved(String businessKey) {
LeaveCancelBillDO bill = leaveCancelBillMapper.selectById(Long.parseLong(businessKey));
if (bill == null) return;
leaveBalanceService.confirmForBill(bill);
leaveBalanceService.cancelReturnForBill(bill);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessRejected(String businessKey) {
LeaveCancelBillDO bill = leaveCancelBillMapper.selectById(Long.parseLong(businessKey));
if (bill == null) return;
leaveBalanceService.releaseReserveForBill(bill, "审批拒绝,释放预占");
}
@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessCancelled(String businessKey) {
LeaveCancelBillDO bill = leaveCancelBillMapper.selectById(Long.parseLong(businessKey));
if (bill == null) return;
leaveBalanceService.releaseReserveForBill(bill, "流程撤回,释放预占");
}
这套设计把职责划得很清楚:
| 组件 | 只做什么 | 不做什么 |
|---|---|---|
| BPM 引擎 | 驱动流程状态 | 不计算假期余额 |
| 请假销假单服务 | 保存单据、提交流程、接收回调 | 不直接改账户字段 |
| 余额服务 | 统一账户和流水变更 | 不关心页面从哪里发起 |
七、数据结构设计:两张表支撑审计闭环
7.1 hrm_leave_account 员工假期账户
| 字段 | 类型建议 | 说明 |
|---|---|---|
id |
bigint | 主键 |
employee_id |
bigint | 员工 ID |
user_id |
bigint | 用户 ID |
leave_type |
int | 假期类型字典值 |
grant_year |
int | 发放年度 |
granted_days |
decimal | 累计发放天数 |
used_days |
decimal | 已使用天数 |
reserved_days |
decimal | 预占天数 |
available_days |
decimal | 可用天数 |
carry_forward_days |
decimal | 结转天数 |
expire_date |
date | 到期日期 |
last_grant_time |
datetime | 最近发放时间 |
设计要点:
- 建议按
employee_id + leave_type + grant_year + tenant_id建唯一约束。 - 天数字段保留 1 位小数,支持半天和小时折算。
availableDays是快速查询字段,不替代流水审计。
7.2 hrm_leave_ledger 假期余额流水
| 字段 | 类型建议 | 说明 |
|---|---|---|
id |
bigint | 主键 |
employee_id |
bigint | 员工 ID |
user_id |
bigint | 用户 ID |
leave_type |
int | 假期类型 |
grant_year |
int | 发放年度 |
change_type |
varchar | 变动类型 |
change_days |
decimal | 变动天数 |
before_balance |
decimal | 变动前可用余额 |
after_balance |
decimal | 变动后可用余额 |
business_type |
varchar | 业务来源类型 |
business_id |
bigint | 业务来源 ID |
remark |
varchar | 备注 |
设计要点:
- 流水只追加,不覆盖。
- 业务来源字段必须保留,否则流水无法回到单据。
- 变动前后余额必须写入,避免后续账户被调整后失去当时口径。
八、到期清零与额度调整:自动任务也必须写流水
8.1 到期清零
到期清零不能静默修改余额。系统任务同样要写流水:
java
@Transactional(rollbackFor = Exception.class)
public void clearExpiredBalance() {
List<LeaveAccountDO> accounts = leaveAccountMapper.selectList();
LocalDate today = LocalDate.now();
for (LeaveAccountDO account : accounts) {
if (account.getExpireDate() == null || account.getExpireDate().isAfter(today)) {
continue;
}
if (defaultZero(account.getAvailableDays()).compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
BigDecimal before = defaultZero(account.getAvailableDays());
account.setAvailableDays(BigDecimal.ZERO);
leaveAccountMapper.updateById(account);
createLedger(account, "EXPIRE_CLEAR", before.negate(), before, BigDecimal.ZERO,
account.getId(), "到期清零");
}
}
8.2 员工参数变更后自动重算年假
当员工工龄起算日、奖励年假、部门方案变化时,系统可以刷新当年年假账户。
java
private void refreshEmployeeLeaveAccount(EmployeeDO employee, LeaveTypeDO leaveType, Integer year) {
LeaveAccountDO account = leaveAccountMapper
.selectByEmployeeAndTypeAndYear(employee.getId(), leaveType.getDictValue(), year);
if (account == null) {
initOrGetAccount(employee, leaveType, year);
return;
}
BigDecimal oldGrantedDays = defaultZero(account.getGrantedDays());
BigDecimal newGrantedDays = calculateGrantDays(employee, leaveType, year);
BigDecimal deltaDays = newGrantedDays.subtract(oldGrantedDays).setScale(1, RoundingMode.HALF_UP);
if (deltaDays.compareTo(BigDecimal.ZERO) == 0) {
return;
}
BigDecimal before = defaultZero(account.getAvailableDays());
BigDecimal after = maxZero(before.add(deltaDays));
account.setGrantedDays(newGrantedDays);
account.setAvailableDays(after);
leaveAccountMapper.updateById(account);
createLedger(account, deltaDays.compareTo(BigDecimal.ZERO) > 0 ? CHANGE_TYPE_ADJUST_ADD : CHANGE_TYPE_ADJUST_SUB,
deltaDays, before, after, account.getId(), BUSINESS_TYPE_LEAVE_ACCOUNT, "员工假期参数变更,自动重算年假额度");
}
这类自动调整最容易引发争议,所以更需要流水说明。
九、产品设计亮点
| 设计点 | 实现方式 | 价值 |
|---|---|---|
| 账户 + 流水 | hrm_leave_account + hrm_leave_ledger |
当前状态和历史过程分离 |
| 提交预占 | reserveForBill 扣可用、加预占 |
防止审批中余额透支 |
| 审批确认 | confirmForBill 预占转已用 |
审批通过口径清楚 |
| 拒绝释放 | releaseReserveForBill 回补可用 |
流程失败不占员工权益 |
| 销假退回 | cancelReturnForBill 按实际天数退差额 |
支持请假销假闭环 |
| 规则合成 | 假种 + 部门方案 + 员工个性参数 | 覆盖企业差异化假勤规则 |
| 前端摘要 | 表单实时调用 /summary |
提交前就能发现余额不足 |
| 员工档案查询 | 账户表格 + 流水弹窗 | HR 可解释、可审计 |
| 自动清零 | clearExpiredBalance 写清零流水 |
自动任务也可追踪 |
| 幂等控制 | 按业务单据和变动类型查重 | 防止重复回调重复扣减 |
十、快速体验路径
可以按下面路径体验 RuoYi Office 的假期余额设计:
- 进入「人力资源管理 -> 假勤管理 -> 请假配置」,维护假种、部门假期方案、年假规则。
- 进入「人力资源管理 -> 人事档案 -> 员工档案」,维护员工入职日期、工龄起算日和奖励年假。
- 打开「人力资源管理 -> 假勤管理 -> 请假销假」,新建一张请假销假申请单。
- 选择启用余额的假种,例如年假。
- 填写预计开始时间、预计结束时间、预计天数,观察"假期余额(天)"字段刷新。
- 提交审批后,余额账户进入预占状态。
- 审批通过后,预占转为已用。
- 在销假节点填写实际天数,如果实际天数小于预计天数,差额会退回余额。
- 回到员工档案查看假期账户和余额流水,确认每一次变动都有业务来源和备注。
结语
假期余额管理不是一个简单的减法题,而是一套涉及员工权益、审批状态、规则配置和审计追踪的账户系统。
RuoYi Office 的方案可以总结为一句话:规则决定额度,账户承载余额,流水解释变化,流程触发动作。 这套模型不只适用于年假,也可以复用到调休余额、福利额度、积分账户、补贴额度等企业管理场景。
RuoYi Office - 一个平台,管好整个企业
在线演示:http://ruoyioffice.com/web/(账号:admin / admin123)
技术咨询:添加微信 17156169080,备注「RuoYi Office」
如果觉得不错,欢迎给项目一个 Star 支持。
