一、一个场景:李四出差了,单子归谁批
孙倩递了一张请假单:10 月 14 日到 15 日休年假。单子走「含驳回流程」,审批链是 上级审批(李四)→ 经理审批(王五)。
李四这几天在外地出差。出发前,他在「我的委托」里配了一条:· 全部流程 · 委托给张三 · 10/13 ~ 10/16 · 启用。
当晚,单子流转到上级审批节点:
- 李四的待办里有这张单;
- 张三的待办里也有这张单;
- 张三看完,写下「行程已知会,同意」提交,单子流转到经理审批;
- 审批记录里,上级审批这一步的办理人:张三。

这就是「委托代批」:配一条委托记录,之后所有"本该给某人"的审批单,在建任务那一刻自动把被委托人一并写进参与者------原人保留,两人任一可办。
看着只是"一张表 + 一个查询",但要在八种语言的引擎里做成同一套行为,需要钉死的语义有十来条。这篇按 配置 → 判据 → 并入 → 记录 → 跨语言 → 迁移 的顺序,把它完整讲一遍。以下所有截图和读数,都来自本机起的一套演示(Python 后端 + jeeflow-ui 组件包)。
二、委托不是转办:并入不替换、不接力、不扩名册
第一条设计决定:命中委托 = 把被委托人追加进参与者集合。不是替换,不是换人。
| 动作 | 效果 | 什么时候用 |
|---|---|---|
| 委托代批 | 追加被委托人;原人保留 | 出差 / 休假期间 |
| 转办 | 换人,摘掉原办理人 | 这张单交给别人 |
| 加签 | 追加一个参与者 | 加一个会签 / 知会人 |

三条细节同样是设计的一部分:
不接力。 A 委托了 B、B 又委托了 C------C 不会收到 A 的单。委托只对"建单那一刻的原始参与者"生效,被委托人自己的委托不再往外展开,从根上避免环状委托。演示里有个天然样本:孙倩自己配了"委托周明",她发起的单,发起节点的参与者是「孙倩、周明」------周明的委托人吴婷没有出现。
不扩名册。 串行会签的每一步都是独立建任务:代理人只进当一步任务的参与者,不会去动会签的投票名册。
只对新单生效。 参与者是任务落库时的一次性快照;改委托只影响之后新建的单,已经建出来的单不变------待办、已办、详情读的都是同一张参与者表,查询侧不用再维护第二套算法。

三、什么时候算人:建单那一刻,一条判定链
委托的判定时机是固定的:任务参与者解析完成之后、落库之前,引擎对每个参与者查一次生效委托,命中就追加。
覆盖范围是全部建单路径,而不是只有"发起":发起、办理推进、串行会签的每一步、跳转、回退------每一次建任务都会重算一遍。单子是在流转中不断产生新任务的,只在发起处挂钩会漏掉后面每一步。
判定本身是一条单链:

