ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# MES源码拆解:工单、报工、质检一体的车间执行系统

C# MES源码拆解:工单、报工、质检一体的车间执行系统

简介:这套基于C#的MES系统源码,面向工业信息化开发者、MES实施工程师及.NET程序员,旨在帮助读者从代码层面理解制造执行系统的核心实现。资源共95个文件,压缩包仅1.81MB,以cs源码、resx界面资源、sql数据库脚本为主,辅以dll依赖库、sln工程文件和说明文档,结构清晰便于阅读。源码采用典型的三层架构,涵盖主控制板、数据采集、基础资料、数据报表等模块,并包含生产订单、工序、设备状态等实体类设计,以及SQL Server建表脚本,可让学习者直观掌握业务逻辑与数据存储的映射关系。同时,资源内置NPOI、IrisSkin4等第三方库,展示了Excel数据导出和界面美化的实践用法。已有3217人学习下载,适合希望深入MES系统二次开发或提升C#工程能力的中高级开发者研读、复用与扩展。

1. C# MES 源码拆解:一套把工单、报工、质检串起来的车间执行系统

这两年制造企业上 MES 基本是刚需,但真正能在中小车间落地的 C# MES 源码却不多见。我帮某电子装配厂接过一套 MES 项目,改造前计划员每天靠 Excel 工单和微信群报工,设备状态靠老师傅用眼睛看,老板问“这批货能不能按时交”没人答得上来。换成这套 C# MES 之后,最明显的变化不是“无纸化”,而是工单、工序报工、质检和追溯被一张网串了起来:操作工在采集终端上刷卡报工,检验员录入不合格品,看板实时刷新,计划员终于能回答交期问题。这套源码适合三类人:想把车间数据管起来的制造企业 IT、做工业软件二次集成的开发者、以及想学 MES 业务逻辑的 C# 程序员。

2. 架构与技术选型:这套 C# MES 的模块边界画在哪

2.1 功能地图:工单、报工、质检、追溯如何串成一条线

在制造执行系统里,最核心的不是某个炫酷页面,而是“工单”这条主线。这套源码把业务拆成了四个闭环:计划层拿到 ERP 下发的生产订单后,在 MES 里生成工单并拆成多道工序任务;车间层通过采集终端对工序报工,合格数、不良数实时回写;质量层对首检、巡检、完工检生成检验单,不合格品触发返工或报废;追溯层则把物料批次、设备编号、操作工、检验结果全部挂到工单时间轴上。听起来模块多,但代码里每个闭环都对应一个独立的领域服务,没有各写各的 Controller。

业务流程大概是这样:工单创建后按工艺路线拆成 ProcessTask,每道工序都有“待开工—生产中—已报工—已质检”的状态流转;报工数据写入 WorkReport 流水表;检验结果写入 InspectionRecord;产品追溯码则通过批次号反查所有工序记录。这个设计最值得学习的地方,是把“工序任务”和“报工流水”分开,而不是只记录一个“最终产量”。没有 ProcessTask 这张中间表,后面做工序级追溯和返工定位会非常痛苦。

2.2 技术栈组成:为什么是 C# .NET 而不是 Web 前端

看这套源码之前,我翻过一些市面上的 MES Demo,很多是 Java 或 PHP 写的,C# 开发者接手后第一件事是去对应 Spring 那一套概念。这套源码给我的第一印象是“技术栈很收敛”:后端是 ASP.NET Core Web API,管理端用 Razor Pages,车间采集端是 WinForms,数据库用 SQL Server,设备接入走 Modbus TCP/RTU 和 OPC UA 标准协议。

收敛带来的好处很实在:产线工控机上跑 WinForms 采集端,不容易受浏览器内核、杀毒软件、网页弹窗影响;管理端用 Razor Pages,IT 部门二次开发时不需要再搭一套前端工程。如果团队只会 C#,这个技术组合几乎不需要跨语言协作,从数据库到界面全是同一套语法体系。

层次选型负责内容
后端服务ASP.NET Core Web API工单、报工、检验、设备状态接口
管理端ASP.NET Core Razor Pages计划排产、看板、不良品处理
车间采集端WinForms (.NET 6)刷卡报工、PLC 数据采集、异常上报
数据库SQL Server 2019+主数据、流水、事件表
设备接入Modbus TCP/RTU、OPC UA连接 PLC、仪表、传感器

