简介一份基于C#的MES制造执行系统源码资料包定位于.NET开发人员、工业信息化工程师及MES二次开发学习者。资料围绕C#实现MES的核心环节展开涵盖三层架构、实体类数据模型、数据库访问、实时数据采集、工作流引擎、报表可视化、权限安全及服务集成等关键代码便于对照理解制造执行系统的落地方式。包内共95个文件压缩包约1.81MB以54个cs源码文件为主配合24个resx界面资源、5个DLL库、3个SQL脚本以及Markdown说明文档和工程文件sln/csproj/config可支撑项目打开、编译与按模块查阅。目录按主控制板、基础资料、数据报表、数据采集等模块划分说明文档与数据库脚本齐全方便快速定位到对应功能。目前已有3217人学习下载。通过这套源码可掌握MES从主程序调度、界面交互到数据库表设计、报表输出和NPOI导出的完整代码骨架对后续系统定制、功能扩展或技术改造都有直接的参考价值。1. C# MES 源码拆解一条产线从纸质流转卡到系统派工的落地记录MES 系统的复杂度很少在功能数量上而在状态一致性上。工单重复下发一次流转卡就会打重号产品批次追溯直接断开后面所有报工数据都失去意义。这套 C# MES 系统源码我完整拆过一遍后最大的感受是它的价值不在界面而在工单下发、工序报工和质检判定这一段后端逻辑结构清楚、边界明确。适合做制造执行系统二次开发的 C# 工程师也适合工厂里负责数字化落地的 IT 工程师照着跑起来就能看到一条产线从纸质流转卡变成系统派工的过程。2. 先把架构立住WinForms 客户端、Web API 服务端与 SQL Server 的选型理由2.1 客户端选 WinForms 而不是 WPF工位机硬件和扫码外设说了算车间工位机大多不是新采购的办公电脑而是前几年配的工控机4G 内存、无独显是常态。WPF 的渲染和动画在这种配置上很容易吃 CPU界面切换一多就开始卡WinForms 是轻量级直接绘制DataGridView 绑数据、TabControl 切页面都足够顺滑。更重要的是MES 工位端要接扫码枪、电子秤、RFID 读卡器这类外设WinForms 对串口、USB HID 的兼容性好调试时出问题也容易定位。这套源码的客户端就是标准 WinForms 结构主窗体做导航各个功能页用 UserControl 承载一个 exe 加一个配置文件就能分发到工位机。管理端可以做 Web 页面但工位端我建议保持 WinForms。理由很简单产线上操作工只需要扫码、确认、看结果界面元素越少越好浏览器要维护内核版本、会崩溃、会吃内存换一台旧工控机还要重新调兼容性。WinForms 的部署方式决定了它在这种环境下最不容易翻车。2.2 服务端选 Web API SQL Server强事务场景下的稳妥组合服务端用 ASP.NET Core Web API而不是传统的 WebService 或者直接连数据库的客户端架构。Web API 的好处是扫码枪、PDA、看板、管理后台都能通过 HTTP 接入客户端不再直连数据库连接数只在服务端消耗。车间局域网偶尔掉包HTTP 请求断了可以重试比维护一条长时间不断的 TCP 通道省心得多。数据库方面我推荐 SQL Server。MES 最怕的是数据不一致工单要改状态、流转卡要批量生成、报工要写记录这是一条强事务链。SQL Server 在事务、锁、死锁检测上比 MySQL 默认配置更稳工厂里 IT 普遍对 SQL Server 的备份和运维更熟。下表是几个关键选型对比对比维度WinForms Web API纯 Web 页面传统 WebService工位机性能占用低无浏览器负担中高浏览器吃内存低离线容错本地可暂存部分数据依赖网络断线白屏差扫码枪兼容好串口和 USB 都行依赖浏览器权限一般部署难度分发 exe 加配置浏览器访问服务端配置复杂EF Core 做数据访问层换数据库的成本也不高。如果公司要求全开源方案把连接串换到 PostgreSQL 再改少量 SQL 方言基本能跑但这套源码默认的 SQL Server 路径是最省事的。2.3 工单状态机与模块边界0/1/2/9 状态码背后的经验状态码不要用 1、2、3、4 这种连续数字用 0、1、2、9。原因很简单0 是待下发1 是生产中2 是已完工9 是已关闭。查询时 OrderBy(Status) 会把有效状态排在前面关闭的单子自然沉底而且 9 这个状态在日志里一眼能认出是终态不会和流程中的状态混淆。模块边界必须明确工单模块只管状态和数量工艺模块只管工序顺序报工模块不碰排产。客户端只允许调 API不允许直连数据库改表。出了问题时看 API 日志就能定位是参数问题还是业务问题不需要去数据库翻操作记录。状态更新时我一般会加一行条件更新做乐观锁var updated await _db.Database.ExecuteSqlRawAsync( UPDATE WorkOrder SET Status 2, FinishTime GETDATE() WHERE WorkOrderNo p0 AND Status 1, workOrderNo); if (updated 0) return ApiResult.Fail(工单状态不是生产中不能完工);这里的关键是 WHERE 条件里带了旧状态。两个请求同时提交时只有一个能更新成功另一个 updated 为 0直接返回失败。没有这一步并发场景下状态会被后面的请求覆盖这是 MES 状态错乱最常见的源头。3. 工单下发与工序报工核心表结构、事务边界和条码规则3.1 三张核心表的建模订单、流转卡、报工记录怎么关联MES 追溯的最小单位不是工单而是流转卡。一张工单对应多张流转卡每张卡一个唯一条码扫卡才能报工报工记录挂在流转卡下面。工单是主数据流转卡是执行数据报工是结果数据三层分开后面做状态回退才不会把历史搞乱。核心建表语句如下CREATE TABLE WorkOrder ( WorkOrderNo varchar(32) PRIMARY KEY, ProductCode varchar(32) NOT NULL, PlanQty int NOT NULL, Status int NOT NULL DEFAULT 0, LineCode varchar(4) NOT NULL, DispatchTime datetime NULL, FinishTime datetime NULL ); CREATE TABLE FlowCard ( CardNo varchar(32) PRIMARY KEY, WorkOrderNo varchar(32) NOT NULL, Seq int NOT NULL, Status int NOT NULL DEFAULT 0 ); CREATE TABLE OperationReport ( ReportId bigint IDENTITY(1,1) PRIMARY KEY, CardNo varchar(32) NOT NULL, OperationCode varchar(16) NOT NULL, Qty int NOT NULL, NgQty int NOT NULL DEFAULT 0, ReportTime datetime NOT NULL, ReportUser varchar(32) NOT NULL ); CREATE UNIQUE INDEX UX_WorkOrder_Status ON WorkOrder(WorkOrderNo, Status); CREATE UNIQUE INDEX UX_FlowCard_CardNo ON FlowCard(CardNo);WorkOrderNo 是 ERP 下发的生产订单号ProductCode 是物料编码PlanQty 决定流转卡张数FlowCard 的 CardNo 就是贴在流转卡上的条码Seq 是这张卡在工单里的顺序OperationReport 每扫一次卡写一条Qty 是合格数NgQty 是不良数分开记才能在报表里算一次合格率。两个唯一索引是防重复的底线后面避坑章节会展开讲。3.2 工单下发事务里同时改状态和生成流转卡工单下发这一步是整个系统的入口逻辑上要把“改工单状态”和“批量生成流转卡”放在同一个事务里。只改状态没生成卡产线扫不了码只生成卡没改状态工单列表里还是待下发。核心方法如下public async TaskApiResult DispatchWorkOrder(string workOrderNo, int planQty, string lineCode) { using var scope new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); try { var exists await _db.WorkOrders.CountAsync(w w.WorkOrderNo workOrderNo w.Status ! 9); if (exists 0) return ApiResult.Fail(该工单已下发禁止重复操作); var order new WorkOrder { WorkOrderNo workOrderNo, PlanQty planQty, LineCode lineCode, Status 1, DispatchTime DateTime.Now }; _db.WorkOrders.Add(order); for (int i 1; i planQty; i) { _db.FlowCards.Add(new FlowCard { CardNo BuildFlowCardNo(lineCode, workOrderNo, i), WorkOrderNo workOrderNo, Seq i, Status 0 }); } await _db.SaveChangesAsync(); scope.Complete(); return ApiResult.Ok(); } catch (Exception ex) { Log.Error(ex, DispatchWorkOrder failed: {WorkOrderNo}, workOrderNo); return ApiResult.Fail(下发失败 ex.Message); } } private string BuildFlowCardNo(string lineCode, string workOrderNo, int seq) { return ${lineCode}{DateTime.Now:yyyyMMdd}{workOrderNo}{seq:0000}; }事务包裹住状态变更和流转卡生成两条数据要么都写入要么都回滚。exists 查询是业务层的防重校验配合数据库唯一索引兜底。条码规则是“产线代码 日期 工单号 顺序号”比如 A1 线、工单 WO202501001、第 7 张卡生成的卡号就是 A1202501 01P03 A0000位数固定扫码枪读取和人工比对都不容易出错。3.3 工序报工防重、防跳序、不良品单独记账报工逻辑比下发更频繁最容易出问题的是重复扫码和跳工序。下面这个方法把校验放在写入之前public async TaskApiResult ReportOperation(string cardNo, string operationCode, int qty, int ngQty) { var card await _db.FlowCards.FirstOrDefaultAsync(c c.CardNo cardNo); if (card null) return ApiResult.Fail(流转卡不存在); if (card.Status 9) return ApiResult.Fail(该流转卡已报废不能报工); var lastReport await _db.OperationReports .Where(r r.CardNo cardNo) .OrderByDescending(r r.ReportId) .FirstOrDefaultAsync(); string expectedOp GetNextOperationCode(lastReport?.OperationCode); if (operationCode ! expectedOp) return ApiResult.Fail($工序顺序错误当前应报{expectedOp}); _db.OperationReports.Add(new OperationReport { CardNo cardNo, OperationCode operationCode, Qty qty, NgQty ngQty, ReportTime DateTime.Now, ReportUser CurrentUser() }); if (ngQty 0) { _db.NgRecords.Add(new NgRecord { CardNo cardNo, OperationCode operationCode, NgQty ngQty }); } await _db.SaveChangesAsync(); return ApiResult.Ok(); }先查流转卡是否存在再查状态是否已报废然后通过最后一条报工记录推断当前应报工序GetNextOperationCode 从内存缓存的工艺路线表里取下一道工序防止跳序。不良品数量大于 0 时同时写 NG 记录表这样追溯时能看出不良发生在哪道工序。注意这里没有用事务因为报工和 NG 记录是同一批写入SaveChanges 本身就在一个事务里完成如果这两张表分属不同库才需要显式开事务。4. 设备数据采集OPC UA 点位读取、均值入库与看板刷新4.1 先要点位表再写连接OPC UA 初始化与点位标识设备采集是 MES 里最像黑匣子的一块。很多新设备原生支持 OPC UA连接方便但前提是你必须从设备供应商手里拿到点位表。点位表里会写明每个参数的 NodeId例如ns2;sLine1.Press.CurrentA表示 1 号产线压机 A 相电流。没有点位表就只能翻梯形图一点一点试费时且容易漏。我一般用 OPCFoundation 的 UA .NET Standard 库做客户端初始化流程如下var endpointUrl opc.tcp://192.168.1.20:4840; var appConfig ApplicationInstance.LoadConfiguration(Opc.Ua.Client.config); var endpoint new EndpointDescription(new Uri(endpointUrl)); var endpointConfig await EndpointDescription.CreateAsync(appConfig, endpoint); var session await Session.CreateAsync( appConfig, endpointConfig, false, MES_Collector, 60000, new UserIdentity(), null); var nodeId new NodeId(ns2;sLine1.Press.CurrentA); var value await session.ReadValueAsync(nodeId, CancellationToken.None);session 名称 MES_Collector 是给服务端识别客户端用的60000 是会话超时毫秒数产线网络不稳定时不要设太短。不同版本的 OPCFoundation 库 API 名称略有差异以你 NuGet 引用的版本为准但整体结构是连接、创建会话、读节点三步。上线前拿钳形电流表实测一个点位读数对得上再放开采集这一步能省后面十倍的排查时间。4.2 轮询采集与批量写库5 秒周期加内存缓冲采集频率要看设备节拍。SMT 贴片这类高节拍设备建议 2 到 5 秒一轮注塑机、压铸机 10 秒也够。不要每轮都写库否则 SQL Server 的日志会疯长磁盘很快就爆。常见做法是内存缓冲凑够一批再批量入库private readonly ListPointValue _buffer new(); private void InitCollector() { var timer new System.Threading.Timer( TimerCallback, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } private async void TimerCallback(object state) { var readTime DateTime.Now; var points await _opcClient.ReadBatchAsync(_pointConfigs); foreach (var p in points) { _buffer.Add(new PointValue { PointCode p.Key, AvgValue p.Value, StartTime readTime, EndTime readTime }); } if (_buffer.Count 50) { var batch _buffer.ToList(); _buffer.Clear(); await _db.PointValues.AddRangeAsync(batch); await _db.SaveChangesAsync(); } }每 5 秒一轮读取50 条阈值对应约 4 分钟落一次库。如果你要看均值报表可以在入库时做聚合把一段时间内的读值合并成一条平均值记录而不是直接铺开存明细。点位表里如果有压力、温度这种缓变参量可以存均值像电流这种波动大的建议同时存最大值方便后面分析设备异常。4.3 质量阈值与看板刷新连续超限才报警采集数据不接看板价值就少了一半。SignalR 是 Web API 场景下最顺手的推送方案服务端判定异常后直接推给前端看板public class MesHub : Hub { public async Task SendQualityAlert(string lineCode, string pointCode, double value) { await Clients.All.SendAsync(OnQualityAlert, lineCode, pointCode, value); } }阈值判定我建议加连续次数限制例如连续 3 个采集周期超限才推送报警避免设备启动瞬间的电流尖峰让看板频繁闪红灯。判定逻辑放在后台定时任务里不占请求线程。前端收到推送后对应产线卡片变红并显示点位值和超限时间这就是一个最小的设备状态看板。5. MES 上线的坑重复工单、扫码错位、连接池耗尽的排查记录5.1 重复工单与重复流转卡数据完整性的头号杀手现象工位机网络延迟操作员点“下达”没反应又点了一次几秒后列表里出现两条内容完全相同的工单流转卡重复生成追溯时一批产品出现两个卡号。原因客户端设置了超时重试服务端没做防重校验数据库也没有唯一索引两个请求先后通过了状态检查。解决WorkOrder 表的 WorkOrderNo 加唯一索引FlowCard 表的 CardNo 加唯一索引服务端方法入口查 exists状态非关闭的工单直接拒绝。并发场景下业务校验只能拦正常人唯一索引才是兜底。每次上线我都先确认这两个索引在不在。5.2 扫码枪焦点错位回车触发了相邻按钮现象操作员扫完流转卡系统自动把下一道工序的报工按钮触发了或者扫码枪输入的数字串进了数量框报工数量变成一长串数字。原因扫码枪模拟键盘输入结束时会带一个回车扫完以后输入框失去焦点回车就触发了焦点所在位置的控件。解决把扫码输入框的焦点锁死LostFocus 时重新 Focus在 KeyDown 里拦截回车键统一走扫描处理逻辑不让控件默认响应窗口切走再切回来时也要恢复焦点。这种问题只在产线实测时出现开发环境很难复现只能在代码层面提前防住。5.3 车间电脑时间不一致追溯时间线倒挂现象报表里的报工时间比实际慢两小时换班统计错位追溯同一张流转卡在不同工序上的时间线是倒挂的。原因工位机用的都是本地时间有的电脑没连时间同步有的操作员手动调过时间本地时间完全不可信。解决所有写入数据库的时间字段统一用 SQL Server 的 GETDATE() 或服务端 DateTime.Now客户端传上来的时间一律忽略。数据库字段默认值也别设客户端时间这一点在开发时不痛不痒上了产线就是大问题。5.4 数据库连接池耗尽整条产线同时断连现象上线第二天一个车间所有工位机同时报“数据库连接超时”重启客户端后正常几小时又复发。原因某个查询方法里自己 new 了一个 DbContext用完后没有释放定时轮询的连接也一直占着不放连接池被占满新请求全部排队超时。解决在 ASP.NET Core 里用 AddDbContext 注册DbContext 生命周期设为 Scoped请求结束自动释放定时轮询改用短连接读完即关。排查时直接看 SQL Server 的 sys.dm_exec_requests能看到大量连接卡在等锁或处于睡眠状态。5.5 服务路径带空格OPC 服务启动失败现象Windows 服务安装成功但启动失败事件查看器报“找不到文件”服务路径明明存在。原因默认安装路径是C:\Program Files\MES Service程序内部用相对路径拼接配置文件和 OPC 客户端时路径里的空格把字符串断开了。解决部署目录固定到不含空格的纯英文路径例如D:\MESService所有配置文件路径用 Path.Combine 拼接不要手写字符串。这个问题最容易翻车也最不起眼我见过不止一个项目卡在这里。6. 拿到源码后怎么二次开发接口约定、压测验证与状态回退拿到源码后我先做的一件事是把与 ERP 的接口字段固定下来。生产订单号、物料编码、批次号、计划数量这四类字段是必须的而且接口要做成幂等——同样的订单号重复推送后端只认第一次后面直接返回成功不做二次写库。常见的做法是在接口入口查一次订单号是否已存在存在就返回成功。对接测试时约定好 50 条订单的往返耗时避免 ERP 批量下发时把 MES 拖垮。其次要在测试库跑一轮并发压测。我习惯开 15 个并发线程每个线程连续下发 10 张工单跑完检查工单总数、流转卡总数和 CardNo 重复数三者只要有一项对不上就说明事务或唯一索引有问题。另外还要测中途断网场景客户端发一半断掉服务端事务必须回滚数据库里不能残留半截数据。这套流程走完状态机基本就稳了。状态回退是最容易翻车的功能。一张已完工的流转卡因为误扫要退回上一道工序最安全的做法是做一个 Reopen 接口把状态从已完工改回上一工序同时写一条操作日志记录操作人、原因和时间。血泪经验是有次我图省事直接在数据库里 UPDATE 状态后来追溯时发现流转卡状态和报工记录对不上整条记录作废。从那以后我每次做状态变更都强制走接口加日志的流程多一行代码少一次追溯事故。希望这套源码里的状态机设计和避坑记录能帮到你至少让你在定方案时不至于白踩那些我已经踩过的坑。本文还有配套的精品资源点击获取