☰
C# MES系统源码拆解:从工单下发到追溯闭环的落地经验
2026/10/23 14:56:47 网站建设 项目流程

简介:一份基于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 Task<ApiResult> 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 Task<ApiResult> 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,例如ns=2;s=Line1.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("ns=2;s=Line1.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 List<PointValue> _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 状态,后来追溯时发现流转卡状态和报工记录对不上,整条记录作废。从那以后,我每次做状态变更都强制走接口加日志的流程,多一行代码,少一次追溯事故。希望这套源码里的状态机设计和避坑记录能帮到你,至少让你在定方案时不至于白踩那些我已经踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询