资讯详情

资讯详情

基于C#的MES加工装配模拟系统开发实战

简介基于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 ListOperation 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 ListMaterialReq 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, StatusM01 CNC加工中心 0、M02 CNC加工中心 0、A01 装配工位 0物料MaterialCode, StockQty, SafetyStockMAT001 冲压件 500 100、BOLT001 螺丝 2000 300、SEAL001 密封圈 800 150员工EmployeeId, Name, SkillLevelE001 张工 4级、E002 李工 3级、E003 王工 5级主数据尽量放到SQLite的种子脚本里程序启动时检查表为空就Insert。这样反复演示不会污染基础数据。2.3 用内存数据库还是SQLite选型理由模拟系统常见做法是SQLite单文件库原因很实际零配置、一个dll搞定、C#用Microsoft.Data.Sqlite直接连复制文件就能迁移。纯内存Dictionary启动最快但关掉程序数据全丢想演示追溯根本没素材。市面上很多开源的MES系统其实也走了类似路线先做仓储接口后换MySQL或PostgreSQL。我一般会在系统启动参数里加一个--memory开关用依赖注入切换实现public interface IStorage { ListMoOrder 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 Dictionarystring, int _stock; public AssemblyChecker(Dictionarystring, int stock) { _stock stock; } // 返回缺料文字为空代表齐套 public string CheckMaterials(Operation op, int qtyToBuild) { var missing new Liststring(); 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, ActionWorkReport 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 ActionDeviceData OnDataReceived; void Start(); void Stop(); } public class SimulatedDeviceGateway : IDeviceGateway { public event ActionDeviceData 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 Sourcemes.db;ModeReadWriteCreate;CacheShared;Default Timeout30; using var conn new SqliteConnection(connectionString); conn.Open(); var cmd conn.CreateCommand(); cmd.CommandText PRAGMA journal_modeWAL; PRAGMA busy_timeout30000;; cmd.ExecuteNonQuery();逻辑说明WAL模式让读操作和写操作互不阻塞busy_timeout30000让SQLite在锁冲突时等待30秒而不是立即报错。模拟系统的报工频率不高这两个参数足够。做真实MES时如果并发超过几十个客户端再考虑换PostgreSQL。5.2 工序状态显示混乱缺少状态版本号现象看板上同一道工序一会儿显示进行中一会儿显示已完工刷新后又回到待开始。原因工序状态只存在工单主表里的Status字段多道工序共用一个字段。线程A读的时候是待开始线程B读的时候也是待开始B提交已完工A再提交进行中就把B的状态盖掉了。解决工序状态单独存放每条工序一条记录并且带上Rev版本号。更新时使用带条件的UPDATEint 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 Sourcemes.db;Poolingtrue;); conn.Open(); return conn; }每方法内用using保证连接释放。Poolingtrue让底层句柄被复用不会有频繁打开文件的开销。另外长事务要放在短事务前面避免一个事务占着写锁不放手。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里最难的骨头已经啃下一大半。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →