简介这是一款面向电子工程师、硬件研发团队及BOM管理初学者的开源物料清单协同管理软件解决Excel分散维护BOM导致的版本混乱、多人协作困难、数据孤岛等问题。软件基于C#开发采用集中式数据库架构支持实时多用户编辑、完整版本控制并深度集成Ciiva电子元器件搜索API实现BOM与供应商库存、价格、技术参数等信息的自动同步。资源包共50个文件含37个核心C#源码文件涵盖API调用、数据模型、主界面逻辑、5个运行依赖DLL、2个项目配置文件.csproj及图标、配置、本地化资源等整体仅315KB轻量易部署。目前已有838人学习下载读者可直接编译运行Demo项目快速掌握BOM数据建模、第三方API对接、桌面端协同架构设计等实战能力尤其适合需要落地轻量级BOM管理工具的中小研发团队。1. BOM Management Software为什么把Excel物料清单塞进集中式数据库不是“多此一举”而是产线停线前最后的止损动作某制造类客户曾用27个Excel文件维护同一款工业控制器的BOM——研发改了电阻值采购在另一个表里更新供应商编码生产又在第三个Sheet里标注替代料号。直到某天贴片机报错0402封装的电容被误发成0603整批PCBA返工。事后复盘发现三个Excel之间版本差异达11处而最后一次“同步”是靠微信发了个“最终版_v3_真的最终.xlsx”。这不是段子是真实发生的BOM失控现场。BOM Management Software物料清单管理软件的核心价值从来不是“把Excel存进数据库”这个动作本身而是通过集中式数据库强制建立变更可追溯、权限有边界、状态有快照、引用有校验的四重约束。它解决的不是“怎么存”而是“谁改的、改了什么、影响哪些、能否回滚”。本方案聚焦开源实现路径不依赖商业PLM系统不引入复杂中间件用轻量级技术栈PostgreSQL Python Flask Vue构建可落地的BOM中枢。适合中小研发团队、硬件初创公司、教育类实验室——你不需要懂ERP但必须能跑通pip install和psql。接下来所有步骤均基于真实部署过3个产线项目的最小可行配置参数已调至开箱即用级别。2. 从Excel到结构化数据库BOM数据建模与迁移脚本实操BOM不是扁平表格而是带层级、带属性、带关系的图状结构。直接pandas.read_excel()后to_sql()会埋下灾难性隐患父子关系断裂、版本混叠、单位缺失。必须先做语义建模再写迁移逻辑。2.1 BOM核心实体建模为什么不能只建一张bom_items表常见误区是把Excel所有列直接映射为数据库字段。但BOM本质是“物料用量层级状态”的组合体。我们定义四个核心实体materials物料主数据唯一料号、名称、规格、单位、基础属性bom_headersBOM头产品型号、版本号、生效日期、创建人、审批状态bom_linesBOM行关联头ID、父项料号、子项料号、用量、层级深度、是否替代料bom_revisions版本快照记录每次变更的diff摘要、操作人、时间戳提示bom_lines.parent_item_id和bom_lines.child_item_id均指向materials.item_code而非自增ID。这是保证跨BOM复用物料主数据的关键设计。2.2 Excel解析与清洗处理真实世界中的“脏BOM”真实Excel BOM常含合并单元格、空行、备注列、多级标题行。以下Python脚本完成三件事识别BOM结构、提取层级、标准化字段名。# parse_bom_excel.py import pandas as pd import re def detect_bom_structure(file_path): 检测Excel中BOM起始行、层级列、用量列 df pd.read_excel(file_path, headerNone) # 扫描前10行找含Level或层级的行作为标题行 header_row None for i in range(min(10, len(df))): row df.iloc[i].astype(str).str.contains(r(?i)level|层级|序号|item, naFalse) if row.any(): header_row i break if header_row is None: raise ValueError(未找到BOM标题行请检查Excel格式) # 读取实际数据跳过标题行以上 df_data pd.read_excel(file_path, skiprowsheader_row) # 标准化列名去除空格、转小写、替换特殊字符 df_data.columns [re.sub(r[^a-zA-Z0-9_], _, col.strip().lower()) for col in df_data.columns] return df_data def clean_bom_dataframe(df): 清洗BOM DataFrame填充层级、过滤空行、标准化用量 # 假设层级列名为 level 或 层级 level_col [c for c in df.columns if level in c.lower() or 层级 in c][0] # 向下填充层级Excel中常合并单元格仅首行有值 df[level_col] df[level_col].ffill() # 过滤完全为空的行 df df.dropna(howall) # 提取用量匹配 10 PCS、2EA、5.5uF 中的数字部分 qty_col [c for c in df.columns if qty in c.lower() or 用量 in c][0] df[qty_col] df[qty_col].astype(str).str.extract(r([\d.]), expandFalse).astype(float) return df # 使用示例 if __name__ __main__: raw_df detect_bom_structure(sample_bom.xlsx) cleaned_df clean_bom_dataframe(raw_df) print(cleaned_df[[level, part_number, description, qty]].head())逻辑说明detect_bom_structure()不依赖固定行号而是动态扫描关键词定位标题行适配不同模板clean_bom_dataframe()中ffill()处理Excel合并单元格导致的层级缺失这是90%的BOM导入失败根源用量正则提取r([\d.])专为硬件BOM设计——它能捕获2.2KΩ中的2.2但跳过R102中的102避免误判。2.3 PostgreSQL建表与数据注入带约束的初始化SQL建表语句必须包含业务强约束而非仅语法正确-- 创建物料主表 CREATE TABLE materials ( item_code VARCHAR(50) PRIMARY KEY, name VARCHAR(200) NOT NULL, spec TEXT, unit VARCHAR(20) DEFAULT PCS, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- 创建BOM头表一个产品型号可有多个版本 CREATE TABLE bom_headers ( id SERIAL PRIMARY KEY, product_code VARCHAR(50) NOT NULL, version VARCHAR(20) NOT NULL, effective_date DATE, status VARCHAR(20) DEFAULT draft CHECK (status IN (draft,released,obsolete)), created_by VARCHAR(100), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), UNIQUE (product_code, version) ); -- 创建BOM行表关键外键层级校验 CREATE TABLE bom_lines ( id SERIAL PRIMARY KEY, header_id INTEGER NOT NULL REFERENCES bom_headers(id) ON DELETE CASCADE, parent_item_code VARCHAR(50) REFERENCES materials(item_code), child_item_code VARCHAR(50) NOT NULL REFERENCES materials(item_code), quantity NUMERIC(12,4) NOT NULL CHECK (quantity 0), level_depth INTEGER NOT NULL CHECK (level_depth 0), is_substitute BOOLEAN DEFAULT FALSE, note TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- 确保同一BOM头内父子关系不自指 CHECK (parent_item_code ! child_item_code) ); -- 创建版本快照表每次发布生成一条记录 CREATE TABLE bom_revisions ( id SERIAL PRIMARY KEY, header_id INTEGER NOT NULL REFERENCES bom_headers(id), diff_summary JSONB, -- 存储{added: [...], removed: [...], changed: [...]} operator VARCHAR(100), operated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() );参数说明UNIQUE (product_code, version)防止同一型号重复发布相同版本CHECK (level_depth 0)拦截负数层级常见于Excel公式错误ON DELETE CASCADE保证删除BOM头时自动清理所有行避免孤儿数据JSONB类型用于diff_summary支持高效查询“该版本新增了哪几个电阻”。3. 开源BOM管理后台Flask API Vue前端最小闭环搭建不推荐从零手写全栈。采用成熟开源组件拼装后端用Flask-SQLAlchemy轻量、调试友好前端用Vue3 Element Plus组件丰富、文档清晰。重点在于BOM特有交互的实现逻辑。3.1 Flask后端BOM版本发布与差异计算APIBOM发布不是简单INSERT需原子化完成三件事插入新头、批量插入行、生成diff快照。以下为关键路由# app.py from flask import Flask, request, jsonify from sqlalchemy import text from models import db, BOMHeader, BOMLine, Material, BOMRevision import json app.route(/api/bom/publish, methods[POST]) def publish_bom(): data request.get_json() # 1. 创建BOM头 header BOMHeader( product_codedata[product_code], versiondata[version], effective_datedata.get(effective_date), statusreleased, created_bydata[operator] ) db.session.add(header) db.session.flush() # 获取自增ID但不提交 # 2. 批量插入BOM行假设data[lines]是列表 lines [] for line in data[lines]: # 校验子项物料是否存在 child Material.query.filter_by(item_codeline[child_item_code]).first() if not child: return jsonify({error: f物料 {line[child_item_code]} 不存在}), 400 lines.append(BOMLine( header_idheader.id, parent_item_codeline.get(parent_item_code), child_item_codeline[child_item_code], quantityfloat(line[quantity]), level_depthint(line[level_depth]), is_substituteline.get(is_substitute, False), noteline.get(note, ) )) db.session.bulk_save_objects(lines) # 3. 计算与上一版差异关键逻辑 last_header BOMHeader.query.filter( BOMHeader.product_code data[product_code], BOMHeader.status released ).order_by(BOMHeader.id.desc()).first() if last_header: # 查询上一版所有子项 last_items {l.child_item_code for l in last_header.lines} current_items {line[child_item_code] for line in data[lines]} diff { added: list(current_items - last_items), removed: list(last_items - current_items), changed: [] # 实际项目中需比对用量/层级等字段 } # 插入快照 revision BOMRevision( header_idheader.id, diff_summaryjson.dumps(diff), operatordata[operator] ) db.session.add(revision) db.session.commit() return jsonify({success: True, header_id: header.id})逻辑说明db.session.flush()在事务内获取新头ID避免commit()后无法回滚bulk_save_objects()比循环add()快10倍以上处理千行BOM时显著降低API延迟差异计算仅做集合运算新增/删除因硬件BOM中“用量变更”需人工确认不自动标记为changed——这是刻意设计的保守策略。3.2 Vue前端BOM树形展开与Excel导出组件BOM天然为树结构但前端渲染需解决两个痛点1层级缩进如何响应式计算2导出Excel时保留层级视觉效果。以下为关键Vue组件逻辑!-- BOMTree.vue -- template div classbom-tree el-tree :datatreeData :propsdefaultProps node-keyid show-checkbox check-changehandleCheckChange :expand-on-click-nodefalse span classcustom-tree-node slot-scope{ node, data } span span :style{ paddingLeft: ${node.level * 20}px } {{ data.item_code }} - {{ data.name }} /span /span span classnode-actions el-button sizemini click() append(node)Append/el-button /span /span /el-tree el-button clickexportToExcel导出为Excel含层级缩进/el-button /div /template script export default { data() { return { defaultProps: { children: children, label: label }, treeData: [] } }, methods: { // 将扁平BOM行转换为树结构关键算法 buildTreeFromLines(lines) { const map new Map() const roots [] // 第一遍建立所有节点映射 lines.forEach(line { map.set(line.child_item_code, { id: line.id, item_code: line.child_item_code, name: line.description || , level_depth: line.level_depth, quantity: line.quantity, children: [] }) }) // 第二遍按层级深度构建父子关系 lines.forEach(line { const node map.get(line.child_item_code) if (line.level_depth 0) { roots.push(node) } else { // 找到父节点上一层级中最后一个节点 const parentCandidates Array.from(map.values()) .filter(n n.level_depth line.level_depth - 1) if (parentCandidates.length 0) { const parent parentCandidates[parentCandidates.length - 1] parent.children.push(node) } } }) return roots }, exportToExcel() { // 使用 SheetJS 导出关键为每行添加indent属性控制缩进 const ws_data this.treeData.map(node ({ 层级: .repeat(node.level_depth * 2) node.item_code, 描述: node.name, 用量: node.quantity })) /* generate worksheet */ const ws XLSX.utils.json_to_sheet(ws_data) /* add to workbook */ const wb XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, BOM) /* write workbook */ XLSX.writeFile(wb, BOM_${new Date().toISOString().slice(0,10)}.xlsx) } } } /script参数说明paddingLeft: ${node.level * 20}px是前端视觉缩进非数据缩进buildTreeFromLines()采用两遍扫描法第一遍建节点第二遍按level_depth挂载父子关系避免递归导致的栈溢出Excel导出中 .repeat(node.level_depth * 2) node.item_code用空格模拟缩进因Excel原生不支持树形单元格这是最兼容的方案。4. 避坑指南BOM管理中5个血泪经验换来的必踩雷区BOM系统上线后最常爆发问题的不是代码而是数据治理盲区。以下是真实项目中反复出现的5类问题按现象→原因→解决三步给出可执行方案。4.1 现象BOM发布成功但下游ERP系统报“物料不存在”原因BOM行中child_item_code与ERP物料主数据编码规则不一致。例如BOM中写C0402X5R1E104K而ERP要求CAP-0402-104K-50V。解决在bom_lines表增加erp_mapping_code字段并在发布API中强制校验。添加数据库触发器CREATE OR REPLACE FUNCTION check_erp_code_exists() RETURNS TRIGGER AS $$ BEGIN IF NOT EXISTS (SELECT 1 FROM erp_materials WHERE code NEW.erp_mapping_code) THEN RAISE EXCEPTION ERP物料 % 不存在, NEW.erp_mapping_code; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_check_erp_code BEFORE INSERT ON bom_lines FOR EACH ROW EXECUTE FUNCTION check_erp_code_exists();4.2 现象同一BOM头下出现两个完全相同的子项如两个R101原因Excel导入时未去重且数据库未建唯一约束。解决在bom_lines表添加复合唯一索引CREATE UNIQUE INDEX idx_unique_child_per_header ON bom_lines (header_id, child_item_code, parent_item_code);注意parent_item_code参与索引因替代料is_substituteTrue允许同一子项在不同父项下重复出现。4.3 现象BOM树形展示时子项出现在错误的父节点下原因Excel中层级列Level数值非严格递增如出现0,1,2,2,1导致算法误判父节点。解决在清洗脚本中增加层级校验def validate_level_sequence(df): levels df[level].tolist() for i in range(1, len(levels)): if levels[i] levels[i-1] 1: # 层级跳跃超过1 raise ValueError(f层级序列异常第{i}行层级{levels[i]}前一行{levels[i-1]}) return df4.4 现象用户反馈“找不到上周发布的BOM版本”原因bom_headers.status默认为draft发布API未将状态更新为released导致前端查询时过滤掉。解决在发布API中显式设置statusreleased并在数据库层面加NOT NULL约束ALTER TABLE bom_headers ALTER COLUMN status SET NOT NULL; UPDATE bom_headers SET status released WHERE status IS NULL;4.5 现象导出Excel后打开提示“文件已损坏”原因XLSX.writeFile()生成的文件未设置正确的Content-Type或前端响应头缺失。解决后端API返回文件流而非前端生成app.route(/api/bom/export/int:header_id) def export_bom_excel(header_id): # 服务端生成Excel并返回 output io.BytesIO() # ... 构建workbook逻辑 workbook.save(output) output.seek(0) return send_file( output, mimetypeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet, as_attachmentTrue, download_namefbom_{header_id}.xlsx )5. BOM变更影响分析用SQL递归查询找出“一个电阻改动影响多少PCBA”BOM的价值高峰不在静态存储而在动态影响分析。当某颗电阻规格变更需快速定位所有受影响的产品型号及版本。这需要PostgreSQL的WITH RECURSIVE能力而非应用层遍历。5.1 构建物料影响路径从子项反向追溯所有父BOM核心思路给定一个child_item_code如R101查出所有直接/间接使用它的BOM头。注意“间接”指R101→POWER_MODULE→MAIN_BOARD。-- 递归查询R101影响的所有BOM头含间接 WITH RECURSIVE bom_impact AS ( -- 初始直接使用R101的BOM头 SELECT DISTINCT bh.id, bh.product_code, bh.version, 1 as depth FROM bom_headers bh JOIN bom_lines bl ON bh.id bl.header_id WHERE bl.child_item_code R101 UNION ALL -- 递归这些BOM头作为子项被其他BOM使用 SELECT DISTINCT bh2.id, bh2.product_code, bh2.version, bi.depth 1 FROM bom_impact bi JOIN bom_lines bl2 ON bi.product_code bl2.child_item_code JOIN bom_headers bh2 ON bl2.header_id bh2.id WHERE bi.depth 5 -- 防止无限递归 ) SELECT * FROM bom_impact ORDER BY depth, product_code;提示depth 5是安全阈值硬件BOM极少超过5层嵌套设过大可能拖慢查询。5.2 生成影响报告SQL直接输出Markdown表格将上述查询结果转为可读报告供工程师快速决策-- 生成Markdown格式的影响报告 SELECT CONCAT(| , product_code, | , version, | , depth, |) as markdown_row FROM ( WITH RECURSIVE bom_impact AS ( SELECT DISTINCT bh.id, bh.product_code, bh.version, 1 as depth FROM bom_headers bh JOIN bom_lines bl ON bh.id bl.header_id WHERE bl.child_item_code R101 UNION ALL SELECT DISTINCT bh2.id, bh2.product_code, bh2.version, bi.depth 1 FROM bom_impact bi JOIN bom_lines bl2 ON bi.product_code bl2.child_item_code JOIN bom_headers bh2 ON bl2.header_id bh2.id WHERE bi.depth 5 ) SELECT * FROM bom_impact ORDER BY depth, product_code ) t UNION ALL SELECT |---|---|---| -- 表头分隔符 UNION ALL SELECT | 产品型号 | 版本 | 影响深度 |;执行后直接得到| 产品型号 | 版本 | 影响深度 | |---|---|---| | MAIN_BOARD | V2.1 | 1 | | POWER_MODULE | V1.0 | 1 | | SYSTEM_X | V3.0 | 2 |5.3 自动化影响预警当BOM行变更时触发邮件通知在bom_lines表上创建触发器当quantity或child_item_code变更时调用外部脚本发送通知-- 创建触发器函数 CREATE OR REPLACE FUNCTION notify_bom_change() RETURNS TRIGGER AS $$ DECLARE affected_products TEXT[]; BEGIN -- 查询所有受此行变更影响的产品型号 SELECT ARRAY_AGG(DISTINCT bh.product_code) INTO affected_products FROM bom_headers bh WHERE bh.id NEW.header_id; -- 调用外部Python脚本需配置plpythonu扩展 PERFORM pg_notify(bom_change, json_build_object( product, affected_products[1], old_qty, OLD.quantity, new_qty, NEW.quantity, changed_at, NOW() )::text ); RETURN NEW; END; $$ LANGUAGE plpgsql; -- 绑定触发器 CREATE TRIGGER trigger_notify_bom_change AFTER UPDATE OF quantity, child_item_code ON bom_lines FOR EACH ROW WHEN (OLD.quantity IS DISTINCT FROM NEW.quantity OR OLD.child_item_code IS DISTINCT FROM NEW.child_item_code) EXECUTE FUNCTION notify_bom_change();然后用Python监听pg_notify事件import psycopg2 import smtplib from email.mime.text import MIMEText def listen_for_bom_changes(): conn psycopg2.connect(dbnamebom_db userpostgres) cursor conn.cursor() cursor.execute(LISTEN bom_change;) while True: if select.select([conn], [], [], 5)[0]: conn.poll() while conn.notifies: notify conn.notifies.pop(0) payload json.loads(notify.payload) send_alert_email(payload) def send_alert_email(payload): msg MIMEText(f物料 {payload[product]} 的BOM用量已变更{payload[old_qty]} → {payload[new_qty]}) msg[Subject] f[BOM预警] {payload[product]} 用量变更 # ... 邮件发送逻辑这是我在线上环境跑了一年多的方案不追求大而全但确保每个环节都经得起产线拷问。比如影响分析SQL我把它做成运维同学也能直接在psql里执行的命令而不是藏在某个API后面比如Excel导出坚持用服务端生成因为前端生成的文件在IE11下必然损坏——而产线电脑还有一半是Win7IE11。技术选型没有银弹只有场景适配。当你第一次用WITH RECURSIVE查出那个隐藏三层的BOM影响链时你会明白所谓“管理”就是把混沌的Excel关系变成数据库里一条可执行、可验证、可追溯的SQL。希望帮到你。本文还有配套的精品资源点击获取