简介:面向高校软件工程、信息管理专业的课程设计与毕业设计场景,这套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.2(4.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 和参数,不碰MessageBox;BLL 负责校验、生成单号、开事务,返回bool + out string msg给界面显示;UI 层只做赋值和刷新。这个分工守住了,后面加单据类型才不会牵一发动全身。
需要留意的是,不少同名学生作业会把事务直接写在窗体按钮的Click事件里,SQL 也拼在窗体内。能跑,但一旦要加个"红冲"或者"调拨",就得把同一段逻辑复制第三遍。看到这种写法,先别急着改,跑通再重构,顺序别反。
2.3 数据库表结构:六张主表支撑整套业务
核心表不多,主档两张、单据两张、从表两张,再加用户和权限:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| T_Goods | 商品档案 | GoodsCode(唯一)、GoodsName、Unit、SafeStock、IsDelete |
| T_Supplier | 供应商档案 | SupplierName、Contact、Phone、IsDelete |
| T_Stock | 当前库存 | GoodsId(主键)、Qty |
| T_StockIn | 入库单主表 | InId、InNo(唯一)、SupplierId、InDate、Operator |
| T_StockInDetail | 入库单明细 | DetailId、InId、GoodsId、Qty、Price |
| T_StockOut / T_StockOutDetail | 出库单主从表 | 结构同上,多一个领用部门 |
| T_User | 用户与角色 | UserName、Password(MD5 大写)、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, List<StockInDetailModel> 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/11;Visual 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 name="WhDb" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=WhDB;User ID=sa;Password=你的密码;" providerName="System.Data.SqlClient" /> <!-- Windows 身份验证版本,本地开发更省事,二选一 --> <!-- <add name="WhDb" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=WhDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> --> </connectionStrings> </configuration>name="WhDb"必须和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)) = @u,C# 侧统一BitConverter.ToString(md5).Replace("-", "").ToUpper(),再加一句txtUser.Text.Trim()。三处都改完,这类问题基本绝迹。
5.4 高分屏上界面糊成一团:DPI 缩放
现象:在笔记本上跑,按钮和文字发虚,DataGridView的列头挤在一起,换个显示器又正常。
原因:WinForms 老项目默认不声明 DPI 感知,系统按 125% 缩放时对整个窗口做位图拉伸。
解决:给 UI 项目加一个app.manifest,写入<dpiAware>true</dpiAware>,同时把窗体的AutoScaleMode设为Dpi。改完字号可能需要微调,但至少不再是糊的。
5.5 编译报「未能加载文件或程序集」:平台目标不一致
现象:解决方案生成成功,运行时报找不到某个 DLL,或者报「尝试加载格式不正确的程序」。
原因:主程序和引用的 DLL 平台目标不一致,一个x86一个AnyCPU,在 64 位系统上就打架;也有情况是 DLL 没被拷到输出目录。
解决:把整个解决方案的平台目标统一成x86(老项目求稳),或者全改AnyCPU并取消「首选 32 位」;引用的第三方 DLL 在属性里把「复制到本地」设为True,确保它出现在bin\Debug下。
6. 二次开发:加一张调拨单,顺手把权限收进配置
拿到这套代码之后,绝大多数人的第一诉求是"再加个功能"。最合适的练手目标是调拨单——它和入库出库同构,但要一次改两处库存,正好把事务理解透。
| 改造点 | 具体动作 |
|---|---|
| 数据库 | 复制T_StockIn结构建T_Transfer,明细表存FromGoodsQty/ToGoodsQty |
| Model | 加TransferModel、TransferDetailModel,字段对齐表结构 |
| DAL | 复用SaveStockIn的骨架,把一条UPDATE拆成"减出库仓、加入库仓"两条 |
| BLL | 单号前缀换DB,校验两头仓库不能相同、数量必须大于 0 |
| UI | 复制入库窗体改名,把供应商下拉换成仓库下拉 |
| 权限 | T_User.RoleId加一条菜单权限记录,登录后按角色隐藏按钮 |
核心那段事务代码几乎可以照抄,只是库存更新从一条变两条,两条都必须带Qty >= @Qty的判断,任何一条@@ROWCOUNT = 0就整体回滚。加完之后,验证方法还是老一套:调拨 5 件,两个仓库的库存差正好是 5,然后故意让目标仓库编码不存在,确认主表没留下孤儿单。
权限这块,我一般会把菜单可见性也放进配置表,而不是在窗体里写if (role == "admin")。硬编码的后果是每加一个角色都要重新编译一次,交给别人维护时基本没人找得到判断藏在哪里。
最后说个习惯:每次动这套代码之前,先把数据库完整备份一份,再在init.sql里同步改一遍建表语句,让脚本和实际库对得上。我吃过一次亏——本地库手动加了字段,跑了半个月才发现脚本里没有,换台机器部署直接崩在登录页,那天下午全在补脚本。从那以后我每次改表都强制走一遍"先改脚本、再执行脚本、最后改代码"的顺序,图的就是睡得着觉。希望帮到你。
本文还有配套的精品资源,点击获取