
简介面向智能仓储与物流自动化开发者的WCS仓库控制系统C#完整源码包基于VS2022与MVC框架实现覆盖提升机、堆垛机等设备的调度管理、监控界面与SQL数据存储适合正在搭建或二次开发仓储控制系统的中高级.NET工程师参考。压缩包共收录2003个文件总容量136.51MB其中cs源码与dll编译库构成核心业务逻辑html、js、css支撑前端监控页面png、gif等图片资源用于界面展示sql脚本提供数据库初始化数据文件分类清晰便于按模块检索与调试。源码涉及设备调度、删除等接口实现如AddDispatchEquipmentAsync、DeleteDispatchEquipmentAsync并附带PLC通讯的CPU插槽配置说明S7-300/400为2S7-1200/1500对应设置可帮助理解WCS与底层设备交互方式减少现场联调排错成本。该资源已有744人学习下载适用于需要快速上手WCS项目架构、掌握堆垛机/提升机调度逻辑或扩展设备监控功能的开发者。1. 智能立体仓库WCS控制系统先搞清楚它管什么再谈C#源码怎么写自动化立体库里真正干活的是堆垛机、提升机和输送线但让这些设备不乱套的却是WCS。一套WCS至少要管三件事接收WMS的任务、把任务拆成设备能执行的动作、再把设备状态实时回传并落到数据库。标题里的C#源码 VS2022 MVC框架 SQL不是一个花哨的微服务架构而是工厂车间环境里最常用来落地这套系统的组合。这篇笔记适合两类人一类是做上位机C#开发、被安排去接立体仓库项目的工程师另一类是用第三方WCS软件但想自己做二次开发或维护的老手。先说明WCS最难的不是界面是任务状态机和通信异常处理。2. WCS与WMS/PLC的分工为什么用MVC框架写设备调度2.1 一个入出库任务在三层系统里是怎么流转的WMS仓库管理系统管的是库存账和订单它关心的是“托盘A应该放在货位B”它不关心堆垛机怎么移动。PLC管的是电机和传感器它只执行升、降、伸叉、走行这类原子动作。如果让WMS直接指挥PLCWMS只要下一条“入库”指令PLC就得执行几十个动作任何一个中间环节出错WMS根本不知道在哪一步。WCS夹在中间负责把任务“翻译”成动作序列并且跟踪每个动作的完成情况。以入库为例WMS向WCS下发“入库1号库区B12货位”。WCS先查任务表看看是哪个提升机负责输送线1哪个堆垛机负责B区。然后给提升机下发“从站台1取货到B区接口”给堆垛机下发“从B区接口接货到12货位”。注意不是一次性全部发下去而是等到提升机到达接口并完成卸货才给堆垛机下发下一步。这就是WCS和普通上位机的本质区别它不是简单的“按钮发指令”而是要维护一套多设备协同的状态流。WCS还需要处理异常比如堆垛机报警了WCS要把正在执行的任务置为“挂起”通知WMS暂停后续下发同时把报警设备从任务池里摘掉。等设备恢复后再决定是继续执行还是回库。这些逻辑如果写死在PLC里会很乱因为多个PLC之间没有全局视角放到WCS里用数据库和内存状态配合同步反而清晰很多。再看出库的流程WMS下发“出库B12货位至站台2”WCS先判断当前堆垛机是否在B区如果不在要先把堆垛机调到B区然后让它取货。这里有一个容易忽略的环节堆垛机取货后不是直接送到站台2而是先放到提升机接口由提升机降到一层输送线再送到站台2。WCS必须把整条链路拆成多个子任务每完成一个子任务才推进下一个。看下面这张表就是任务状态机的基本流转任务状态触发条件界面显示数据库关键动作PendingWMS下发任务待执行插入任务记录Assigned找到空闲设备并下发指令已分配更新设备状态为忙碌Running设备返回“开始执行”执行中记录开始时间Completed设备返回“到位”已完成记录完成时间Exception设备报警或超时异常更新设备状态为报警这张表就是你把WCS讲给PLC工程师听的时候最通用的语言。很多新手把精力花在界面上其实状态定义得不清楚后面所有调度逻辑都会绕来绕去。2.2 设备状态模型与任务状态机先定义枚举再写业务写WCS第一步要定状态枚举。我一般会在代码里先定义两个枚举TaskStatus 和 DeviceStatus。任务状态至少要拆到5个待执行Pending、已分配Assigned、执行中Running、已完成Completed、异常Exception。有的需求还会加“挂起”但挂起本质是异常的一种用状态字段加一个子状态或者加一个标志位都行别一上来就设计十几个状态容易绕晕。设备状态则分在线/离线/空闲/忙/报警。在线和离线是通信层的事空闲和忙是任务调度层的事报警是业务层的事。把它们全塞进一个枚举会让代码很难看我的做法是通信层用Connected/Disconnected调度层用Idle/Busy报警用单独的颜色标记不放在枚举里。这里给一份最小定义的C#代码public enum TaskStatus { Pending 0, // 待执行WMS已下发WCS还没分配设备 Assigned 1, // 已分配到设备等待设备确认 Running 2, // 设备正在执行动作序列 Completed 3, // 动作序列已全部完成 Exception 4 // 异常挂起等待处理 } public enum DeviceStatus { Disconnected 0, // 通信断开 Connected 1, // 通信正常空闲 Working 2, // 通信正常正在执行任务 Alarm 3 // 通信正常但有报警 }这里有个关键点TaskStatus存数据库时建议用int不要用varchar存中文名因为状态经常要在SQL语句里比较int走索引更快而且改显示名时不需要改数据。DeviceStatus里的Disconnected和Connected是通信层维护的每次收到心跳包就更新Working和Alarm是业务层更新的。时序上要特别注意设备网络断线时状态会从Working变成Disconnected但等网络恢复后设备可能还在执行原来的动作所以WCS要在恢复心跳后去主动查询设备当前位置和任务进度而不是直接标记为闲置。2.3 为什么选C# VS2022 MVC框架而不是别的方案很多上位机老工程师会选WinForm因为拖控件方便。但WCS有一个特点监控界面不是给一个操作员点按钮用的而是要展示多设备的实时状态、任务队列、报警列表并且要在车间多台电脑上同时访问。WinForm做这种界面要么做CS架构客户端更新麻烦要么用Windows远程桌面体验很差。MVC框架天然适合做BS架构控制器处理设备上报模型层做业务逻辑视图层放监控页面一台服务器部署车间所有电脑用浏览器打开就行。VS2022对C#开发的好处主要是调试体验和现代语法支持。我用VS2022的理由很实际断点调试、即时窗口、附加上去调试和SQL Server绑定这些功能在设备调试现场非常有用。尤其是“附加上去调试”WCS作为服务跑在工控机上调试时不方便停服务可以直接附加到进程打断点看状态。MVC框架配合Razor模板可以把监控界面写成模板页设备状态表格、任务表格都通过Razor生成省掉大量手写HTML拼接。SQL Server作为数据存储也是工厂里的老熟人事务、存储过程、作业调度都齐全。也许有人说现在不都用前后端分离吗但WCS的业务场景往往在内网并发低维护人员少用MVC反而更直接。前端框架再加一层光环境依赖就能让你在客户现场浪费半天。我的选择标准很简单如果这个项目只需要在一个车间内用客户端是浏览器且没有复杂交互MVC就是最快能交活、最容易被下一个人接手的方式。标题里“智能立体仓库WCS控制系统”本质上是一个任务管理中间件MVC的“实体—数据库—操作页面”正好对得上。2.4 用SQL Server做任务队列的几条原则这里额外说说SQL部分因为WCS的任务表本质上就是一个多生产者多消费者的队列。WMS是生产者堆垛机/提升机的调度线程是消费者。用SQL Server做队列时第一条原则是不要把状态字段设计成自由文本而是用tinyint枚举这样可以配合索引做条件过滤。第二条原则是避免在事务里先SELECT再UPDATE要直接用带WHERE状态条件的UPDATE语句这也是第5章会展开的坑。第三条原则是优先级排序不能依赖自增ID因为WMS的插单需求随时存在必须在任务表里单独设置Priority字段。这三条原则对于身边的现场工程师来说可能不如写代码那么直观但它们决定了后面调度逻辑写起来顺不顺。尤其是第二条很多从MIS系统转过来的人习惯用ORM先查再改在WCS这种高并发任务分配场景下一定会出问题。我会在后面第5章用真实的冲突现象再做一遍说明。3. 从零搭建WCS工程VS2022下的项目分层与SQL数据模型3.1 用VS2022创建MVC项目并按设备域拆分层打开VS2022新建ASP.NET Core Web应用程序模型-视图-控制器模板命名SmartWcs。框架选.NET 6 LTS基本上能覆盖工控现场老的Windows Server环境。HTTPS配置可以勾选也可以不勾选内网很多工控机没有有效证书直接用HTTP反而少一档麻烦。项目结构建议按职责拆成这样SmartWcs/ Controllers/ TaskController.cs DeviceController.cs MonitorController.cs Models/ TaskEntity.cs DeviceEntity.cs Services/ ITaskService.cs TaskService.cs DeviceService.cs DeviceLayer/ IDeviceDriver.cs StackerDriver.cs HoistDriver.cs Repositories/ TaskRepository.cs DeviceRepository.cs Views/ Monitor/Index.cshtml Task/List.cshtml然后在Program.cs中注册依赖注入把服务注册成单例。注意设备驱动是有状态的不能每次请求new一个要注册为Singletonvar builder WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); builder.Services.AddSingletonIDeviceDriver, SimulatedDeviceDriver(); // 现场换成真实驱动 builder.Services.AddScopedITaskService, TaskService(); builder.Services.AddSingletonDeviceStateCache(); var app builder.Build(); app.MapControllerRoute( name: default, pattern: {controllerMonitor}/{actionIndex}/{id?}); app.Run();逻辑说明AddSingleton设备驱动保证只有一个通信连接不会被多个HTTP请求重复创建连接AddScoped任务服务适合每个HTTP请求独立的事务上下文DeviceStateCache用来缓存设备状态避免频繁查数据库。参数说明SimulatedDeviceDriver是我在开发阶段用的仿真设备驱动现场换成StackerDriver或HoistDriver时不需要改动Controller。如果你用的是.NET Framework的MVC 5对应的写法是在Global.asax里注册但这套示例用ASP.NET Core MVC。3.2 任务、设备和日志三张表SQL Server建表脚本WCS数据库我一般建三张核心表WcsTask、DeviceStatus、DeviceLog。任务表是核心先建它CREATE TABLE dbo.WcsTask ( TaskId BIGINT IDENTITY(1,1) PRIMARY KEY, TaskType TINYINT NOT NULL, -- 1入库 2出库 3盘点 TargetLocation NVARCHAR(16) NOT NULL, -- 目标货位如 B-12-03 DeviceId INT NOT NULL, -- 分配的设备ID Status TINYINT NOT NULL DEFAULT 0, -- 0待执行 1已分配 2执行中 3完成 4异常 Priority INT NOT NULL DEFAULT 5, -- 1最高 5普通 10低 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), StartTime DATETIME NULL, FinishTime DATETIME NULL, ErrorMsg NVARCHAR(200) NULL ); CREATE INDEX IX_WcsTask_Status ON dbo.WcsTask(Status) INCLUDE (TaskId, DeviceId);这里重点说明几个设计TaskType用tinyint而不是nvarchar减少存储和比较开销TargetLocation用NVARCHAR(16)因为货位编码是字母加数字的组合比如“B-12-03”千万别用INT存Status必须加索引因为调度线程每秒都可能按Status0查询Priority字段单独存在方便插单时直接UPDATE。设备状态表要与任务表分开因为设备状态是高频更新的实时数据任务表是低频的日志型数据CREATE TABLE dbo.DeviceStatus ( DeviceId INT PRIMARY KEY, DeviceName NVARCHAR(50) NOT NULL, DeviceType TINYINT NOT NULL, -- 1堆垛机 2提升机 3输送线 CommStatus TINYINT NOT NULL DEFAULT 0, -- 0离线 1在线 WorkStatus TINYINT NOT NULL DEFAULT 0, -- 0空闲 1忙碌 2报警 CurrentPosition NVARCHAR(16) NULL, LastHeartbeat DATETIME NULL );这张表的CurrentPosition是设备最后一次上报的位置我在实际项目里也会用它显示在监控页面上。日志表更简单只要字段有LogType、DeviceId、Message、CreateTime就够了。要记住日志表不要记录设备心跳这种高频数据否则一天就能写几十万行第5章会专门说这个坑。3.3 设备通信接口让提升机和堆垛机都走同一套方法WCS和设备打交道常见做法是TCP/IP Modbus TCP协议也有PLC自带的Socket服务器或者直接用串口。为了让任务调度代码不依赖具体设备品牌我习惯先抽象一个IDeviceDriver接口public interface IDeviceDriver : IDisposable { bool Connect(); void Disconnect(); Taskbool SendCommandAsync(int deviceId, string command, CancellationToken ct); event EventHandlerDeviceMessage MessageReceived; }然后堆垛机驱动和提升机驱动分别实现这个接口。比如一个走TCP的堆垛机驱动最简化版本是这样public class StackerDriver : IDeviceDriver { private TcpClient? _client; private CancellationTokenSource? _cts; public async Taskbool SendCommandAsync(int deviceId, string command, CancellationToken ct) { try { if (_client null || !_client.Connected) { _client new TcpClient(); _cts new CancellationTokenSource(2000); await _client.ConnectAsync(192.168.1.20, 502, _cts.Token); } var buffer Encoding.ASCII.GetBytes(command \r\n); await _client.GetStream().WriteAsync(buffer, ct); return true; } catch (Exception ex) { // 记录日志触发断线重连 return false; } } }逻辑说明command这里用字符串表示指令真实项目里会是Modbus寄存器地址和值。参数说明连接超时用CancellationTokenSource(2000)控制避免PLC不回应导致线程挂死不要每次发送都new TcpClient否则端口资源会耗尽。还有一个容易忽略的点接收PLC主动上报的位置消息不能靠轮询要让驱动里单独跑一个ReceiveTask读到数据后触发MessageReceived事件让上层更新缓存。这个事件是WCS实时性的核心很多C#上位机教程里对TcpClient的用法停留在“发送后等待接收”但在WCS里必须改成异步事件模型。4. 设备监控界面MVC怎么把堆垛机状态实时刷到页面上4.1 用Controller提供状态接口View用fetch轮询刷新监控界面浏览器的核心是每隔1秒拉一次设备状态和任务列表。MVC中Controller的Action返回JSONView中用JS fetch。Controller代码public class MonitorController : Controller { private readonly DeviceStateCache _cache; public MonitorController(DeviceStateCache cache) { _cache cache; } [HttpGet] public IActionResult GetDeviceStates() { var devices _cache.GetAll(); return Json(devices); } }View代码script function loadDevices() { fetch(/Monitor/GetDeviceStates) .then(resp resp.json()) .then(devices { const tbody document.getElementById(deviceTable); tbody.innerHTML ; devices.forEach(d { const cls d.commStatus 1 ? ok : danger; tbody.insertAdjacentHTML(beforeend, tr class${cls} td${d.deviceName}/td td${d.commStatus 1 ? 在线 : 离线}/td td${d.workStatus 0 ? 空闲 : (d.workStatus 1 ? 忙碌 : 报警)}/td td${d.currentPosition}/td /tr); }); }); } setTimeout(function tick() { loadDevices(); setTimeout(tick, 1000); }, 1000); /script逻辑说明这里用setTimeout而不是setInterval可以避免浏览器切后台导致请求积压每次都等上一次请求返回后再开始下一次。参数说明刷新间隔我建议1秒PLC本身反馈周期在100到500毫秒1秒足够操作员看清状态再快只会增加不必要的压力。Controller返回的是内存缓存不是直接查数据库这样页面怎么刷新都不会拖慢SQL Server。如果你手头项目用的是.NET Framework MVC 5思路完全一样只是fetch在旧浏览器里可能需要引入polyfill。4.2 任务下发从页面表单到设备指令的完整链路监控界面不能只看还要手动干预紧急插单、重新执行异常任务。MVC表单提交到ControllerController调用TaskService下发。页面JavaScriptasync function submitTask() { const body { taskType: 1, targetLocation: document.getElementById(loc).value, priority: 5 }; const resp await fetch(/Task/Create, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }); const result await resp.json(); if (result.success) location.reload(); }Controller:[HttpPost] public async TaskIActionResult Create([FromBody] TaskCreateDto dto) { var taskId await _taskService.CreateAsync(dto); return Json(new { success taskId 0, taskId }); }TaskService里的CreateAsync按三步走插入任务表状态为Pending从设备缓存里找一个同类型且状态为Working0的设备调用IDeviceDriver.SendCommandAsync把第一个动作发给设备。注意顺序不能反先写数据库再发设备指令。如果先发指令再写库设备已经开始执行了服务这时宕机任务就丢了。第5章会讲我在这上面吃过的亏。4.3 设备上报事件与界面刷新的联动设备驱动收到PLC上报时会触发MessageReceived事件WCS将这事件转发到DeviceStateCache并更新内存状态。因为设备上报频率可能达到10到50Hz如果每个报文都写SQL数据库会成为瓶颈。缓存更新代码public class DeviceStateCache { private readonly ConcurrentDictionaryint, DeviceStatus _map new(); public void UpdateFromMessage(DeviceMessage msg) { var status _map.GetOrAdd(msg.DeviceId, new DeviceStatus()); status.CommStatus 1; status.WorkStatus msg.WorkStatus; status.CurrentPosition msg.CurrentPosition; status.LastHeartbeat DateTime.Now; } }逻辑说明ConcurrentDictionary保证多线程写入不冲突。设备上报的数据只更新内存只有任务完成或报警才写日志表甚至这能大幅降低数据库写入量。很多初次接触WCS的工程师习惯把所有数据都落库结果SQL Server天天高负载其实监控界面上显示的实时数据根本不需要落库——落库的是不可变的事件比如“任务完成”“报警发生”。5. WCS上线避坑记录设备断线、任务并发和SQL死锁5.1 设备断线重连没有心跳的通信跑几天就“假死”现象提升机监控界面上一直显示忙碌但实际设备已经停了现场按下急停再恢复WCS还是认为它在忙。原因WCS和设备之间的TCP连接被防火墙或者PLC程序静默断开驱动程序没有处理Socket异常读取线程卡在ReadAsync上没有任何报错。解决给设备驱动加一个心跳任务每5秒发送一次查询指令超时3秒没收到回复就主动断开重连。重连时不要只new TcpClient要先把旧的释放并重置CancellationTokenSource。我一般把心跳写在DeviceService里与设备驱动分开这样断线重连不影响正在执行的任务状态。5.2 任务并发分配多个堆垛机能抢到同一个任务现象两个堆垛机同时冲向同一货位差点撞车。原因WCS从数据库查询Pending任务时两个调度线程同时读到同一个任务都判定自己空闲都去下发。解决不能“先查再更新”要用SQL原子操作把任务状态CAS式改掉UPDATE WcsTask SET Status 1, DeviceId deviceId WHERE TaskId taskId AND Status 0; -- 如果 ROWCOUNT 1 才允许下发指令逻辑说明SQL Server执行UPDATE时会加行锁第二个线程的UPDATE会阻塞等第一个线程提交后它的WHERE Status0已经匹配不上影响行数为0。所以TaskService里判断影响行数为0就说明任务已被别人抢走当前设备继续找下一个任务。同样的方法用于设备绑定UPDATE DeviceStatus SET WorkStatus1 WHERE DeviceIdid AND WorkStatus0。5.3 页面越用越卡别在View里直接查库现象车间电脑打开监控页面开始正常半小时后点击任何菜单都转圈。原因页面每秒刷新一次设备状态Controller每次去查SQL Server并且做了多个COUNTSQL Server的锁和tempdb被拖垮。解决常驻内存的DeviceStateCache页面只查缓存数据库只保存历史记录。缓存更新由设备上报事件驱动不由页面请求驱动。另外在Razor视图里不要写类似foreach (var item in ViewBag.Devices)这样的每刷新一次就触发一次数据库查询的代码先在Controller里把数据准备好再传给视图。5.4 堆垛机位置累积误差靠定时器移动不如靠绝对坐标现象堆垛机走位偏差越来越大入库货位放偏货叉撞到立柱。原因多数堆垛机用增量编码器反馈位移PLC累计脉冲数链轮打滑或机械间隙会造成误差累积WCS如果只凭PLC返回的移动步数计算位置误差每次都带进下一次定位。解决在设备通信层加一个“绝对位置校准”指令当堆垛机回到轨道起点或经过光电开关时PLC上报绝对坐标WCS用这个值修正缓存中的CurrentPosition。如果PLC不支持就在轨道两端加接近开关每次到位后强制校准一次。这个坑光靠写C#代码解决不了要和机械电气一起配合。5.5 SQL日志表无限膨胀别把所有动作都写进日志表现象系统上线一周日志表2GB数据库备份变慢。原因把设备每次位置心跳都INSERT进日志表每分钟几千条记录把SQL Server拖慢。解决分两类记录——业务日志任务创建/完成/异常和通信日志心跳/指令。通信日志不应默认开启只有调试时打开并写到文件而不是SQL Server。SQL Server只保留业务日志并且每天用SQL作业把3天前的历史归档到Archive表。很多人在开发阶段习惯Debug.WriteLine到了生产环境就忘了关这也是日志膨胀的一个重要来源。6. 把WCS做得更耐用的三个小技巧任务优先级、模拟器和SQL事务6.1 任务优先级如何调度WMS下发的订单可能同时包含加急任务。在TaskService里查询待分配任务时不要用Order By CreateTime改成SELECT TOP 5 * FROM WcsTask WHERE Status 0 ORDER BY Priority ASC, CreateTime ASC;同时用一个SemaphoreSlim保证同一时刻只有一个分配线程在运行防止多个线程同时取出任务又因为设备竞争产生锁冲突。这个信号量要作为整个WCS调度的全局闸门否则即使SQL更新是原子的调度线程太多也会产生大量无效更新。6.2 设备仿真器不接PLC也能跑通全套流程开发WCS最痛苦的是调试时设备不在身边。我的做法是写一个SimulatedDeviceDriver实现同样的IDeviceDriver接口。发送指令时它模拟PLC的运动逻辑比如收到“取货”指令后延迟1到2秒回复一个位置变化事件。在Program.cs里用配置切换if (config[UseSimulator] true) services.AddSingletonIDeviceDriver, SimulatedDeviceDriver(); else services.AddSingletonIDeviceDriver, TcpStackerDriver();这样开发环境跑仿真现场跑真实设备业务层代码一行不用改。我第一次做立体库项目时就是靠这个模拟器在办公室里把任务状态机调通到现场只用了两天就把真实设备接上了。6.3 用SQL事务保证任务与设备状态一致任务分配时一个比较稳妥的写法是使用事务并配合条件更新BEGIN TRAN; UPDATE WcsTask SET Status1, DeviceIddeviceId, StartTimeGETDATE() OUTPUT inserted.TaskId WHERE TaskIdtaskId AND Status0; IF ROWCOUNT1 BEGIN UPDATE DeviceStatus SET WorkStatus1 WHERE DeviceIddeviceId; COMMIT; -- 返回成功向设备发送指令 END ELSE BEGIN ROLLBACK; -- 返回失败 END不要用SELECT再UPDATE因为两个操作之间其他请求可能改掉状态。事务保证任务状态和设备状态的更新要么同时成功要么同时失败。如果设备指令发送失败任务服务要捕获异常并把任务标记为Exception同时把设备状态回滚成空闲这样至少账实一致。我在现场吃过“先发指令后写库”的亏设备动了一半服务崩溃恢复后库存对不上最后花了半天人工盘点。后来定了一条规矩任何一次设备动作都以数据库状态变更为起点以数据库日志为终点。这套思路不一定是最先进的但在WCS这种控制系统里越简单越可靠。希望帮到你。本文还有配套的精品资源点击获取