别再给主数据只做删除:从停用、归档到 ReasonCode 策略引擎

主数据不能随便删:SaaS ERP 里的 inactive、archive、block 与策略引擎设计

做业务系统时,经常会遇到一个看起来很小的需求:

text 复制代码
这个商品不用了,能不能删?
这个单位写错了,能不能删?
这个客户后续不合作了,能不能隐藏?
系统初始化的数据能不能停用?

如果只把它当成 CRUD,答案会很简单:提供一个删除接口,数据库打上删除标记,列表查不出来就结束。

但在 SaaS ERP 里,主数据不是孤立数据。商品、单位、客户、供应商、仓库、账户、税率这些基础资料,一旦被业务单据、库存流水、财务分录、报表或历史审计引用,它们就不再只是"当前配置",而是历史事实的一部分。

所以主数据生命周期治理的第一条原则是:

text 复制代码
用户不想再使用某个主数据,不等于系统可以删除它。

这篇文章聊一次主数据生命周期策略引擎的设计。重点不是某个接口怎么写,而是怎么把 removeinactivearchiveblock 的业务语义拆清楚,再用 Policy / Executor / PreCheck / ReasonCode 把规则收敛起来。

1. 主数据为什么不能随便删

先看一个很常见的例子:商品单位。

某个商户早期把"箱"和"盒"的换算关系配错了,后来想删除重建。如果这个单位从来没被使用,删除没问题。但如果它已经出现在采购单、销售单、库存流水或历史报表里,直接删除就会产生连锁问题:

text 复制代码
历史单据上的单位名显示不出来。
历史金额和数量解释不清。
库存换算口径丢失。
报表回填缺字段。
审计追溯时找不到当时的基础资料。

商品也一样。

一个商品不再售卖,不代表它可以从系统里消失。它可能还有库存,可能在未完成订单里,可能被 BOM、促销、价格策略、会员权益、采购记录或财务分录引用。

真正的问题不是"能不能删",而是:

text 复制代码
这条主数据现在处于什么生命周期状态?
它是否还能进入新业务?
它是否必须保留历史回显?
当前动作应该允许、阻断,还是建议换一种动作?

2. remove、inactive、archive、block 不是一回事

很多系统混乱的根源,是把所有"不想再看到"的诉求都塞进删除。

更合理的模型是把动作拆开。

remove 是删除。它适合处理未被使用的误录数据,比如刚创建错的单位、未被任何业务引用的测试商品。它的前提是:没有库存、没有在途单据、没有历史交易、没有软引用。

inactivedisable 是停用。它表达的是:这条数据不再参与新业务选择,但历史记录仍然能回显。比如某个单位不再允许新商品使用,某个支付方式不再开放,某个客户暂时冻结交易。

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 做国际化和交互。

这有几个好处:

  1. 文案可以由前端统一管理,不受后端英文调试信息影响。
  2. 批量操作时可以逐行展示不同阻断原因。
  3. 后端能稳定表达规则,前端能稳定表达体验。
  4. 后续新增原因码时,不需要改一堆字符串判断。

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 复制代码
新业务入口看当前可用性。
历史回显看历史事实完整性。

如果把这两个口径混在一起,就会出现两类问题:

  1. 新业务还能选到不该使用的数据。
  2. 历史报表因为过滤了归档数据而显示不完整。

前者污染未来,后者破坏过去。

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 做主数据生命周期治理,我会按这个顺序落地:

  1. 先定义动作:REMOVE / DISABLE / ENABLE / ARCHIVE / RESTORE
  2. 再定义决策:ALLOW / BLOCK / NEED_DISABLE / NEED_ARCHIVE / CONFIRM_REQUIRED
  3. 再定义原因码:系统内置、存在引用、存在库存、存在在途单据、存在历史交易、被 BOM 引用、被促销引用、未知异常。
  4. 建统一预检入口,只负责返回结构化预检结果。
  5. 建策略接口和执行器,执行器只按实体类型和动作路由策略。
  6. 商品、单位、客户、账户等领域各自实现策略。
  7. 真正的删除、停用、归档、恢复仍走领域自己的接口。
  8. 执行阶段重复校验,避免预检到执行之间状态变化。
  9. 新业务选择器默认隐藏归档/停用,历史回显和报表按 ID 保留解析能力。
  10. 管理页支持"包含已归档""包含停用"筛选,避免用户找不到旧数据。

这个版本不复杂,但它把主数据生命周期从"删不删"提升成了"状态、规则、风险、展示、历史事实"的完整治理模型。

14. 结语

主数据生命周期治理,本质上不是一个删除功能。

它是在回答一个更底层的问题:

text 复制代码
业务对象进入历史之后,系统如何既保护过去,又不污染未来?

删除只能处理未被使用的错误数据。

停用让它不再进入新业务。

归档让它退出日常视野但保留历史身份。

阻断则把库存、在途、交易、引用这些事实摆到用户面前,并给出可执行的下一步。

对于 SaaS ERP 来说,主数据不是配置表里的几行记录,而是业务、库存、履约、财务和审计共同依赖的事实锚点。

主数据不能随便删。真正成熟的设计,是让它有生命周期。

相关推荐
黄俊懿16 分钟前
【架构师从入门到进阶】第五章:DNS&CDN&网关优化思路——第十节:网关安全-单向加密
网络·数据库·计算机网络·安全·架构·系统架构·架构师
怕浪猫20 分钟前
用了两年 AI 编程工具后,我重新理解了什么是「资深工程师」
算法·面试·架构
2601_963749101 小时前
越华环保集团|河湖工况下数字化污水治理云边协同采集架构实现
人工智能·架构
代码方舟1 小时前
零信任架构实战:基于天远手机携号转网V即时版构建自动化营销风控网关
人工智能·智能手机·架构·自动化
会周易的程序员1 小时前
企业私有 AI 算力服务器架构设计:异构四节点 + QUIC 微服务
运维·服务器·c++·人工智能·微服务·架构
2601_962218612 小时前
万象生鲜系统全链路溯源一码查询技术实现食材来源可查
分布式·微服务·云原生·架构
2601_962218473 小时前
万象生鲜系统跨仓调拨算法助力生鲜企业供应链数字化协同运营
大数据·运维·微服务·云原生·架构
MartinYeung54 小时前
[论文学习]激活差异揭示后门:SAE架构对比研究
人工智能·学习·架构
黄俊懿4 小时前
【架构师从入门到进阶】第五章:DNS&CDN&网关优化思路——第八节:网关-注入攻击与预防
sql·网络安全·架构·系统架构·架构师·架构设计