如果你所在的环境不允许用 SQL Server,EF Core 的 Provider 配置写在 Infrastructure 层,换成 MySQL 或 PostgreSQL 也能跑,但仓储查询里用到表提示的部分要微调。

2.3 目录结构:源码包拿到手先看哪几个项目

源码包不是一个单项目,而是标准的解决方案结构,我把它拆给你看:

Mes.sln ├─ src │ ├─ Mes.Core # 领域实体、接口定义、状态机、业务规则 │ ├─ Mes.Infrastructure # EF Core DbContext、仓储实现、事务封装 │ ├─ Mes.Api # ASP.NET Core Web API,对车间客户端和管理端暴露接口 │ ├─ Mes.WebAdmin # Razor Pages 管理后台,看板、工单、检验页面 │ ├─ Mes.Collector # WinForms 数据采集客户端,跑在车间工控机上 │ └─ Mes.Device # 设备驱动抽象,Modbus/OPC UA 的适配器 ├─ database │ ├─ 01_schema.sql # 建表脚本 │ └─ 02_seed.sql # 初始化种子数据 └─ docs ├─ 接口文档.md └─ 部署手册.md

我一般建议先从 Mes.Core 读起,因为实体和状态机全在这里;然后是 Mes.Api 的工单控制器,把接口到服务的调用链走一遍;最后才看 Mes.Collector,因为采集端涉及 PLC 地址映射,没有业务背景容易看懵。数据库脚本要配合实体一起看,光看 SQL 看不出状态流转的约束。

3. 核心流程走读:工单下达到完工入库的代码链路

3.1 工单主数据建模:表结构与状态机设计

工单表的设计直接决定后面所有业务好不好写。这套源码里 WorkOrder 表的字段不算多,但关键状态位设计得很清楚:

CREATE TABLE dbo.WorkOrder ( Id INT IDENTITY PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL, ProductCode NVARCHAR(32) NOT NULL, PlanQty INT NOT NULL, Status TINYINT NOT NULL, -- 0待下达 1生产中 2完工 3已结案 4已取消 PlanStartTime DATETIME2 NULL, PlanEndTime DATETIME2 NULL, CreatedAt DATETIME2 NOT NULL, UpdatedAt DATETIME2 NOT NULL, CONSTRAINT UQ_WorkOrder_OrderNo UNIQUE (OrderNo) );

Status 字段没有直接用字符串,而是用 TINYINT 枚举存,代码里对应WorkOrderStatus这个枚举。这样做的目的是在 C# 层可以用强类型判断,避免到处写魔法字符串。我见过很多 MES 把状态写成'PRODUCING'、'FINISHED'这样的字符串,一旦命名不统一,后期统计口径全是坑。

创建工单时,核心逻辑在服务层,我从源码里摘了精简版:

[HttpPost("api/workorder")] public async Task<IActionResult> Create([FromBody] CreateWorkOrderRequest req) { var order = new WorkOrder { OrderNo = await _orderNoGenerator.NextAsync("WO"), ProductCode = req.ProductCode, PlanQty = req.PlanQty, Status = WorkOrderStatus.Created, CreatedAt = _clock.Now() }; // 拉取工艺路线,按工序拆成多个 ProcessTask var route = await _routeRepo.GetByProductAsync(req.ProductCode); if (route == null || route.Steps.Count == 0) return BadRequest("该产品未维护工艺路线"); foreach (var step in route.Steps.OrderBy(s => s.Seq)) { order.Tasks.Add(new ProcessTask { StepCode = step.StepCode, Seq = step.Seq, PlanQty = req.PlanQty, Status = ProcessTaskStatus.Waiting }); } await _orderRepo.InsertAsync(order, cancellationToken); await _unitOfWork.CommitAsync(cancellationToken); return Ok(new { order.Id, order.OrderNo }); }

这段代码的逻辑是:传入产品编码后,自动从工艺路线表查出该产品所有工序,并为每道工序生成一个 ProcessTask 任务记录。注意_unitOfWork.CommitAsync将工单头和工序任务放在同一个事务里,避免出现“工单创建了,但工序任务没生成”的脏数据。OrderNo由生成器生成,前缀 WO 加序列号,保证唯一且可读。

3.2 报工与派工实现:事务边界里藏着多少坑

报工是 MES 里并发量最大的操作,也是最容易出数据问题的环节。源码里报工接口长这样:

[HttpPost("api/workorder/{id}/report")] public async Task<IActionResult> Report(int id, ReportRequest req) { using var tx = await _db.Database.BeginTransactionAsync(); // 1. 锁定工序任务行,防止并发重复报工 var task = await _taskRepo.GetForUpdateAsync(id, req.StepCode); if (task == null || task.Status != ProcessTaskStatus.Running) return BadRequest("当前工序未开工"); // 2. 合格数累加后不能超过计划数 if (task.ReportedQty + req.GoodQty > task.PlanQty) return BadRequest("报工数超过计划数"); // 3. 写报工流水,同时增加成品/半成品库存 await _reportRepo.InsertAsync(new WorkReport { WorkOrderId = id, StepCode = req.StepCode, GoodQty = req.GoodQty, DefectQty = req.DefectQty, OperatorCode = req.OperatorCode, DeviceCode = req.DeviceCode, ReportTime = _clock.Now() }); task.ReportedQty += req.GoodQty; if (task.ReportedQty >= task.PlanQty) task.Status = ProcessTaskStatus.Completed; await _taskRepo.UpdateAsync(task); await _stockService.IncreaseAsync(req.WarehouseCode, task.ProductCode, req.GoodQty); await tx.CommitAsync(); return Ok(task); }

这里最关键的是GetForUpdateAsync,它在 SQL Server 里执行的是带UPDLOCK, ROWLOCK的行级锁查询。如果不锁行,两个操作工同时扫同一张工单报工,累加时互相覆盖,库存和报工数就对不上。事务边界把“报工流水写入、工序状态更新、库存增加”包在一起,任何一步失败都整体回滚。

报工还有个隐藏问题:有些老师傅习惯一次把整班产量报上去,而不是每完成一道工序就报一次。这套源码在接口层强制校验ReportedQty + GoodQty <= PlanQty,从源头挡住超计划报工。

3.3 质检闭环:检验单与不合格品处理的代码走向

质检模块在源码里是独立的一个InspectionService,与报工松耦合。检验动作不是简单记录“合格/不合格”,而是会触发后续流程:

public class InspectionService { public async Task<int> JudgeAsync(int inspectionId, bool isPass, string reason) { var inspection = await _inspectionRepo.GetByIdAsync(inspectionId); if (inspection.Status != InspectionStatus.Pending) throw new DomainException("该检验单已处理"); if (isPass) { inspection.Status = InspectionStatus.Approved; await _processTaskRepo.AdvanceAsync(inspection.WorkOrderId, inspection.StepCode); } else { inspection.Status = InspectionStatus.Rejected; // 生成不合格品记录,返工还是报废由不良代码决定 await _ncrRepo.InsertAsync(new NcrRecord { WorkOrderId = inspection.WorkOrderId, StepCode = inspection.StepCode, DefectCode = inspection.DefectCode, Qty = inspection.InspectionQty, Status = NcrStatus.Open }); } await _inspectionRepo.UpdateAsync(inspection); return inspection.Id; } }

合格时调用AdvanceAsync把工序任务从“待质检”推进到下一道工序;不合格时不直接回退原工单状态,而是生成一张 NCR(不合格品处理记录)。这个设计很关键:返工不是把原工序状态改回去,而是创建一条独立的返工流程,和原工单通过 WorkOrderId 关联。这样统计一次合格率时,不会因为状态回退把已完工的工序又算成在制。

注意:如果不合格就简单地把工单状态改成“生产中”,会丢掉不合格品被返工过几次的过程信息,追溯时只看到最终结果。

4. 设备数据采集与看板:从 PLC 变量到车间大屏

4.1 采集网关:PLC 数据如何进入 MES

MES 光有报工还不行,设备状态采集是另一个大头。这套源码里Mes.Collector是 WinForms 程序,内部跑着一个后台轮询服务,通过 Modbus TCP 从 PLC 读产量和运行状态。核心代码摘出来大概是这样:

public class ModbusPoller : BackgroundService { private readonly IPlcClient _plc; private readonly IMesApiClient _api; private readonly ILogger<ModbusPoller> _logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { // DB10.DBD4 存产量,DB10.DBX0.2 存运行状态 var totalCount = await _plc.ReadInt32Async("DB10.DBD4"); var isRunning = await _plc.ReadBooleanAsync("DB10.DBX0.2"); await _api.ReportDeviceStatusAsync(new DeviceStatusUpdate { DeviceCode = _deviceCode, TotalCount = totalCount, Running = isRunning, ReadAt = DateTimeOffset.Now }); } catch (Exception ex) { // 网络抖动或PLC繁忙时只记日志,不能杀进程 _logger.LogWarning(ex, "{DeviceCode} 数据上报失败", _deviceCode); } await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); } } }

这里两个地址的含义要解释一下:DB10.DBD4是西门子 PLC 里的数据块寻址,DBD4表示从第 4 个字节开始读一个 32 位整数;DBX0.2是位寻址,读第 0 个字节的第 2 位。不同 PLC 品牌地址写法差异很大,比如三菱用D100,罗克韦尔用B3:0/2,所以源码把 PLC 驱动抽象成IPlcClient,具体驱动在Mes.Device项目里做适配。

轮询间隔我在现场一般设 1 到 3 秒,太频繁会把 PLC 的 Modbus 通讯模块拖死,太慢看板看不出实时效果。上报失败时只记录日志不重试,因为下一秒的轮询会带上最新值,重试反而会造成消息积压。

4.2 看板接口:服务端推送与前端轮询的取舍

设备状态上报到 MES 后,看板页面需要实时展示。源码里用的是 SignalR 推送,而不是前端每两秒轮询一次接口。集线器代码非常简洁:

public class DashboardHub : Hub { public async Task SubscribeToProduction() { await Groups.AddToGroupAsync(Context.ConnectionId, "production"); } public async Task SendDeviceSnapshot(IReadOnlyList<DeviceSnapshot> snapshots) { await Clients.Group("production").SendAsync("OnDeviceSnapshots", snapshots); } }

浏览器打开看板页面时,先调用SubscribeToProduction加入名为production的组,后台收到新设备状态后调用SendDeviceSnapshot,组内所有客户端都会收到消息。这套机制比轮询好在两个地方:一是网络占用低,二是事件驱动,数据变化立刻推过去。

SignalR 有一个常见问题是断线重连。车间网络经常有交换机重启、WiFi 不稳定,前端 JavaScript 脚本里必须写重连逻辑;另外网关重连后,看板要先主动拉一次全量快照,把断线期间漏掉的数据补回来,不然大屏会一直停在上次的值。

4.3 与 ERP 的接口边界:到底谁负责物料入库

很多 MES 项目做着做着就和 ERP 吵起来了——MES 产生了完工入库记录,ERP 那边库存却没变。这套源码的处理方式是:MES 只写本地业务表,再通过一张MesSyncLog表把待同步的数据排队,由后台任务推给 ERP 的 API。同步日志表设计为:

CREATE TABLE dbo.MesSyncLog ( Id INT IDENTITY PRIMARY KEY, BizType NVARCHAR(32) NOT NULL, -- WorkReportFinished BizId INT NOT NULL, -- 对应业务表主键 Status TINYINT NOT NULL, -- 0待处理 1成功 2失败 RetryCount INT NOT NULL DEFAULT 0, Payload NVARCHAR(MAX) NULL, -- JSON 消息体 CreatedAt DATETIME2 NOT NULL );

每次做完完工入库,先写一条BizType=WorkReportFinished的同步日志,后台任务每 30 秒扫一次待处理日志,调用 ERP 接口,成功后更新 Status,失败则重试并记录次数。这个模式叫“本地消息表保证最终一致”,比当场调 ERP 接口健壮得多。ERP 系统升级维护时,MES 侧不会因为同步失败而卡住生产业务。

5. 避坑指南:MES 上线阶段最典型的五类翻车点

5.1 报工成功但库存扣减“消失”

现象:操作工报工后工单状态更新了,但过一会儿发现库存没增加,有时候还出现库存负数。

原因:报工和库存增加不在同一个事务里,或者压根没走事务。我调试时发现有版本的代码先写工单表,再去调仓储库存,中间 Web API 发生异常,入库操作被跳过。

解决:把所有写操作放进BeginTransactionAsync包裹,报工流水、工序状态、库存变动统一提交。排查时看是否用了行锁查询,并发场景下两个请求同时读到相同库存值,后续加出来的数会互相覆盖。

5.2 看板数值间隔好几分钟才刷新

现象:设备明明在运行,看板上的产量却卡在五分钟前,车间主任一直问是不是系统坏了。

原因:网关单线程顺序上报,某一台 PLC 地址不通时,TCP 超时等了几十秒,后续设备数据全部排队。另外看板页用了 SignalR,但断线后没重连,服务端发的消息客户端根本收不到。

解决:每个设备一个后台轮询任务,互不阻塞;PLC 读取设置较短超时时间;前端做断线重连,并在重连成功后主动拉一次全量快照。日志里加了每个设备的最后上报时间,排查慢的问题时先看last_report_time分布。

5.3 质检合格率统计老是“对不上”

现象:质检员录的合格数和报工的好品数明明一致,但每天统计报表合格率时忽高忽低。

原因:报表里把“检验完成时间”和“报工时间”混用,跨班次统计时,晚班 23:55 录入的检验单被归到昨天,产量却归到今天。还有客户端本地时间没校准,比服务器快了五分钟。

解决:在时间统一由服务端生成DateTimeOffset.Now,报表统计一律取“业务发生时间”字段,并且分成“报工时间”和“检验时间”两个维度展示。现场排查时把 SQL 里的时间条件打印出来,看看是不是时间边界定义的问题。

5.4 返工单状态卡死

现象:不合格品走完返工流程后,原工单状态一直停在“生产中”,无法结案。

原因:返工逻辑直接改了原工序任务的状态,但原工单的其他工序已经完工,状态机里根本没有“完工后再回到生产中”的路径。数据库里出现状态矛盾,前端页面正常操作无法闭合。

解决:必须建立独立的返工流程任务,用ReworkOrderId关联原工单,返工完成后再把结果回写原工单的“抽检字段”,而不是修改原工序状态。源码里在领域层加了状态转换校验,任何非法状态跳转直接抛异常。

5.5 工艺路线复制到另一个车间后工序错乱

现象:同一个产品在两个车间生产,A 车间要三道工序,B 车间要五道工序,但系统按产品编码拉出来的路线始终是一份。

原因:工艺路线表设计成了“产品编码 + 工序列表”,没有车间维度。只要 A 车间工艺员改了工序,B 车间的工单下发也跟着变。

解决:工艺路线主数据加WorkshopCode字段,工单创建时按“产品 + 车间”匹配路线。这是一个非常典型的主数据设计问题,改表本身不难,难的是让生产、工艺、计划三个部门认可这个口径。源码里路由查询会区分RouteType,复制到新车间时先在种子数据里建好路线再测试。

6. 二次开发思路:给 MES 加一个“返工复检”场景

假设你现在接到一个新需求:质检员发现 NCR 记录里有一批返工完成的产品需要做二次检验,只有复检通过才能流入成品库。直接在原检验流程上加状态会搞乱现有逻辑,我的做法是先扩表再扩服务。

先在数据库里加一张返工复检单:

CREATE TABLE dbo.ReworkInspection ( Id INT IDENTITY PRIMARY KEY, NcrId INT NOT NULL, WorkOrderId INT NOT NULL, StepCode NVARCHAR(32) NOT NULL, Qty INT NOT NULL, Result TINYINT NOT NULL DEFAULT 0, -- 0待检 1合格 2不合格 InspectedBy NVARCHAR(32) NULL, InspectedAt DATETIME2 NULL );

然后在InspectionService里加一个方法:

public async Task<int> CreateReworkInspectionAsync(int ncrId, string inspector) { var ncr = await _ncrRepo.GetByIdAsync(ncrId); if (ncr.Status != NcrStatus.ReworkCompleted) throw new DomainException("返工未完成,不能复检"); var entity = new ReworkInspection { NcrId = ncr.Id, WorkOrderId = ncr.WorkOrderId, StepCode = ncr.StepCode, Qty = ncr.Qty, Result = 0, InspectedBy = inspector, InspectedAt = _clock.Now() }; await _reworkInspectionRepo.InsertAsync(entity); return entity.Id; }

最后在管理端加一个页面按钮,调这个接口生成复检单,复检结果再回写 NCR 状态。整个链路只涉及一张新表、一个服务方法、一个页面按钮,不碰原有工序状态机。从那以后我每次给产线上新功能,都强制自己先画出状态机、标好事务边界再动手改表,返工、报废、暂停这些业务分支都要提前想清楚,免得上线后靠手工改数据库“擦屁股”。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进