跳转到内容

案例 36:请假/加班/出差审批流转

本案例对应 USE-CASES.md 场景 36(诉求 44:请假/加班/出差审批流转),属于 OA 协同办公领域。请假、加班、出差三类申请共用一个统一入口,规则脚本只负责「什么条件路由给谁」的单一口径,余额校验、证明门控、加签、境外升级、高管同级规避等业务兜底放在 Service 层,最终输出首审/终审角色、加签角色、审批时限与退回原因。

员工审批申请由 ApprovalRequest 统一承载:申请类型 approvalType 分为 LEAVE 请假、OVERTIME 加班、TRIP 出差;请假再按 leaveType 区分 PERSONAL 事假、SICK 病假、ANNUAL 年假,时长以小时计、按每天 8 小时折算。申请还携带出差预算金额、申请人职级(普通员工/主管/部门经理/高管)、加班是否跨期补录、病假是否附医院证明、年假余额是否充足、是否境外出差等要素。

分级审批口径全部外置在规则脚本中:请假按假别分别设档——年假超过 80 小时报 VP、超过 24 小时报部门经理、其余主管;病假超过 40 小时报 VP、超过 24 小时报部门经理;事假超过 40 小时报 VP、超过 8 小时报部门经理。出差按预算金额分级:3 万元以上 CEO、1 万元以上 CFO、3000 元以上 VP、其余部门经理。加班则以「跨期补录或单次超过 16 小时」为界升级到部门经理。

Service 在纯路由之外承担真实 OA 系统的兜底策略:年假余额不足直接退回,病假超过 3 个工作日(24 小时)无医院证明直接退回;长病假与跨期补录加班在审批人之外加签 HR(核发病假工资、复核考勤原始记录);境外出差若预算只够部门经理档则升级 VP,并提示完成外事/安全审批备案;高管申请统一将终审上提至 CEO,规避同级自审。最后按终审角色回算审批时限(CEO 48/CFO 36/VP 24/部门经理 16/主管 8 小时)。

决策结果 ApprovalRoutingDecision 记录申请单号、状态(ROUTED 已路由待审批 / REJECTED 退回补正)、首审与终审角色、加签角色列表、审批时限、路由说明与退回原因,可直接驱动 OA 审批流节点流转与消息提醒。

  • 分级门槛多且差异细微:三种假别各有小时阈值(80/24、40/24、40/8),出差又有 3 万/1 万/3000 三条金额线,硬编码在流程代码里极易写错且难以审计。
  • 路由之外的例外策略缠绕:病假证明门控、年假余额校验、长病假加签 HR、跨期补录复核、境外升级备案,与分级逻辑混在一个流程节点里,改一处怕动全身。
  • 合规回避要求难落地:高管申请不能同级自审,终审必须上提 CEO;这类组织回避规则散落在审批流配置中,常常漏配。
  • 审批时限口径不统一:不同终审角色对应不同 SLA,时限调整(如 VP 从 24 小时改为 12 小时)需要发版改代码。
  • 跨期补录存在考勤舞弊空间:超过 3 个自然日补录加班需要更高级别审批并加签 HR 复核原始记录,普通补录与跨期补录必须分流。
  • 制度调整依赖 IT 排期:劳动制度与差旅标准每年可能调整,业务部门无法自助修改阈值,规则与代码解耦诉求强烈。
