
做制造业ERP和做通用进销存、做电商后台完全是两码事。我接手过好几套制造业ERP项目有做机械加工的、有做电子组装的也做过注塑车间的。说实话踩过的坑比走过的桥还多——倒不是技术多难难的是制造业业务本身太复杂物料要追溯、工单要排产、设备要采集数据、品质要管控批次。这些业务逻辑叠在一起对系统架构的要求就完全不一样了。这篇内容围绕“制造业ERP系统架构与C#实现思路”展开把我实际操盘这类项目时的架构决策、技术选型、核心代码思路和常见坑一次性整理出来。适合正在做或准备做制造业ERP的 .NET 工程师、想从传统进销存转型做生产管理的开发者以及公司内部要上ERP项目的技术负责人参考。1. 制造业ERP到底在管什么1.1 和传统进销存软件的本质区别很多团队一开始会犯同一个错误把制造业ERP当成一个大号的进销存来做。进销存的核心是“账”——采购单、销售单、库存单把单据录好账就平了。但制造业ERP的核心是“物和账的一致性”管的是物料在整个生产过程中的流动原材料什么时候到、领出去多少、哪个工单用了、半成品放在哪个工序、成品入了哪个仓位每一笔和实际动作必须对得上。举个例子传统进销存里“领料”就是一个出库单据。但在制造业ERP里领料要关联生产工单要按BOM物料清单定额控制超领要走审批余料要退回报废要单独记录半成品入库要带工序信息。这些业务动作如果只停留在“记账”层面车间根本不敢用——因为账和实物永远对不上。所以做这类系统第一件事不是写代码而是把业务流程梳理清楚。我一般会先做一轮现场调研把仓库、车间、品质、采购、计划这几个关键岗位都聊一遍画出业务流程图再动架构。这一步省不掉省了后面返工的成本远大于前期投入。1.2 制造业ERP的四大核心业务域制造业ERP虽然模块很多但真正核心的业务域就四个其他都是围绕它们展开的业务域核心职责关键数据工程数据定义产品怎么造BOM、工艺路线、物料主数据、版本计划与排产决定什么时候造多少工单、MRP运算结果、产能负荷车间执行驱动现场实际作业报工记录、领退料、工序流转、工时品质追溯保证造出来的东西合格批次、序列号、检验记录、不良处理这四个域之间是强耦合的。BOM不准MRP排出来的计划就是废纸工单执行没记录品质追溯就断链报工数据不准成本核算就是一笔糊涂账。架构上如果把这几个域的数据割裂开各自为政最后一定会有大量跨模块的“手工台账”出现也就是用户私下用Excel在那补数据。这也是为什么我一直建议制造业ERP的架构设计首要目标是保证核心业务域的数据模型统一和流程闭环而不是过早追求技术上的花哨拆分。2. 系统架构选型为什么我不建议一上来就上微服务2.1 模块化单体前两年的最优解每次聊架构总有人问“要不要上微服务”。我的回答很直接如果你的用户规模撑不到上千人同时在线、业务复杂度撑不到几十个独立团队并行开发微服务只会给你带来痛苦不会带来收益。制造业ERP的特点是事务性强、模块间关联紧密。一个领料动作要同时扣库存、登记工单用料、记录批次追溯这几个操作必须在一个事务里完成。微服务把库存服务和工单服务拆开之后本地事务就变成了分布式事务要么引入消息队列做最终一致性要么做Saga复杂度直线上升。我见过一个项目硬上微服务拆了十几个服务结果最核心的“报工库存扣减”接口因为要在两个服务之间协调定义了一堆补偿逻辑最后生产一上线数据对不上天天在补单。所以我现在操盘这类项目前两年一律推荐“模块化单体”架构整个系统一个应用但在代码层做好模块边界将来真要拆微服务可以按模块一个包一个包地拆出去。这样既保证了事务的强一致性又保留了后续演进的余地。2.2 分层架构与项目结构模块化单体内部的分层我的习惯是标准的四层外加一个共享内核ERP.Web 表示层ASP.NET Core Web API Blazor WebAssembly ERP.Application 应用层用例编排、DTO、命令/查询处理 ERP.Domain 领域层实体、领域服务、仓储接口 ERP.Infrastructure 基础设施层EF Core、Redis、第三方对接 ERP.Shared 共享内核通用工具、枚举、扩展方法这四层之间有严格的依赖规则Web只引用ApplicationApplication只引用DomainInfrastructure引用Domain并实现仓储接口。Domain层不依赖任何框架和基础设施这样才能保证核心业务逻辑的稳定。有人觉得四层架构老土但ERP这种业务密集型系统要的就是稳定和可测试性。领域层把BOM展开、MRP运算、工单状态流转这些核心逻辑写清楚用单元测试把边界条件都测到位比你用什么花哨的架构模式都管用。2.3 领域划分与核心表设计在模块化单体里模块之间的边界通过代码上的命名空间和禁止跨模块引用除了Shared来控制。我会在Application层按模块分文件夹但数据库层面是共用一个库的这样事务还能保住。核心表设计有几个关键点必须提前定下来所有表必须有主键、创建时间、更新时间、创建人、更新人制造业ERP的数据审计需求很重后面的追溯全依赖这些字段。涉及数量的字段全部用decimal禁止用float。BOM用量、库存数量、工单数量如果用浮点数累计误差经过几个月就会大到离谱。状态字段用int枚举比varchar更可控、更省空间结合状态机统一管理。物料主数据、BOM等需要带生效版本用“生效日期版本号”的复合条件查询避免后期做历史追溯时抓瞎。举个具体例子BOM表我当时的设计是BOM_HeaderBOM单头BOM编号、物料ID、版本号、生效日期、失效日期、状态 BOM_DetailBOM单行BOM编号、子件物料ID、用量、损耗率、工序序号这样一份BOM就是一个头加多行明细版本靠“物料ID版本号”唯一约束来管。后续做MRP展开或者做成本核算直接用物料ID关联到对应生效版本即可。3. C#实现中的几个核心环节3.1 物料清单的递归展开与成本汇总BOM展开是制造业ERP最经典的算法题没有之一。一个成品经过多级加工每一级都有子件子件下面还有子件展开时要按层级把所有物料汇总还要带上每层的数量系数。比如产品A由1个部件B和2个零件C组成部件B又由3个零件D组成那展开结果就是需要1个B、2个C、3个D。如果BOM有几十层中间还有损耗率手工算就疯了。在C#里实现我一般用递归方法加字典缓存避免重复计算public class BomExpander { private readonly Dictionarystring, ListBomDetail _bomCache; private readonly Dictionarystring, decimal _resultCache new(); public BomExpander(Dictionarystring, ListBomDetail bomCache) { _bomCache bomCache; } // 返回物料ID - 总用量 public Dictionarystring, decimal Expand(string parentMaterialId, decimal quantity 1m) { var result new Dictionarystring, decimal(); ExpandLevel(parentMaterialId, quantity, result, new HashSetstring()); return result; } private void ExpandLevel(string parentId, decimal qty, Dictionarystring, decimal result, HashSetstring visited) { if (!_bomCache.TryGetValue(parentId, out var details)) return; foreach (var det in details) { // 考虑损耗率实际用量 理论用量 * (1 损耗率) decimal required qty * det.UsagePerParent * (1m det.ScrapRate); if (!result.ContainsKey(det.ChildMaterialId)) result[det.ChildMaterialId] 0m; result[det.ChildMaterialId] required; // 防止循环BOMA包含BB又包含A导致的死循环 if (visited.Contains(det.ChildMaterialId)) continue; visited.Add(det.ChildMaterialId); ExpandLevel(det.ChildMaterialId, required, result, visited); visited.Remove(det.ChildMaterialId); } } }这里有两个关键点是我踩过坑之后才明白的第一损耗率不是只在最底层加而是每一层都要按数量系数累乘。很多新手只算最后汇总时加一次结果采购的数永远差一截。第二循环BOM一定存在。设计上虽然不允许但老系统导过来的数据经常有环。所以递归里必须用visited集合防止死循环否则一个接口就能把IIS打挂。3.2 工单状态机设计制造业工单从创建到关闭状态流转非常严格。最常见的一条主线是已创建 → 已排产 → 已下达 → 生产中 → 已完工 → 已关闭但中间还有一堆分支下达后可能被暂停生产中可能被部分完工品质检验不合格可能被冻结。如果这些状态流转在代码里散着写用一堆 if/else 判断后期加一个状态所有相关接口都要翻一遍。我的做法是用一个简单状态机把这些流转统一管理起来。不夸张地说状态机是制造业ERP里性价比最高的设计模式之一。public enum WorkOrderStatus { Created 1, Scheduled 2, Released 3, InProduction 4, Completed 5, Closed 6, Suspended 7, Frozen 8 } public class WorkOrderStateMachine { private static readonly Dictionary(WorkOrderStatus, WorkOrderEvent), WorkOrderStatus _transitions new() { [(WorkOrderStatus.Created, WorkOrderEvent.Schedule)] WorkOrderStatus.Scheduled, [(WorkOrderStatus.Scheduled, WorkOrderEvent.Release)] WorkOrderStatus.Released, [(WorkOrderStatus.Released, WorkOrderEvent.Start)] WorkOrderStatus.InProduction, [(WorkOrderStatus.InProduction, WorkOrderEvent.ReportFinish)] WorkOrderStatus.Completed, [(WorkOrderStatus.Completed, WorkOrderEvent.Close)] WorkOrderStatus.Closed, [(WorkOrderStatus.Released, WorkOrderEvent.Suspend)] WorkOrderStatus.Suspended, [(WorkOrderStatus.Released, WorkOrderEvent.Freeze)] WorkOrderStatus.Frozen, // 恢复等更多流转... }; public static WorkOrderStatus Next(WorkOrderStatus current, WorkOrderEvent evt) { if (_transitions.TryGetValue((current, evt), out var next)) return next; throw new InvalidOperationException($非法状态流转: {current} - {evt}); } }这个方案的妙处在于所有合法流转集中在一个表里新增状态只需要改这一个字典而且非法流转会在第一时间抛异常暴露问题而不是默默地在某个角落被忽略。后来做权限控制也是基于这个状态机做扩展的某个操作只允许该状态下的用户执行判断逻辑清晰多了。3.3 车间报工的高并发与乐观锁制造业ERP里并发冲突最严重的场景就是车间报工。几十个工人同时用扫码枪报工同一张工单、同一道工序好几台设备同时提交产量数据。如果直接用UPDATE WorkOrder SET FinishedQty FinishedQty qty WHERE Id id并发是没问题但数据库行锁会激烈竞争高峰期甚至会把工单表打爆。更麻烦的是报工不只是更新工单还要扣减在制库存、记录工序流转日志、算工时。这四五个操作必须保证一致性。我的方案是两步走第一步使用rowversion时间戳做乐观锁。每次读取工单时带上当前版本号更新时在 WHERE 条件里带上版本号如果影响行数是0说明被别的线程抢先改了让用户重新刷新再提交。// Entity public class WorkOrder { public long Id { get; set; } public decimal FinishedQty { get; set; } public byte[] RowVersion { get; set; } // SQL Server rowversion } // Update var result await db.WorkOrders .Where(w w.Id cmd.Id w.RowVersion cmd.OriginalVersion) .ExecuteUpdateAsync(s s .SetProperty(w w.FinishedQty, w w.FinishedQty cmd.Qty) .SetProperty(w w.RowVersion, w SqlServerDbFunctionsExtensions.NewRowVersion()));第二步报工接口在数据库层面用存储过程或一个单独的事务包裹保证工单更新、库存扣减、日志写入在同一次数据库事务里完成。如果其中任何一步失败整个事务回滚不会出现“工单更新了但库存没扣”这种数据不一致。这里有一点要提醒虽然C#的TransactionScope很方便但如果你用的是EF Core多张表更新时要注意它默认开启的隐式事务。高并发场景下事务持有时间越长锁冲突概率越大。实际操作中我会把事务范围控制在“读到的数据尽量少、更新的数据尽量直接”不要在一个事务里再去查其它表查了就加锁加了锁别人就得等。3.4 与设备数据采集的对接制造业ERP和纯软件项目最大的不同就是它要跟车间里五花八门的设备打交道。工单在某个工序开工时常常需要从设备上读取加工参数或产量数据。常见的对接方式有几种PLC通过Modbus TCP把数据吐出来、设备自带OPC UA服务、或者设备厂商提供专用的DLL/SDK。我做过的项目里Modbus TCP 和 TCP Socket 裸协议对接的情况最多。以 Modbus TCP 为例用 C# 实现一个简单的读取方法并不复杂public class ModbusTcpClient : IDisposable { private readonly TcpClient _client; private ushort _transactionId 0; public ModbusTcpClient(string ip, int port) { _client new TcpClient(); _client.Connect(ip, port); } // 读取保持寄存器 public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddress, ushort count) { var request BuildReadRequest(unitId, startAddress, count); _client.GetStream().Write(request, 0, request.Length); var response ReadResponse(); // 解析返回值实际项目中还要校验事务ID、功能码错误标志等 var result new ushort[count]; for (int i 0; i count; i) { result[i] (ushort)((response[9 i * 2] 8) | response[10 i * 2]); } return result; } }但这里有个很深的坑TCP 长连接读取设备数据如果用同步的Read方法一个设备卡住不响应就能把整个后台线程拖死。所以生产环境我用的是异步回调模式参考那篇关于C# Socket BeginReceive回调的文章——设备在线就正常收数据设备离线了一段时间就自动标记状态不阻塞主流程。另一个经验是设备采集服务不要和ERP主程序放在同一个进程里。我在架构里一直把设备采集拆成独立的 Windows 服务或后台 Worker Service通过消息队列简单场景用阻塞队列或Channel把数据推给ERP主程序。这样设备端的抖动不会拖垮主系统的API。4. 数据导入、缓存与日志的实战方案4.1 大批量BOM导入SqlBulkCopy的正确用法制造业ERP上线初期最大的数据迁移工作量往往就是BOM导入。几千上万条BOM明细用EF Core一条条AddSaveChanges那速度能让你怀疑人生——每一行都会有实体追踪、状态检测、SQL生成一万条数据能跑十几分钟。正确做法是用SqlBulkCopy直接走数据库的批量导入通道。我在项目里的一个封装如下public async Task BulkInsertAsync(DataTable table, string destinationTableName) { using var bulk new SqlBulkCopy(_connection) { DestinationTableName destinationTableName }; bulk.BulkCopyTimeout 300; bulk.BatchSize 1000; foreach (DataColumn col in table.Columns) { bulk.ColumnMappings.Add(col.ColumnName, col.ColumnName); } await bulk.WriteToServerAsync(table); }用的时候先把BOM数据组装成一个DataTable列名和目标表一致然后一次性导入。一万条明细导入时间通常在几秒到十几秒之间比EF Core快两个数量级。但要注意几个细节SqlBulkCopy默认不触发触发器和外键约束所以目标表的业务校验要在写入前完成。比如子件不存在、用量为负数这些必须在DataTable组装阶段就过滤掉。如果BOM表有自增主键SqlBulkCopy时主键列不要带上或者用KeepIdentity false。大批量写入会造成日志文件暴涨测试环境你可能没感觉生产环境要把恢复模式设为简单模式或者导入完成后做一次日志收缩。这个我吃过亏——一次导入把生产库日志文件干到几十GB磁盘直接满了。4.2 缓存策略哪些数据值得缓存ERP系统的特点是读多写少的数据特别多。物料主数据、BOM定义、工艺路线、单位换算率这些数据一天变不了一次但每次报工、排产、算成本都要查。如果每次都打数据库再好的硬件也扛不住。我常用的缓存策略分三层第一层静态字典类数据物料单位、仓库列表、状态枚举启动时一次性加载到内存用ConcurrentDictionary维护。第二层BOM、工艺路线这类变化不频繁但量大的用IMemoryCache加过期时间缓存过期时间一般设30分钟到1小时。第三层报表统计、看板数据用Redis做分布式缓存因为这类数据多个API节点可能需要共享。重点说一下IMemoryCache在工厂环境里的一个坑车间现场网络环境不稳定的情况很常见API节点可能有多个负载均衡部署。如果一台API节点更新了物料数据另一台节点的内存缓存还是旧的就会出现“明明改了价格那边还是老价格”的诡异问题。所以我后来统一建议凡是会跨节点修改的数据一律用Redis做缓存内存缓存只放纯只读的字典数据。取舍很简单——一致性要求高的走Redis数据准确度要求不高的走MemoryCache。4.3 日志与异常处理别把所有日志都塞进文本文件C#项目里日志框架我推荐 Serilog因为它可以把结构化日志直接写到数据库、Elasticsearch、文件多种目标。制造业ERP的日志需求比较特别除了记录系统运行异常还要记录业务操作日志来做审计。结构化的关键是把上下文信息一起记录下来。比如记录一个“报工失败”的日志光有一句话没用必须包含工单号、操作员、报工数量、当前工单状态、错误堆栈。这样排查问题时你才能快速定位而不是去翻数据库。Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File(logs/erp-.log, rollingInterval: RollingInterval.Day) .WriteTo.MSSqlServer(_connectionString, sinkOptions: new MSSqlServerSinkOptions { TableName Sys_Log, AutoCreateSqlTable true }) .Enrich.WithProperty(Application, ERP) .CreateLogger();我踩过的一个实际教训是车间报工高峰期日志量非常大如果把所有Information级日志都写进数据库数据库很快就会被日志表拖垮。最后我们只把Warning以上级别写到数据库普通操作日志写文件关键业务审计领料、入库、报工单独写一张独立的审计表。这样既不影响性能审计用途的数据也不会被系统日志淹没。5. 常见问题排查实录5.1 并发死锁接口突然变慢的元凶制造业ERP里死锁最常见的场景就是多个接口同时更新同几张表但更新顺序不一致。比如接口A先更工单再更库存接口B先更库存再更工单并发时就容易互相等锁SQL Server检测到循环等待后会杀掉其中一条事务客户端直接报死锁错误。排查方法很直接启用 SQL Server 的跟踪标志 1222 来捕获死锁图然后分析死锁图里两个事务的资源申请顺序。修复方案也很粗暴统一所有接口的更新顺序先更工单、再更库存、再更日志全局一致死锁概率大幅下降。实测下来这个操作解决了我们项目里90%的死锁问题剩下10%是某些报表的长事务和写事务冲突后来把报表改成读已提交快照RCSI隔离级别就解决了。5.2 库存数据和实物对不上这个问题的根源几乎永远是流程没闭环。最常见的是“边角料”处理一个领料工单定额是100kg实际用了85kg剩15kg车间直接扔到料箱里没做退库操作ERP库存就比实物少了15kg。这种情况系统再强大也没用必须靠管理动作约束。我做过的一个有效改进是在库存事务类型上做强制所有库存变化必须关联业务单据领料单、退料单、报工单、请检单不允许任何“零散调库”的操作入口。盘点时的盈亏也必须走独立的“盘点调整单”流程带审批。这样库存的每一笔变动都能溯源账实不一致的排查范围就缩小了。5.3 报表越跑越慢的优化思路制造业ERP跑一段时间后报表慢是必然的。工单明细表、库存流水表动辄几百万行按日期过滤的月度生产报表用EF Core直接查一次查询拖到五秒以上看板页面基本没法用。我的优化优先级是先看执行计划加索引。绝大多数慢查询是缺索引不是SQL写法问题。高频报表用物化视图或者定时任务预计算把结果集缓存到一张汇总表。不再被实时查询的旧数据迁移到历史库。比如超过两年的已完成工单移到归档表。症状可能原因常用手段单表查询慢缺索引看执行计划加复合索引多表关联慢join列类型不一致统一外键类型加索引报表越来越慢数据量增长历史数据归档/预聚合高峰时段锁等待长事务缩短事务分批提交页面加载慢无分页/查询字段过多分页查询DTO只读必要字段这五个是制造业ERP项目里最常见的性能问题基本按这个表排查一圈大多数性能投诉都能兜住。5.4 关于防止反编译的提醒C# 编译出来的 DLL 很容易被反编译这在制造业ERP这种会上部署到甲方机房的项目里是个现实问题。如果不想让客户把代码拿走至少要做两件事一是用混淆器比如 ConfuserEx处理核心业务DLL二是核心算法和业务规则尽量放在服务端客户端只做扫码和展示减少暴露面。但有一点要认清混淆只能提高门槛不能完全阻止逆向。真正值钱的是业务逻辑和行业经验代码本身反而没那么容易被同行直接抄走因为它揉合了你们对制造流程的理解。6. 验收上线前别忘的几件事6.1 数据迁移验证上线前一定要做一次完整的数据迁移演练而且要用生产环境的数据量来测不要用测试库里那几千条数据。我见过最惨的一次迁移脚本在测试库跑5分钟生产库跑完用了一个半小时直接在切换窗口内把上线流程拖崩了。BOM、库存余额、未完工工单这些核心数据的迁移要做到“迁移后唯一性校验”和“总量对账”。比如库存迁移完要按物料分类汇总和原系统做个对账差一分钱都不能上线。6.2 权限与审计制造业ERP的权限粒度要比普通管理系统细得多。不同车间主管只能看自己车间的工单采购员只能改自己负责的采购单质检员只能录入自己工序的检验结果。这些权限如果不在架构阶段预留后面加会非常痛苦。我一般会建立一个“角色-权限-数据范围”的三级模型数据范围决定“能看哪些”功能权限决定“能做什么”。项目做好后在后台管理界面上两种权限要能分别配置理由很简单——同一个“仓库主管”角色在不同工厂可能管着不同的仓库。6.3 验收清单照着一条条过每次上线前我都会让测试团队拿着下面的清单过一遍虽然简单但非常有效每个核心业务动作在系统里能否找到对应单据和操作日志库存余额在任何业务发生时是否保持动态一致不出现负数库存工单状态是否不可能出现“已完成”又继续报工的非法路径BOM变更后已下达的工单是否按旧版本执行不能自动用新BOM断电或网络中断后系统能否通过日志和事务机制恢复到一致状态这几条看着简单但每一条背后都是一个“血泪故事”。比如BOM变更那条如果按下达时间直接取最新版本车间可能用新材料做出了客户不要的老产品报废一批哭都来不及。最后的几点体会做制造业ERP这几年我最大的体会是这个领域的技术难点从来不在“怎么写代码”而在于“怎么把制造业的复杂性装进一个可靠、可维护、可演进的系统里”。C#/.NET 在这个领域其实非常有优势——强类型、成熟生态、EF Core 和性能工具链都很完善加上 .NET 原生支持 Windows 生态和车间很多 Windows 工控机对接非常顺。但工具再好架构决策失误了也白搭。我的建议很朴素先把业务吃透再谈技术。先做好模块化单体再考虑要不要上微服务。先保证事务一致和数据可追溯再去追求华丽的技术栈。这一套做下来系统可能不够“炫”但一定够稳——制造业要的就是稳。最后再分享一个小技巧C#里部署ERP到客户机房时用 Costura.Fody 把依赖的DLL合并进主程序发布就变成一个文件客户升级的时候直接替换exe省掉一大堆“少了个DLL跑不起来”的售后工单。这种事看着不起眼但在项目上线初期能给你省至少几十个技术支持的夜晚。