资讯动态

C# WinForms仓库管理系统源码:四层架构与事务库存实践

发布时间:2026/10/26 2:05:04 来源:尧图企业网站定制
简介面向高校软件工程、信息管理专业的课程设计与毕业设计场景这套C#仓库管理系统提供可直接编译运行的完整.NET解决方案适合正在学习WinForm桌面开发、需要参考真实业务模块拆分与数据库设计的初、中级开发者。压缩包共500个文件以326个cs源文件、152个resx窗体资源和3个csproj工程文件为主体另含dll依赖库、sql建库脚本及ssk皮肤文件整体约2.3MB代码覆盖换班管理、单据查询、销售分析、库存管理、统计报表、日常管理、系统设置与权限控制等功能界面借助IrisSkin2.dll实现换肤。已有5641人学习下载在同类课程设计参考中具备一定认可度。读者可从中获取完整工程目录、窗体界面与业务逻辑代码、数据库脚本以及单据编号关联操作员、同一操作员禁止多机登录等细节处理思路便于对照梳理仓库管理系统的功能闭环与实现方式。1. 仓库管理系统能干什么从一张入库单说起去年帮一个学弟看他那套 C# 仓库管理系统打开解决方案二十几个项目里有一半叫 Class1但数据库脚本、入库出库、库存台账居然真能跑通。这套 .NET 源码是很典型的课程设计、毕业设计产物WinForms 做界面、四层结构分层、SQL Server 存数据、ADO.NET 手写 SQL功能覆盖商品档案、供应商、入库、出库、库存台账、用户权限附完整源程序。它适合两类人一类是正在做毕业设计、需要一份能跑通也能改的实例另一类是想拿个小体量 .NET 项目练手、把事务和并发重新捡起来的人。下面按我自己跑通的顺序讲。2. 技术栈与目录结构四层骨架和六张核心表2.1 技术选型为什么是 WinForms ADO.NET先把基座说清楚.NET Framework 4.7.24.8 也能开、WinForms、System.Data.SqlClient、SQL Server 2012 及以上。整套东西没有 NuGet 依赖打开就能编译这一点对毕业设计场景特别关键——答辩机、宿舍机、打印店那台老电脑最怕的就是还原包失败。选 WinForms 而不是 WPF理由很实在拖控件快DataGridView拿来就是一个现成的表格编辑器入库明细、出库明细直接用表格行编辑省掉一堆自定义控件的工作量。选 ADO.NET 手写 SQL 而不是 Entity Framework一方面是环境里少一层依赖另一方面是答辩时每一句 SQL 都能指着讲心里不虚。数据访问层统一走SqlParameter传参不做字符串拼接。这条不是洁癖是血泪经验——课程设计里最容易被挑刺的地方就是登录窗口那句select * from T_User where UserName txtUser.Text 改起来其实就几行的事。2.2 解决方案目录四层各放什么源码包里的解决方案大致长这样不同版本名字可能有出入但分层和职责基本一致WarehouseSystem.sln ├── WarehouseSystem.Model # 实体类一个表一个类只放属性 │ ├── Goods.cs │ ├── StockIn.cs │ └── StockInDetail.cs ├── WarehouseSystem.DAL # 数据访问SQL 全在这一层 │ ├── SqlHelper.cs # 连接、ExecuteNonQuery/Query/Scalar │ ├── GoodsDAL.cs │ └── StockInDAL.cs ├── WarehouseSystem.BLL # 业务规则单号生成、事务、校验 │ ├── StockInBLL.cs │ └── StockBLL.cs ├── WarehouseSystem.UI # WinForms 窗体 │ ├── FrmLogin.cs │ ├── FrmMain.cs │ ├── FrmStockIn.cs │ └── FrmStockQuery.cs └── WarehouseSystem.DB # 建库脚本 / 备份文件 └── init.sqlModel 层只写属性不写逻辑这样 BLL 里拼参数、DAL 里绑定参数都顺手DAL 只认 SQL 和参数不碰MessageBoxBLL 负责校验、生成单号、开事务返回bool out string msg给界面显示UI 层只做赋值和刷新。这个分工守住了后面加单据类型才不会牵一发动全身。需要留意的是不少同名学生作业会把事务直接写在窗体按钮的Click事件里SQL 也拼在窗体内。能跑但一旦要加个红冲或者调拨就得把同一段逻辑复制第三遍。看到这种写法先别急着改跑通再重构顺序别反。2.3 数据库表结构六张主表支撑整套业务核心表不多主档两张、单据两张、从表两张再加用户和权限表名作用关键字段T_Goods商品档案GoodsCode唯一、GoodsName、Unit、SafeStock、IsDeleteT_Supplier供应商档案SupplierName、Contact、Phone、IsDeleteT_Stock当前库存GoodsId主键、QtyT_StockIn入库单主表InId、InNo唯一、SupplierId、InDate、OperatorT_StockInDetail入库单明细DetailId、InId、GoodsId、Qty、PriceT_StockOut / T_StockOutDetail出库单主从表结构同上多一个领用部门T_User用户与角色UserName、PasswordMD5 大写、RoleId库存单独抽一张T_Stock而不是每次从明细里SUM出来是个很实用的决定明细表会越滚越大实时汇总在几千条数据时就开始卡而仓库场景最常看的就是现在有多少。代价是明细和库存必须在一个事务里同步这一点第 3 章会重点讲。建表脚本里有两处细节值得抄GoodsCode上加唯一索引防止手滑录入重复编码主档表用IsDelete做逻辑删除因为历史单据还引用着它物理删会直接断掉外键关系。-- 商品档案编码唯一删除只打标记历史单据才不会断链 CREATE TABLE T_Goods ( GoodsId INT IDENTITY(1,1) PRIMARY KEY, GoodsCode VARCHAR(20) NOT NULL, GoodsName NVARCHAR(50) NOT NULL, Unit NVARCHAR(10) NOT NULL DEFAULT N件, Spec NVARCHAR(50) NULL, SafeStock INT NOT NULL DEFAULT 0, -- 低于它就在台账里标红 IsDelete BIT NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX UX_Goods_Code ON T_Goods(GoodsCode); -- 库存表一个商品一行靠事务保证和明细同步 CREATE TABLE T_Stock ( GoodsId INT PRIMARY KEY REFERENCES T_Goods(GoodsId), Qty INT NOT NULL DEFAULT 0 );SafeStock这个字段一开始容易被忽略但它撑起了整个系统的预警卖点后面台账查询的CASE WHEN就是围绕它写的。Qty用INT而不是DECIMAL是按件计量的简化如果你们的物料按公斤走这里要改成DECIMAL(18,3)同时 BLL 里的比较和界面的输入校验都得跟着改别只改一处。3. 核心模块拆解一张入库单从录入到库存变动3.1 数据访问层一个 SqlHelper 就够用三层结构里最先要看的永远是SqlHelper它决定了后面所有代码的写法。这套源码里的版本很朴素三个方法增删改、查表、取单值。public static class SqlHelper { // 连接串只在 App.config 里维护一份换机器只改这里 public static readonly string ConnStr ConfigurationManager.ConnectionStrings[WhDb].ConnectionString; // 增删改返回受影响行数调用方据此判断成功与否 public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); // 参数化杜绝拼接注入 conn.Open(); return cmd.ExecuteNonQuery(); } } // 查询直接返回 DataTable绑 DataGridView 时零转换 public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) using (var adp new SqlDataAdapter(cmd)) { if (ps ! null) cmd.Parameters.AddRange(ps); var dt new DataTable(); adp.Fill(dt); // SqlDataAdapter 自己开关连接不用手动 Open return dt; } } // 单值登录校验、取当天最大单号都用它 public static object ExecuteScalar(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(ConnStr)) using (var cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteScalar(); } } }params SqlParameter[]这个写法让调用端很省事SqlHelper.Query(sql, new SqlParameter(id, 3))参数个数随意。Query返回DataTable是这套代码的一个取向——不做实体映射直接把表丢给DataGridView.DataSource少写几十行转换代码代价是 BLL 里取值要靠列名列名一改就静默出错这一点在二次开发时要盯住。using三连是必须保留的SqlConnection是稀缺资源忘了 Dispose 在长时间开着的收银台、仓库终端上会慢慢把连接池耗干表现出来就是用一会儿就卡住重启又好了。3.2 入库单保存一个事务里干完三件事入库是整套系统里最值得逐行读的方法写主表、写明细、累加库存三步必须同生共死。中间任何一步失败主表多一条空单、或者明细有了库存没加都会让后续对账变成玄学。public bool SaveStockIn(StockInModel head, ListStockInDetailModel details, out string msg) { msg string.Empty; if (details null || details.Count 0) { msg 入库明细不能为空; return false; } using (var conn new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran conn.BeginTransaction()) // 一个连接、一个事务贯穿到底 { try { // 1) 主表单号由 BLL 先生成这里只负责落库并拿回自增主键 const string sqlHead INSERT INTO T_StockIn(InNo, SupplierId, InDate, Operator, Remark) VALUES(InNo, SupplierId, InDate, Operator, Remark); SELECT CAST(SCOPE_IDENTITY() AS INT);; int inId; using (var cmd new SqlCommand(sqlHead, conn, tran)) // 注意第三个参数 { cmd.Parameters.AddWithValue(InNo, head.InNo); cmd.Parameters.AddWithValue(SupplierId, head.SupplierId); cmd.Parameters.AddWithValue(InDate, head.InDate); cmd.Parameters.AddWithValue(Operator, head.Operator); cmd.Parameters.AddWithValue(Remark, (object)head.Remark ?? DBNull.Value); inId Convert.ToInt32(cmd.ExecuteScalar()); } // 2) 明细 3) 库存累加逐条在同一事务里走 const string sqlDetail INSERT INTO T_StockInDetail(InId, GoodsId, Qty, Price) VALUES(InId, GoodsId, Qty, Price);; const string sqlStock UPDATE T_Stock WITH(UPDLOCK, ROWLOCK) SET Qty Qty Qty WHERE GoodsId GoodsId; IF ROWCOUNT 0 INSERT INTO T_Stock(GoodsId, Qty) VALUES(GoodsId, Qty);; // 首次入库库存行还不存在 foreach (var d in details) { if (d.Qty 0) throw new Exception($商品 {d.GoodsName} 的数量必须大于 0); using (var cmd new SqlCommand(sqlDetail, conn, tran)) { cmd.Parameters.AddWithValue(InId, inId); cmd.Parameters.AddWithValue(GoodsId, d.GoodsId); cmd.Parameters.AddWithValue(Qty, d.Qty); cmd.Parameters.AddWithValue(Price, d.Price); cmd.ExecuteNonQuery(); } using (var cmd new SqlCommand(sqlStock, conn, tran)) { cmd.Parameters.AddWithValue(GoodsId, d.GoodsId); cmd.Parameters.AddWithValue(Qty, d.Qty); cmd.ExecuteNonQuery(); } } tran.Commit(); // 走到这一行才算数 return true; } catch (Exception ex) { tran.Rollback(); // 主表、明细、库存一起回滚 msg ex.Message; return false; } } } }几个参数和写法值得掰开说。SCOPE_IDENTITY()取的是当前作用域内刚插入行的自增 ID比IDENTITY安全因为后者会被触发器里的插入带偏。所有SqlCommand都必须传tran作为第三个参数漏一个就会报当前命令需要事务或者更糟——那条语句独立提交回滚时它不跟着退。(object)head.Remark ?? DBNull.Value处理的是空备注直接传null给参数会抛异常。WITH(UPDLOCK, ROWLOCK)是给库存行加更新锁让并发入库排队而不是互相覆盖。IF ROWCOUNT 0后面补INSERT的写法是为了兼容商品第一次入库、库存表还没这一行的情况。看起来啰嗦但比在循环外先查一次再判断要快也少了一次往返。3.3 出库扣减把防超卖写进 UPDATE 的 WHERE 里出库比入库危险因为要先判断库存够不够。新手最容易写成的样子是先SELECT Qty在 C# 里比一下够了再UPDATE两个人同时点出库时两个请求可能都读到 10 件然后各自扣 8 件库存直接变成 -6。翻车点就在这里。// 出库扣减够不够交给数据库判断避免先查后扣的并发缺口 const string sqlOut UPDATE T_Stock WITH(UPDLOCK, ROWLOCK) SET Qty Qty - Qty WHERE GoodsId GoodsId AND Qty Qty; -- 条件不满足就不更新 IF ROWCOUNT 0 BEGIN RAISERROR(N库存不足或商品不存在, 16, 1); RETURN; END;WHERE ... AND Qty Qty是整段的核心库存判断和扣减在一条语句里完成SQL Server 会在锁的保护下原子执行不存在中途被插队的窗口。RAISERROR用级别 16 抛业务错误C# 侧的catch (SqlException)拿到消息后原样显示给操作员比返回 -1 再猜原因清楚得多。出库单和入库单结构对称只是主表多一个领用部门、明细价格用来算成本。这里我做了一个取舍出库时如果商品从未入过库T_Stock里没有行UPDATE的ROWCOUNT为 0直接报库存不足而不是补一条 0 库存的行。这个行为要跟业务方确认有的仓库要求允许负库存做暂估那就得改。3.4 库存台账一条 SQL 带出预警状态台账界面是这套系统里查询最频繁的地方LEFT JOIN主档和库存一次把没入过库的商品也带出来避免明明建了档案却查不到的困惑。SELECT g.GoodsCode, g.GoodsName, g.Unit, ISNULL(s.Qty, 0) AS Qty, -- 从未入库的商品显示 0 而不是 NULL g.SafeStock, CASE WHEN ISNULL(s.Qty, 0) g.SafeStock THEN N低于安全库存 ELSE N正常 END AS Status FROM T_Goods g LEFT JOIN T_Stock s ON g.GoodsId s.GoodsId WHERE g.IsDelete 0 AND (kw OR g.GoodsCode LIKE kw OR g.GoodsName LIKE kw) ORDER BY Status DESC, g.GoodsCode;用LEFT JOIN而不是INNER JOIN是这条查询唯一值得强调的地方INNER JOIN会把零库存商品整行吞掉而仓库最需要看到的恰恰是那些应该有货但没货的。ISNULL(s.Qty, 0)同理防止界面显示空白。ORDER BY Status DESC依赖中文排序让低于安全库存排在前面如果数据库排序规则不是中文建议改成ORDER BY CASE WHEN ... THEN 0 ELSE 1 END更稳。3.5 界面层单号生成与表格绑定单号规则是RK yyyyMMdd 三位流水按天重置。这段代码短但藏着并发隐患值得单独拎出来看。private string BuildInNo() { string prefix RK DateTime.Now.ToString(yyyyMMdd); string sql SELECT ISNULL(MAX(InNo), ) FROM T_StockIn WHERE InNo LIKE p; var max SqlHelper.ExecuteScalar(sql, new SqlParameter(p, prefix %))?.ToString(); int seq 1; if (!string.IsNullOrEmpty(max) max.Length prefix.Length int.TryParse(max.Substring(prefix.Length), out int last)) seq last 1; // 当天已有单据就在最大号上 1 return prefix seq.ToString(D3); // D3 保证 001 而不是 1 }参数p传的是prefix %配合LIKE只扫当天的单号MAX走得动索引。ToString(D3)补零让单号长度固定排序和肉眼核对都舒服。但这段逻辑在两个人同时点新增入库时会生成同一个单号最后靠InNo的唯一索引报错兜底——现象是偶尔保存失败重试就好。单机版、演示版完全可以接受真要上线多人用我一般会把它换成一张T_Sequence(前缀, 日期, 当前值)表加UPDLOCK自增或者干脆用数据库序列比在应用层取MAX靠谱得多。界面层剩下的活就是把DataTable丢给dataGridView1.DataSource明细页用一个内存DataTable承接用户新加的行点保存时整体传给 BLL。这样新增行和已存行用同一套列结构不用为编辑状态写两套渲染逻辑。4. 从零跑起来环境、还原与第一次登录4.1 环境清单与版本对应按这个顺序准备基本不会卡Windows 10/11Visual Studio 2019 或 2022安装时勾上「.NET 桌面开发」SQL Server 2012 及以上任意版本Express 也行.NET Framework 4.7.2 或 4.8 运行时。几个容易被忽略的点VS 2022 打开老项目时如果提示目标框架不受支持多半是项目写的是 4.5 或 4.0去项目属性里改成 4.7.2 再生成SQL Server Express 的实例名是.\SQLEXPRESS而不是.连接字符串里要写全如果机器上装了多个 SQL 实例用 SSMS 连的时候看清楚实例名别把脚本执行到隔壁库去了。4.2 数据库还原与连接字符串修改源码包里通常有两种交付方式一个.bak备份文件或者一个init.sql建库脚本。脚本更透明推荐走脚本。# 用 sqlcmd 执行建库脚本-i 指定脚本-d 指定目标库 sqlcmd -S .\SQLEXPRESS -U sa -P 你的密码 -i init.sql # 或者直接建一个空库再用 SSMS 打开 init.sql 全选执行 sqlcmd -S .\SQLEXPRESS -U sa -P 你的密码 -Q CREATE DATABASE WhDB脚本跑完之后打开WarehouseSystem.UI下的App.config把连接串改成自己机器的configuration connectionStrings !-- 账号密码登录适合 SQL 身份验证开启的实例 -- add nameWhDb connectionStringData Source.\SQLEXPRESS;Initial CatalogWhDB;User IDsa;Password你的密码; providerNameSystem.Data.SqlClient / !-- Windows 身份验证版本本地开发更省事二选一 -- !-- add nameWhDb connectionStringData Source.\SQLEXPRESS;Initial CatalogWhDB;Integrated SecurityTrue; providerNameSystem.Data.SqlClient / -- /connectionStrings /configurationnameWhDb必须和SqlHelper里ConfigurationManager.ConnectionStrings[WhDb]的键完全一致大小写也别改。这行名字对不上程序启动就抛NullReferenceException报错点还在SqlHelper的静态字段上很容易看歪方向。4.3 编译运行与初始账号用 VS 打开.sln右键解决方案「还原 NuGet 包」如果没有依赖这步会一闪而过然后「生成解决方案」。把WarehouseSystem.UI设为启动项目F5。首次登录账号一般在建库脚本末尾的INSERT INTO T_User里常见是admin / 123456密码存的是 MD5 大写串。如果登录提示用户不存在先去 SSMS 里SELECT * FROM T_User确认脚本到底插没插进去——脚本执行到一半报错中断、后面语句没跑是很常见的情况。数据库连接失败时最快的定位办法是拿 SSMS 用同一套连接参数连一次。SSMS 能连上而程序连不上问题基本都在配置文件SSMS 也连不上就去查实例名、TCP/IP 协议有没有启用、防火墙有没有放行 1433。4.4 冒烟测试用一张单子验证全链路跑通登录只是第一步真正验证这套代码是否完整走一遍这条链路新建商品档案记下编码→ 新建供应商 → 入库 10 件 → 查库存台账应为 10 → 出库 3 件 → 台账应为 7 → 再出库 100 件应当被拦下并提示库存不足。最后那步最重要。如果它允许扣成 -93说明出库走的是先查后扣的老写法或者UPDATE的WHERE少了库存判断这时候就该回到 3.3 节对着改了。这一步花两分钟能省掉后面一整轮对账的痛苦。5. 避坑与排查五个让人卡半天的问题5.1 启动就崩连接字符串没进到 exe.config现象F5 一启动就弹「未将对象引用设置到对象的实例」堆栈指向SqlHelper的静态字段初始化。原因App.config里的name和代码里取的键不一致或者改的是项目根目录的App.config但程序读的是bin\Debug\WarehouseSystem.UI.exe.config两个文件不同步。解决先确认键名一字不差再重新生成一次让配置文件拷到输出目录实在拿不准就在SqlHelper里加一句if (string.IsNullOrEmpty(ConnStr)) throw new Exception(连接串为空)把玄学错误变成明确错误。5.2 保存成功但库存没变命令漏了事务参数现象入库单能保存主表和明细都有数据T_Stock里的数量一动不动重启程序也一样。原因SqlCommand构造时没传tran那条语句走的是自己的隐式事务并独立提交外层Commit只提交了它管得着的那部分回滚自然也退不掉它。解决所有参与同一事务的SqlCommand必须写成new SqlCommand(sql, conn, tran)而且要复用同一个conn。检查办法很简单在Commit前故意throw一次看主表有没有残留行——有残留就说明有语句逃出了事务。5.3 登录提示用户不存在密码比对的大小写与空格现象T_User里明明有admin输入admin / 123456就是登不进去换Admin也不行。原因库里存的是MD5大写串代码里算完没ToUpper()或者文本框里粘进了尾随空格还有一种情况是数据库排序规则区分大小写WHERE UserName u对不上。解决SQL 侧写成WHERE LTRIM(RTRIM(UserName)) uC# 侧统一BitConverter.ToString(md5).Replace(-, ).ToUpper()再加一句txtUser.Text.Trim()。三处都改完这类问题基本绝迹。5.4 高分屏上界面糊成一团DPI 缩放现象在笔记本上跑按钮和文字发虚DataGridView的列头挤在一起换个显示器又正常。原因WinForms 老项目默认不声明 DPI 感知系统按 125% 缩放时对整个窗口做位图拉伸。解决给 UI 项目加一个app.manifest写入dpiAwaretrue/dpiAware同时把窗体的AutoScaleMode设为Dpi。改完字号可能需要微调但至少不再是糊的。5.5 编译报「未能加载文件或程序集」平台目标不一致现象解决方案生成成功运行时报找不到某个 DLL或者报「尝试加载格式不正确的程序」。原因主程序和引用的 DLL 平台目标不一致一个x86一个AnyCPU在 64 位系统上就打架也有情况是 DLL 没被拷到输出目录。解决把整个解决方案的平台目标统一成x86老项目求稳或者全改AnyCPU并取消「首选 32 位」引用的第三方 DLL 在属性里把「复制到本地」设为True确保它出现在bin\Debug下。6. 二次开发加一张调拨单顺手把权限收进配置拿到这套代码之后绝大多数人的第一诉求是再加个功能。最合适的练手目标是调拨单——它和入库出库同构但要一次改两处库存正好把事务理解透。改造点具体动作数据库复制T_StockIn结构建T_Transfer明细表存FromGoodsQty/ToGoodsQtyModel加TransferModel、TransferDetailModel字段对齐表结构DAL复用SaveStockIn的骨架把一条UPDATE拆成减出库仓、加入库仓两条BLL单号前缀换DB校验两头仓库不能相同、数量必须大于 0UI复制入库窗体改名把供应商下拉换成仓库下拉权限T_User.RoleId加一条菜单权限记录登录后按角色隐藏按钮核心那段事务代码几乎可以照抄只是库存更新从一条变两条两条都必须带Qty Qty的判断任何一条ROWCOUNT 0就整体回滚。加完之后验证方法还是老一套调拨 5 件两个仓库的库存差正好是 5然后故意让目标仓库编码不存在确认主表没留下孤儿单。权限这块我一般会把菜单可见性也放进配置表而不是在窗体里写if (role admin)。硬编码的后果是每加一个角色都要重新编译一次交给别人维护时基本没人找得到判断藏在哪里。最后说个习惯每次动这套代码之前先把数据库完整备份一份再在init.sql里同步改一遍建表语句让脚本和实际库对得上。我吃过一次亏——本地库手动加了字段跑了半个月才发现脚本里没有换台机器部署直接崩在登录页那天下午全在补脚本。从那以后我每次改表都强制走一遍先改脚本、再执行脚本、最后改代码的顺序图的就是睡得着觉。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