主数据不能随便删:SaaS ERP 里的 inactive、archive、block 与策略引擎设计
做业务系统时,经常会遇到一个看起来很小的需求:
text
这个商品不用了,能不能删?
这个单位写错了,能不能删?
这个客户后续不合作了,能不能隐藏?
系统初始化的数据能不能停用?
如果只把它当成 CRUD,答案会很简单:提供一个删除接口,数据库打上删除标记,列表查不出来就结束。
但在 SaaS ERP 里,主数据不是孤立数据。商品、单位、客户、供应商、仓库、账户、税率这些基础资料,一旦被业务单据、库存流水、财务分录、报表或历史审计引用,它们就不再只是"当前配置",而是历史事实的一部分。
所以主数据生命周期治理的第一条原则是:
text
用户不想再使用某个主数据,不等于系统可以删除它。
这篇文章聊一次主数据生命周期策略引擎的设计。重点不是某个接口怎么写,而是怎么把 remove、inactive、archive、block 的业务语义拆清楚,再用 Policy / Executor / PreCheck / ReasonCode 把规则收敛起来。
1. 主数据为什么不能随便删
先看一个很常见的例子:商品单位。
某个商户早期把"箱"和"盒"的换算关系配错了,后来想删除重建。如果这个单位从来没被使用,删除没问题。但如果它已经出现在采购单、销售单、库存流水或历史报表里,直接删除就会产生连锁问题:
text
历史单据上的单位名显示不出来。
历史金额和数量解释不清。
库存换算口径丢失。
报表回填缺字段。
审计追溯时找不到当时的基础资料。
商品也一样。
一个商品不再售卖,不代表它可以从系统里消失。它可能还有库存,可能在未完成订单里,可能被 BOM、促销、价格策略、会员权益、采购记录或财务分录引用。
真正的问题不是"能不能删",而是:
text
这条主数据现在处于什么生命周期状态?
它是否还能进入新业务?
它是否必须保留历史回显?
当前动作应该允许、阻断,还是建议换一种动作?
2. remove、inactive、archive、block 不是一回事
很多系统混乱的根源,是把所有"不想再看到"的诉求都塞进删除。
更合理的模型是把动作拆开。

remove 是删除。它适合处理未被使用的误录数据,比如刚创建错的单位、未被任何业务引用的测试商品。它的前提是:没有库存、没有在途单据、没有历史交易、没有软引用。
inactive 或 disable 是停用。它表达的是:这条数据不再参与新业务选择,但历史记录仍然能回显。比如某个单位不再允许新商品使用,某个支付方式不再开放,某个客户暂时冻结交易。
archive 是归档。它比停用更偏长期收纳:从日常管理视图里收起,不再进入新业务入口,但保留身份、历史关系和审计价值。比如一个商品下架很久,库存和在途都处理完了,但历史销售、库存流水和报表仍然需要解释。
block 不是一个目标状态,而是一次动作的决策结果。它表示:当前动作不能执行。阻断本身还应该带上原因码和建议动作,比如"存在历史交易,不能删除,建议归档"。
如果用一句话区分:
text
remove 解决误录清理。
inactive 解决新业务禁用。
archive 解决长期收纳。
block 解决风险拦截。
这四个概念不拆开,系统会逐渐变成两难:删了会破坏历史,不删又污染新业务选择器。
3. 删除失败不应该只返回一个错误 message
早期删除接口经常这样设计:
json
{
"success": false,
"message": "该数据已被引用,不能删除"
}
这对用户来说不够。
首先,"被引用"太粗。到底是有库存,还是有未完成订单,还是有历史交易,还是系统内置数据?处理方式完全不同。
其次,前端无法做结构化交互。它只能弹一个 toast,用户看完还是不知道下一步做什么。
更好的返回应该是决策模型:
json
{
"entityId": 1001,
"action": "REMOVE",
"decision": "NEED_ARCHIVE",
"reasonCode": "HAS_TRANSACTION",
"suggestAction": "ARCHIVE"
}
这里的 message 可以保留给日志和调试,但用户可见文案不应该依赖它。前端应该基于 decision + reasonCode + suggestAction 做国际化和交互。
这有几个好处:
- 文案可以由前端统一管理,不受后端英文调试信息影响。
- 批量操作时可以逐行展示不同阻断原因。
- 后端能稳定表达规则,前端能稳定表达体验。
- 后续新增原因码时,不需要改一堆字符串判断。
4. 先预检,再执行
主数据生命周期操作最好拆成两步:
text
pre-check:告诉用户哪些能做,哪些不能做,为什么不能做,建议做什么。
execute:真正执行删除、停用、启用、归档、恢复。
预检不是为了让前端代替后端做校验。正式执行时,后端仍然必须再做最终校验。
预检的价值是提前给用户反馈,尤其是批量场景。
比如用户批量选择 20 个商品点"归档"。其中 12 个允许归档,5 个存在库存,2 个存在未完成订单,1 个是系统初始化数据。此时如果只在执行接口里返回失败,用户体验会很差。
预检可以把结果按实体 ID 拆开:
text
商品 A:ALLOW
商品 B:BLOCK / HAS_STOCK
商品 C:BLOCK / HAS_IN_TRANSIT_ORDER
商品 D:NEED_ARCHIVE / HAS_TRANSACTION
前端就可以只对允许的部分继续执行,或者展示一个确认弹窗,让用户理解每一行为什么不能做。
5. Policy / Executor 是策略模式,不是上帝服务
主数据生命周期有一个明显特点:入口可以统一,但规则一定不统一。
商品要看库存、在途订单、历史交易、BOM、促销。
单位要看系统内置、商品换算、历史单据引用。
客户要看应收、订单、储值、会员权益。
账户要看余额、流水、凭证、默认配置。
如果把这些规则都写进一个 if/else 大方法里,最后一定会变成谁也不敢改的上帝服务。
更合适的是策略模式:

通用执行器只做三件事:
text
根据 entityType 找策略。
根据 action 判断策略是否支持。
调用策略返回预检结果。
每个领域策略只关心自己的业务规则。
伪代码可以这样理解:
java
public interface LifecyclePolicy {
EntityType supportEntityType();
boolean supportAction(LifecycleAction action);
List<PreCheckResult> preCheck(PreCheckCommand command);
}
执行器大概是:
java
public List<PreCheckResult> preCheck(PreCheckCommand command) {
LifecyclePolicy policy = policies.stream()
.filter(item -> item.supportEntityType() == command.getEntityType())
.filter(item -> item.supportAction(command.getAction()))
.findFirst()
.orElse(null);
if (policy == null) {
return blockAsUnsupported(command);
}
return policy.preCheck(command);
}
这里的重点不是代码多漂亮,而是边界清楚:执行器不懂商品库存规则,也不懂单位引用规则。它只负责路由。
6. 为什么一期只做 PreCheck,不把执行也统一掉
很多人看到策略引擎后,会自然想把执行也放进去:
text
preCheck()
execute()
接口上当然可以预留 execute,但第一版不一定要开放。
原因很简单:主数据执行动作的副作用差异太大。
商品归档可能要更新商品主档状态,并影响 SKU 选择器。
单位停用可能只是更新启停用状态,但要保留历史回显。
客户停用可能要检查应收余额、未完成订单、会员权益。
账户禁用可能影响收款、付款、默认账户和财务分录。
如果过早把执行也塞到通用层,通用层就会开始承接事务、日志、权限、领域事件、缓存刷新、搜索索引同步等一堆副作用。最后策略引擎会从"规则路由"膨胀成"业务执行中台",反而破坏领域边界。
所以更稳的第一版是:
text
统一 PreCheck。
领域接口执行。
执行时再次校验。
等多个领域的执行动作真的稳定之后,再抽象通用执行模板,而不是一开始就把所有生命周期动作集中化。
7. ReasonCode 是产品和工程之间的契约
生命周期预检最有价值的字段不是 message,而是 reasonCode。
常见原因码可以包括:
text
SYSTEM_INIT:系统初始化数据
HAS_REFERENCE:存在引用
HAS_STOCK:存在库存
HAS_IN_TRANSIT_ORDER:存在未完成或在途单据
HAS_TRANSACTION:存在历史交易
BOUND_BY_BOM:被 BOM 引用
BOUND_BY_PROMOTION:被促销引用
UNKNOWN_ERROR:未知异常
这些原因码看起来只是枚举,实际是产品交互、国际化、运维排查和后端规则之间的公共语言。
比如同样是不能删除:
text
SYSTEM_INIT:告诉用户这是系统预置项,不能变更。
HAS_STOCK:引导用户先处理库存。
HAS_IN_TRANSIT_ORDER:引导用户先完成或取消单据。
HAS_TRANSACTION:建议归档,不建议删除。
HAS_REFERENCE:展示引用关系,或提示先解绑。
如果没有原因码,所有失败都会变成"不能删除"。这对用户和客服都没有帮助。
8. 新业务选择器要过滤,历史回显不能过滤
主数据生命周期上线后,最容易出问题的地方不是管理页,而是选择器和历史回显。

