资讯动态

制造业ERP系统架构设计与C#落地实践:从BOM到生产工单的核心建模

发布时间:2026/10/15 15:45:41 来源:尧图企业网站定制
制造业ERP是典型的高复杂度业务系统涉及物料、计划、库存、生产、成本、质量多个业务域的联动。很多团队在做这类系统时最大的困惑不是某个接口怎么写得优雅而是整个系统从哪里切分、数据怎么组织、事务边界划在哪。我参与过几个制造企业ERP的从零搭建也接手过不少半成品系统做过重构这篇就把我梳理过的架构思路和C#实现方案完整展开讲一讲。适合正在做或准备做制造业主导型ERP系统的架构师、后端开发、项目负责人参考也适合刚接触这个领域想建立整体认知的开发者阅读。1. 制造业ERP的架构定位先理清楚要解决什么问题1.1 制造业ERP与通用进销存的核心差异很多团队在启动项目时容易犯一个错误把制造业ERP当成进销存系统加几个按钮来做。我和几个同行聊过这个问题大家都有同感——进销存管的是货流转制造业ERP管的是价值流转 实物流转 信息流转这三条线的叠加。对比一下就很清晰通用进销存的核心对象是商品、供应商、客户、仓库制造ERP的核心对象要复杂得多除了物料还有工艺路线、工作中心、工序、生产工单、物料清单BOM、批次追溯、成本归集。同样是库存这个词在进销存里可能就是库存数量在制造ERP里却要区分可用量、在途量、冻结量、质检待检量、已分配未发料量。我见过一个项目因为没有区分这些库存状态车间领料和生产排产之间反复扯皮最后只能返工改数据模型。所以架构设计的第一步不是选技术框架而是把业务概念先理清楚。制造ERP围绕的核心链条是销售订单/预测 → 主生产计划MPS→ 物料需求计划MRP→ 生产工单 / 采购申请 → 车间执行 / 到货入库 → 产成品入库 → 成本核算 → 财务凭证。每一条链路上的数据都不是孤立的它们之间存在严格的引用和状态承接关系。架构上要能够支撑这条链条在数据层面的贯通。1.2 单体优先还是微服务优先我的选择逻辑谈架构必谈微服务但在制造业ERP这个领域我个人的经验是绝大多数项目不应该一上来就搞微服务而应该做模块化单体并预留演进空间。原因有几条第一制造业ERP的模块间耦合天然非常紧密。生产工单要校验物料库存MRP要反查销售订单成本核算要读取所有生产移动记录。如果硬拆成服务绝大部分的业务操作都会变成跨服务事务分布式事务的一致性处理会成为噩梦。我见过某公司把库存拆成独立服务结果每次发料要同时更新库存服务、工单服务和成本服务的三个数据源线上频繁出现数据不一致排查要结合三个系统的日志才能定位运维成本极高。第二制造企业的IT团队规模通常有限微服务所需的注册中心、网关、链路追踪、配置中心、多环境部署这些基础设施不是所有团队都有能力长期维护。单体在开发效率、联调成本、事务处理上对团队友好得多。第三单体不等于一坨代码。我所谓的模块化单体是在一个解决方案Solution里按业务域拆成多个工程Project模块之间通过明确的接口和领域服务交互禁止跨模块直接引用对方的仓储或表。这样如果将来某个域确实需要独立部署比如多工厂独立或者MES系统的吞吐需求远超ERP其他模块可以把该模块所在的工程整体抽出去做一层防腐隔离发布成独立服务。所以架构风格上的结论是用DDD的分层思想组织代码用模块化单体的物理结构保证当前阶段的可维护性同时所有模块之间的调用通过应用层接口进行方便未来拆分。这是我在实际项目里走下来最稳妥的方案。2. 核心领域模型拆解从BOM到生产工单的建模思路2.1 物料主数据与BOM物料清单物料主数据是制造ERP的字典表几乎所有业务单据都会引用它。物料主数据至少要包含这几类信息物料编码、名称、规格型号、计量单位、物料属性原材料/半成品/成品/辅料/包装物、默认仓库货位、计划策略自制/外购/委外、安全库存、提前期、批次管理标识、序列号管理标识。其中计划策略直接决定MRP运算时是生成生产工单还是采购订单这个字段如果建错整个计划模块都会算错。BOM是产品结构的技术描述描述用什么、用多少来制造一个母件。这里有几个容易踩坑的点BOM版本。研发部门在生产过程中会持续改进工艺如果只有一个BOM版本在生产中使用等你发现要变更的时候数据已经乱了。所以BOM设计必须带版本号生产工单在下达时快照当时有效的BOM版本这样即使未来BOM改了已经投产的工单仍然按旧BOM执行完工后在财务核算时才不会出现材料成本对不上的情况。替代料。制造业没有哪家企业敢保证所有物料供应永远稳定。替代料关系是BOM模块里不可少的一块。设计替代料时不只是在BOM行上挂一个替代料编码而是要定义替代优先级和替代条件如缺料自动替代 / 指定替代。替代料还会反向影响成本核算因为实际使用的替代料价格和原BOM物料价格往往不同工单执行记录上要把实际用料写清楚。多阶BOM。凡是要做MRP的系统就必须处理多阶BOM的递归展开。比如装配件A由部件B和C组成B又由零件D和E组成MRP计算时要一并展开到最底层。这个展开逻辑处理得好不好直接影响性能一个中等规模产品的完整BOM几千行是常态递归展开几百上千个产品的时候不能每次都重新全量展开。我常用的方法是保存一份产品结构的展开明细缓存表当某物料的BOM变更时把这个物料所涉及的母件缓存都作废重建。查询性能提升非常明显。2.2 工艺路线与工序建模工艺路线是制造ERP里面比BOM更灵活、也更难建模的数据。它描述物料在哪些工作中心上加工、需要哪些工序、标准工时是多少。很多团队把工艺路线设计成跟BOM差不多的二维表结构字段挂物料编码、工序号、工作中心、工时这样能用但工序之间的关系表达不完整。正确定义工艺路线至少需要三个层次工艺路线头版本、工序集合、工序间的衔接关系如并行、串行、可选分支。一个工艺路线可以有多个版本因为同一种产品可能有多条可行路线比如外协路线和自制路线。当前生产用哪条路线由计划和工艺模块协同确定。工序建模时工时报工的粒度很重要。粗粒度的做法是每张工单只报一次总工时适合管理不精细的车间细粒度的做法是按工序报工配合工票来统计每道工序的合格数量、报废数量和工时耗费这对后续的成本核算和计件工资计算都有用。我从实际业务需求出发的建议是工序级报工是一开始就要支持的能力即使前期不用也不要设计成无法扩展的扁平结构。2.3 生产工单状态机是关键生产工单Work Order是整个制造执行信息流的主线。它承接计划层的排产结果向下指导车间领料、工序流转、完工汇报同时反向触发仓库发料、财务成本归集。这个对象的状态流转我建议用严格的状态机来管理不要让状态字段被随便赋值。生产工单建议的状态设计状态含义允许的前置状态触发的联动动作创建工单已生成未审核无生成工单号记录创建人和时间已下达审核通过可以投产创建生成投料需求、生成工序计划预留库存生产中车间已开始领料/开工已下达锁定物料分配开始累计实际工时已完工全工序完成待入库生产中生成产成品入库单触发成本计算已入库产成品已入成品库已完工关闭工单释放计划约束已关闭财务结算完毕归档已入库写入历史表不可再修改已取消手动取消/异常终止创建、已下达释放预留库存标记作废特别要说的是状态变更不能只改一个字段必须有一张状态流转历史表WorkOrderStatusLog记录从哪个状态变到哪个状态、操作人、时间、原因说明。这张表在追溯、审计、问题复盘时价值巨大。我在项目中遇到过一张6万工单的表被业务人员删状态改数据的情况幸亏有状态历史表对账时把责任定位得清清楚楚。生产工单还要挂接WIP在制品数据。简单的生产流程里工单领料后全部记在工单上复杂的流程里一个工单会按工序分批流转每道工序上都有在制品数量。这个如果提前不设计后面做车间看板和生产进度查询就会非常吃力。2.4 库存模型现存量、可用量、流水账库存模型是制造业ERP架构中被低估的一个模块。很多系统把库存表设计成物料仓库批次数量每次出入库就直接更新这张表表面看逻辑简单但在高并发多工厂环境下问题非常致命。我推荐的双层库存模型现存量表InventoryBalance保存维度的当前数量汇总维度物料仓库/库位批次库存状态可用、冻结、待检等。这张表可以被实时查询但要控制写并发。流水账表InventoryLedger保存每一次库存移动的完整记录包括单据类型采购入库、生产入库、生产领料、盘点调整、调拨、销售出库等、来源单号、目标单号、数量、移动前后余量、操作人、时间。关键原则任何库存变化必须先写流水后更新现存量两者在同一数据库事务中完成。这样即使现存量被一次性清空误操作流水还在业务和数据都能恢复。如果条件允许保留操作前后的余量快照对排障帮助极大。库存状态的设计也不能忽略。制造业场景里同一批物料在仓库里可以处于可用和待检状态。原材料到货后先记待检质量部门检验合格后转为可用。这个场景如果在库存模型里没有体现后面做质检模块时就只能硬塞逻辑非常别扭。3. C#技术栈与工程落地从框架选型到事务边界3.1 框架选型.NET时代的技术底座C#实现制造业ERP当前我比较推荐的技术栈是.NET 8LTSASP.NET Core Web APIEF Core 8ORMSQL Server或PostgreSQL作为主数据库。这套组合生态成熟EF Core在复杂查询、迁移、事务支持方面都够用。为什么是EF Core而不是Dapper很多老牌开发对Dapper有感情但ERP这类系统最大的特点就是实体关系复杂、业务规则繁多。EF Core的变更追踪、导航属性、事务封装能显著减少样板代码配合原生SQL执行复杂报表查询也方便。只要注意不要写出深层的笛卡尔爆炸查询EF Core的性能并不差。分层结构我习惯这样组织src/ ├── Erp.Domain // 领域层实体、值对象、聚合根、领域服务、仓储接口 ├── Erp.Application // 应用层DTO、命令/查询对象、应用服务Handler ├── Erp.Infrastructure // 基础设施层EF Core DbContext、仓储实现、外部服务适配 ├── Erp.Api // 表现层Controllers、Filters、认证配置、中间件 └── Erp.Shared // 共享层通用工具、枚举、常量、消息包装依赖方向是从外围指向核心Api → Application → DomainInfrastructure在Domain之下实现其定义的接口。这个方向不能破环一旦Application层往Infrastructure层直接引用DbContext项目的边界就崩了。CQRS命令查询职责分离在ERP里值得做但不必搞得过于严格。简单来说写操作走Command读操作走Query写和读可以分别使用不同的模型。这样做最大的好处不是提升性能而是让代码意图清晰你要创建工单这里就是一个CreateWorkOrderCommand业务规则在这个Handler里统一执行你要查工单列表这就是一个查询直接对只读视图操作。尤其当报表和业务查询密密麻麻堆在Controller里的时候CQRS能让你的Controller变得非常干净。3.2 事务边界什么时候使用事务什么时候绝不能使用ERP的核心业务如创建采购收货单、工单完工汇报、库存调拨这些操作都跨多个表必须用ACID事务保护。EF Core使用IDbContextTransaction或依赖SaveChanges默认事务可以保证同一个DbContext内多个实体变更在一个事务中提交。但真实系统的坑不在于事务没开而在于事务开得过长、范围过大。两个多小时的分布式事务演练中我发现一个典型的反面案例有同事在创建采购收货单时在事务内部调用了ERP之外的供应商门户API来推送通知结果对方接口超时30秒数据库里十几行数据锁了30秒直接拖垮了当时的入库台仓库那边扫码枪全都转圈圈。我的硬性规范是事务范围尽量控制在仓储方法或应用服务的一个操作内事务内禁止调用外部远程接口HTTP、消息队列发送等待确认事务内禁止等待用户输入事务内的数据量要控制批量导入时按500-1000条分批提交不要一股脑SaveChanges把所有数据都塞在一个事务里。如果确实要在业务流程中引入外部动作比如创建工单后发送消息给车间看板正确做法是先提交本地业务事务再发送消息如果消息发送失败则落一张待发布事件表由后台定时补偿发送。这个模式我在项目里称为本地消息表比引入分布式事务框架简单可靠得多。3.3 并发控制别让超发和重复入库悄悄发生制造业ERP的并发问题主要集中在两类场景一个是库存扣减并发超发/超卖另一个是唯一性约束并发同一张单重复提交。库存扣减的经典错误写法是var balance await _db.InventoryBalances.FirstOrDefaultAsync(x x.Id id); balance.AvailableQty - qty; await _db.SaveChangesAsync();这个写法在并发时必出问题两个请求同时读到可用量100各自减掉80最后保存成20但实际已经发出去了160件。正确做法要么用乐观并发RowVersion要么用SQL原子的UPDATE字段表达式var rows await _db.Database.ExecuteSqlRawAsync( UPDATE InventoryBalances SET AvailableQty AvailableQty - {0}, UpdatedAt {1} WHERE Id {2} AND AvailableQty {0}, qty, DateTime.Now, id); if (rows 0) { throw new InsufficientStockException(); }先更新同时校验受影响行数如果没有行被更新说明库存不够直接抛业务异常。这是最简单有效的防超发手段。唯一性约束并发重复提交则要依靠数据库层的唯一索引兜底。例如同一工单对同一个批次的领料记录可以在数据库上加唯一索引WorkOrderId, BatchId, MaterialId应用层即使校验漏了数据库层也能把重复数据挡在外面。3.4 主数据隔离与权限模型制造企业往往有多家工厂/车间比如有基础数据工厂、机械加工工厂、装配工厂等。每个工厂都有自己的库存、工单和工艺数据。ERP架构上要支持多组织数据隔离。最稳妥的做法是核心业务表都挂一个OrgId字段所有查询和写入都强制带上当前用户的组织上下文仓储层在实现时统一拼接OrgId过滤条件。把组织隔离逻辑抽到应用服务层或DbContext的查询过滤器中在EF Core里用全局查询过滤器处理几乎不增加开发负担。读多租户相关文档时看到有人用Schema隔离或者独立数据库隔离但单体内靠OrgId字段过滤是成本最低、灵活性最高的一种。权限模型用RBAC基于角色的访问控制就够了。用户-角色-权限三个层次权限点在Controller或应用服务上通过特性标注。需要额外注意的一个点是制造业ERP里数据权限要跟组织权限配合比如同是仓库角色工厂A的仓管员不应该看到工厂B的库存。这个我没在设计初期考虑周全后来在查询层面上补了一道组织数据可见范围的过滤返工成本不小。4. 一个生产工单全流程的实操实现从建模到动作4.1 工单下达的完整业务链路挑一个高频核心操作来完整梳理下达生产工单ReleaseWorkOrder。很多新手做这个功能时只想着改状态字段但真实业务中这个操作会连带触发一系列动作缺一个后面就乱。我列一下完整的步骤校验工单当前状态是否为已创建校验BOM是否存在且为启用版本并且所有子项物料在主数据中存在校验工艺路线是否存在有效版本检查必要物料在仓库中是否足够若工单策略要求领料前校验为每个子项物料生成投料预留锁定可用量创建工单的生产工序计划从工艺路线复制工序集合将工单状态置为已下达插入状态变更历史写入一条工单下达事件到本地消息表等待推送车间看板或MES。从步骤可以看出一个工单下达不是一个Update操作而是多条数据在同一事务中的联动。我在代码里的实现方式是这样的public async TaskResult Handle(ReleaseWorkOrderCommand command, CancellationToken ct) { await using var tx await _db.Database.BeginTransactionAsync(ct); var workOrder await _db.WorkOrders .Include(x x.Items) .SingleAsync(x x.Id command.WorkOrderId, ct); if (workOrder.Status ! WorkOrderStatus.Created) return Result.Fail(只有已创建状态的工单才能下达); // 校验BOM和工艺路线 var bom await _bomRepository.GetReleasedByProductId(workOrder.ProductId, ct); if (bom is null) return Result.Fail(该产品没有已发布的BOM版本); var route await _routeRepository.GetReleasedByProductId(workOrder.ProductId, ct); if (route is null) return Result.Fail(该产品没有已发布的工艺路线); // 生成投料预留 foreach (var bomLine in bom.Lines) { var reserved await _inventoryService.TryReserveAsync( workOrder.FactoryId, bomLine.MaterialId, workOrder.Qty * bomLine.UsageQty, ct); if (!reserved.Success) return Result.Fail($物料 {bomLine.MaterialCode} 库存不足可用量 {reserved.AvailableQty}); } // 创建工序计划并变更状态 workOrder.CreateOperationsFrom(route); workOrder.Release(); _db.WorkOrderStatusLogs.Add(new WorkOrderStatusLog(workOrder.Id, WorkOrderStatus.Created, WorkOrderStatus.Released, _currentUser.Id)); await _db.SaveChangesAsync(ct); _outbox.AddOutboxMessage(new WorkOrderReleasedEvent(workOrder.Id)); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); return Result.Ok(); }有两点要特别说明第一步的TryReserveAsync内部执行的就是前面说的原子UPDATE防超发最后一步Outbox消息在同一个事务里写库这样事务提交后消息无论如何都不会丢如果推送车间看板失败后台补偿任务扫描Outbox表重试。4.2 领域模型的代码示意生产工单聚合的实体设计我习惯把工单头、工单子项、工序计划放在同一个聚合里因为它们的生命周期绑定在一起外部不能绕过工单头直接修改子项。public class WorkOrder : AggregateRoot { public string WorkOrderNo { get; private set; } public string ProductId { get; private set; } public decimal PlannedQty { get; private set; } public WorkOrderStatus Status { get; private set; } public DateTime? ReleaseTime { get; private set; } public DateTime? FinishTime { get; private set; } private readonly ListWorkOrderItem _items new(); private readonly ListWorkOrderOperation _operations new(); public IReadOnlyCollectionWorkOrderItem Items _items; public IReadOnlyCollectionWorkOrderOperation Operations _operations; public void Release() { if (Status ! WorkOrderStatus.Created) throw new InvalidOperationException(只有已创建状态的工单才能下达); Status WorkOrderStatus.Released; ReleaseTime DateTime.Now; } public void AddOperation(OperationDefinition op, int sequence) { _operations.Add(new WorkOrderOperation(this.Id, op.Id, op.Name, sequence)); } }实体内部方法负责校验状态和规则而不是在应用服务里散落一堆if判断。这个习惯坚持下来业务规则的归属就非常清楚工单自己的规则写在工单类里跨实体的规则如需要校验库存写在应用服务或领域服务里。4.3 投料发料的实务处理细节制造业车间发料的实务比想象中繁琐。系统设计上投料单PickList是生产领料的依据领料单上每一行对应工单的一个物料项并注明应发数量、实际发料数量、批次号。发料动作完成后线边仓或工序暂存区库存增加原材料中心仓库存减少。这里面有一个细节容易被忽略实际发料量经常不等于应发量。原因可能是来料不足少发、装配过程中发现物料不良多发、或者按称重计量的物料存在损耗差异。如果系统强制发料数量必须等于应发数量车间就会数据造假要么改单据要么先领了不做账后面盘点全乱。所以我建议发料单允许实际发料与应发不同但超过一定偏差比例比如5%时必须填写原因代码便于后续分析损耗。这个弹性和严格之间的平衡是制造ERP设计中最微妙的部分之一。完工汇报也要做类似处理。实作合格数量、报废数量、返工数量分别记录实际工时和标准工时对比供后续核算效率和成本。5. 制造ERP实施中的常见坑与排查技巧5.1 数据一致性之坑单据与库存对不上ERP上线后最常见的问题就是库存账面数和实物数对不上。排查顺序我建议先看流水账再看单据步骤是找到对不上的物料从InventoryLedger里按时间倒序拉出最近所有移动记录核对每一笔移动对应的源单据采购收货单、生产入库单、领料单等是否存在、是否作废检查是否存在跳过流水直接改现存量的代码路径——优先排查盘点调整、期初导入、废料处理这几个容易写死的地方检查是否有定时任务重复执行导致重复入账。我们曾有一次对账发现仓库多了几十件成品追查后发现是某台电脑上的排程程序卡在内存里每天凌晨重跑了一遍完工入库任务。从那以后我要求所有定时任务必须带幂等控制要么在数据库记录执行任务ID和批次状态要么在单据号上带上任务标识保证重复执行不会重复入账。5.2 性能瓶颈BOM递归与报表查询ERP系统运行半年后最大的性能瓶颈往往不是简单的CRUD而是两类查询多阶BOM展开查询、跨模块汇总报表。BOM递归展开前面已经提过用展开缓存表解决这里再补充一个实战技巧缓存表不要只存母件→所有子件的列表最好把层级号Level一起缓存这样做缺料分析时直接按层级排序不需要中途递归。对于报表查询CQRS里读模型不直接查业务库而是查专门建的只读投影表或视图。投影表在事务里同步更新或者通过后台任务异步更新比如工单执行进度报表里面的已完工数量、已领料数量、当前状态都可以在一张宽表里冗余存储。虽然冗余了一些字段但报表响应时间可以从十几秒降到几百毫秒。ERP系统的大多数查询报表都是高频率低并发牺牲一点存储一致性的复杂度换取用户体验是非常划算的。5.3 逻辑易错清单那些业务人员不会明说但系统必须处理的事做ERP系统有些需求不会写在需求文档里但在车间里确实天天发生我这里列几个都是我吃过亏以后积累下来的负库存处理。库存不足时到底允不允许负数过账我的经验是强制不允许负库存但要有紧急发料这样的特批通道由更高权限的人审批后允许负库存同时限定在24小时内必须补单。完全没有特批通道紧急生产就会被卡死完全放开负库存库存数据一定会乱。数量精度问题。制造业常有小数数量比如按公斤发的油漆。数据库里数量字段用decimal(18,3)还是decimal(18,4)这个选择在期初字段设计时就要统一。四舍五入规则不一致会造成后续统计差异建议所有数量字段用统一精度并在系统里约定舍入规则。多人同时操作同一张单。工单控制面板上计划员和车间主任可能同时打开同一张工单一个在改排产数量一个在录入报工。这时候如果不做并发控制后提交的人会覆盖先提交的人的数据。所以工单、BOM这类核心单据必须加RowVersion行版本号提交时校验版本不一致就拒绝并提示刷新。删除必须是逻辑删除。ERP里任何业务单据都不建议物理删除用IsDeleted标志尤其是已经发生过库存移动的单据。只有草稿状态的单据才允许物理删除。这一点要作为代码评审的红线。5.4 与MES/WMS/财务系统的集成边界制造企业的ERP不会孤立运行它通常要和MES制造执行系统、WMS仓储管理系统、财务系统协同。集成边界划得清不清晰直接决定项目整体复杂度。我的原则是ERP管计划和控制MES管现场执行WMS管仓库操作财务系统管账务系统间通过API和事件交互不共享数据库不互相直接改表。以工单为例ERP下达工单后把工单号、产品、数量、工艺路线发给MESMES执行过程中把工序报工、设备数据回传给ERPERP根据报工结果做完工入库。这里的关键是明确谁发起、谁确认。如果MES说工序已完工ERP这边要不要直接改工单状态建议是MES回传的数据在ERP侧先落表状态变更需要ERP侧的业务规则校验确认不能盲信任何外部系统的消息。这样即使MES那边数据异常也不会污染ERP的核心业务数据。财务集成上物料移动发料、入库、报废会自动生成对应的财务凭证。这里存在一个天然的时间差业务单据在业务期已经生效但财务月结期间会冻结某些过账期间。所以ERP要增加一个会计期间的概念单据的记账日期落在哪个期间是否允许在当前期间过账这是财务集成中最常见的冲突来源。提前跟财务团队确认会计期间的打开和关闭规则能让这个集成顺畅得多。一点个人体会把制造业ERP系统完整地搭过一遍之后我有一个很深的感受这类系统的复杂度不在某个算法也不在某个框架而在大量业务规则的交织。C#和.NET提供了很好的工程基础EF Core、CQRS、状态机、Outbox模式这些工具都能在合适的位置发挥价值但真正决定系统成败的是把业务概念理到多清楚、把事务边界划到多清晰、把异常路径想得多周全。如果再让我做一个新的制造ERP项目我会先花两周只画领域模型做业务梳理一行的代码都不写。把BOM、工单、库存、工艺路线这些核心对象的关系、状态、联动规则在纸面上讨论清楚后面写代码反而快得多。这个习惯是我在几次返工之后才真正养成的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