
简介一套完整的仓库管理系统开发源代码面向具备一定基础的初中级开发者也适合有仓储信息化需求的团队快速搭建原型。项目基于主流ASP.NET MVC框架可使用VS2019或VS2022打开同时兼容MySQL与SQL Server调整连接字符串即可部署运行环境适配成本低。代码结构清晰分层合理覆盖登录、权限、出入库管理等核心业务模块。资源包共2821个文件压缩后约42.7MB核心包含数百个.cs后端逻辑、.cshtml视图页面以及.dll依赖库另有大量.js脚本、.png图标、.css样式和.html静态页面。前端资源与后端代码层次清晰便于对照学习MVC分层架构、出入库流程和权限设计。包内还有全局入口、Api接口及数据库备份文件数据访问设计完整。已有210人学习下载适合课程设计、毕业设计或企业仓库项目起步能节省搭框架时间直接聚焦业务定制与二次开发无论是学习还是二次开发均具参考价值。1. 仓库管理系统用 ASP.NET MVC 开发为什么至今还有人选它我在工厂和物流园里见过太多这样的项目WMS仓库管理系统、ERP 的库存模块、扫码出入库看板后台清一色是 ASP.NET MVC 写的。它的定位很明确——面向企业内部、部署在 Windows Server、和 Excel 导出、标签打印、老旧数据库对接是天然近亲。核心价值是MVC 分层把页面和业务分开开发节奏稳包维护成本低招人容易。这个标题说的不是“又一个玩具仓库”而是一条能直接落在生产环境的开发路线适合给制造业、电商仓配、第三方物流做内部系统的 .NET 团队。我先把话挑明用 ASP.NET MVC 写仓库管理系统不是因为新潮而是因为它处理“表单录入 库存账目 权限 报表”这套组合拳效率比很多人想象的更高。接下来我按自己落过一次的方案拆开讲从建表到并发扣库存再到踩坑每一步都可以抄。2. 搭 ASP.NET MVC 仓库系统的骨架实体建模、仓储分层与依赖注入三板斧2.1 实体建模先定 5 张核心表别在数据库里裸写仓库管理系统的数据模型绕不开这几张基础表货品Product、仓库Warehouse、库存Inventory、出入库单据StockIn / StockOut、库存流水StockLog。很多新手上来先把数据库表建好然后直接拖数据集控件这样页面是跑起来了但业务逻辑全堆在视图里改一个字段要翻一整夜。我一般用 EF Code First 把模型写在代码里先用 C# 类把数据结构定住再通过迁移生成数据库。这样做有一个直接好处实体即约束字段注释写在类上后来接手的人看代码就能看懂表结构。下面是我常用的实体精简版public class Product { public int Id { get; set; } [Required, MaxLength(50)] public string ProductCode { get; set; } // 货品编码唯一 [Required, MaxLength(128)] public string ProductName { get; set; } public string Spec { get; set; } // 规格型号 public int SafeStock { get; set; } // 安全库存低于这个值提示补货 public bool IsActive { get; set; } public virtual ICollectionInventory Inventories { get; set; } } public class Warehouse { public int Id { get; set; } [Required, MaxLength(50)] public string WarehouseCode { get; set; } [Required, MaxLength(128)] public string WarehouseName { get; set; } public string Address { get; set; } } public class Inventory { public int Id { get; set; } public int ProductId { get; set; } public int WarehouseId { get; set; } public string BatchNo { get; set; } // 批次号 public decimal Quantity { get; set; } // 可用库存decimal 是关键 [Timestamp] public byte[] RowVersion { get; set; } // 乐观并发控制 } public class StockIn { public int Id { get; set; } public string InBillNo { get; set; } // 入库单号 public int ProductId { get; set; } public int WarehouseId { get; set; } public string BatchNo { get; set; } public decimal Quantity { get; set; } public DateTime CreateTime { get; set; } public string OperatorName { get; set; } } public class StockLog { public int Id { get; set; } public string BizType { get; set; } // In / Out / Adjust public int BizBillId { get; set; } // 关联单据 Id public int ProductId { get; set; } public int WarehouseId { get; set; } public string BatchNo { get; set; } public decimal ChangeQty { get; set; } // 变动的数量正负 public decimal BeforeQty { get; set; } public decimal AfterQty { get; set; } public DateTime CreateTime { get; set; } public string OperatorName { get; set; } }这里有两个参数要特别解释。第一库存数量必须用 decimal不能用 double。仓库账目要求精确到小数点后四位double 的二进制浮点误差会在累计出库后暴露库存变成 9.999999999999999财务对账直接翻车。第二Inventory 表上的唯一索引 “WarehouseId ProductId BatchNo” 必须建立我用 Fluent 配置而不是数据注解因为 EF Code First 的 IndexAttribute 在部分旧版本里对多列唯一索引支持有限。唯一索引加 RowVersion是防并发超卖的第一道防线。2.2 仓储层接口先声明Controller 不直接碰 DbContext实体建好了下一步不是急着写 Controller而是把数据访问收口到仓储接口。为什么仓库系统后期一定会加报表、加接口、加第三方对接如果 Controller 里到处 new DbContext后面改动就是灾难。仓储层的模式是接口定义动作实现类写 EF 逻辑Controller 只依赖接口不知道数据是怎么查出来的。以库存操作为例接口声明如下public interface IStockRepository { Inventory FindInventory(int warehouseId, int productId, string batchNo); void InStock(InStockCommand cmd); void OutStock(OutStockCommand cmd); ListStockLog GetLogs(int productId, DateTime start, DateTime end); }接口旁边配一个命令对象 InStockCommand 和 OutStockCommand把“从页面传来的参数集合”封装成强类型比到处传零散参数干净得多。这个接口设计有一个容易被忽略的原则每个方法名都对应一个仓库业务动作而不是一个数据库增删改查。FindInventory、InStock、OutStock 是业务语言GetProducts、DeleteProduct 是数据语言前者让代码可读性高很多。实现类里通过构造函数拿到 WmsDbContext而不是在方法里 new这样方便后续做单元测试。DbContext 的写法也有讲究连接字符串名称固定避免多个环境部署时配错数据库。另外一个关键点连接字符串里必须加上 MultipleActiveResultSetstrue。EF6 在“边迭代边查导航属性”的时候会报“连接已打开”的错误这个参数能从根上缓解。示例connectionStrings add nameWmsDb connectionStringData Source.;Initial CatalogWmsDB;Integrated SecurityTrue;MultipleActiveResultSetsTrue providerNameSystem.Data.SqlClient / /connectionStrings2.3 用 Unity 做依赖注入注册代码与三个注意点仓储接口写好了Controller 里怎么拿到实现ASP.NET MVC 5 时代最常见的做法是 Unity 加 UnityDependencyResolver。这一步新手容易抄错我见过最多的问题是注册顺序乱、生命周期不对、resolve 报错。正确顺序是在 Application_Start 里先建容器再注册类型最后替换 Controller 工厂。代码长这样var container new UnityContainer(); container.RegisterTypeWmsDbContext(new PerRequestLifetimeManager()); container.RegisterTypeIStockRepository, StockRepository(); DependencyResolver.SetResolver(new UnityDependencyResolver(container));Controller 构造函数里直接声明依赖public class StockController : Controller { private readonly IStockRepository _stockRepository; public StockController(IStockRepository stockRepository) { _stockRepository stockRepository; } }三个注意点。第一WmsDbContext 必须注册 PerRequestLifetimeManager也就是每次请求一个实例如果注册成单例多个请求共用一个 DbContext高并发下会出现上下文状态错乱这是黑匣子问题里最典型的一类。第二注册顺序不是严格必须但建议先注册依赖项再注册依赖别人的类型否则 Unity 在解析时容易报“无法解析类型 x”。第三不要用 Global.asax 里的静态变量去接容器实例统一通过 DependencyResolver 拿否则后面写单元测试时替换不了。3. 把入库、出库和库存扣减写进控制器事务边界与流水约束是关键3.1 入库操作的完整代码更新库存同时写流水仓库系统的核心不是页面美观而是“账实一致”。入库动作包含两件事一是把数量累加到 Inventory 表二是在 StockLog 里写一条流水。这两件事必须在同一个事务里完成。我用的做法是拿到 DbContext 后先 BeginTransaction再执行库存更新与流水写入最后一起 Commit。先看入库实现public void InStock(InStockCommand cmd) { using (var tx _db.Database.BeginTransaction(IsolationLevel.ReadCommitted)) { try { var inventory _db.Inventories .FirstOrDefault(x x.WarehouseId cmd.WarehouseId x.ProductId cmd.ProductId x.BatchNo cmd.BatchNo); if (inventory null) { inventory new Inventory { WarehouseId cmd.WarehouseId, ProductId cmd.ProductId, BatchNo cmd.BatchNo, Quantity 0 }; _db.Inventories.Add(inventory); } var beforeQty inventory.Quantity; inventory.Quantity cmd.Quantity; _db.StockLogs.Add(new StockLog { BizType In, BizBillId cmd.InBillId, ProductId cmd.ProductId, WarehouseId cmd.WarehouseId, BatchNo cmd.BatchNo, ChangeQty cmd.Quantity, BeforeQty beforeQty, AfterQty inventory.Quantity, CreateTime DateTime.Now, OperatorName cmd.OperatorName }); _db.SaveChanges(); tx.Commit(); } catch { tx.Rollback(); throw; } } }这段代码的逻辑说明先按“仓库 货品 批次”查库存查不到就新建一条 Quantity0 的记录再累加查到了直接在原基础上加。BeforeQty 取的是累加前的值流水里记录变动前后两个数字这样以后盘点时能倒推每一步发生什么。IsolationLevel.ReadCommitted 是 SQL Server 默认隔离级别对大多数仓库场景已经足够不用一上来就开 Serializable那会把自己的并发性能拖垮。3.2 出库扣库存的并发控制条件 UPDATE 而不是先读后写出库比入库多一个大坑并发超卖。两个人的浏览器同时提交同一批次的出库代码里如果写成“先查库存判断足够再减数量”两个请求都读到 Quantity100都认为够扣最后库存变成负数。破解办法一句话把判断和扣减放进同一条 UPDATE 语句让数据库的行锁来兜底。EF 里的写法是用 ExecuteSqlCommand 执行条件更新int rows _db.Database.ExecuteSqlCommand( UPDATE dbo.Inventory SET Quantity Quantity - qty, RowVersion CAST(newRowVersion AS rowversion) WHERE ProductId pid AND WarehouseId wid AND BatchNo batchNo AND Quantity qty, new SqlParameter(qty, cmd.Quantity), new SqlParameter(pid, cmd.ProductId), new SqlParameter(wid, cmd.WarehouseId), new SqlParameter(batchNo, cmd.BatchNo), new SqlParameter(newRowVersion, Guid.NewGuid().ToByteArray())); if (rows 0) { throw new InvalidOperationException(库存不足或批次不存在); }这里的巧妙之处在于 WHERE 子句带了 Quantity qtySQL Server 在执行时会对该行加排他锁第二个并发事务必须等第一个提交或回滚从根上杜绝了负库存。逻辑说明Update 影响行数是 0 时只可能是两种原因批次不存在或者数量不够抛异常后外层事务统一回滚。RowVersion 是 timestamp 类型更新时强制变化它这样之后用乐观并发检查时不会误判。3.3 视图层的表单与校验ModelState 怎么拦住脏数据后端事务再稳前端也得把脏数据挡在门外。入库单页面我用强类型视图模型 数据注解校验ModelState.IsValid 不通过就直接返回视图。视图模型示例public class InStockViewModel { [Required] public int ProductId { get; set; } [Required] public int WarehouseId { get; set; } [Required, MaxLength(50)] public string BatchNo { get; set; } [Range(0.0001, 9999999.9999, ErrorMessage 入库数量必须大于 0)] public decimal Quantity { get; set; } public string OperatorName { get; set; } }对应的视图核心表单只保留关键字段using (Html.BeginForm(InStock, Stock, FormMethod.Post)) { Html.AntiForgeryToken() Html.ValidationSummary(true) div classform-group Html.LabelFor(m m.ProductId) Html.DropDownListFor(m m.ProductId, ViewBag.Products as SelectList, new { class form-control }) Html.ValidationMessageFor(m m.ProductId) /div div classform-group Html.LabelFor(m m.BatchNo) Html.TextBoxFor(m m.BatchNo, new { class form-control }) /div div classform-group Html.LabelFor(m m.Quantity) Html.TextBoxFor(m m.Quantity, new { class form-control }) Html.ValidationMessageFor(m m.Quantity) /div button typesubmit classbtn btn-primary提交入库/button }控制器里判断 ModelState 的写法是固定的先判断校验是否通过不通过就重新填充下拉框数据返回原视图通过再调用仓储方法成功后重定向到列表页。这样做的含义是“Post-Redirect-Get”用户刷新页面时不会重复提交单据。校验不通过的返回原视图时一定要重新设置 ViewBag.Products否则下拉框空白用户还要重新选货品这种细节决定了表单好不好用。4. 给仓库系统加护栏ActionFilter 登录与日志、特殊路由、防重提交4.1 ActionFilter 做登录态检查与操作日志注意 MVC 和 Web API 的区别仓库系统不是公开网站每个操作都要知道是谁干的。ASP.NET MVC 里最顺手的方式是写 ActionFilterAttribute 挂在控制器或方法上。这里有一个命名空间问题经常让人栽跟头System.Web.Mvc.ActionFilterAttribute 给 MVC 的 Controller 用System.Web.Http.Filters.ActionFilterAttribute 给 Web API 的 ApiController 用。项目里两种程序集都引用的时候继承的基类必须写全命名空间否则编译器报二义性。我先写一个登录校验过滤器public class LoginAuthorizeAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { if (filterContext.HttpContext.Session[UserId] null) { filterContext.Result new RedirectResult(~/Account/Login); return; } base.OnActionExecuting(filterContext); } }逻辑说明OnActionExecuting 在 Action 执行前拦截Session 里没有用户编号就跳登录页有就放行。这个过滤器比在 controller 里每个方法都判断 Session 要省事得多。再补充一个操作日志过滤器public class OperationLogAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { filterContext.HttpContext.Items[_startTime] DateTime.Now; } public override void OnActionExecuted(ActionExecutedContext filterContext) { var startTime (DateTime)filterContext.HttpContext.Items[_startTime]; var duration DateTime.Now - startTime; // 写入日志表控制器名、方法名、用户名、耗时 using (var db new WmsDbContext()) { db.OperationLogs.Add(new OperationLog { Controller filterContext.RouteData.Values[controller]?.ToString(), Action filterContext.RouteData.Values[action]?.ToString(), UserName filterContext.HttpContext.User?.Identity?.Name, DurationMs duration.TotalMilliseconds, CreateTime DateTime.Now }); db.SaveChanges(); } } }这里的关键细节是耗时计算不能依赖静态字段或线程静态字段必须把开始时间塞进 HttpContext.Items因为同一个用户的不同请求会复用线程池里的线程用静态变量会把两个人的耗时算串这是排查日志时最容易碰到的“玄学”现象。4.2 给某个出库方法指定特殊路由Attribute Routing 的写法仓库系统里有些动作天生要暴露成接口比如扫码枪调用“按单号出库”。ASP.NET MVC 5 支持 Attribute Routing可以在方法上直接指定路由模板不需要在 RouteConfig 里写一堆自定义路由规则。启用只需一行routes.MapMvcAttributeRoutes();放在 RouteConfig.RegisterRoutes 里和原有默认路由共存。然后控制器里这样写[RoutePrefix(warehouse/stock)] public class StockController : Controller { [Route({id}/outbound)] [HttpPost] public ActionResult Outbound(int id, OutboundViewModel model) { // 出库逻辑 } }这样/warehouse/stock/108/outbound会被路由到 StockController 的 Outbound 方法同时 id 参数自动绑定到方法上的 int id。它和默认路由{controller}/{action}/{id}的区别在于路由模板写死在方法上URL 结构和控制器物理名称解耦改起来不用翻 Global.asax。有一个注意点启用 Attribute Routing 后如果控制器里某个方法没有 [Route]它仍会走默认路由这对存量项目是友好的。但要小心路由冲突比如已经有默认的 stock/outbound/108再声明成 outbound 会匹配两个 action启动时直接抛异常解决办法是统一加上 RoutePrefix。4.3 防重复提交的两种做法令牌校验与按钮禁用仓库操作员录单时容易手抖或者网络卡顿点两次提交就等于录两张单。防重我分两层做。第一层是 AntiForgeryToken它防的是跨站伪造请求顺带也能拦掉一部分重复提交[HttpPost] [ValidateAntiForgeryToken] public ActionResult InStock(InStockViewModel model) { if (!ModelState.IsValid) { ViewBag.Products new SelectList(_db.Products.ToList(), Id, ProductName); return View(model); } try { _stockRepository.InStock(new InStockCommand { ProductId model.ProductId, WarehouseId model.WarehouseId, BatchNo model.BatchNo.Trim(), Quantity model.Quantity, OperatorName Session[UserName]?.ToString() ?? system }); return RedirectToAction(Index); } catch (Exception ex) { ModelState.AddModelError(, ex.Message); ViewBag.Products new SelectList(_db.Products.ToList(), Id, ProductName); return View(model); } }逻辑说明请求进来先验证令牌令牌对不上直接拒绝。业务成功后 RedirectToAction 跳列表页刷新变成了 GET单据不会重复。第二层是业务级幂等在 InStockCommand 里加一个客户端生成的 GUID表里建唯一索引重复提交同一张单会触发 DuplicateKey 异常这种兜底连“用户开了两个窗口同时提交”都能防住。按钮禁用只做体验优化不能当防重手段因为禁用状态在前端绕开它太容易了。5. 仓库管理系统里最常见的 5 个踩坑现场从 N1 查询到并发超卖5.1 打开入库单列表要好几秒EF 懒加载引发的 N1现象入库单列表页数据只有几百条打开却要等三四秒数据库 CPU 飙高。原因视图里循环访问了 StockIn.Product 和 StockIn.Warehouse而这两处是导航属性。EF 默认启用懒加载每访问一次导航属性就发一条 SQL几百条单据就是上千次查询。解决查询时用 Include 预加载一次性把关联数据带出来var list db.StockIns .Include(s s.Product) .Include(s s.Warehouse) .OrderByDescending(s s.CreateTime) .Take(100) .ToList();Include 要控制深度连续 Include 三层以上时生成的 SQL 会有大量 LEFT JOIN数据量大了反而更慢。一个更推荐的做法是直接 Select 投影成视图模型只取需要的列这样既避免懒加载又降低了数据传输量。5.2 订单取消后库存没恢复事务边界放错了现象客户取消一张出库单业务员在系统里做了取消操作单据状态变成“已取消”但库存数量没加回来盘点对不上。原因代码里先更新出库单状态再调库存回补方法两个操作不在同一个事务里。第一次 SaveChanges 成功第二次抛异常没捕获数据库就停在“单销了、库存没动”的中间状态。解决把状态更新和库存回补包进同一个 TransactionScope或者干脆在同一个 DbContext 事务里先加库存再改状态一次性提交。这个坑的深层教训是凡是涉及两张以上表的写操作一律从开头就考虑事务边界不要指望“后面再补偿”。5.3 库存计算出现一堆小数double 惹的祸现象库存数显示 2.9999999999999996财务人员拿这个数去对账直接发飙。原因实体里用了 double 或 float 存数量二进制浮点计算有精度损失。解决全链路换 decimal。C# 侧把属性类型改成 decimalSQL Server 侧字段类型改成 decimal(18,4)第三层是数据库里新增脏数据要写脚本清洗不能用 Update 加一个固定值否则会把历史数据再算错一遍。页面显示时统一格式化Model.Quantity.ToString(N2)。这个坑属于一次性改到位别用“四舍五入到两位”做表面掩饰库存明细和汇总会因为多次四舍五入越差越多。5.4 同时出库同一批货库存变负数超卖了现象两个仓管员同时扫码出库同一批货总出库量大于库存量月末盘点时发现库存是负数。原因代码是“先查库存判断够不够再扣减”的三步走两个请求同时读到同一个库存快照都认为自己扣完没问题。解决用前面写过的条件 UPDATE 方式把判断放进 SQL 语句的 WHERE 条件里通过受影响行数判断是否扣成功。如果还想更激进一点可以开事务加锁提示但大多数仓库系统用条件 UPDATE 就够了。另外出库逻辑里写库存日志的 BeforeQty 和 AfterQty必须基于当前行最新数值不能拿内存里的旧值。5.5 操作员用到一半被踢下线IIS 回收了 Session现象仓库库里拿扫码枪的人操作到一半突然跳回登录页重新登录又能用。原因ASP.NET MVC 默认 Session 存在进程内存里IIS 应用池在回收或超时后会清空。解决办法是把 Session 挪到进程外web.config 里两项二选一system.web sessionState modeStateServer stateConnectionStringtcpip127.0.0.1:42424 cookielessfalse timeout60 / /system.websystem.web sessionState modeSqlServer sqlConnectionStringData Source.;Initial CatalogWmsSessionDB;Integrated SecurityTrue allowCustomSqlDatabasetrue cookielessfalse timeout60 / /system.webStateServer 部署简单但状态服务进程挂了全部人掉线SqlServer 最稳还要记得给会话数据库做定期备份。这里有一条血泪经验Session 里只存用户编号和姓名这类轻量数据别塞查询结果集否则每个用户占用几十 MB内存再大也扛不住。6. 先别急着迁移 ASP.NET Core把这 3 个技巧在老项目里用熟6.1 把控制器方法改成 async / await释放 IIS 线程老项目还跑在 .NET Framework 4.7 上IIS 的线程池是稀缺资源。当一个控制器做数据库查询时线程是阻塞的高峰时几百个并发请求就能把线程池打满。改成异步后数据库等待期间线程返回线程池吞吐量立刻不一样。改动成本很低public async TaskActionResult Index(int page 1) { var products await db.Products .OrderBy(p p.Id) .Skip((page - 1) * 20) .Take(20) .ToListAsync(); return View(products); }注意同一个 DbContext 实例不能同时跑两个异步操作如果你的方法里需要并行查询两张表要么分开建上下文要么用 Task.WhenAll 之前先想清楚 DbContext 是不是线程安全的。这个顺序能少踩一半的坑。6.2 把 Unity 换成原生 DI减少一个依赖版本坑Unity 在 .NET Framework 项目里很顺手但版本停更后迁移到 Core 时会发现它的生命周期管理方式和 Microsoft.Extensions.DependencyInjection 对不上。我一般会写一个适配器把 IDependencyResolver 直接对接 Unity 容器这样以后替换容器时只动一个类。核心代码是把 GetService 和 GetServices 转发给 UnityContainer 的 Resolve。容器注册的代码保持原样Controller 构造函数签名不变测试时换一个 Fake 容器即可。这件事早点做收益不是功能层面而是以后升级框架时少一层牵绊。6.3 用 Attribute Routing 把内部接口逐步 API 化仓库系统被扫码枪、PDA 或移动端看板调用时不必另起一个项目直接在现有 Controller 上加 API 路由即可。写法是[Route(api/stock/{barcode}/in)] [HttpPost] public JsonResult ApiInStock(string barcode, decimal qty) { // 调用同一套 IStockRepository只是返回 Json }这样做的好处是登录过滤器和操作日志过滤器能直接复用业务逻辑也在同一层不用在两个工程里维护两份。坏处是路由多了以后RouteTable 里可能出现冲突所以 API 路由统一加 “api/” 前缀这是我最常用的边界线。等以后量大了再拆独立站点那时候只需要把 Controller 层搬走仓储层和业务层接口不变。久久比较下来真正让一个仓库系统能长期用顺的不是换框架是分层、事务和并发控制这些基本功。每回接这样的项目我都是在老 MVC 代码里把这套练熟了再谈迁移。希望帮到你。本文还有配套的精品资源点击获取