BOM多视图与版本基线:NX装配映射、大树性能及状态权限实践
BOM 这两个字,在研发产品设计管理平台这个圈子里出现频率极高,但真正把它做扎实的项目并不多。我参与过的这套基于 NX 的平台,第一版已经把设计 BOM 从三维装配里抽了出来,能按树展开、能导出表格、能看图号,当时团队觉得这块基本收工。结果三个月后,制造端拿着一份自己手工维护的清单找上门,说平台上的 BOM “只能看不能用”。这件事让我彻底改变了看法:BOM 的设计与管理,难的不是把树画出来,而是让这棵树在不同角色、不同时间点上都说得通。这篇接着上一篇聊,重点放在多视图拆分、NX 装配映射、版本基线、大树性能与状态权限这几件真正会让人返工的事上。如果你正在做类似平台,或者正准备接手 BOM 模块,下面这些内容应该能帮你少走几段弯路。
1. 为什么第二篇要谈多视图,而不是继续讲表结构
第一篇里我们把 BOM 的表结构、父子关系、基本的增删改查过了一遍,那是地基。但地基之上盖什么房子,取决于业务到底要几个“视角”去看同一批物料。很多平台在第一版只做了一个视图,叫“BOM”,然后所有部门都往这一个视图上提需求,最后它变成了一棵谁都不敢动的怪物树。
1.1 单视图 BOM 撑不过三个月的三个信号
第一个信号是制造端开始在平台外维护 Excel。他们的理由很朴素:平台上的 BOM 是按装配层级组织的,而车间要按工序、按工位、按采购批次去领料,层级对不上。你让他们在平台上凑合看,他们就干脆导出后自己改一份,改完的这份才是“真 BOM”,平台上的那份沦为参考资料。
第二个信号是同一个零件在不同产品里出现了不同的件号。这往往不是编码规则的问题,而是设计端复制模型时改了名、或者从旧项目直接搬过来没走编码申请。等到采购对账时发现两个件号指向同一个供应商物料,价格却谈了两遍,问题才暴露出来。
第三个信号是变更单批下来了,但没人说得清在制品受不受影响。设计说我只改了一个安装孔,制造说这批零件已经投产了,仓库说还有 200 件旧版在架。这三个回答拼不到一起,本质上是平台只有一个“当前 BOM”,没有“某个时间点的 BOM”。
这三个信号只要出现一个,就说明单视图方案已经到顶了,必须拆视图。拆视图不是为了好看,是为了让每个角色只面对自己关心的那部分结构,同时共享同一套底层物料主数据。
1.2 设计 BOM、制造 BOM、服务 BOM 各自回答什么问题
我把这三者的差别整理成一张表,这张表在项目里被反复拿出来对齐认知,效果比开三次会都好。
| 维度 | 设计 BOM(EBOM) | 制造 BOM(MBOM) | 服务 BOM(SBOM) |
|---|---|---|---|
| 核心问题 | 产品由哪些零件构成 | 产品怎么被造出来 | 产品坏了怎么修 |
| 组织依据 | 装配层级、功能模块 | 工艺路线、工序、工位 | 可更换单元、维修层级 |
| 粒度 | 到零件、标准件 | 到毛坯、半成品、辅料 | 到备件包、易损件 |
| 数量含义 | 装配用量 | 含工艺损耗的领用量 | 备件推荐量 |
| 主要责任人 | 设计工程师 | 工艺工程师 | 服务工程师 |
| 变更节奏 | 随设计迭代,较频繁 | 随工艺定型,相对稳定 | 随故障统计,周期长 |
理解这张表的关键在于:三者不是三份数据,而是同一套数据的三种投影。设计 BOM 是源头,制造 BOM 在它基础上增加工艺性节点(比如把一根型材拆成下料、焊接、机加工三道),服务 BOM 则做聚合(把一组螺钉打包成一个维修包)。如果平台把它们做成三套独立数据,后面同步会变成噩梦;如果做成一套数据加视图配置,初期建模会累一点,但长期省事得多。
我的建议是底层统一用“物料 + 父子关系 + 属性集”这一套模型,视图差异通过节点类型和视图标记来表达。一个节点可以同时属于 EBOM 和 MBOM,但在 MBOM 里它多挂了工艺属性。这样既不会数据冗余,也不会因为视图切换导致对不上账。
2. NX 装配树到平台 BOM 对象的映射细节
这是整个模块里最容易出问题、也最考验设计者功底的一段。三维工具里的装配结构和数据库里的树形结构,看上去都是树,实际上语义差别很大。直接按“一个组件一行”去映射,十有八九会在后面某个环节翻车。
2.1 装配实例、零件版本与唯一件号的三层拆解
在 NX 里,一个零件文件可以被同一装配多次引用,每次引用是一个组件实例,各自有独立的定位矩阵、抑制状态、引用集设置。这意味着“零件”和“零件在装配中的出现”是两个概念。
如果平台 BOM 的一行对应“零件”,那么同一个零件被装两次就只算一行,数量写 2,看似合理,但丢掉了两个安装位置的信息。做装配工艺的人需要知道这两个位置分别在哪、装配顺序如何,一旦丢失,MBOM 就编不出来。
如果平台 BOM 的一行对应“实例”,那么结构完整,但数据量会成倍膨胀,一棵三千个零件的装配,实例节点可能上万。
我的做法是三层分离:
- 件号层(Part Number):唯一标识一个物料,全局唯一,是采购、库存、成本的锚点。
- 版本层(Revision):件号下的一次定型,属性、模型、图纸都挂在这一层。
- 出现层(Occurrence):一次装配引用,记录父节点、路径、数量、位号、定位信息。
平台上展示的树,本质是“出现层”的树,但每一行都指向一个“版本”,版本再指向“件号”。这样做的好处是:做 BOM 对比时按件号+路径比较,做物料统计时按件号聚合,做装配说明时按出现层取位号,三个需求互不干扰。
2.2 属性映射表怎么定:固定字段、扩展属性与继承规则
属性映射这件事,最怕的是“工程师说这个属性也要带过来”,然后你一个个往上加,最后表宽到没人看得懂。我的经验是先划三条线:固定字段、扩展字段、继承规则。
固定字段是平台自己要用到的,必须有,且格式统一。扩展字段来自设计工具里的自定义属性,走一套属性字典注册机制,不直接进主表。继承规则用来处理“子件没填、父件有值”的情况,比如材料、表面处理、重量单位。
下面这张表是我们在项目里实际落地的一版固定字段映射,供参考:
| 平台字段 | 数据来源 | 取值方式 | 必填 | 说明 |
|---|---|---|---|---|
| 件号 | 零件属性中的编号字段 | 直接读取 | 是 | 唯一键,比较时忽略大小写与首尾空格 |
| 名称 | 零件属性中的名称字段 | 直接读取 | 是 | 多语言环境取主语言 |
| 版本 | 版本属性 | 直接读取 | 是 | 与发布状态绑定,草稿版本不参与发布 |
| 数量 | 装配中的实例计数 | 按父子关系聚合 | 是 | 仅对同件号同父节点聚合 |
| 材料 | 材料属性 | 直接读取 | 否 | 影响后续工艺路线推荐 |
| 单重 | 计算属性 | 体积乘密度 | 否 | 密度取材料库,缺省值要显式标注 |
| 位号 | 实例属性 | 直接读取 | 否 | 电气、线束类零件必需 |
| 备注 | 自定义属性 | 白名单读取 | 否 | 只允许注册过的键进入 |
提示:数量字段一定要明确“是装配用量还是含损耗用量”。这两种语义混在一列里,后面做领料计算必然出错,而且错得很隐蔽。
2.3 映射过程中最容易丢的三类信息
第一类是引用集差异。设计人员为了减轻模型负担,常把装配做成简化表示,某些子件在简化表示里不加载。如果你按“当前加载的组件”去抽取,就会漏掉一批零件。正确做法是遍历装配结构的完整定义,而不是遍历当前显示状态。
第二类是抑制与替换。装配里存在被抑制的组件,也存在可替换件(比如同一安装位可以装 A 型或 B 型传感器)。如果平台只取“生效”的那一个,可配置产品的 BOM 就做不出来。我的处理是把抑制状态和替换组都作为属性带过来,在平台上用配置条件去筛选,而不是在抽取阶段就丢掉。
第三类是装配族成员。同一个族表可以生成多个变体,每个变体的零件组合不同。抽取时必须按变体逐个展开,否则平台上的 BOM 永远是“标准型”,客户要的选配型根本对不上。
这三类信息丢失的共同特点是:抽取当下看不出来,等制造或服务端提需求时才暴露,那时候数据已经入库,回补成本极高。所以我在项目里坚持一条:抽取器必须支持“全量结构导出”模式,把抑制、替换、族表信息一并落库,哪怕前端暂时不展示。
3. 版本、基线与变更:BOM 其实是一条时间轴
很多人把 BOM 理解成一棵树,这个理解只对了一半。加上时间维度之后,BOM 更像是一条不断分叉、不断合并的时间轴,树只是某个时刻的切片。平台如果只存“当前树”,那么所有关于“当时是什么样”的问题都答不了。
3.1 基线冻结与“有效日期 + 版本”的双维度追溯
基线的本质是一次不可变快照。做法上有两种:全量快照和增量快照。全量快照是把当时整棵树复制一份存起来,查询简单,但存储开销大;增量快照只存与上一版基线的差异,省空间,但查询要回放链条,实现复杂度高。
我选的是全量快照 + 压缩存储。原因很实际:BOM 查询的频率远高于写入频率,回放链条带来的复杂度和性能不确定性,在项目周期里是负担不起的。一个产品基线,几万个节点,序列化压缩后通常也就几兆,完全在可接受范围内。
光有基线还不够,还要有有效日期。工程实践中经常出现“这批货按旧版做,下批按新版做”的情况,所以每个基线要带生效时间和失效时间。查询时用“业务日期落在有效区间内”来定位版本,而不是用“最新版本”。这个设计在试制转量产阶段特别有用。
3.2 BOM 差异比对的实现思路与结果呈现
变更评审会上,最常被问的一句话是“到底改了什么”。如果平台答不上来,评审就只能靠人肉翻图纸。差异比对功能看着不起眼,实际使用频率非常高。
我的实现思路是把树拍平成行,用“件号 + 路径”作为比较键,做三路归并。伪代码如下:
def diff_bom(old_rows, new_rows): def key(r): return (r["part_no"].strip().upper(), r["path"]) old_map = {key(r): r for r in old_rows} new_map = {key(r): r for r in new_rows} added, removed, changed = [], [], [] for k in new_map.keys() - old_map.keys(): added.append(new_map[k]) for k in old_map.keys() - new_map.keys(): removed.append(old_map[k]) for k in old_map.keys() & new_map.keys(): o, n = old_map[k], new_map[k] fields = ["qty", "revision", "material"] delta = {f: (o[f], n[f]) for f in fields if o[f] != n[f]} if delta: changed.append({"key": k, "delta": delta}) return added, removed, changed结果呈现上有个小技巧:不要只给三张表,要给一棵合并树。新增节点标绿、删除标红、变更标黄,父子关系保留。评审的人看树比看表快得多,尤其是结构变化的时候。
3.3 变更影响面分析:从哪里断,往哪里传
影响面分析是变更管理的核心价值。我的做法分两步:向上追溯和向下扩散。
向上追溯用路径前缀匹配。每个节点存了完整路径(比如/P100/A200/B300),要找某个零件的所有上级产品,就是查路径里包含该节点标识的所有行。这个查询用普通的 LIKE 前缀匹配就能做,前提是路径字段建了索引。
向下扩散要复杂一点,因为同一个子件可能被多个产品共用。这时候要区分“直接影响”(该子件本身的变更)和“间接影响”(因为父件变更导致的下层重算)。我的经验是,间接影响不要自动展开到最底层,否则一个通用螺钉的变更能拉出几千条影响记录,评审根本看不过来。做法是设一个展开深度阈值,超过阈值的只提示“存在深层影响”,让人工决定要不要继续下钻。
注意:影响面分析的结果一定要带“变更前版本”和“变更后版本”,只给件号没有意义。评审的人需要知道影响的是哪一版,才能判断在制品要不要拦。
4. 大 BOM 的加载与编辑性能:从全量拉取到按需展开
这一节讲的是最容易被低估、也最容易在上线后被投诉的部分。演示环境里几十个节点的树,怎么查都快;真实产品几万个节点,写法稍有不当就是十几秒的白屏。
4.1 一次全量查询引发的超时
项目早期我们犯过一个典型错误:打开 BOM 页面时,一次性把整棵树查出来,再交给前端渲染。后端用的是递归查询,每层递归里还要去查属性表,形成了典型的 N+1 问题。测试数据三千个节点时勉强能跑,等真实产品数据导进来,两万多个节点,接口直接超时。
排查过程其实很直观:先看数据库慢日志,发现同一条属性查询被执行了上万次;再看执行计划,属性表关联字段没建索引;最后确认业务上根本不需要一次性展示全树,用户展开一层看一层就够了。
修复方案分三块:索引补齐、查询改造、前端改懒加载。其中索引是最便宜的优化,成本几乎为零,收益却最大。我的经验是,BOM 相关的表,父节点字段、路径字段、件号字段这三个索引是底线,缺一不可。
4.2 路径枚举与懒加载的组合拳
树形结构在关系库里有几种常见存法,各有取舍:
| 方案 | 查询子树 | 查询祖先 | 插入 | 适用场景 |
|---|---|---|---|---|
| 邻接表(只存父节点) | 需递归,较慢 | 需递归,较慢 | 快 | 层级浅、改动频繁 |
| 路径枚举(存完整路径) | 前缀匹配,快 | 字符串解析,较快 | 需维护路径 | 查询多、层级深 |
| 闭包表(存所有祖先后代关系) | 直接关联,快 | 直接关联,快 | 写放大明显 | 查询极多、写入少 |
BOM 的典型特征是查询远多于写入,且层级可能很深(十几层不稀奇),所以我选的是路径枚举为主、邻接表为辅的组合:每个节点既存父节点标识,也存完整路径。展开某一层时,用父节点标识直接查子节点,一次一条 SQL;查整棵子树时,用路径前缀匹配,一次搞定。
-- 展开某一层 SELECT id, part_no, name, qty, has_children FROM bom_node WHERE parent_id = #{parentId} AND baseline_id = #{baselineId} ORDER BY seq_no; -- 查整棵子树 SELECT id, part_no, path, level FROM bom_node WHERE path LIKE CONCAT(#{nodePath}, '/%') AND baseline_id = #{baselineId};这里有个细节值得说:has_children这个冗余字段很值钱。前端渲染展开箭头时,如果没有它,每一行都要额外查一次“有没有子节点”,又是 N+1。加一个布尔字段,抽取时算好,渲染时直接用,成本极低。
4.3 批量编辑的事务边界与冲突处理
BOM 编辑的典型操作是批量改数量、批量替换件号、批量删除节点。这些操作如果不控制事务边界,很容易出现“改了一半失败”的脏数据。
我的原则是:一次用户操作 = 一个事务。用户在界面上勾选二十行改了数量,点保存,这二十行要么全成功要么全回滚。不要做成逐行提交,那样失败后用户根本不知道哪几行生效了。
并发冲突用乐观锁解决。每个节点带一个版本号字段,更新时带上旧版本号做条件,影响行数为零就说明有人先改了,返回明确的冲突提示,让用户刷新后重试。
UPDATE bom_node SET qty = #{newQty}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion};还有一个坑是删除节点。删父节点时子节点怎么处理?级联删除看起来干脆,但误删的代价太大。我的做法是删除前先做一次“影响预览”,把将被删除的节点数、涉及的关键件列出来让用户确认,同时软删除保留数据,配合回收站功能。这个设计在上线后救过两次场,都是工程师手滑选错了范围。
5. 状态机、权限与发布链路上的几处暗礁
BOM 不是一个静态文档,它有生命周期:草稿、评审、发布、冻结、作废。每个状态下谁能改、能改什么,规则没定清楚,后面必然出现“已发布的 BOM 被人偷偷改了”这种事。
5.1 状态流转与可编辑性的绑定
我们把状态定义成六个:草稿、设计中、评审中、已发布、已冻结、已作废。可编辑性直接跟状态绑定,不走额外配置,减少理解成本。
| 状态 | 可编辑结构 | 可编辑属性 | 可发起变更 | 下游可见 |
|---|---|---|---|---|
| 草稿 | 是 | 是 | 否 | 否 |
| 设计中 | 是 | 是 | 否 | 部分 |
| 评审中 | 否 | 否 | 否 | 部分 |
| 已发布 | 否 | 否 | 是 | 是 |
| 已冻结 | 否 | 否 | 否 | 是 |
| 已作废 | 否 | 否 | 否 | 否 |
关键点在于“评审中”和“已发布”两个状态必须锁死结构。很多平台的漏洞就出在这里:界面上按钮置灰了,但接口没做校验,有人直接调接口照样能改。所以状态校验一定要放在服务层,不能只靠前端。
5.2 权限粒度:按节点授权还是按角色授权
权限这块我踩过一次坑。最初设计成“按节点授权”,想法是精细,实际用起来完全失控——一个产品几千个节点,逐个授权不现实,最后大家全申请管理员权限。
后来改成三层:产品级角色 + 节点类型规则 + 字段级脱敏。产品级角色决定你能不能进这个产品;节点类型规则决定你能不能改标准件、外购件、自制件;字段级脱敏处理成本、供应商这类敏感信息。这样绝大多数场景用角色就能覆盖,少数特殊需求再用节点例外规则补充。
提示:权限判断要缓存,但缓存失效策略要跟着权限变更走。我见过因为缓存没失效,用户被降权后还能改数据的案例,排查了很久才发现是缓存问题。
5.3 发布快照与下游对接
发布这个动作,本质是“把当前树固化成一个不可变版本,并通知下游”。这里有两个要点。
一是快照要包含足够的信息,不能只存结构。下游拿到快照后要能独立使用,所以件号、名称、版本、数量、材料、单位这些基础属性必须一起进去,不能靠回查主表,否则主表一改,历史快照就失真了。
二是发布要有幂等性。同一个版本重复点发布,不能生成两份快照,也不能报错。做法是用“产品 + 版本 + 基线号”做唯一约束,重复提交直接返回已有快照。这个细节在小规模测试时不容易发现,上线后用户手抖多点几次就暴露了。
6. 几处踩过的坑,写出来省得你再踩一遍
数量字段的浮点误差。BOM 数量用浮点数存,做累加时会出现 0.30000000000000004 这种结果,导出到表格里很难看,更麻烦的是比对时判断不相等。我的做法是数量统一用定点小数存,保留四位,比较时按精度取整。这个改动看着小,但避免了大量“明明没改却显示变更”的误报。
循环引用。理论上 BOM 不该有环,但数据从外部导入时真会出现自己包含自己的情况。如果不做检测,展开树时就是死循环。我的做法是写入时做一次祖先链检查,路径字段里已经包含当前节点标识就直接拒绝,报错信息明确到具体行。
大小写与空格导致的重复件号。ABC-001和abc-001在数据库里是两条记录,在业务上是同一个东西。这类脏数据一旦进库,后续所有统计都偏。解决方式是在入口做归一化:去首尾空格、统一大写、去掉不可见字符,并且给件号建唯一索引(归一化后的字段)。存量数据用脚本清洗一遍,清洗前先出报告让人确认,别直接改。
删除节点后的孤儿数据。软删除父节点但没标记子节点,导致子节点还能被某些查询查出来,出现在不该出现的地方。修法是把删除做成一个事务,父节点和所有后代一起标记,同时路径字段加一个删除标记前缀,查询时统一过滤。
导入时的部分成功。批量导入 BOM,一千行里失败了三十行,如果整体回滚,用户要重来;如果部分提交,用户不知道哪三十行没进去。我的做法是返回详细的失败清单,包含行号、字段、失败原因,整体仍然回滚,让用户改完再导。这个体验比“部分成功”好得多,因为 BOM 数据的完整性比导入速度重要得多。
我个人在实际操作中的体会是,BOM 模块真正的难点从来不在技术选型上,而在“语义对齐”上——同一个词在设计、工艺、采购、仓储嘴里含义完全不同。平台的价值不是把这些差异消灭掉,而是给每个差异找到一个明确的存放位置,让不同角色看到自己想看的那一面,同时底层还是同一份数据。做到这一点,后面无论接多少下游系统,心里都有底。