新建采购单、销售单、库存单、POS 搜索商品时,默认应该隐藏归档商品和停用单位。
否则用户会继续选到已经不该使用的旧数据,生命周期状态就失去意义。
但历史详情、报表、审计、按 ID 精确解析时,不能简单过滤归档或停用。
因为历史单据上当时用的就是这个商品、这个单位、这个客户。它现在归档了,不代表历史事实可以消失。
这就是主数据生命周期里非常重要的一条边界:
text
新业务入口看当前可用性。
历史回显看历史事实完整性。
如果把这两个口径混在一起,就会出现两类问题:
- 新业务还能选到不该使用的数据。
- 历史报表因为过滤了归档数据而显示不完整。
前者污染未来,后者破坏过去。
9. 行级锁定比整单锁定更可演进
商品多单位配置是一个典型例子。
如果某个商品下面有多个 SKU、多个单位换算关系,早期系统可能只返回一个整体锁:
text
这个商品已经被使用,整品配置不可编辑。
这个方案简单,但会牺牲很多正常操作。
比如商品 A 已经使用过"个"这个基础单位,但后来想新增一个"箱"的售卖单位。如果系统只支持整品锁,那用户连新增单位都做不了。
更细的做法是行级锁定:
text
已被历史单据或库存使用的 SKU:不能删除,不能改影响历史计算的字段。
未被使用的 SKU:允许删除或编辑。
已被使用的换算关系:不能改比例、精度、基础单位。
新的单位或换算关系:允许新增。
这类设计体现的是同一个原则:不要因为一部分历史事实需要保护,就把整个主数据对象永久冻结。
10. block 不一定是坏体验
很多产品会担心阻断太多会让用户觉得系统难用。
但在 ERP 里,真正糟糕的体验不是阻断,而是系统让用户做了一个不可解释的动作。
比如删除一个有历史交易的商品,短期看用户很爽,长期看会变成:
text
老订单商品名没了。
库存流水解释不清。
财务凭证关联不到业务对象。
报表维度缺失。
客服查历史查不到。
这时再修,成本远高于一开始阻断。
好的阻断应该做到三点:
text
告诉用户为什么不能做。
告诉用户应该先处理什么。
告诉用户有没有替代动作。
所以 BLOCK 不是一句"禁止",而是一套结构化反馈:
text
decision = BLOCK
reasonCode = HAS_STOCK
suggestAction = null
或者:
decision = NEED_ARCHIVE
reasonCode = HAS_TRANSACTION
suggestAction = ARCHIVE
11. 策略引擎的扩展方式
当后续要接入新的主数据类型时,不应该改通用执行器的大量判断。
更好的方式是新增一个领域策略:
text
CustomerLifecyclePolicy
VendorLifecyclePolicy
WarehouseLifecyclePolicy
AccountLifecyclePolicy
TaxLifecyclePolicy
每个策略只回答自己的问题:
text
我支持哪类实体?
我支持哪些生命周期动作?
这个动作在当前实体上能不能做?
如果不能做,原因是什么?
是否建议替代动作?
这样通用层稳定,领域规则可以持续演进。
在实现上,还要注意两个工程细节。
第一,批量预检要批量查询引用,不要每个实体循环查一次数据库。否则生命周期预检会变成管理页上的 N+1 查询。
第二,预检结果要按实体 ID 返回。批量操作最需要的是逐行反馈,而不是一个总失败。
12. 常见反模式
第一种反模式:所有不用的数据都走删除。
这会破坏历史事实,是 ERP 系统里最危险的简化。
第二种反模式:只做一个 status 字段,不区分停用、归档和删除。
字段看起来统一了,语义却混了。最后选择器、管理页、报表、历史回显都要靠猜。
第三种反模式:后端只返回 message,前端按字符串判断。
这会让国际化、批量展示和后续规则扩展都变得很脆。
第四种反模式:预检通过后,执行阶段不再校验。
预检和执行之间有时间窗口。别人可能新建了单据,库存可能变化,引用关系也可能变化。执行阶段必须兜底校验。
第五种反模式:通用生命周期服务直接跨领域查所有表。
短期写得快,长期会把领域边界打穿。通用层应该负责路由和统一结果模型,领域策略负责自己的引用判断。
13. 我会如何落第一版
如果让我给一套 SaaS ERP 做主数据生命周期治理,我会按这个顺序落地:
- 先定义动作:
REMOVE / DISABLE / ENABLE / ARCHIVE / RESTORE。 - 再定义决策:
ALLOW / BLOCK / NEED_DISABLE / NEED_ARCHIVE / CONFIRM_REQUIRED。 - 再定义原因码:系统内置、存在引用、存在库存、存在在途单据、存在历史交易、被 BOM 引用、被促销引用、未知异常。
- 建统一预检入口,只负责返回结构化预检结果。
- 建策略接口和执行器,执行器只按实体类型和动作路由策略。
- 商品、单位、客户、账户等领域各自实现策略。
- 真正的删除、停用、归档、恢复仍走领域自己的接口。
- 执行阶段重复校验,避免预检到执行之间状态变化。
- 新业务选择器默认隐藏归档/停用,历史回显和报表按 ID 保留解析能力。
- 管理页支持"包含已归档""包含停用"筛选,避免用户找不到旧数据。
这个版本不复杂,但它把主数据生命周期从"删不删"提升成了"状态、规则、风险、展示、历史事实"的完整治理模型。
14. 结语
主数据生命周期治理,本质上不是一个删除功能。
它是在回答一个更底层的问题:
text
业务对象进入历史之后,系统如何既保护过去,又不污染未来?
删除只能处理未被使用的错误数据。
停用让它不再进入新业务。
归档让它退出日常视野但保留历史身份。
阻断则把库存、在途、交易、引用这些事实摆到用户面前,并给出可执行的下一步。
对于 SaaS ERP 来说,主数据不是配置表里的几行记录,而是业务、库存、履约、财务和审计共同依赖的事实锚点。
主数据不能随便删。真正成熟的设计,是让它有生命周期。