- 看这个人名下、当前流程作用域有没有委托行;
- 有:取最新一条(按 id),对这条做四项判据;
- 判否(没有记录、或这条没通过判据):再看全部流程作用域的最新一条------精确判否、全局兜底;
- 命中:把被委托人追加进参与者。
判据四项:
- 时间窗:开始时间 ≤ 现在 ≤ 结束时间,两头允许留空(留空 = 该侧不限);
- 启用 :只认
enabled == 1(白名单式判定,"2"、空值、脏值一律不算启用,第五节细说); - 自委托:被委托人 ≠ 授权人(自己委托给自己,不生效);
- 作用域 :流程名留空 = 全部流程兜底;填了流程名,就按流程模型名匹配(比如
leave)。
流程名的匹配口径:以流程模型名 为准(流程 JSON 里的 name 字段,即 ProcessModel.name),模型未带时回落到流程定义表里的名字;判空前先 trim。所以配 leave 命中的是"流程模型名 = leave"的那支流程,而不是定义表里的显示名。
每个节点的参与者,各算各的委托:演示里「经理审批」节点的参与者,也自动并进了王五自己的委托人赵六。
四、取哪一条:先取最新,再裁决
同一个作用域下可以存在多条记录,取行顺序本身就是设计的一部分:
同一作用域,先按 id 取最新一条,再对这条做裁决;判否不回头找更旧的。
用起来就是一句话:你最后改的那条,就是生效的那条。
- 出差改期:直接把窗口往后改,改完即生效;
- 提前回岗:把这条停用,新单立即不再并人------不会"翻回"更早的记录;
- 委托期间到了:窗口自会兜住(窗外的新单不并人),记录留着不影响。
本机实测(同一张单、同一个节点,只看上级审批的参与者是谁):
| 最新一条委托的样子 | 上级审批参与者 |
|---|---|
| 窗内 + 启用 + 委托张三 | 李四、张三 |
| 窗内 + 停用 | 仅李四 |
| 窗内 + 自委托(改委托给自己) | 仅李四 |
| 窗内 + enabled=2 | 仅李四 |
取哪一条落到 SQL 层就一句:
sql
WHERE operator = ? [AND process_name = ?]
ORDER BY id DESC LIMIT 1
判据不写进 SQL(不写 enabled 条件、不写时间窗),先取出最新一条,再交四项判据裁决------这样"停用 / 改期立即生效"才有保证。
还有一条工程口径:同一份规范对内存仓储和 SQL 仓储同时生效,同一个引擎的两种仓储实现必须给出同一个答案(本地单测没连库时跑内存仓,行为也不能漂)。
五、enabled 的两个侧面:读侧白名单,写侧归一
enabled 这个字段值得单说,因为它在"读"和"写"两侧的职责不同,而且是分开定的:
- 读侧 :只有整数
1生效。0、2、NULL、脏值一律不生效------白名单式判定,不做"非 0 即真"的宽松解析。 - 写侧 :
processSurrogate/save不传 → 落 1;传了但不可解析成整数("abc"、"1x"、空串)→ 落 0 ,并且保存/建单必须成功、不报错。
写侧的动机很朴实:一条委托录入走偏,结果应该是"存下来了但没启用",而不是"保存接口 500"。一个字段的格式问题,不该把整个动作打断。
六、谁能看到谁的委托:归属不变式
「我的委托」只属于自己。这条在两层上保证:
- 门面层 :分页自动带上"授权人 = 当前用户"的归属条件。集成方注入登录人;缺省 / 空值时按契约归一(演示环境缺省为
user1)。 - 仓储层 :绕过门面直连仓储时,归属条件为空或缺失 → 返回空页,绝不允许退化成"全库台账"。

把这条放在引擎与仓储两层,而不是交给每个宿主自己做:换集成方式、直连 SPI,归属保证都还在。
七、八个引擎,同一套答案
上面的语义不是某一家的实现细节,而是统一规范里的一节:spec/06 §4.5「委托代理」。八种语言的引擎(Java / Go / Python / Node / PHP / Rust / MoonBit / C#)按同一套行为实现。
跨栈验收里,委托占了三个用例格:两组取行判据(时间窗 / 启用 / 自委托 / 取最新)+ 一组归属不变式;13 套集成后端联同 MoonBit 插件形态,跑的是同一组夹具。
在手边的四个后端上做了个只读抽查(同一个请求:查李四名下的委托分页):
| 后端 | 返回的行 | 返回范围 |
|---|---|---|
| Python | 1 条 | 只含李四的行 |
| Java | 1 条 | 只含李四的行 |
| Go | 1 条 | 只含李四的行 |
| Node | 1 条 | 只含李四的行 |
换哪个语言的后端,委托的语义不变------这是 jeeflow 八语言联邦一贯的目标。
八、如果你从 mldong 内置版迁过来:三条方向差异
jeeflow 的委托语义与 mldong 框架内置工作流模块 有三处方向不同。两版都自洽,规范以 jeeflow 为准;迁移前,把自己环境里的委托配置对照下面三条普查一遍即可------重点关注三类配置:"全部流程"与"某流程"并存 、最近停用或改期过 、历史上有"委托给自己"的行。
| 判据点 | 内置版 | jeeflow | 迁移时留意 |
|---|---|---|---|
| 作用域优先级 | 全部流程优先 | 精确优先,判否回落 | 接管人可能不同 |
| 择优顺序 | 先过滤再取最新 | 先取最新再裁决 | 停用后是否落回旧记录 |
| 自委托 | 未单独过滤 | 自委托不生效 | 先清理自委托历史行 |
九、回到使用:一份小小的对照清单
- 出差 / 休假用委托 (加人,原人保留);某张单子不想管,用转办(换人)。
- 只针对某类流程:填流程名(流程模型名);全都要管:留空。
- 改期:直接改原记录的窗口;提前回岗:停用(或删除)该条。
- 代理人办完的单,审批记录记的是代理人的名字------谁办的,记谁。
参考资料(发布版保留纯文本链接)
- 委托代理规范(spec/06 §4.5):jeeflow-doc.mldong.com/spec/06-fac...
- 引擎仓库(八语言):github.com/mldong/jeef...
- 开源演示站(八语言在线体验):jeeflow-demo.mldong.com
- 集成演示站(十三套框架后端):jeeflow-pro.mldong.com
- 前端组件包(含委托页):www.npmjs.com/package/@ml...