做甲方数字化负责人这几年,审批流是我打交道最多的功能。上线容易,善后难——审批发出去想撤回、撤回后要改、改完重新提交,这一串看起来简单的动作,状态设计的坑一个接一个。去年我们的报销审批上了一次大改版,专门治理撤回与重新提交,这篇把设计过程和踩过的边界讲清楚。结论先行:撤回的本质是状态机回退,难点不在回退本身,而在回退时要不要连带处理已经发生的副作用——通知发了撤不回、并行会签撤一半怎么办,边界想清楚,代码才写得稳。
一、从一个投诉说起
1.1 撤回之后发生了什么
改版的直接起因是一个投诉。员工小张提交报销后发现有张发票金额填错,点了撤回,改完重新提交。问题来了:他的审批人是我,我手机上先后收到两条待办——撤回前的那条和重新提交后的那条。前一条其实已失效,但待办列表里它还活着,我差点批了那条废单。投诉的原话是:撤回到底撤没撤干净,你们系统自己知道吗。
1.2 老实现的缺陷解剖
翻代码看老实现,撤回就是把单据状态从审批中改回草稿,仅此而已。三个缺陷裸露着:已产生的待办通知不清理,审批人那边成为僵尸待办;撤回时审批人可能已经点了同意,已批节点与新状态矛盾,谁先谁后没有仲裁;重新提交后流程从头再走,前面已批的节点全部作废,审批人重复劳动。三个缺陷指向同一个病根:撤回被当成了一个简单的状态修改,而它实际牵动一整条流程链上的连带状态。
1.3 设计目标重新划定
改版目标定为四条:撤回后僵尸待办必须同步失效;已批节点的处置有明确规则而不是随机应变;重新提交尽量保留已批成果,减少审批人重复劳动;撤回窗口有边界,不能任何时刻都能撤。第四条最容易被忽略——撤回自由如果无边界,流程的严肃性就没了,审批这件事会变成拉锯战。
二、状态机的重新设计
2.1 状态与事件的梳理
头一件事是把散落在代码里的隐式状态全部显式化。单据状态明确为:草稿、审批中、已通过、已拒绝、已撤回、已作废。事件明确为:提交、撤回、同意、拒绝、重新提交。状态机核心是转移矩阵——哪些状态下哪些事件被允许,矩阵之外的动作一律拒绝。撤回的合法前提被严格限定:仅审批中状态可撤,且流程中不存在已经处理完成的会签仲裁节点。转移判断的核心逻辑:
function canWithdraw(order, operator):
if order.status != IN_APPROVAL: return NO, "状态不允许"
if operator != order.applicant: return NO, "仅发起人可撤"
if any(node.type == ARBITRATION and node.done): return NO, "已产生仲裁结论"
return YES
function withdraw(order):
assert canWithdraw(order) == YES
cancelPendingTodos(order) # 撤销全部在途待办
order.status = WITHDRAWN
archiveFlowSnapshot(order) # 已批节点存档为快照
notifyApprovers(order, "发起人已撤回")
2.2 已批节点怎么处置
撤回时已批节点怎么办,我们定了分层规则:发起后从未有人审批过的,重新提交走全新流程;有人已批的,重新提交时已批节点的意见作为快照存档,新流程里展示参考但不算数。曾经考虑过更激进的方案——已批节点直接继承,重新提交只走剩下的节点,被否掉了。原因是不安全:撤回往往意味着内容有修改,前面批的是旧内容,继承等于让审批人对没看过的内容背书。快照展示是折中:既尊重了前次劳动,又不让旧审批为新内容担保。
2.3 僵尸待办的清理
撤回动作里最见工程功力的是在途待办清理。待办可能散落在多个入口:应用内待办列表、消息推送、邮件。设计上以应用内待办为事实源,撤回时批量置为已失效状态,推送和邮件里的旧链接点开后引导到单据最新状态页。这里有个细节:清理必须与撤回在同一个事务里,先撤状态再清待办,中间失败就整体回滚,杜绝状态撤了待办还在的中间态。分布式通知做不到强一致,就做到最终一致加失效引导,用户体验上等价。
2.4 撤回窗口的边界
撤回窗口限定为:流程未走完之前均可撤,但存在两种例外。例外一:会签节点全部审批人已表态,结论已生成,此时不可撤——会签结论往往是下游动作的触发器,撤回会造成下游已执行动作悬空。例外二:流程带出了不可逆的自动动作,比如自动生成的凭证已入账,此时撤回入口直接关闭,提示走反向冲销流程。撤回的自由是流程给的,不是天然的,边界画在哪,流程的严肃性就立在哪。
三、重新提交的设计
3.1 重新提交不是简单再来一遍
重新提交的设计要点是差异感知。重新提交时系统对比新旧内容,生成变更摘要:金额从多少改到多少、附件增减几个、明细行变动几条。变更摘要随流程发给审批人,审批人一眼看到改了什么,而不是从头再看一遍。上线后我的直属体验:审一张改过的报销单,耗时从两分钟降到三十秒,看的只是差异。差异感知这件事,成本不高,收益极大,因为它替所有审批人省了重复阅读。
3.2 修改字段的分级
并非所有修改都该触发全流程重走。我们把表单字段分了级:金额、收款账户这类关键字段修改,重新提交走全流程;备注、附件补充这类非关键字段修改,且单据未进入财务处理环节,允许仅通知审批人确认而不重走流程。分级规则配置化,每种单据类型自己定义关键字段清单。分级让重新提交的成本和修改的影响对等,不至于改个错别字也要兴师动众。
3.3 撤回记录的留痕
每次撤回和重新提交全程留痕:谁、何时、以什么理由撤回,改了什么重新提交。留痕在两个场景兑现价值:审计时撤回链完整可查,防止借撤回之名行篡改之实;统计时撤回率成为流程健康度指标——某类单据撤回率异常高,说明表单设计有问题或规则不清,源头治理有了数据抓手。上线半年,报销类单据的撤回率从一成八降到半成,降幅主要来自表单必填项和智能校验的优化,撤回数据反过来推动了源头改进,这是设计时没想到的循环。
四、上线后的验证
新状态机上线后运行半年,僵尸待办投诉归零,撤回相关的咨询从每周几条降到每月一两条。审计部门在年中检查里专门表扬了撤回留痕链——以往的检查里,撤回重提是审计盲区,中间内容改了什么全靠猜,现在每一步都有快照可回放。流程健康度看板上线后,我们还发现一个反直觉现象:允许撤回之后,总审批时长反而缩短了——员工不再为了改一个错字而等到被拒绝再重提,少走了一整个来回。
五、总结
审批撤回的设计要点归结成四条:状态显式化,转移矩阵管住一切动作,矩阵之外无路径;副作用连带清理,撤状态必撤待办,同一事务不留中间态;边界明确,会签已决、不可逆动作发生即关窗,自由让位于严肃;重提有差异感知,分级触发流程,改多少走多少。这些设计没有一条是高深的,共同点只是把边界提前想清楚。流程功能大多如此——写得快的是状态修改,写得稳的是状态机,差别就在有没有把那些不合法的路径也设计进去。
常见问题
问:审批人已经点了同意,发起人同时点了撤回,谁赢?
答:用版本仲裁:每次操作带上它看到的单据版本号,落库时版本不匹配即失败。谁先提交谁生效,后到的操作收到版本冲突提示,界面刷新后按新状态引导下一步。关键是别让两个操作都成功——同意落库后撤回自然失败,撤回落库后同意按钮已失效。并发仲裁不做在提示语里,做在数据库的版本约束里,这才是硬保证。
问:撤回会不会被用来绕过审批,反复试探?
答:留痕加频次治理。撤回链全程记录,审计视角下反复撤改的单据自动标黄;频次上设软限制,同一单据撤回超过三次,重新提交时升级给上一级审批人并附全部撤改记录。治理的目标不是禁止撤回,而是让试探行为透明可见——透明本身就是威慑,比硬性封堵更有效也更合情理。
问:并行会签撤一半,有人批了有人还没批,怎么算?
答:看会签规则。任意一票通过即生效的:已有一票通过,结论已生成,撤回窗口关闭;全票通过才生效的:只要有任何一人未表态,撤回仍可行,已表态的作为快照存档。要点是撤回判定要看结论生成与否,而不是简单看有没有人点过。会签的撤回边界,由会签的生效规则反推,规则和边界必须配套设计。
问:重新提交后的单据,和原单据是什么关系?
答:建议新单号加关联链,而不是同一单号复用。新单据引用原单据编号,原单据状态标为已撤回并锁定只读。这样审计链路完整:原单的内容、撤回理由、新单的变更,一条链查到底。单号复用看似方便,实则让历史记录互相覆盖,对账和审计都是灾难。链条关系在界面上要透出:审批人能看到这张单的前世今生,信任感反而更强。