SpringBoot+Flowable 业务单据状态设计:审批、执行与作废,为什么不能共用一个 status
一张借用单显示“已通过”,资产管理员却说设备还没有归还;另一张采购单也显示“已通过”,仓库却只收到一半。两个页面都没有错,问题在于“通过”只回答了审批结果,却被用户理解成业务已经结束。当一个 status 同时承担审批、履行和有效性,越完整的企业业务,越容易被迫增加含义模糊的状态。
▲ 状态矩阵是“先审批、后执行”业务的示例,不是所有模块的统一规则。审批、执行和有效性共同决定能做什么,不必被塞进一条线性状态链。
一、状态设计首先是业务语言设计
状态是对某个业务问题的简洁回答。审批状态回答“是否获得授权”,执行进度回答“已经履行多少”,有效性回答“这张单据还能否继续使用”。它们可能有关联,但不是同一个问题。
例如,借用申请通过后,资产可以进入使用状态,但借用关系仍然持续到归还;采购订单通过后,可以分多次入库;报销申请通过后,也未必已经完成付款。把这些记录统一标成“完成”,会让列表简洁,却让业务失去重要信息。
真正需要统一的是状态的定义和操作语义,而不是强迫所有业务共用一套数字枚举。一个框架可以统一流程状态,但不能因此把资产、库存、付款和人事变更都视为相同的履行过程。
| 维度 | 需要回答的问题 | 常见表现 |
|---|---|---|
| 审批 | 当前授权决定是什么 | 未提交、审批中、通过、拒绝 |
| 履行 | 业务已经执行到什么程度 | 未执行、部分执行、完成,或待归还、已归还 |
| 有效性 | 是否允许继续产生新业务 | 有效、关闭、作废等业务定义 |
| 并发版本 | 用户依据的数据是否还是当前版本 | revision、更新时间或其他冲突依据 |
最后一项并不是业务状态,却会影响状态变更是否安全。两个人同时看到“待归还”,不能仅靠页面上的同一个绿色标签,保证他们不会创建重复归还业务。
也不必给每张表机械增加四个字段。若执行进度能够从已生效的明细事实准确汇总,可以不重复保存;若为了查询性能保留汇总字段,就要明确它是可重建的投影,而不是与明细各自维护的第二份事实。
二、借用、归还与资产卡片,分别在描述什么
1. 借用单获批,表示授权与占用已经发生
在 RuoYi Office 的资产实现里,业务流程回调会更新processStatus。借用单审批通过后,服务继续处理资产占用,并将借用单的returnStatus设置为待归还。这里已经存在两个不同的状态维度。
▲ 真实借用详情页面。页面上的流程结果与业务履行不能混为一谈,借用获批并不表示资产已经归还。
这是一个很好的业务边界:对当前资产借用实现,审批通过可以直接触发资产占用,不需要凭空再发明一个“待发放”状态;但整个借用关系的结束仍然需要归还业务。建模应忠于已有产品动作,而不是为了讲状态机而增加用户根本不会操作的步骤。
2. 归还是一笔新的业务,不是把借用单改回草稿
归还单通过sourceAllocateId关联来源借用单。提交时要校验来源是否是借用业务、是否已经通过、是否已归还,以及是否已有正在处理的归还申请。
▲ 真实归还详情页面。该历史样本的来源借用单字段为空,因此这里只展示归还页面结构,不把它当作来源校验已通过的证据;下文关联约束以当前源码为准。
这样保留了两个不同事实:某日批准借出,另一日批准归还。若只把原单状态从“使用中”改成“空闲”,查询当时为什么借出、何时归还、由谁确认时,就会缺少业务证据。
同样的思想可以推广到退货、退款、冲销和调拨,但不能直接复用“归还”的枚举。每一种反向业务都有自己的数量、时间和授权条件,所谓通用框架应该帮助保留这些差别,而不是消灭它们。
3. 资产卡片反映当前结果,单据记录形成结果的过程
归还审批通过后,当前实现会释放资产卡片上的使用信息,并回写来源借用单的归还状态和日期。卡片、借用单、归还单各有职责,不能把其中任意一个的状态当作全部事实。
▲ 真实资产卡片页面,用来查看当前结果。此处卡片与前两张单据并非同一资产,只用于说明页面职责,不作为同一次借还联调的证明。
需要注意,“当前结果”通常是不断变化的。如果设备之后再次借出,卡片会显示新的使用情况;历史借用单仍然应保留当时的业务依据。用当前卡片替代历史单据,会让过去的记录跟着现在一起变化。
三、先定义合法组合,再定义按钮和迁移
多维状态并不是任意组合。以“必须审批后执行”的业务为例,审批中且已经执行完成,通常不应出现;已通过且未执行,则可能是完全正常的待处理状态。但对于审批本身就触发执行的业务,某些中间组合可能只有瞬时意义,根本不需要展示给用户。
因此,应先列出业务不变量,再决定状态字段。例如:“未获授权不得占用资产”“已归还的借用关系不得再次归还”“来源业务不匹配不得回写”“失效单据不得继续增加执行事实”。这些规则比一张画得漂亮的状态流转图更有约束力。
| 操作意图 | 典型前置条件 | 形成的事实 |
|---|---|---|
| 提交借用 | 必填完整、资产可借、单据可提交 | 新的流程实例或审批请求 |
| 审批通过 | 回调属于当前单据和流程 | 授权结果及对应资产占用 |
| 发起归还 | 来源已通过、尚未归还、无在途重复申请 | 独立归还单据 |
| 归还通过 | 来源与资产关系仍然有效 | 释放占用并回写来源履行结果 |
| 关闭或作废 | 符合该业务的失效条件 | 保留原事实,限制后续动作 |
不要先把“撤回、取消、作废、冲正”都画成一根指向草稿的箭头。撤回通常针对审批过程;取消针对尚未履行的业务意图;作废意味着原凭证不再作为后续依据;冲正则通过新的事实抵消既有影响。这些词在企业里可能有不同约定,产品必须给出自己的定义。
已经产生库存、占用或资金影响后,直接恢复草稿尤其危险。页面看似回到了起点,相关系统却未必恢复。正确方式是先判断能否执行相应反向业务,以及反向业务是否需要新的审批和记录。
四、让每一次状态变化都能找到业务证据
一个可维护的设计会把主单、执行事实、流程关联与操作记录分开。主单适合提供当前概览;执行事实说明发生了什么;流程实例说明授权如何形成;审计记录说明是谁用什么命令改变了系统。
▲ 通用设计示意。当前资产模块的 processStatus、returnStatus、sourceAllocateId 提供实际参照;图中的统一命令记录和事件体系属于扩展设计。
如果业务支持部分执行,不建议只保存一个PARTIAL状态。还需要可计算的数量或金额依据。例如申请十件、实际执行六件、撤销一件,剩余量如何计算,必须有明细支持。状态只是对这些事实的归纳,不是替代事实的字符串。
对不支持部分归还的现有模块,也不能在文章里因为图中出现“部分执行”就宣称已经支持分批归还。是否允许、如何关联明细和校验数量,都需要额外设计和实现。抽象模型可以覆盖更广场景,但产品能力必须逐项核实。
流程回调先确认“我在处理哪张单”
下面保留当前资产回调的主要逻辑。它先查询业务单据,再校验流程业务类型,最后更新审批状态并处理获批动作;与这些逻辑无关的注解和周边代码已省略。
@Transactional(rollbackFor=Exception.class)publicvoidupdateProcessStatusForFlow(AssetBillTypeEnumflowBillType,StringbusinessKey,Integerstatus){Longid=Long.parseLong(businessKey);AssetAllocateDOexist=allocateMapper.selectById(id);if(exist==null){throwexception(ASSET_ALLOCATE_NOT_EXISTS);}assertFlowMatchesBill(flowBillType,exist);allocateMapper.updateById(newAssetAllocateDO().setId(id).setProcessStatus(status));if(BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(status)){onAllocateApproved(exist,flowBillType);}}这段实现证明了审批状态与业务动作的连接点,但不能仅凭@Transactional就断言具备跨系统恰好一次处理、乱序回调防护或完整事件去重。若业务允许撤回重提、重新发起流程,还应明确回调是否属于当前绑定的流程实例。
旧流程的迟到消息不能覆盖新流程的结果。更稳妥的扩展是让回调同时携带流程实例标识、业务关联和事件身份,在受控事务中检查匹配关系。无法确认归属的消息进入异常记录,而不是猜测应该作用于当前单据。
来源校验比“按钮只显示一次”更可靠
当前归还提交逻辑已经把来源业务与归还进度放到服务端校验。以下按源码整理关键分支,保留业务错误语义。
privatevoidvalidateReturnSource(AssetAllocateSaveReqVOrequest){LongsourceId=request.getSourceAllocateId();if(sourceId==null)return;AssetAllocateDOsource=allocateMapper.selectById(sourceId);if(source==null||!Integer.valueOf(ALLOCATE_TYPE_BORROW).equals(source.getAllocateType())){throwexception(ASSET_ALLOCATE_RETURN_SOURCE_INVALID);}if(!BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(source.getProcessStatus())){throwexception(ASSET_ALLOCATE_BORROW_NOT_APPROVED);}if(Integer.valueOf(RETURN_STATUS_RETURNED).equals(source.getReturnStatus())){throwexception(ASSET_ALLOCATE_ALREADY_RETURNED);}assertNoActiveReturn(sourceId,request.getId());}真实方法还会校验资产使用情况和公司范围,不能在落地时遗漏。查询到“没有在途归还”只是规则判断的一部分;若要在高并发下严格防止两个请求同时通过,还需要锁定同一来源资源或使用其他数据库原子约束。文章不把先查后写描述为天然并发安全。
五、并发问题要按命令处理,而不是按页面处理
用户打开页面之后,状态可能已经被别人改变。前端隐藏按钮可以减少误操作,但不能保证提交时的事实仍然相同。服务端必须在执行命令时重新检查状态、权限和数据版本。
▲ 建议时序:命令记录、状态和执行事实一起提交,外部消费者按事件标识处理。Outbox 等事务事件机制是增强方案,不代表所有现有模块都已采用。
下面是并发命令的设计伪代码。仓储接口代表数据库事务能力,前提是命令标识具有租户内唯一约束,版本更新是原子操作,冲突时会回滚本次事务。
@Transactional(rollbackFor=Exception.class)publicResultexecute(Commandcommand,Subjectsubject){Resultprevious=commands.findMatchingResult(subject.tenantId(),command.id(),command.payloadDigest());if(previous!=null)returnprevious;Billbill=bills.lockForUpdate(subject.tenantId(),command.billId());permission.requireOperate(subject,bill,command.action());if(bill.revision()!=command.expectedRevision()){thrownewStateConflictException("业务已变化,请刷新后确认");}rules.requireAllowed(bill,command);Executionfact=executionFactory.create(bill,command);executions.insert(fact);bills.applyTransition(bill,fact);Resultresult=Result.from(fact);commands.saveUnique(command,subject,result);outbox.append(BusinessEvent.from(fact));returnresult;}这里的幂等不是“重复请求直接返回成功”。同一个命令标识携带不同参数时,应报冲突;同一个业务对象上的不同合法操作,也不能被误判成重复。命令身份、业务身份和执行事实身份分别承担不同职责。
事务事件还应防止“数据库提交了,通知却丢了”。使用 Outbox 等方式时,先把待发送事件与业务事实一起保存,再由独立投递器重试。消费者仍然需要去重,因为投递成功但确认丢失时可能再次收到消息。
如果业务动作涉及外部设备、第三方接口或另一个数据库,单个本地事务不能把它们全部包住。此时可以设计“处理中、待核实、需要补偿”等明确的处理状态,但这些是操作执行状态,不应随手污染业务审批枚举。
六、前端应该展示业务语言,而不是暴露状态码组合
后端可以把可执行操作作为结果的一部分返回,例如允许查看、编辑、提交或发起归还,并带上不允许操作的原因。前端仍应遵守权限体系,但不需要在多个页面复制几十组状态判断。
显示上也应保留维度。借用单可以同时展示“审批通过”和“待归还”,而不是合成一个含义模糊的“处理中”。列表需要简洁时,可以选一个业务主状态,再在详情说明构成它的事实,关键是始终保持定义一致。
错误提示要告诉用户如何继续。“状态异常”几乎没有帮助;“该借用单已存在审批中的归还申请,请查看原申请”则能引导用户处理重复业务。对于并发冲突,返回最新状态和关联单号通常比要求用户反复点击更有效。
但是,可执行操作列表只能作为交互提示,不能作为授权令牌。攻击者可以跳过页面直接发请求,旧页面也可能携带过期操作。最终校验必须留在服务端的命令处理入口。
七、怎样验证状态模型没有隐藏矛盾
状态模型应围绕不变量测试,而不只测试“从 A 走到 B”。同一条业务路径的重复、逆序、部分失败与并发,往往比正常路径更容易暴露设计问题。
| 测试场景 | 要守住的不变量 | 预期结果 |
|---|---|---|
| 同一回调重复到达 | 不能重复制造执行影响 | 识别已处理事实或安全重放 |
| 旧流程回调晚到 | 不覆盖新实例状态 | 拒绝或记录为过期事件 |
| 两个归还申请同时提交 | 来源关系不能重复结束 | 按原子约束只接受合法请求 |
| 页面打开后别人已操作 | 不按旧状态继续写入 | 返回冲突并提示刷新 |
| 外部动作结果不明 | 不伪造业务成功 | 保留待核实状态与证据 |
| 作废已有履行的单据 | 不抹掉已经发生的事实 | 要求明确反向操作或拒绝 |
数据修复也需要同样的纪律。直接修改状态字段也许能让按钮重新出现,却可能破坏台账、数量和流程之间的关系。管理后台若提供纠错入口,应要求说明原因、列出影响对象,并通过专用业务操作完成,而不是对所有人暴露“任意改状态”。
对于历史数据,可以定期检查不变量,例如已归还来源单是否有对应有效归还记录、在用资产是否有可解释的占用来源。发现差异时,应先区分展示汇总滞后和真正业务缺失,再选择重建投影或补充纠错记录。
八、现有工程怎样渐进改造
第一步不是引入一个复杂状态机框架,而是给当前状态字段写清定义。把“审批状态”和“业务进度”混用的地方标出来,确认每个操作真正产生的业务事实。
第二步把散落在 Controller、页面和回调里的关键规则集中到业务服务,保留已有权限与租户边界。对于资产借用归还,可以先围绕来源单、在途归还和卡片占用建立测试。
第三步补齐并发与历史证据:识别命令、绑定流程实例、限定迁移、记录反向业务。并不要求每个模块马上引入事件总线,但不能让网络重试和人工纠错继续依赖运气。
第四步才考虑抽象共用组件。可以统一操作日志、冲突提示和命令执行骨架,但“什么算完成、什么情况下可以取消”必须留给业务定义。过早统一枚举,往往只是把不同模块的复杂性压进一个越来越长的 switch。
体验时,可按资产借用单、归还单、资产卡片三个入口查看真实行为。本文状态矩阵用于解释通用设计,资产实现以实际的借用和归还语义为准,不把未实现的部分执行或通用补偿引擎当作已有功能。
常见问题
一个状态字段一定是错误设计吗?
不是。简单、严格线性的业务可以只有一个状态。问题是把互相独立、会同时存在的业务事实塞进同一字段,导致组合越来越难解释。
审批通过后是否应该统一进入“待执行”?
不应一刀切。有的业务在审批回调中就完成主数据变更或资产占用,有的还需要仓库、财务等角色继续执行。状态应对应真实动作,不为了形式完整增加无用步骤。
有了数据库事务,是否还需要幂等?
需要。本地事务保证一次执行内部的提交关系,不自动识别两次请求是不是同一业务意图,也不保证跨系统消息只投递一次。
作废单据可以物理删除吗?
是否允许删除由业务留存与审计要求决定。已经形成履行事实的单据,通常需要保留可解释的来源和反向记录;不能只因为界面想隐藏,就删除支撑台账的证据。
结语
一个可维护的企业系统,不是状态枚举最多的系统,而是每个状态都能回答明确问题、每个动作都能找到业务证据的系统。审批、履行和有效性分清之后,前端更容易解释,后端更容易校验,发生异常时也更容易恢复。
RuoYi Office 的资产借用归还和流程回调提供了实际业务基础。在这些事实之上逐步补齐命令、并发与历史治理,比把所有问题交给一个 status 字段,更接近企业系统长期运行的真实需要。
如果这篇对你有用,点个「在看」或收藏。