SpringBoot+Vue3 企业假期余额系统设计:账户、流水、预占、销假退回与到期清零全链路拆解

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 没有把所有假期都强行纳入余额扣减。例如事假可能不需要余额,年假、调休假、福利假则需要余额控制。这个开关来自假期规则体系:

层级 作用 典型字段
假种基础层 定义年假、病假、事假、调休假等基础假种 codedictValuedefaultDaysbalanceEnabled
部门方案层 不同部门可绑定不同假期规则 deptIdleaveRulesannualRules
员工个性层 支持个人工龄起算日、奖励天数 workAgeStartDateannualBonusDays
余额账户层 规则计算后的年度账户结果 grantedDaysusedDaysavailableDays

图 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 变动类型,如 GRANTRESERVECONFIRM_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_BILLbusinessId = 请假单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 } },
  );
}

前端表格列设计也很直接:

账户列 作用
假期类型 区分年假、调休、福利假
发放年度 区分不同年度账户
发放天数 年度额度来源
已用天数 审批通过后确认使用
预占天数 正在审批中的占用
可用天数 员工当前可申请额度
到期日期 年底清零或结转依据
余额流水 打开该假种的流水明细

流水弹窗列包含:

流水列 作用
变动类型 展示 GRANTRESERVECONFIRM_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,即使 beforeBalanceafterBalance 一样,也能解释"预占已经正式确认使用"。

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 的假期余额设计:

  1. 进入「人力资源管理 -> 假勤管理 -> 请假配置」,维护假种、部门假期方案、年假规则。
  2. 进入「人力资源管理 -> 人事档案 -> 员工档案」,维护员工入职日期、工龄起算日和奖励年假。
  3. 打开「人力资源管理 -> 假勤管理 -> 请假销假」,新建一张请假销假申请单。
  4. 选择启用余额的假种,例如年假。
  5. 填写预计开始时间、预计结束时间、预计天数,观察"假期余额(天)"字段刷新。
  6. 提交审批后,余额账户进入预占状态。
  7. 审批通过后,预占转为已用。
  8. 在销假节点填写实际天数,如果实际天数小于预计天数,差额会退回余额。
  9. 回到员工档案查看假期账户和余额流水,确认每一次变动都有业务来源和备注。

结语

假期余额管理不是一个简单的减法题,而是一套涉及员工权益、审批状态、规则配置和审计追踪的账户系统。

RuoYi Office 的方案可以总结为一句话:规则决定额度,账户承载余额,流水解释变化,流程触发动作。 这套模型不只适用于年假,也可以复用到调休余额、福利额度、积分账户、补贴额度等企业管理场景。

RuoYi Office - 一个平台,管好整个企业

在线演示:http://ruoyioffice.com/web/(账号:admin / admin123)

技术咨询:添加微信 17156169080,备注「RuoYi Office」

如果觉得不错,欢迎给项目一个 Star 支持。

复制代码
相关推荐
张小洛1 小时前
Spring 常用类深度剖析(工具篇 05):Assert:用断言代替 if-throw,代码更清爽
spring·log4j·参数校验·validate·assert·spring 常用类·代码简化
潘祖记2 小时前
# 一行命令让 AI 接管全屋智能:FeyaGate Skill 保姆级接入教程,小米/涂鸦/美的/易微联全搞定
人工智能·后端·asp.net
晚风_END11 小时前
Linux|操作系统|最新版openzfs编译记录
linux·运维·服务器·数据库·spring·中间件·个人开发
小码哥_常11 小时前
告别MySQL!大厂集体转投PostgreSQL,到底藏着什么玄机?
后端
FYKJ_201012 小时前
springboot校园兼职平台--附源码02041
java·javascript·spring boot·python·eclipse·django·php
刀法如飞13 小时前
Go数组去重的20种实现方式,AI时代解决问题的不同思路
后端·算法·go
AI人工智能+电脑小能手13 小时前
【大白话说Java面试题】【Java基础篇】第30题:JDK动态代理和CGLIB动态代理有什么区别
java·开发语言·后端·面试·代理模式
swipe13 小时前
别再把 AI 聊天做成纯文本:从 agui 这个前后端项目,拆解“可感知工具调用”的流式 AI UI
后端·langchain·llm
GetcharZp13 小时前
GitHub 爆火!纯 Go 编写的文件同步神器 Syncthing,凭什么成为程序员的标配?
后端