简介:基于C#的工厂MES加工装配模拟系统源码包,面向毕业设计选题与工业信息化方向学习者,定位为可直接运行、二次开发与教学演示的完整项目。系统以制造执行为核心,覆盖生产订单管理、物料需求计划、生产调度、设备状态监控、质量控制等模块,完整再现MES从任务下发到装配完成的业务流程,便于理解企业级生产管理系统的数据流转与界面交互。压缩包共420个文件,大小约12.54MB,以175个cs源码文件为主体,配合36个dll动态库、28个resources资源文件、28个resx界面资源、sln/csproj工程文件及数据库mdf/ldf备份,还原后即可编译调试;同时含exe可执行程序、config配置文件、图片与音频素材等辅助资源,支撑界面展示和运行演示。项目采用分层架构,数据层可选用SQL Server或MySQL,通过ADO.NET或Entity Framework完成数据访问封装,业务层与ASP.NET界面层分离,涵盖数据库设计、数据访问、业务编排、多线程并发、异常处理、日志记录、权限管理及性能优化等企业级实践。已有1079人学习下载,适合希望系统掌握C#应用开发、并深入理解MES实现机制的学习者研读。
1. 基于C#的工厂MES加工装配模拟系统:它解决了什么实际问题
一条产线在制品堆成山,车间主任早上要的报表下午才从Excel里拼出来,ERP只管订单,中间过程全靠老师傅脑子记——这是我在中小工厂实施遇过最多的开场。基于C#的工厂MES加工装配模拟系统源码.zip,核心价值不是让你直接上线,而是在不接PLC、不碰数据库服务器的前提下,先用C#把工单、工序、报工、设备状态、装配齐套这些MES最关键的逻辑跑起来,像演戏一样把车间流程预演一遍。它能解决三个问题:验证流程设计合不合理、给老板演示数字化车间到底长什么样、为后续做真实MES或对接上位机练手。适合刚学C#想找个实际场景的开发者、准备做MES选型的工艺员、以及需要快速落地的实施顾问。
2. 系统架构与数据建模:把车间流程拆成可模拟的对象
模拟系统最忌一开始就把表设计成几百个字段的怪物。我通常按界面层、业务逻辑层、数据访问层三层拆,界面层用WinForms或WPF,业务层只关心对象和状态,数据层先用SQLite落盘。这样后续换成真实数据库甚至对接OPC UA,都只是替换最底下那一层。
2.1 订单→工单→工序的拆解模型
MES的核心对象是工单。ERP下发的是销售订单或生产计划,MES先把订单拆成工单,再把工单拆成多道工序。每一道工序需要描述:序号、名称、设备类型、标准工时、物料清单。下面是我在模拟系统里常用的两个类:
public class MoOrder { public string OrderNo { get; set; } public string ProductCode { get; set; } public int PlanQty { get; set; } public int Status { get; set; } // 0待下发 1已下发 2生产中 3已完成 public DateTime StartTime { get; set; } public List<Operation> Operations { get; set; } = new(); } public class Operation { public int Seq { get; set; } public string Name { get; set; } public string DeviceType { get; set; } public int StdMinutes { get; set; } public List<MaterialReq> Materials { get; set; } = new(); }MoOrder用List 保存工序,而不是用一张单独的工序表通过外键关联,是为了让模拟代码更直观,AssemblyChecker遍历工单内的工序列表时不需要反复查询数据库。Status字段用int不用string,主要考虑后面统计OEE和状态流转判断比特串去重快,另外也方便和下拉框的索引对应。
新手容易把工单、工序、报工三种对象揉在一张大表里,最终导致状态互相覆盖。正确做法是:工单持有工序列表,工序持有物料需求列表,报工记录单独存放并引用工单号+工序序号。这个引用关系在模拟装配齐套检查时特别有用,递归遍历BOM不再是难题。
2.2 设备、物料、人员的三类主数据
模拟系统必须有主数据种子,否则每次演示都要手输设备、物料和员工。设备要模拟状态,物料要模拟库存,人员要模拟报工,这三类对象是MES的底座。
public class Device { public string DeviceId { get; set; } public string DeviceType { get; set; } public int Status { get; set; } // 0空闲 1运行 2故障 3维护 public string CurrentOrderNo { get; set; } public double TotalRunMinutes { get; set; } } public class Material { public string MaterialCode { get; set; } public string Name { get; set; } public int StockQty { get; set; } public int SafetyStock { get; set; } } public class Employee { public string EmployeeId { get; set; } public string Name { get; set; } public int SkillLevel { get; set; } }这三段代码里的关键参数:Device.Status用int枚举,实际程序里我会再写一个静态类DeviceStatusConst来存常量名,避免魔法数字。Material.SafetyStock是安全库存,装配缺料提醒会拿SafetyStock和StockQty做比较。SkillLevel可以留着以后做派工排序,比如高级工优先分配到关键工序。
演示用的初始数据不需要多,够讲清故事就行。我一般会建4台CNC加工中心、2个装配工位、1个质检台,物料放冲压件、螺丝、密封圈十几条,人员放5名操作工。下面是常见主数据初始值的参考表格:
| 对象 | 关键字段 | 模拟初始值 |
|---|---|---|
| 设备 | DeviceId, DeviceType, Status | M01 CNC加工中心 0、M02 CNC加工中心 0、A01 装配工位 0 |
| 物料 | MaterialCode, StockQty, SafetyStock | MAT001 冲压件 500 100、BOLT001 螺丝 2000 300、SEAL001 密封圈 800 150 |
| 员工 | EmployeeId, Name, SkillLevel | E001 张工 4级、E002 李工 3级、E003 王工 5级 |
主数据尽量放到SQLite的种子脚本里,程序启动时检查表为空就Insert。这样反复演示不会污染基础数据。
2.3 用内存数据库还是SQLite?选型理由
模拟系统常见做法是SQLite单文件库,原因很实际:零配置、一个dll搞定、C#用Microsoft.Data.Sqlite直接连,复制文件就能迁移。纯内存Dictionary启动最快,但关掉程序数据全丢,想演示追溯根本没素材。市面上很多开源的MES系统其实也走了类似路线,先做仓储接口,后换MySQL或PostgreSQL。
我一般会在系统启动参数里加一个--memory开关,用依赖注入切换实现:
public interface IStorage { List<MoOrder> LoadOrders(); void SaveOrder(MoOrder order); void SaveReport(WorkReport report); }IStorage就是仓储接口,模拟系统里有两个实现:MemoryStorage和SqliteStorage。MemoryStorage内部用ConcurrentDictionary,用于刚启动时的快速演示;SqliteStorage用于正式演示和追溯查询。为什么要这样设计?因为你在模拟系统阶段就把数据访问抽象掉了,后面真实上线时写一个OracleStorage或MySqlStorage,业务层一行不用改。这比一开始就绑定SQLite要稳妥得多。
需要注意的是,SQLite并非无所不能。模拟系统一天假设产生100个工单、每工单5道工序,一天也就500条报工记录,SQLite完全够用。但如果你把设备实时状态按分钟采样,一天1440条,一年累计也有几十万条,这时候就要给DeviceData表加按天分表或者定期归档。模拟阶段可以不管,但你要知道边界在哪里。
3. 用C#实现加工与装配的核心流程:从排产到报工
流程是MES的骨架。很多模拟系统翻车不是因为代码跑不动,而是流程状态互相矛盾,比如工单还是已下发但工序已经报了完工。这里我把排产、装配、报工三个主流程单独拆开讲,每段都给可运行的代码。
3.1 工单下发与工序流转的状态机
先做状态机,再写业务逻辑。工单状态有创建、下发、生产、完成、关闭,工序状态有等待、进行、完工、暂停。如果只用一个int字段存储工单状态,多道工序会互相覆盖,所以我建议工单状态只放在MoOrder.Status,工序状态单独由每道Operation内部字段管理。
下面是工单下发的标准动作:
public enum OrderStatus { Created = 0, Released = 1, InProgress = 2, Completed = 3, Closed = 4 } public class OrderService { private readonly IStorage _storage; public OrderService(IStorage storage) { _storage = storage; } public void ReleaseOrder(string orderNo) { var order = _storage.LoadOrder(orderNo); if (order == null) throw new Exception("工单不存在"); if (order.Status != OrderStatus.Created) throw new InvalidOperationException($"工单{orderNo}当前状态不允许下发"); order.Status = OrderStatus.Released; // 下发后第一道工序进入等待开始状态 if (order.Operations.Count > 0) { order.Operations[0].Status = 1; // 1代表等待 } _storage.SaveOrder(order); } }这段代码的逻辑说明:ReleaseOrder先加载工单,再校验当前状态必须是Created,这样才能保证流程不跳级。工单下发后,把第一道工序置为等待,但工单整体还是Released,只有接到第一道工序的报工时工单才变成InProgress。这个状态机规则要在代码注释里写清楚,否则后面加返工流程会乱。
这里有个重要的参数习惯:状态用枚举的数值存库,但展示给用户的文案单独用扩展方法映射,不要把中文直接存进数据库。因为以后你改显示文案时不需要动数据,只需要改映射表。
3.2 装配工位的齐套检查与缺料模拟
装配是MES里最体现业务逻辑的地方。加工工序一次只加工一个零件,装配工序要同时满足多种物料才能开工。齐套检查流程是:读工单当前工序的物料清单,乘以计划数量,和仓库库存逐项比较,有缺料就返回缺料清单。
public class MaterialReq { public string MaterialCode { get; set; } public string MaterialName { get; set; } public int QuantityPerUnit { get; set; } } public class AssemblyChecker { private readonly Dictionary<string, int> _stock; public AssemblyChecker(Dictionary<string, int> stock) { _stock = stock; } // 返回缺料文字,为空代表齐套 public string CheckMaterials(Operation op, int qtyToBuild) { var missing = new List<string>(); foreach (var mat in op.Materials) { int required = mat.QuantityPerUnit * qtyToBuild; if (!_stock.TryGetValue(mat.MaterialCode, out int stock) || stock < required) { missing.Add($"{mat.MaterialName} 需{required},库存{stock}"); } } return string.Join(";", missing); } public void LockMaterials(Operation op, int qtyToBuild) { foreach (var mat in op.Materials) { _stock[mat.MaterialCode] -= mat.QuantityPerUnit * qtyToBuild; } } }CheckMaterials和LockMaterials分开是刻意为之。检查只读不写,适合放在界面输入框的LostFocus事件里,用户改数量时实时提示缺哪几种料。LockMaterials在用户点“开始生产”才调用,这时候才真正扣库存。如果检查时就直接扣库存,用户取消了操作,库存就少了,还得写回滚逻辑。
参数说明:qtyToBuild是本次要生产的数量,不是工单总数量。装配可能按批次分批生产,所以齐套检查必须用“本次开工数量”而不是工单计划数。这是很多模拟系统容易错的地方,把整个工单数量全锁了,库存明明够一批却提示缺料。
3.3 报工与产量统计:时间戳和良品率怎么记
报工数据是MES所有统计报表的食物。一条报工记录至少要包含工单号、工序序列、操作工、时间、完工数、不良数、设备号。少了任何一项,后面的OEE和产品追溯都缺胳膊少腿。
public class WorkReport { public string OrderNo { get; set; } public int OperationSeq { get; set; } public string EmployeeId { get; set; } public DateTime ReportTime { get; set; } public int GoodQty { get; set; } public int DefectQty { get; set; } public string DeviceId { get; set; } public int TotalQty => GoodQty + DefectQty; public double Yield => TotalQty == 0 ? 0 : (double)GoodQty / TotalQty; } public class ReportService { public void SubmitReport(WorkReport report, Action<WorkReport> onSubmitted) { if (report.TotalQty <= 0) throw new ArgumentException("完工和不良数量之和必须大于0"); _storage.SaveReport(report); onSubmitted?.Invoke(report); } }这里用了C#委托的典型场景:SubmitReport只负责保存报工数据,保存成功后通过onSubmitted回调通知看板刷新、工单进度更新、设备状态复位。调用方可以传lambda表达式,也可以传一个普通方法。好处是ReportService不依赖具体UI类,以后做Web前端时逻辑层能直接复用。
时间戳参数有个血泪经验:数据库里存UTC时间,界面上显示本地时间。如果直接存DateTime.Now,不同电脑时区不一致,报表的时间和产量曲线会错乱。我一般用DateTime.UtcNow写入,读取时ToLocalTime转换。模拟系统看似单机不用考虑时区,但你后面接了多车间服务器就知道了。
4. 设备与异常模拟:让演示系统有血有肉
真实MES的设备数据来自PLC,模拟系统里设备数据来自定时器和随机数。但随机数不能乱用,否则演示效果时好时坏。这一章讲怎么让设备模拟得真实且可复现,同时留下接真实设备的接口。
4.1 设备状态随机变化模拟OEE计算
设备状态在空闲、运行、故障之间切换。模拟逻辑可以很简单:设备在运行状态时,每个时间片有5%概率故障,故障持续3到8个时间片后恢复。这样一天下来设备自然会产生停机时长,OEE不会一直是100%,演示才有说服力。
public class DeviceSimulator { private readonly Random _rnd = new Random(20240601); private int _state = 1; // 0空闲 1运行 2故障 private int _faultRemainMinutes; public int State => _state; public double FaultMinutes { get; private set; } public void Tick(int minutes) { if (_state == 1 && _rnd.NextDouble() < 0.05) { _state = 2; _faultRemainMinutes = _rnd.Next(3, 9); } if (_state == 2) { FaultMinutes += minutes; _faultRemainMinutes -= minutes; if (_faultRemainMinutes <= 0) { _state = 0; // 恢复后回到空闲,等待派工 } } } public double CalcOEE(double planMinutes, int actualOutput, int theoreticalOutput, double yield) { if (planMinutes <= 0 || theoreticalOutput <= 0) return 0; double availability = (planMinutes - FaultMinutes) / planMinutes; double performance = (double)actualOutput / theoreticalOutput; return availability * performance * yield; } }Tick方法是模拟引擎的核心,每个时间片调用一次。5%故障概率和3到9分钟的故障时长都是参数,你可以改成别的值适应不同演示场景。OEE计算三个因子相乘,availability反映设备停机损失,performance反映速度损失,yield反映质量损失,这是国际通用的算法,放到真实项目里也是这套公式。
固定随机种子new Random(20240601)是演示翻车后悔药的关键。如果没有它,每次运行设备故障的时间和次数都不同,上午跑OEE是82%,下午变74%,老板会质疑系统不稳定。固定种子后,只要模拟时间片长度不变,结果完全一致。
4.2 异常事件与复产流程模拟
光有设备状态跳变还不够,MES要有异常闭环:设备故障了必须生成记录,维修完成后必须登记恢复时间,这样才能统计故障原因和平均修复时间。异常记录表是模拟系统里容易忽略但又非常重要的实体。
public class FaultRecord { public string DeviceId { get; set; } public DateTime FaultStart { get; set; } public DateTime? FaultEnd { get; set; } public string Reason { get; set; } public string Handler { get; set; } } public void CreateFault(string deviceId, string reason, string handler) { var record = new FaultRecord { DeviceId = deviceId, FaultStart = DateTime.UtcNow, Reason = reason, Handler = handler }; _storage.InsertFault(record); } public void RecoverFault(string deviceId, DateTime faultStart) { _storage.UpdateFaultEnd(deviceId, faultStart, DateTime.UtcNow); }FaultEnd是DateTime?,也就是可空类型,这是C#里很有用的细节。未恢复的故障记录FaultEnd为null,查询故障列表时直接过滤IsNull,看板上显示“维修中”。恢复时更新FaultEnd,同时把设备状态从故障改为空闲,再走一次“空闲到运行”的派工逻辑。
这个模块要配合界面做演示,在故障记录表格里点“恢复”按钮调用RecoverFault,而不是让模拟器自动恢复所有故障。原因很简单:演示时要给客户讲解“如果设备坏了,谁来报修、谁来确认恢复”的流程,自动恢复会让人感觉流程是假的。
4.3 对接真实设备的预留接口(OPC UA/数据库)
模拟系统最大的价值是流程验证,最终还是要往真实设备数据靠拢。我习惯在项目里定义一个IDeviceGateway接口,设备数据无论来自模拟器还是OPC UA服务,对上层业务代码都是同一套事件订阅。
public interface IDeviceGateway { event Action<DeviceData> OnDataReceived; void Start(); void Stop(); } public class SimulatedDeviceGateway : IDeviceGateway { public event Action<DeviceData> OnDataReceived; public void Start() { // 启动一个System.Threading.Timer,每秒触发一次 } public void Stop() { // 停止Timer } } public class DeviceData { public string DeviceId { get; set; } public int State { get; set; } public double Speed { get; set; } public int TotalCount { get; set; } }这里用event Action 而不是普通的接口方法调用,是为了模拟“设备主动上报”的实时特性。真实场景里PLC或OPC UA服务器会不断推送数据,订阅方看到数据后更新UI,这套事件模型和真实上位机的编程方式一模一样。
接口里故意只放了Start、Stop和OnDataReceived,没有传参。为什么?因为每个设备的采集参数不同,模拟阶段不需要在接口里暴露具体点位,等真正对接OPC UA时,再写一个OpcUaGateway类实现这个接口,构造函数里传入设备节点和读写间隔。这样整个模拟系统不需要因为更换数据源而改动业务层代码。
5. 数据落地与追溯:SQLite存储与查询的4个避坑点
模拟系统跑到后面,一定会把报工记录和设备状态落盘。这里最容易出问题的是并发写入和数据格式不一致。我把在C#里用SQLite常见的5个坑按“现象→原因→解决”写出来,每条都是排过错的经验。
5.1 报工记录并发写入导致数据丢失
现象:模拟系统开多个窗口同时报工,下班后发现SQLite里的报工记录比界面上显示少了十几条,而且数据库文件体积异常增大。
原因:多个线程没有经过统一写入口,各自打开连接写SQLite。SQLite默认在多个进程或线程并发写时,后提交的事务会覆盖先提交的,甚至返回database is locked。
解决:连接串里加默认超时,并开启WAL日志模式:
var connectionString = "Data Source=mes.db;Mode=ReadWriteCreate;Cache=Shared;Default Timeout=30"; using var conn = new SqliteConnection(connectionString); conn.Open(); var cmd = conn.CreateCommand(); cmd.CommandText = "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=30000;"; cmd.ExecuteNonQuery();逻辑说明:WAL模式让读操作和写操作互不阻塞,busy_timeout=30000让SQLite在锁冲突时等待30秒而不是立即报错。模拟系统的报工频率不高,这两个参数足够。做真实MES时如果并发超过几十个客户端,再考虑换PostgreSQL。
5.2 工序状态显示混乱:缺少状态版本号
现象:看板上同一道工序一会儿显示进行中,一会儿显示已完工,刷新后又回到待开始。
原因:工序状态只存在工单主表里的Status字段,多道工序共用一个字段。线程A读的时候是待开始,线程B读的时候也是待开始,B提交已完工,A再提交进行中,就把B的状态盖掉了。
解决:工序状态单独存放,每条工序一条记录,并且带上Rev版本号。更新时使用带条件的UPDATE:
int affected = cmd.ExecuteNonQuery(); // SQL: // UPDATE Operation SET Status = @newStatus, Rev = Rev + 1 // WHERE OrderNo = @orderNo AND Seq = @seq AND Rev = @expectedRev;参数说明:affected等于0说明更新影响行数为0,也就是Rev已经变了,需要提示用户“操作已被其他人更新,请重新加载”。这是数据库乐观锁的经典做法,也是C#里处理并发最简单的方案,不需要引入锁机制。模拟系统里没有真实多人操作,但也要养成这个习惯,因为你迟早要把这套代码给别人用。
5.3 多个线程共用一个SqliteConnection
现象:后台定时器每隔一秒读设备状态,同时用户界面上正在刷新产量报表,程序抛出“SQLite Error 5: database is locked”。
原因:连接对象被多个线程共享,读写交错,SQLite底层文件锁又只能同时一个写者,导致操作排队超过默认超时。更隐蔽的是,有些人把SqliteConnection放在全局静态变量里,以为共享就叫连接池。
解决:不共享同一个连接,每次操作都新建连接,并且开启连接池。Microsoft.Data.Sqlite默认行为其实是每次new连接都会复用底层缓存,所以关键是让每次Connection创建后及时Close,不要用静态变量长期持有。
public SqliteConnection CreateConnection() { var conn = new SqliteConnection("Data Source=mes.db;Pooling=true;"); conn.Open(); return conn; }每方法内用using保证连接释放。Pooling=true让底层句柄被复用,不会有频繁打开文件的开销。另外长事务要放在短事务前面,避免一个事务占着写锁不放手。
5.4 日期时间存储格式导致报表排序错乱
现象:按时间排序的产量曲线图,早上8点的数据跳到晚上9点后面,看起来毫无规律。
原因:直接用DateTime.Now.ToString()存成TEXT,不同模块写入时格式不一致,有的带毫秒,有的不带。TEXT排序按字母序,“2024-06-01 8:02”排在“2024-06-01 19:30”后面,因为字符“8”大于“1”。
解决:所有时间戳统一存成INT类型的Unix时间戳,或者固定格式“yyyy-MM-dd HH:mm:ss.fff”。我推荐INT时间戳,排序是数值排序,索引效率也更高。
cmd.CommandText = "INSERT INTO WorkReport(ReportTime) VALUES(@reportTime)"; cmd.Parameters.AddWithValue("@reportTime", DateTime.UtcNow.Ticks);说明:用DateTime.UtcNow.Ticks是C#里的标准做法,它不会受区域格式影响,读出来直接用new DateTime(long ticks)还原。如果用字符串,一定要在写入前统一格式化,并且所有模块都用同一个静态方法生成。
5.5 模拟器随机种子问题导致演示不可复现
现象:同样的操作步骤,上午演示OEE是86%,下午变成78%,客户质疑系统数据不可控,甚至以为你在造假。
原因:代码里用了new Random(),每次运行拿当前时间做种子,随机序列完全不同。演示场景需要的是“相同输入相同输出”,才能解释清楚优化前后对比。
解决:把Random的种子放到配置里,演示时固定,回归测试时再换。
private readonly Random _rnd = new Random(20240601);使用固定种子后,整个模拟过程的故障时刻、故障时长都确定,同一个工单跑两遍完全一致。如果要做灵敏度分析,比如测试3%故障率和5%故障率对OEE的影响,只需要改种子或者把概率暴露成命令行参数。这也是我推荐的验证方法:改一个参数,重跑一次,数据可对比才有说服力。
6. 让模拟系统离真实MES更近:扩展点与验证方法
模拟系统的终点不是“能跑”,而是“能让人信服”。我习惯在收尾阶段做三个验证动作。
6.1 三步验证模拟系统跑得对不对
第一步,跑完一天模拟数据后,用SQL手工汇总每个工单的完工总数、不良总数,和界面的产量报表对账,任何不一致优先查状态机卡点。第二步,故意把某道工序的库存改成不足,触发齐套检查,确认装配工位真的被卡住、后续工序不流转。第三步,固定随机种子连续跑两次同场景,结果应该完全一致。这三步能过滤掉绝大部分逻辑bug。
6.2 从模拟到真实的最小替换路径
真要把它改造成能接产线的系统,不要推倒重来。第一替换IDeviceGateway实现,把SimulatedDeviceGateway换成OpcUaGateway,上层订阅逻辑不变。第二要替换IStorage实现,从SQLite换到PostgreSQL,但WorkReport和FaultRecord的表结构可以直接迁过去。第三把设备的模拟概率参数全部删掉,改成从设备网关的真实数据驱动。每一步替换后都跑一遍6.1的三步验证,避免一次性大改导致想后悔都没地方回退。
我个人的习惯是,每次做完模拟系统,一定在源码根目录放一个README,把“哪些是模拟器编的、哪些是真实逻辑”写清楚。否则自己放三个月再看,都会搞不清设备故障数据是真的还是假的。这个方向只要能把工单状态机和齐套检查跑顺,MES里最难的骨头已经啃下一大半。希望帮到你。
本文还有配套的精品资源,点击获取