组成 文件 职责
入参模型 model/ApprovalRequest.java 请假/加班/出差统一申请要素:类型、假别、时长、预算、职级与各类门控标志
决策模型 model/ApprovalRoutingDecision.java 路由状态、首审/终审角色、加签列表、SLA、路由说明与退回原因
规则接口 ApprovalRoutingRules.java 5 个规则方法:三类审批人分级、病假证明门控、角色时限
规则脚本 resources/demo/approval-routing.xml 假别时长档、预算金额档、加班补录档、SLA 时限全部外置
编排服务 service/ApprovalRoutingService.java 余额/证明硬否决、加签、境外升级、高管上提、SLA 回算
测试 ApprovalRoutingServiceTest.java 6 组真实业务形态,11 笔申请覆盖分级、退回、加签与升级
规则方法 入参(@Param) 规则口径 返回
leaveApprover leaveType、hours 年假 >80h VP / >24h 部门经理,其余主管;病假 >40h VP / >24h 部门经理,其余主管;事假 >40h VP / >8h 部门经理,其余主管 String 审批人角色
tripApprover amount 出差预算 ≥3 万 CEO / ≥1 万 CFO / ≥3000 VP / 其余部门经理 String 审批人角色
overtimeApprover hours、retroactive 跨期补录或超过 16 小时 → 部门经理,其余主管 String 审批人角色
sickCertificateRequired hours 病假超过 3 天(24 小时)须医院证明 boolean
slaHours role 按终审角色返回审批时限:CEO 48 / CFO 36 / VP 24 / 部门经理 16 / 其余 8 小时 int

将下列文件按包结构放入 JQuick-Java 测试工程:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试放入 src/test/java/com/github/paohaijiao/demo/,Service 放入 demo/service/,XML 放入 src/test/resources/demo/。放入后执行:

Terminal window
mvn test -Dtest=ApprovalRoutingServiceTest
package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 员工审批申请要素(请假/加班/出差统一入口)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApprovalRequest {
/**
* 申请单号
*/
private String requestId;
private String employeeId;
private String employeeName;
/**
* 申请类型:LEAVE 请假 / OVERTIME 加班 / TRIP 出差
*/
private String approvalType;
/**
* 请假类型:PERSONAL 事假 / SICK 病假 / ANNUAL 年假
*/
private String leaveType;
/**
* 申请时长(小时,请假按每天 8 小时折算)
*/
private double hours;
/**
* 出差预算金额(元)
*/
private double tripAmount;
/**
* 申请人职级:REGULAR 普通员工 / TEAM_LEAD 主管 / DEPT_MANAGER 部门经理 / EXECUTIVE 高管
*/
private String employeeLevel;
/**
* 加班是否为跨期补录(超过 3 个自然日提交)
*/
private boolean retroactiveOvertime;
/**
* 病假是否附医院证明
*/
private boolean sickCertificate;
/**
* 年假余额是否充足
*/
private boolean annualLeaveEnough;
/**
* 是否境外出差
*/
private boolean overseasTrip;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 审批路由决策
*/
@Data
public class ApprovalRoutingDecision {
/**
* 申请单号
*/
private String requestId;
/**
* ROUTED 已路由待审批 / REJECTED 退回补正
*/
private String status;
/**
* 首审角色:TEAM_LEAD / DEPT_MANAGER / VP / CFO / CEO
*/
private String firstApproverRole;
/**
* 终审角色
*/
private String finalApproverRole;
/**
* 加签角色列表(HR / VP / FINANCE 等)
*/
private List<String> countersignRoles = new ArrayList<String>();
/**
* 审批时限(小时)
*/
private int slaHours;
/**
* 路由说明
*/
private List<String> routeNotes = new ArrayList<String>();
/**
* 退回原因
*/
private String rejectReason;
public void addCountersign(String role) {
this.countersignRoles.add(role);
}
public void addNote(String note) {
this.routeNotes.add(note);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 36:请假/加班/出差审批分级路由规则
*/
public interface ApprovalRoutingRules {
/**
* 请假分级审批人:
* 年假 &gt;80h VP / &gt;24h 部门经理,其余主管;
* 病假 &gt;40h VP / &gt;24h 部门经理,其余主管;
* 事假 &gt;40h VP / &gt;8h 部门经理,其余主管
*/
String leaveApprover(@Param("leaveType") String leaveType,
@Param("hours") double hours);
/**
* 出差预算分级审批人:≥3 万 CEO / ≥1 万 CFO / ≥3000 VP / 其余部门经理
*/
String tripApprover(@Param("amount") double amount);
/**
* 加班审批人:跨期补录或超 16 小时 → 部门经理,其余主管
*/
String overtimeApprover(@Param("hours") double hours,
@Param("retroactive") boolean retroactive);
/**
* 病假超 3 天(24 小时)须医院证明
*/
boolean sickCertificateRequired(@Param("hours") double hours);
/**
* 按终审角色返回审批时限(小时)
*/
int slaHours(@Param("role") String role);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN"
"classpath:paohaijiao/dtd/Jquick-java.dtd">
<javas namespace="com.github.paohaijiao.demo.ApprovalRoutingRules">
<java name="leaveApprover" returnClass="java.lang.String">
<![CDATA[
str def leaveApprover(str:leaveType, double:hours) {
if (leaveType == "ANNUAL") {
if (hours > 80.0) {
return "VP";
} else if (hours > 24.0) {
return "DEPT_MANAGER";
} else {
return "TEAM_LEAD";
}
} else if (leaveType == "SICK") {
if (hours > 40.0) {
return "VP";
} else if (hours > 24.0) {
return "DEPT_MANAGER";
} else {
return "TEAM_LEAD";
}
} else {
if (hours > 40.0) {
return "VP";
} else if (hours > 8.0) {
return "DEPT_MANAGER";
} else {
return "TEAM_LEAD";
}
}
}
]]>
</java>
<java name="tripApprover" returnClass="java.lang.String">
<![CDATA[
str def tripApprover(double:amount) {
if (amount >= 30000.0) {
return "CEO";
} else if (amount >= 10000.0) {
return "CFO";
} else if (amount >= 3000.0) {
return "VP";
} else {
return "DEPT_MANAGER";
}
}
]]>
</java>
<java name="overtimeApprover" returnClass="java.lang.String">
<![CDATA[
str def overtimeApprover(double:hours, boolean:retroactive) {
if (retroactive) {
return "DEPT_MANAGER";
} else if (hours > 16.0) {
return "DEPT_MANAGER";
} else {
return "TEAM_LEAD";
}
}
]]>
</java>
<java name="sickCertificateRequired" returnClass="boolean">
<![CDATA[
boolean def sickCertificateRequired(double:hours) {
if (hours > 24.0) {
return true;
} else {
return false;
}
}
]]>
</java>
<java name="slaHours" returnClass="int">
<![CDATA[
int def slaHours(str:role) {
if (role == "CEO") {
return 48;
} else if (role == "CFO") {
return 36;
} else if (role == "VP") {
return 24;
} else if (role == "DEPT_MANAGER") {
return 16;
} else {
return 8;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.ApprovalRoutingRules;
import com.github.paohaijiao.demo.model.ApprovalRequest;
import com.github.paohaijiao.demo.model.ApprovalRoutingDecision;
/**
* 场景 36:请假/加班/出差审批智能流转
*/
public class ApprovalRoutingService {
private final ApprovalRoutingRules rules;
public ApprovalRoutingService(ApprovalRoutingRules rules) {
this.rules = rules;
}
public ApprovalRoutingDecision route(ApprovalRequest req) {
ApprovalRoutingDecision d = new ApprovalRoutingDecision();
d.setRequestId(req.getRequestId());
if ("LEAVE".equals(req.getApprovalType())) {
if (!routeLeave(req, d)) {
return d;
}
} else if ("OVERTIME".equals(req.getApprovalType())) {
routeOvertime(req, d);
} else {
routeTrip(req, d);
}
applyExecutiveEscalation(req, d);
d.setSlaHours(rules.slaHours(d.getFinalApproverRole()));
d.setStatus("ROUTED");
return d;
}
private boolean routeLeave(ApprovalRequest req, ApprovalRoutingDecision d) {
if ("ANNUAL".equals(req.getLeaveType()) && !req.isAnnualLeaveEnough()) {
d.setStatus("REJECTED");
d.setRejectReason("年假余额不足,请核减假期或改走事假流程");
return false;
}
if ("SICK".equals(req.getLeaveType())
&& rules.sickCertificateRequired(req.getHours())
&& !req.isSickCertificate()) {
d.setStatus("REJECTED");
d.setRejectReason("病假超过 3 个工作日(24 小时),须上传医院诊断证明后再提交");
return false;
}
String approver = rules.leaveApprover(req.getLeaveType(), req.getHours());
d.setFirstApproverRole(approver);
d.setFinalApproverRole(approver);
if ("SICK".equals(req.getLeaveType()) && rules.sickCertificateRequired(req.getHours())) {
d.addCountersign("HR");
d.addNote("长病假由 " + approver + " 审批并加签 HR 核发病假工资");
}
return true;
}
private void routeOvertime(ApprovalRequest req, ApprovalRoutingDecision d) {
String approver = rules.overtimeApprover(req.getHours(), req.isRetroactiveOvertime());
d.setFirstApproverRole(approver);
d.setFinalApproverRole(approver);
if (req.isRetroactiveOvertime()) {
d.addCountersign("HR");
d.addNote("跨期补录加班由 " + approver + " 审批并加签 HR 复核考勤原始记录");
}
}
private void routeTrip(ApprovalRequest req, ApprovalRoutingDecision d) {
String approver = rules.tripApprover(req.getTripAmount());
if (req.isOverseasTrip() && "DEPT_MANAGER".equals(approver)) {
approver = "VP";
d.addNote("境外出差预算审批由部门经理升级至 VP");
}
d.setFirstApproverRole(approver);
d.setFinalApproverRole(approver);
if (req.isOverseasTrip() && !"DEPT_MANAGER".equals(approver)) {
d.addNote("境外出差须同步完成外事/安全审批备案");
}
}
private void applyExecutiveEscalation(ApprovalRequest req, ApprovalRoutingDecision d) {
if ("EXECUTIVE".equals(req.getEmployeeLevel()) && !"CEO".equals(d.getFinalApproverRole())) {
d.addNote("高管申请规避同级自审,终审由 " + d.getFinalApproverRole() + " 上提至 CEO");
d.setFinalApproverRole("CEO");
}
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.ApprovalRequest;
import com.github.paohaijiao.demo.model.ApprovalRoutingDecision;
import com.github.paohaijiao.demo.service.ApprovalRoutingService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 36:请假/加班/出差审批流转
* <p>
* 6 组形态:短时事假主管直审、年假分级与余额退回、长病假证明门控与 HR 加签、
* 加班分级与跨期补录、出差预算与境外升级、大额差旅与高管同级规避。
*/
public class ApprovalRoutingServiceTest {
private ApprovalRoutingService service;
@Before
public void setUp() {
ApprovalRoutingRules rules = JQuickJava.create()
.createApi(ApprovalRoutingRules.class, "demo/approval-routing.xml");
this.service = new ApprovalRoutingService(rules);
}
/**
* 普通员工 4 小时事假 → 主管 8 小时内审结
*/
@Test
public void shouldRouteShortPersonalLeaveToTeamLead() {
ApprovalRequest req = leaveReq("A001", "PERSONAL", 4.0, "REGULAR");
ApprovalRoutingDecision d = service.route(req);
Assert.assertEquals("ROUTED", d.getStatus());
Assert.assertEquals("TEAM_LEAD", d.getFirstApproverRole());
Assert.assertEquals("TEAM_LEAD", d.getFinalApproverRole());
Assert.assertEquals(8, d.getSlaHours());
Assert.assertEquals(0, d.getCountersignRoles().size());
}
/**
* 6 天年假余额足 → 部门经理;余额不足 → 退回
*/
@Test
public void shouldRouteAnnualLeaveByDaysAndRejectInsufficientBalance() {
ApprovalRequest enough = leaveReq("A002", "ANNUAL", 48.0, "REGULAR");
enough.setAnnualLeaveEnough(true);
ApprovalRoutingDecision d1 = service.route(enough);
Assert.assertEquals("ROUTED", d1.getStatus());
Assert.assertEquals("DEPT_MANAGER", d1.getFinalApproverRole());
Assert.assertEquals(16, d1.getSlaHours());
ApprovalRequest shortBalance = leaveReq("A003", "ANNUAL", 48.0, "REGULAR");
shortBalance.setAnnualLeaveEnough(false);
ApprovalRoutingDecision d2 = service.route(shortBalance);
Assert.assertEquals("REJECTED", d2.getStatus());
Assert.assertTrue(d2.getRejectReason().contains("年假余额不足"));
}
/**
* 6 天病假有证明 → VP 审批并加签 HR;无证明 → 退回补证
*/
@Test
public void shouldRequireCertificateAndCountersignHrForLongSickLeave() {
ApprovalRequest certified = leaveReq("A004", "SICK", 48.0, "REGULAR");
certified.setSickCertificate(true);
ApprovalRoutingDecision d1 = service.route(certified);
Assert.assertEquals("VP", d1.getFinalApproverRole());
Assert.assertEquals(24, d1.getSlaHours());
Assert.assertTrue(d1.getCountersignRoles().contains("HR"));
Assert.assertTrue(d1.getRouteNotes().get(0).contains("加签 HR 核发病假工资"));
ApprovalRequest noProof = leaveReq("A005", "SICK", 48.0, "REGULAR");
noProof.setSickCertificate(false);
ApprovalRoutingDecision d2 = service.route(noProof);
Assert.assertEquals("REJECTED", d2.getStatus());
Assert.assertTrue(d2.getRejectReason().contains("医院诊断证明"));
}
/**
* 20 小时常规加班 → 部门经理;8 小时跨期补录 → 部门经理加签 HR
*/
@Test
public void shouldRouteOvertimeByHoursAndRetroactiveFlag() {
ApprovalRequest normal = base("A006", "OVERTIME", "REGULAR");
normal.setHours(20);
ApprovalRoutingDecision d1 = service.route(normal);
Assert.assertEquals("DEPT_MANAGER", d1.getFinalApproverRole());
Assert.assertEquals(0, d1.getCountersignRoles().size());
ApprovalRequest retro = base("A007", "OVERTIME", "REGULAR");
retro.setHours(8);
retro.setRetroactiveOvertime(true);
ApprovalRoutingDecision d2 = service.route(retro);
Assert.assertEquals("DEPT_MANAGER", d2.getFinalApproverRole());
Assert.assertTrue(d2.getCountersignRoles().contains("HR"));
Assert.assertTrue(d2.getRouteNotes().get(0).contains("加签 HR 复核考勤原始记录"));
}
/**
* 境内 8000 元出差 → VP;2000 元境外出差 → 升级 VP
*/
@Test
public void shouldRouteTripByBudgetAndEscalateOverseas() {
ApprovalRequest domestic = base("A008", "TRIP", "REGULAR");
domestic.setTripAmount(8000);
ApprovalRoutingDecision d1 = service.route(domestic);
Assert.assertEquals("VP", d1.getFinalApproverRole());
Assert.assertEquals(24, d1.getSlaHours());
ApprovalRequest overseas = base("A009", "TRIP", "REGULAR");
overseas.setTripAmount(2000);
overseas.setOverseasTrip(true);
ApprovalRoutingDecision d2 = service.route(overseas);
Assert.assertEquals("VP", d2.getFinalApproverRole());
Assert.assertTrue(d2.getRouteNotes().get(0).contains("境外出差预算审批由部门经理升级至 VP"));
}
/**
* 5 万差旅 → CEO;高管 1.2 万差旅(本应 CFO)→ 上提 CEO 规避自审
*/
@Test
public void shouldRouteLargeBudgetToCeoAndEscalateExecutive() {
ApprovalRequest large = base("A010", "TRIP", "REGULAR");
large.setTripAmount(50000);
ApprovalRoutingDecision d1 = service.route(large);
Assert.assertEquals("CEO", d1.getFinalApproverRole());
Assert.assertEquals(48, d1.getSlaHours());
ApprovalRequest exec = base("A011", "TRIP", "EXECUTIVE");
exec.setTripAmount(12000);
ApprovalRoutingDecision d2 = service.route(exec);
Assert.assertEquals("CEO", d2.getFinalApproverRole());
Assert.assertTrue(d2.getRouteNotes().get(0).contains("上提至 CEO"));
}
private ApprovalRequest leaveReq(String id, String leaveType, double hours, String level) {
ApprovalRequest r = base(id, "LEAVE", level);
r.setLeaveType(leaveType);
r.setHours(hours);
r.setSickCertificate(false);
r.setAnnualLeaveEnough(false);
return r;
}
private ApprovalRequest base(String id, String type, String level) {
ApprovalRequest r = new ApprovalRequest();
r.setRequestId(id);
r.setEmployeeId("E-" + id);
r.setEmployeeName("测试员工");
r.setApprovalType(type);
r.setLeaveType("PERSONAL");
r.setHours(0);
r.setTripAmount(0);
r.setEmployeeLevel(level);
r.setRetroactiveOvertime(false);
r.setSickCertificate(false);
r.setAnnualLeaveEnough(false);
r.setOverseasTrip(false);
return r;
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=ApprovalRoutingServiceTest

测试覆盖 6 组业务形态,共构造 11 笔申请,断言与源码逐一对应:

测试方法 业务形态 关键断言
shouldRouteShortPersonalLeaveToTeamLead 普通员工 4 小时事假,主管直审 状态 ROUTED;首审/终审均为 TEAM_LEAD;SLA 8 小时;加签数 0
shouldRouteAnnualLeaveByDaysAndRejectInsufficientBalance 48 小时(6 天)年假:余额足与余额不足两笔 余额足终审 DEPT_MANAGER、SLA 16;余额不足状态 REJECTED,退回原因含「年假余额不足」
shouldRequireCertificateAndCountersignHrForLongSickLeave 48 小时病假:有证明与无证明两笔 有证明终审 VP、SLA 24、加签含 HR、备注含「加签 HR 核发病假工资」;无证明 REJECTED,原因含「医院诊断证明」
shouldRouteOvertimeByHoursAndRetroactiveFlag 20 小时常规加班与 8 小时跨期补录 常规加班终审 DEPT_MANAGER、无加签;跨期补录终审 DEPT_MANAGER 且加签 HR,备注含「加签 HR 复核考勤原始记录」
shouldRouteTripByBudgetAndEscalateOverseas 境内 8000 元出差与 2000 元境外出差 境内终审 VP、SLA 24;境外低预算由部门经理升级 VP,备注含「境外出差预算审批由部门经理升级至 VP」
shouldRouteLargeBudgetToCeoAndEscalateExecutive 5 万大额差旅与高管 1.2 万差旅 5 万终审 CEO、SLA 48;高管 1.2 万本应 CFO,上提 CEO,备注含「上提至 CEO」
  • 一方法一口径,阈值全部外置:5 个规则方法分别只回答「请假审批人、出差审批人、加班审批人、是否要病假证明、角色时限」一个问题,假别小时档、预算金额档、SLA 时限全部写在 XML 中,制度调整改 XML 后重建代理即可热更新,无需重新编译业务代码。
  • Service 硬否决短路:年假余额不足、长病假无证明在调用分级规则之前直接置 REJECTED 返回,错误申请根本不进入审批路由,退回原因精准可解释。
  • Service 升级兜底:境外出差的「部门经理档升级 VP」、高管申请的「终审一律上提 CEO 规避同级自审」属于组织合规策略,放在 Java 编排层对规则结果二次修正,规则脚本保持纯粹的金额/时长口径。
  • 加签与 SLA 联动:长病假与跨期补录自动加签 HR 并生成路由说明;终审角色一旦被升级逻辑改写,SLA 立即按新角色回算,保证时限与实际终审人一致。
  • @Param 按名绑定:接口参数通过 @Param("leaveType") 等按名称注入脚本,入参模型演进不影响脚本;脚本字符串比较直接使用 ==,布尔判断采用正向条件提前 return(如 sickCertificateRequired),契合引擎语法约束。
  • 代理复用、ASM 加速、Java 8 信创友好:测试在 @Before 中只创建一次 ApprovalRoutingRules 代理并复用;动态代理 + ASM 调用链无重型依赖,可直接嵌入仍运行在 JDK 8 与国产化 JDK 的 OA 系统。