工作流引擎的委托代批:一张单,两个办理人

116 阅读9分钟

一、一个场景:李四出差了,单子归谁批

孙倩递了一张请假单:10 月 14 日到 15 日休年假。单子走「含驳回流程」,审批链是 上级审批(李四)→ 经理审批(王五)。

李四这几天在外地出差。出发前,他在「我的委托」里配了一条:· 全部流程 · 委托给张三 · 10/13 ~ 10/16 · 启用。

当晚,单子流转到上级审批节点:

  • 李四的待办里有这张单;
  • 张三的待办里也有这张单;
  • 张三看完,写下「行程已知会,同意」提交,单子流转到经理审批;
  • 审批记录里,上级审批这一步的办理人:张三。

上图:李四配了一条委托(全部流程 → 张三,10/13 ~ 10/16);下图:保存后“我的委托”里的一条启用记录

这就是「委托代批」:配一条委托记录,之后所有"本该给某人"的审批单,在建任务那一刻自动把被委托人一并写进参与者——原人保留,两人任一可办。

看着只是"一张表 + 一个查询",但要在八种语言的引擎里做成同一套行为,需要钉死的语义有十来条。这篇按 配置 → 判据 → 并入 → 记录 → 跨语言 → 迁移 的顺序,把它完整讲一遍。以下所有截图和读数,都来自本机起的一套演示(Python 后端 + jeeflow-ui 组件包)。

二、委托不是转办:并入不替换、不接力、不扩名册

第一条设计决定:命中委托 = 把被委托人追加进参与者集合。不是替换,不是换人。

动作效果什么时候用
委托代批追加被委托人;原人保留出差 / 休假期间
转办换人,摘掉原办理人这张单交给别人
加签追加一个参与者加一个会签 / 知会人

同一张单:李四的待办里有它,张三的待办里也有它——原人保留、代理并入

三条细节同样是设计的一部分:

不接力。 A 委托了 B、B 又委托了 C——C 不会收到 A 的单。委托只对"建单那一刻的原始参与者"生效,被委托人自己的委托不再往外展开,从根上避免环状委托。演示里有个天然样本:孙倩自己配了"委托周明",她发起的单,发起节点的参与者是「孙倩、周明」——周明的委托人吴婷没有出现。

不扩名册。 串行会签的每一步都是独立建任务:代理人只进当一步任务的参与者,不会去动会签的投票名册。

只对新单生效。 参与者是任务落库时的一次性快照;改委托只影响之后新建的单,已经建出来的单不变——待办、已办、详情读的都是同一张参与者表,查询侧不用再维护第二套算法。

办理与记录:办理抽屉里申请信息回显、审批意见填写;审批记录里「上级审批」这一步,办理人=张三

三、什么时候算人:建单那一刻,一条判定链

委托的判定时机是固定的:任务参与者解析完成之后、落库之前,引擎对每个参与者查一次生效委托,命中就追加。

覆盖范围是全部建单路径,而不是只有"发起":发起、办理推进、串行会签的每一步、跳转、回退——每一次建任务都会重算一遍。单子是在流转中不断产生新任务的,只在发起处挂钩会漏掉后面每一步。

判定本身是一条单链:

判定链:先看当前流程作用域的最新一条;判否再看“全部流程”的最新一条;命中就把被委托人追加进参与者

  • 看这个人名下、当前流程作用域有没有委托行;
  • 有:取最新一条(按 id),对这条做四项判据;
  • 判否(没有记录、或这条没通过判据):再看全部流程作用域的最新一条——精确判否、全局兜底;
  • 命中:把被委托人追加进参与者。

判据四项:

  1. 时间窗:开始时间 ≤ 现在 ≤ 结束时间,两头允许留空(留空 = 该侧不限);
  2. 启用:只认 enabled == 1(白名单式判定,"2"、空值、脏值一律不算启用,第五节细说);
  3. 自委托:被委托人 ≠ 授权人(自己委托给自己,不生效);
  4. 作用域:流程名留空 = 全部流程兜底;填了流程名,就按流程模型名匹配(比如 leave)。

流程名的匹配口径:以流程模型名为准(流程 JSON 里的 name 字段,即 ProcessModel.name),模型未带时回落到流程定义表里的名字;判空前先 trim。所以配 leave 命中的是“流程模型名 = leave”的那支流程,而不是定义表里的显示名。

每个节点的参与者,各算各的委托:演示里「经理审批」节点的参与者,也自动并进了王五自己的委托人赵六。

四、取哪一条:先取最新,再裁决

同一个作用域下可以存在多条记录,取行顺序本身就是设计的一部分:

同一作用域,先按 id 取最新一条,再对这条做裁决;判否不回头找更旧的。

用起来就是一句话:你最后改的那条,就是生效的那条。

  • 出差改期:直接把窗口往后改,改完即生效;
  • 提前回岗:把这条停用,新单立即不再并人——不会"翻回"更早的记录;
  • 委托期间到了:窗口自会兜住(窗外的新单不并人),记录留着不影响。

本机实测(同一张单、同一个节点,只看上级审批的参与者是谁):

最新一条委托的样子上级审批参与者
窗内 + 启用 + 委托张三李四、张三
窗内 + 停用仅李四
窗内 + 自委托(改委托给自己)仅李四
窗内 + enabled=2仅李四

取哪一条落到 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 插件形态,跑的是同一组夹具。

在手边的四个后端上做了个只读抽查(同一个请求:查李四名下的委托分页):

后端返回的行返回范围
Python1 条只含李四的行
Java1 条只含李四的行
Go1 条只含李四的行
Node1 条只含李四的行

换哪个语言的后端,委托的语义不变——这是 jeeflow 八语言联邦一贯的目标。

八、如果你从 mldong 内置版迁过来:三条方向差异

jeeflow 的委托语义与 mldong 框架内置工作流模块有三处方向不同。两版都自洽,规范以 jeeflow 为准;迁移前,把自己环境里的委托配置对照下面三条普查一遍即可——重点关注三类配置:"全部流程"与"某流程"并存、最近停用或改期过、历史上有"委托给自己"的行。

判据点内置版jeeflow迁移时留意
作用域优先级全部流程优先精确优先,判否回落接管人可能不同
择优顺序先过滤再取最新先取最新再裁决停用后是否落回旧记录
自委托未单独过滤自委托不生效先清理自委托历史行

九、回到使用:一份小小的对照清单

  • 出差 / 休假用委托(加人,原人保留);某张单子不想管,用转办(换人)。
  • 只针对某类流程:填流程名(流程模型名);全都要管:留空。
  • 改期:直接改原记录的窗口;提前回岗:停用(或删除)该条。
  • 代理人办完的单,审批记录记的是代理人的名字——谁办的,记谁。

参考资料(发布版保留纯文本链接)