ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用集中式数据库管理Excel物料清单:开源BOM落地指南

用集中式数据库管理Excel物料清单:开源BOM落地指南 简介这份资源是一套基于C#开发的物料清单管理软件BMMS开源项目面向电子行业需要集中维护BOM与元器件数据的工程师、生产管理人员及二次开发人员。应用支持多用户实时协作与版本控制并与Ciiva电子组件搜索API深度集成可将物料清单、库存、价格、供应商信息统一纳入集中式数据库解决数据分散、协作低效等问题。资源包共50个文件以37个C#源码文件为主辅以5个DLL、项目工程文件、配置及图标等压缩包仅315KB。源码分为Ciiva.Api.Dto与ApiDemo两大部分涵盖认证请求、库存查询、定价获取、供应商组件匹配、替代料搜索等多个API调用模块结构清晰适合直接运行或作为二次开发基础。目前已有838人浏览学习。对于希望搭建内部BOM管理平台或掌握电子元器件API对接流程的开发者这份轻量级项目提供了可运行的示例与完整分层代码能帮助快速理解物料数据建模和外部服务集成的关键实现思路。1. 用集中式数据库管理Excel物料清单BOM Management Software 的开源玩法BOM Management Software 这个名字听起来有点正式但它要解决的问题非常具体把散落在 Excel 里的物料清单BOM收进一个集中式数据库让多人可以同时维护、查询、比对而不是靠微信传文件、靠文件名区分版本。很多工程师已经在 Excel 里维护 BOM 很多年单机用没毛病一旦要协同、要追溯、要做变更影响分析Excel 就明显撑不住了。这篇笔记会按照我实际落地的思路展开先说清楚为什么需要集中式再把最小可复现的建表、导入、校验步骤完整写出来最后告诉你最容易翻车的五个坑。如果你正在做硬件、电子产品或者任何依赖物料清单的研发管理工作这套开源方案值得你投入一两天试一遍。2. 为什么说 Excel 撑不起 BOM 的协作开源集中式数据库的选型逻辑2.1 物料清单的 Excel 日常版本混乱与数据孤岛物料清单的日常维护很多团队都是从一张 Excel 开始的。单个工程师维护单块板子的 BOM其实效率很高筛选、排序、VLOOKUP 都熟改起来也快。但一旦进入协作场景问题就成倍放大。最常见的状态就是共享网盘里出现一堆文件名BOM_v3.xlsx、BOM_v3_最终版.xlsx、BOM_v3_真最终版_with_备注.xlsx。每个人都觉得自己改的是最新的最后对不上版本的时候只能逐个打开比对。更让人头疼的是 Excel 里的合并单元格、隐藏行、跨 sheet 引用一旦有人把行列删错VLOOKUP 的引用链就静默断裂等你发现时往往已经带着错误 BOM 走了好几轮打样。这就是数据孤岛的本质同一份物料数据在多个文件里各自为政没有唯一事实来源。Excel 本身是优秀的表格工具但它不是数据库——锁定单元格、保护工作簿只是防手滑防不了多人同时编辑产生的覆盖冲突。集中式数据库把数据收拢到一处每个字段只有一份权威定义任何人读到的都是同一份最新的数据这是它最大的价值。2.2 集中式数据库解决的三个核心问题一致性、可追溯、并行写入集中式数据库把 Excel BOM 的管理方式从“文件共享”变成“数据服务”它解决的第一个问题是数据一致性。以前物料编码是靠人肉保证“同一种料叫法统一”现在由数据库主键和唯一约束兜底以前同一个物料在不同 Excel 里可能有不同写法现在一个 part_number 对应一行记录。第二个问题可追溯。Excel 里改一个数值是没有痕迹的除非你手动批注。数据库可以通过审计字段、变更日志、触发器把每一次修改记录下来谁改的、什么时候改的、把什么值从什么改成了什么。这在产品出了质量问题时尤其关键能直接定位到是哪一次变更引入了有问题的物料。第三个问题并行写入。多个工程师同时维护同一份 BOM 时数据库的事务和锁机制保证了不会出现互相覆盖。用共享网盘同时编辑一份 Excel后保存的人会覆盖先保存的人而数据库里每个连接各自会话通过事务隔离最后提交的结果是可预期的。我见过一个硬件创业团队五六个人维护一款产品的 BOM长期用共享网盘加 Excel每个版本发布前都要花半天人工核对差异。后来迁移到一个基于 SQLite 的集中式数据库配合一个简单的 Web 管理界面版本核对从半天缩短到十几分钟。不是功能多炫而是数据收口之后差异比对变得无比简单。2.3 开源方案的选型维度别一上来就套 ERP一听说要管 BOM很多人的第一反应是上 ERP。但 ERP 是重流程系统需要物料编码体系、审批流、采购模块配套光初始化就要几周。如果你只是想把 Excel 里的 BOM 收进数据库先别急着上 ERP用一个轻量开源方案跑通再升级也不迟。选型有几个核心维度。第一是部署成本。SQLite 这种嵌入式数据库零配置文件适合单机或小团队MariaDB 更适合多人和 Web 访问。第二是数据模型灵活度。你至少要能表达“父件包含哪些子件、用量是多少、替代料有哪些”如果表结构固化得太死后续加属性会非常痛苦。第三是导入导出兼容性。系统必须能从 Excel 导入也要能导出回 Excel否则前端同事和采购同事会拒绝使用。第四是权限分离。管理员、工程师、只读查看者三种角色要分开。对比维度自建轻量方案现成开源 BOM 工具部署成本低SQLite Python 脚本即可中一般需要 Web 服务环境数据结构灵活性完全可控随时加字段受工具原有模型限制Excel 导入导出自己写脚本最贴合自家格式依赖工具自带功能权限控制自己实现简单场景够用通常自带角色管理维护成本需要自己维护脚本跟随上游更新自建方案适合刚开始做集中化的团队数据量几千条量级、参与人数少、Excel 格式不统一的情况现成开源工具适合人多一点、希望开箱即用、能接受既有数据模型的团队。我的习惯是先跑通最小的端到端流程再决定要不要引入更完整的开源系统。3. 从 Excel 到集中式数据库的落地路径建表、导入、校验一气呵成3.1 先定数据结构物料主表、BOM 关系表和字段规范动手写代码之前先把数据结构定下来。我常用的最小模型是两张表加一张版本表parts 存物料主数据bom_items 存父子装配关系bom_revisions 存版本快照。不到万不得已不加字段够用就好后续可以扩展。-- 物料主表每种物料只有一行 CREATE TABLE parts ( id INTEGER PRIMARY KEY AUTOINCREMENT, part_no TEXT NOT NULL UNIQUE, -- 物料编码全表唯一 name TEXT NOT NULL, -- 物料名称 spec TEXT, -- 规格描述 unit TEXT DEFAULT pcs, -- 单位 category TEXT, -- 分类如电阻/电容/结构件 created_at TEXT DEFAULT (datetime(now)), updated_at TEXT DEFAULT (datetime(now)) ); -- BOM 关系表一个父件包含哪些子件 CREATE TABLE bom_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_part_no TEXT NOT NULL REFERENCES parts(part_no), child_part_no TEXT NOT NULL REFERENCES parts(part_no), qty REAL NOT NULL DEFAULT 1, -- 单个父件需要的数量 position_no TEXT, -- 位号如 R1、C3 note TEXT, -- 备注 revision TEXT NOT NULL DEFAULT V1, -- 所属版本 UNIQUE(parent_part_no, child_part_no, position_no, revision) ); CREATE TABLE bom_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, revision TEXT NOT NULL UNIQUE, description TEXT, created_at TEXT DEFAULT (datetime(now)) );字段规范里最值得注意的几点part_no 全表唯一这直接替代了 Excel 时代的“人工保证唯一”qty 用 REAL 而不是 INTEGER因为用量可能含小数比如胶水按克、线材按米position_no 参与了唯一约束因为同一个父件下可能同一个子件出现在多个位号。3.2 写一个可复现的导入脚本读 Excel、清洗、入库导入脚本是整个落地过程的核心。常见做法是用 pandas 读取 Excel清洗后写进 SQLite。不要手动打开 Excel 抄数据那样既慢又容易出错。下面这段脚本是我实际用的最小版本。import pandas as pd import sqlite3 import sys def normalize_part_no(s): 物料编码统一大写并去除首尾空格避免大小写不一致导致引用断裂 if pd.isna(s): return None return str(s).strip().upper() def import_bom_from_excel(xlsx_path, db_path, sheet_nameBOM): # 读取 Excelheader 用两层兼容“物料信息/用量信息”分组的表头 raw pd.read_excel( xlsx_path, sheet_namesheet_name, header[0, 1], dtype{物料编码: str} # 强制按文本读取避免零件号被读成数字 ) # 压平 MultiIndex 列名 raw.columns [ _.join([str(c) for c in col if Unnamed not in str(c)]) for col in raw.columns ] print(解析到列, list(raw.columns)) conn sqlite3.connect(db_path) # 每批导入放进一个事务中途出错自动回滚 conn.execute(BEGIN) inserted_parts 0 inserted_items 0 for _, row in raw.iterrows(): parent_no normalize_part_no(row.get(父件物料编码_)) child_no normalize_part_no(row.get(子件物料编码_)) if not parent_no or not child_no: print(f跳过空编码行第 {_ 2} 行) continue # 物料主表存在则更新不存在则插入 conn.execute( INSERT INTO parts(part_no, name, spec, unit, category) VALUES(?, ?, ?, ?, ?) ON CONFLICT(part_no) DO UPDATE SET nameexcluded.name, specexcluded.spec, updated_atdatetime(\now\), (child_no, row.get(子件名称_), row.get(子件规格_), row.get(单位_), row.get(分类_)) ) inserted_parts 1 # BOM 关系表 conn.execute( INSERT INTO bom_items(parent_part_no, child_part_no, qty, position_no, note, revision) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(parent_part_no, child_part_no, position_no, revision) DO UPDATE SET qtyexcluded.qty, noteexcluded.note, (parent_no, child_no, float(row.get(用量_, 1)), row.get(位号_), row.get(备注_), row.get(版本_, V1)) ) inserted_items 1 conn.commit() conn.close() print(f导入完成物料 {inserted_parts} 条BOM 明细 {inserted_items} 条) if __name__ __main__: # 用法python import_bom.py ./bom.xlsx ./bom.db import_bom_from_excel(sys.argv[1], sys.argv[2])逻辑说明pandas 读取 Excel 时用 header[0, 1]是因为很多工程师会把表头做成两层分组直接读单层表头会导致列名错位dtype 参数强制把物料编码按字符串读这是为了避免像“0021”这种编码被 Excel 自动转成数字 21。每个物料的插入都走 UPSERT重复导入时不会产生重复行而是更新已有行。参数说明xlsx_path 必须指向真实存在的 Excel 文件sheet_name 默认取名为 BOM 的工作表如果你们的表叫 Sheet1需要改这个参数db_path 是 SQLite 数据库文件路径不存在会自动创建。脚本执行前建议先跑一遍列名打印确认 MultiIndex 压平后的字段名和 row.get 的键一致。3.3 查询级 BOM单层展开、递归展开与孤儿件识别数据入库只是开始真正日常高频用的是查询。单层 BOM 查询直接从 bom_items 表过滤父件编码就行但多层 BOM 展开必须用递归查询。SQLite 支持 WITH RECURSIVE一条 SQL 就能把整棵装配树展开。-- 递归展开某个父件的完整 BOM 树 WITH RECURSIVE bom_tree AS ( -- 初始层直接查目标父件的子件 SELECT bi.parent_part_no, bi.child_part_no, bi.qty, bi.position_no, 1 AS level FROM bom_items bi WHERE bi.parent_part_no ASSY-1001 AND bi.revision V2 UNION ALL -- 递归层把上一层的子件当成父件继续展开 SELECT bi.parent_part_no, bi.child_part_no, bi.qty * bt.qty, bi.position_no, bt.level 1 FROM bom_items bi JOIN bom_tree bt ON bi.parent_part_no bt.child_part_no WHERE bi.revision V2 ) SELECT * FROM bom_tree ORDER BY level, parent_part_no;逻辑说明递归的初始层查出目标装配体的直接子件递归层用 JOIN 把上一层的子件作为下一层的父件继续查同时用乘法把用量累乘到根节点这样最后每一行都是相对总装配件的总用量。level 字段用来区分层级方便在结果里控制缩进。另一个高频校验是查孤儿件——出现在 bom_items 引用里但 parts 表里不存在对应物料或者反过来的情况。这在 Excel 时代几乎无法检查数据库里一条 LEFT JOIN 就能暴露全部问题。-- 查出引用了不存在物料的 BOM 行 SELECT bi.parent_part_no, bi.child_part_no, bi.qty FROM bom_items bi LEFT JOIN parts p ON bi.child_part_no p.part_no WHERE p.part_no IS NULL;3.4 增量更新与撤销机制拒绝全量覆盖式的重复劳动Excel 时代改 BOM 最怕的就是“覆盖式保存”明明只改了一个电阻的阻值却把整个文件发出去别人基于旧版做的修改全部丢失。集中式数据库的增量更新思路完全不同。常见做法是在导入脚本里识别变化量把 Excel 当前内容读出来后跟数据库里的版本做差集然后只更新变化的部分。def diff_bom(df_new, db_path, revision): 对比 Excel 与数据库既有版本输出新增/删除/变更三类差异 conn sqlite3.connect(db_path) old_df pd.read_sql_query( SELECT parent_part_no, child_part_no, qty, position_no FROM bom_items WHERE revision ?, conn, params(revision,) ) new_keys set(zip(df_new[父件物料编码_], df_new[子件物料编码_], df_new[位号_])) old_keys set(zip(old_df[parent_part_no], old_df[child_part_no], old_df[position_no])) added new_keys - old_keys removed old_keys - new_keys print(f新增 {len(added)} 条删除 {len(removed)} 条) for key in added: print( , key) for key in removed: print( -, key) conn.close()这个函数不会直接改数据而是先把差异打印出来人工确认后再执行写入。把“导入”拆成“对比”和“执行”两步就是给自己的后悔药。执行时再包上一层事务一旦发现误操作立刻 ROLLBACK比在 Excel 里按 CtrlZ 可靠得多。撤销机制同理每次导入前自动备份当前版本到 bom_revisions 表带时间戳和描述。回滚时只需要把备份表的数据拷贝回 bom_items整个过程都是可重复的不用再找历史文件。4. 集中式 BOM 数据库落地的五条血泪坑现象、原因、解决4.1 多级表头被读成 MultiIndex 导致列对不上现象pandas 读出来的 DataFrame 列名变成奇怪的二元组比如 (“物料信息”, “编码”)然后脚本里怎么取列都取不到正确值而且 Excel 里看着正常的表头到了 DataFrame 里全是 Unnamed。原因Excel 的表头不是一行而是两行分组表头。第一层是“物料信息”“用量信息”这种分组第二层才是真正的字段名。如果不处理就直接读pandas 会把两层合并成 MultiIndex。解决读取时显式写 header[0, 1]然后用列表推导式把 MultiIndex 压平成“父级_子级”的单层列名。更稳妥的做法是在脚本里加一个列名检查打印出最终列名再继续不要盲猜。4.2 Excel 单元格类型推断让数量和物料编码变成文本现象导入后查数据库发现 part_no 变成了科学计数法或者数字截断比如编码 0021 被存成了 21或者用量字段里有少量非数字字符导致整列被推断成文本数量计算全部报错。原因Excel 单元格本身没有严格的类型约束pandas 读取时会自动推断整列类型。某列大部分是数字、偶尔有空白或文字就会被读成 object 类型后续处理全部走字符串逻辑。解决读 Excel 时对物料编码、规格这类列显式传 dtypestr对用量列用 numeric 并在清洗时强制转换失败的错误值处理掉。原则是源数据在入库前必须明确类型数据库这边字段类型定义清楚不能把类型判断丢给 pandas 自动决定。4.3 组件被多个父件引用时数量翻倍问题出在明细行现象递归展开后某颗物料的总用量明显比实际多。自查发现展开结果里同一层出现了同一子件重复行把几行加总就超了。原因bom_items 的唯一约束没有覆盖完整。同一个子件被同一个父件的多个位号引用时比如一块板子上有 4 个相同的 10KΩ 电阻对应 R5、R11、R22、R37 四行“父件子件”本身重复但加上了 position_no 才不冲突。如果唯一约束漏了 position_noUPSERT 就会互相覆盖。解决在数据模型阶段就定义好唯一约束为 parent_part_no child_part_no position_no revision。导入后做一次自查查询按这四个字段分组统计看有没有违反唯一约束的行。递归展开时按 position_no 分行列出而不是直接合并数量这样既能看总量也能看分布。4.4 大小写和空格不一致导致引用链静默断裂现象递归展开时发现某个子件明明在 parts 表里有记录却怎么都 JOIN 不上。查询结果比 Excel 里少了十几行但没有任何报错。原因Excel 里物料编码的输入标准不受控制同一颗料有时写 “10K-R0402”有时写 “10k_r0402”还有人前面带了个看不见的空格。字符串比较是精确比对任何一个字符差异都会导致 JOIN 不上而 LEFT JOIN 不会告诉你有问题只是返回 NULL。解决导入脚本里强制 normalize所有物料编码统一大写并 strip 首尾空格这部分在图 3.2 中已经预留了 normalize_part_no 函数。同时建一个唯一索引在 part_no 上做大小写不敏感约束从源头杜绝脏数据混入。4.5 循环 BOM 让递归查询死循环必须做深度限制现象递归查询跑了几分钟还在转甚至直接报错栈溢出。检查数据发现 A 的子件里有 BB 的子件里又有 A形成了循环引用。原因Excel 时代没人会故意这么写但复制粘贴行时很容易把父件号错当成子件号粘进去。数据量小的时候可能没触发一旦递归展开整棵树循环就变成了死循环。解决递归 SQL 加 LEVEL 上限我一般限制在 10 层超过就报错停止。同时做一次全库校验查出所有循环引用的组合。最简单的做法是把全部父件-子件对加载到内存里做一次 DFS 检测环发现问题立刻修复数据。这类问题越早发现代价越小拖到生产环境就是灾难。5. 把集中式 BOM 库用成团队的默认工具审计、导出与 ERP 对接的几个习惯5.1 用事务和摘要模式给导入脚本留后悔药导入脚本不要设计成“双击就执行”我一般给脚本加两个参数--dry-run 只打印差异不写库--commit 才真正提交。改成这个习惯之后误操作率大幅下降。第一次跑不熟悉的 Excel 时永远先走 dry-run。python import_bom.py ./bom.xlsx ./bom.db --dry-run python import_bom.py ./bom.xlsx ./bom.db --commit逻辑说明dry-run 模式下把所有差异、新增、删除、变更都打印出来人工确认没毛病之后再 commit。数据量大的时候可以在数据库层再做一层备份导入前把目标版本的表拷贝到备用表导入不满意直接回滚。这个习惯在多人协作时给了所有人安全感——改坏了不是问题问题是没有后悔药。5.2 数据层加审计谁改的、什么时候改的、改了什么集中式数据库最大的隐藏福利是审计能力。我在 bom_items 表上加了两个字段 updated_by 和 updated_at并在应用层写入时带上当前用户名和本地时间。在此基础上加一张变更日志表每次 UPSERT 都把旧值和新值写进日志。CREATE TABLE bom_change_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_part_no TEXT, child_part_no TEXT, position_no TEXT, old_qty REAL, new_qty REAL, updated_by TEXT, updated_at TEXT DEFAULT (datetime(now)) );本质上是在业务层写触发器而不是靠人记“这个版本我改了什么”。某个物料出了质量追溯问题直接查这张表几分钟就能定位到是哪次变更引入的。这一点 Excel 再怎么用也做不到。5.3 导出 Excel 报表时把格式和锁定一起带走把数据收进数据库只是个开始如果导出回 Excel 的体验比原有表格差很多团队会拒绝使用。导出脚本里我会固定做三件事表头加粗加底色、按列宽自适应、对物料编码和用量单元格加保护锁定。这样导出的表格可以直接发采购或生产不用二次加工。from openpyxl import Workbook from openpyxl.styles import Font, PatternFill def export_to_excel(rows, out_path): wb Workbook() ws wb.active headers [父件编码, 子件编码, 用量, 位号, 备注] ws.append(headers) for col, _ in enumerate(headers, 1): ws.cell(1, col).font Font(boldTrue) ws.cell(1, col).fill PatternFill(start_colorDDDDDD, end_colorDDDDDD, fill_typesolid) for row in rows: ws.append(row) wb.save(out_path)我以前吃过一次亏导出的表格没有冻结首行采购同事下拉几百行之后经常看错表头后来加上冻结窗口再也没人抱怨。整个系统跑起来之后你会发现集中式 BOM 管理真正的收益不是“上了一个新系统”而是把最基础的数据资产变成了可以查询、可以回溯、可以自动校验的东西。我自己的习惯是任何新项目进来第一件事就是先建库再导 BOM坚决不在 Excel 里开始画板——这个习惯帮我省下了无数核对版本的时间。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进