案例 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 笔申请覆盖分级、退回、加签与升级 |
规则方法拆分
Section titled “规则方法拆分”| 规则方法 | 入参(@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/。放入后执行:
mvn test -Dtest=ApprovalRoutingServiceTestApprovalRequest.java
Section titled “ApprovalRequest.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 员工审批申请要素(请假/加班/出差统一入口) */@Data@NoArgsConstructor@AllArgsConstructorpublic 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;}ApprovalRoutingDecision.java
Section titled “ApprovalRoutingDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 审批路由决策 */@Datapublic 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); }}ApprovalRoutingRules.java
Section titled “ApprovalRoutingRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 36:请假/加班/出差审批分级路由规则 */public interface ApprovalRoutingRules {
/** * 请假分级审批人: * 年假 >80h VP / >24h 部门经理,其余主管; * 病假 >40h VP / >24h 部门经理,其余主管; * 事假 >40h VP / >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);}approval-routing.xml
Section titled “approval-routing.xml”<?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>ApprovalRoutingService.java
Section titled “ApprovalRoutingService.java”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"); } }}ApprovalRoutingServiceTest.java
Section titled “ApprovalRoutingServiceTest.java”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; }}在工程根目录执行:
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 系统。