☰
C# WinForms C/S库存管理系统:三层架构与入库事务实战
2026/10/26 2:18:58 网站建设 项目流程

简介:这是一份面向C#初学者与桌面应用开发练习者的库存管理系统实践项目源码,采用C/S架构,客户端负责界面交互,服务器端处理数据与业务逻辑,覆盖商品入库、出库、库存查询与盘点等典型场景,可用于课程设计参考或个人练手。压缩包共309个文件、约4.74MB,以62个cs源代码文件为核心,配合resx/resources资源文件、bmp与jpg界面素材、ico图标及dll、exe可执行文件,另附数据库表注释说明文档与Visual Studio解决方案,便于直接打开运行,并理解数据库表结构与前后端交互流程。已有350人学习下载。读者可据此梳理WinForms界面搭建、数据访问与库存业务逻辑的分层写法,参考数据库设计说明完成二次开发,也能把它当作课程设计或毕业设计的原型骨架,对照源码逐步还原一个可运行的库存管理小系统。

1. 一套 C# 写的 C/S 库存管理系统,替掉的是凌晨两点还在核对的 Excel

月底盘账差三箱货,管理员抱着笔记本一格格翻 Excel 找原因,这种场面在中小仓库里一点都不稀奇。这份用 C# 做的库存管理系统走的是最朴素的 C/S 路线:WinForms 客户端直连数据库,装上就能用,不用配 Web 服务器,也不用管浏览器兼容问题。它管的是商品档案、供应商、入库、出库、库存查询和预警这几件实事,适合局域网内几台到几十台电脑的小型仓库、连锁门店后台、工厂备件库。不适合的情况也说清楚:要跨公网访问、要多租户、要手机扫码端,这套骨架撑不住,得换 B/S 或者前后端分离。下面按能跑、能看懂、能改的顺序拆。

2. 技术栈与项目骨架:WinForms 三层架构怎么落地

拿到压缩包先别急着双击 exe。这种 C/S 项目能不能跑起来,八成取决于环境对不对,而不是代码有没有问题。先把技术选型讲清楚,再看目录结构,最后照着步骤把第一次运行做出来。

2.1 为什么这个场景选 C/S 而不是 B/S

库存系统天生是「重录入、重表格」的业务。仓管一天要在界面上敲几百行入库明细,DataGridView 这种可编辑表格控件在 WinForms 里拖一下就成型,单元格校验、Tab 键跳转、批量粘贴 Excel 都是现成的。换成网页端,光一个可编辑表格加键盘导航就要写掉不少前端代码。

局域网环境也是 C/S 的主场。客户端和 SQL Server 在同一网段,查询响应就是毫秒级,盘点时刷几千条库存也不会有转圈等待。打印这块更省事:入库单、货位标签直接调本地打印机,走System.Drawing.Printing就能画,不用折腾浏览器打印的边距玄学。

代价也要认。每台机器都得装客户端,改了代码要重新发一遍;连接字符串写在客户端配置文件里,理论上能被翻出来;SQL 注入要靠参数化查询自己守住,没有 ORM 帮你兜底。所以这套系统适合「十几台到几十台客户端、内网、业务规则半年不怎么变」的场景。真要做跨门店实时汇总、要给老板手机上看报表,C/S 就要开始还债了。

2.2 解决方案与目录结构:一个 sln 里怎么切分

项目一般会拆成四个类库加一个界面工程,这是三层架构最省心的分法,改一层不会牵动另一层。

StockSys.sln ├─ StockSys.Model/ 实体类,和数据库表一一对应 │ ├─ Goods.cs 商品档案 │ ├─ InStockHead.cs 入库单主表 │ ├─ InStockDetail.cs 入库单明细 │ └─ Stock.cs 库存台账 ├─ StockSys.DAL/ 数据访问层,只写 SQL,不写业务判断 │ ├─ SqlHelper.cs 连接、参数、事务的统一入口 │ ├─ GoodsDal.cs │ └─ StockDal.cs ├─ StockSys.BLL/ 业务逻辑层,事务和校验都在这 │ ├─ InStockBll.cs │ └─ StockBll.cs ├─ StockSys.Win/ WinForms 界面 │ ├─ Forms/ │ │ ├─ FrmMain.cs 主窗体,菜单 + 页签 │ │ ├─ FrmInStock.cs 入库单录入 │ │ └─ FrmStockQuery.cs 库存查询 │ ├─ App.config 连接字符串在这 │ └─ Program.cs └─ db/ └─ init.sql 建库建表脚本

对应关系上,Model 是纯数据搬运工,只有属性没有方法;DAL 里每个类对应一张表或一组相关表,方法签名基本是GetById、GetList、Insert、Update;BLL 负责把多个 DAL 调用拼成一个业务动作,事务就开在这一层;Win 层只做取值、校验提示、调 BLL、刷界面,不写 SQL。这个分层不新鲜,但真守住「Win 层不出现 SqlConnection」这条线,后面改数据库、换存储过程会轻松很多。

2.3 运行环境与第一次跑起来

运行前先确认三件事:装没装 .NET 运行时、SQL Server 服务起没起、NuGet 包能不能还原。项目目标框架常见是 .NET Framework 4.6 以上,用 .NET 6/8 重建也能编译,改一下 csproj 里的 TargetFramework 就行。数据库用 SQL Server,Express 版足够。

具体的启动顺序:

  1. 打开 SSMS,新建一个空库,比如StockSys,把db/init.sql整个跑一遍,表结构和基础字典数据就都有了。
  2. 打开StockSys.Win/App.config,把连接字符串改成自己的实例名和账号。
  3. 在 Visual Studio 里右键解决方案,「还原 NuGet 程序包」,主要依赖是System.Data.SqlClient(或Microsoft.Data.SqlClient)和NPOI。
  4. 把StockSys.Win设为启动项目,F5。

连接字符串长这样,注意Server=.表示本机默认实例,用命名实例要写Server=.\SQLEXPRESS:

<configuration> <connectionStrings> <!-- name 必须和 SqlHelper 里取的名字一致,改错这里是最常见的启动失败原因 --> <add name="StockDb" connectionString="Server=.;Database=StockSys;User Id=sa;Password=你的密码;Connect Timeout=5" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

第一次登录前得自己往Users表插一条记录。密码别存明文,常见做法是盐 + SHA256,登录时用同样方式算一遍比对:

-- 盐值随便取,但入库和校验必须用同一个 INSERT INTO Users(UserName, PwdHash, Salt, Role, IsEnabled) VALUES('admin', CONVERT(VARCHAR(64), HASHBYTES('SHA2_256', '你的密码' + 'a1b2c3'), 2), 'a1b2c3', 'Admin', 1);

Role字段决定菜单可见性,管理员能开商品档案和用户管理,普通仓管只看到入库、出库、查询三块。IsEnabled是离职停用开关,比直接删账号安全。

注意:连接超时时间设短一点(比如 5 秒),数据库没起的时候界面能快速报错,而不是卡半分钟让人以为程序死了。

3. 核心链路拆解:一张入库单从录入到库存增加的完整过程

这套系统最值得读的代码不是界面,是「入库单保存」这一条链路。它把主表、明细表、库存表三张表串在一个事务里,任何一步失败都整体回滚。搞懂这条链路,出库、调拨、盘点都是同一个套路。

3.1 表结构:主表、明细表、库存表怎么配合

入库单是典型的主从结构。主表存单号、供应商、日期、制单人、总金额;明细表存这一单里每行商品、批次、数量、单价;库存表则是「当前余量」的快照,按商品 + 仓库 + 批次唯一。

-- 入库单主表 CREATE TABLE InStockHead( Id INT IDENTITY(1,1) PRIMARY KEY, BillNo VARCHAR(32) NOT NULL UNIQUE, -- 单号,业务上唯一 SupplierId INT NOT NULL, InDate DATETIME NOT NULL DEFAULT(GETDATE()), TotalAmount DECIMAL(18,2) NOT NULL DEFAULT(0), Status TINYINT NOT NULL DEFAULT(0), -- 0草稿 1已审核 2已作废 CreateBy VARCHAR(32) NOT NULL, CreateTime DATETIME NOT NULL DEFAULT(GETDATE()) ); -- 入库单明细,外键回指主表 CREATE TABLE InStockDetail( Id INT IDENTITY(1,1) PRIMARY KEY, HeadId INT NOT NULL REFERENCES InStockHead(Id), GoodsId INT NOT NULL, BatchNo VARCHAR(32) NULL, -- 批次号,食品/药品场景必填 Qty DECIMAL(18,3) NOT NULL, Price DECIMAL(18,2) NOT NULL, Amount AS (Qty * Price) PERSISTED -- 计算列,避免前后端各算一遍 ); -- 库存台账,商品 + 仓库 + 批次 唯一 CREATE TABLE Stock( Id INT IDENTITY(1,1) PRIMARY KEY, GoodsId INT NOT NULL, WarehouseId INT NOT NULL, BatchNo VARCHAR(32) NOT NULL DEFAULT(''), Qty DECIMAL(18,3) NOT NULL DEFAULT(0), SafeQty DECIMAL(18,3) NOT NULL DEFAULT(0), -- 安全库存阈值 UpdateTime DATETIME NOT NULL DEFAULT(GETDATE()), CONSTRAINT UQ_Stock UNIQUE(GoodsId, WarehouseId, BatchNo) );

Amount用持久化计算列而不是在代码里算,好处是明细表任何一行插入后金额都不会漏算,导出报表时直接SUM(Amount)就行。Stock上的唯一约束是整套系统不出现重复库存行的底线,没有它,并发时很容易插出两条同商品的库存记录。

3.2 数据访问层:一个够用又不出事的 SqlHelper

DAL 层只留一个统一入口,负责开连接、拼参数、管事务。参数化查询必须强制走这条路,否则迟早出现拼接 SQL 被注入翻车的场面。

public static class SqlHelper { // 从 App.config 读,改配置不用重新编译 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["StockDb"].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 && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } // 事务版本,调用方负责 Begin/Commit,这里只管执行 public static int ExecuteNonQuery(SqlTransaction tran, string sql, params SqlParameter[] ps) { using (var cmd = new SqlCommand(sql, tran.Connection, tran)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); return cmd.ExecuteNonQuery(); } } // 查单值,比如取刚插入的自增主键 public static object ExecuteScalar(SqlTransaction tran, string sql, params SqlParameter[] ps) { using (var cmd = new SqlCommand(sql, tran.Connection, tran)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); return cmd.ExecuteScalar(); } } }

关键点在两个重载的差别:不带SqlTransaction的版本自己开连接自己关,适合读操作;带事务的版本复用调用方的连接,绝不自己Open,否则事务会挂在一个连接上、SQL 却在另一个连接上跑,回滚就成了摆设。using保证连接一定释放,这是连接池不被耗干的前提。

参数统一用SqlParameter传,哪怕是个 int 也不拼字符串:

// 正确写法,参数名带 @,类型和库里一致 SqlHelper.ExecuteNonQuery( "UPDATE Goods SET Price=@Price WHERE Id=@Id", new SqlParameter("@Price", SqlDbType.Decimal) { Value = price }, new SqlParameter("@Id", SqlDbType.Int) { Value = goodsId });

SqlDbType显式指定比让驱动猜更稳,尤其是DECIMAL和DATETIME,猜错会报精度截断或者日期越界。

3.3 业务层事务:一张入库单要么全成功要么全回滚

入库保存要落三张表:主表一条、明细表 N 条、库存表 N 条累加。中间任何一步炸了,都不能留下「单子建了但库存没加」的半截数据,否则第二天盘库就是一脑袋问号。

public bool SaveInStock(InStockHead head, List<InStockDetail> details) { using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 写主表,用 OUTPUT 拿回自增主键,省一次查询 string sqlHead = @"INSERT INTO InStockHead(BillNo,SupplierId,InDate,TotalAmount,Status,CreateBy) OUTPUT INSERTED.Id VALUES(@BillNo,@SupplierId,@InDate,@Total,@Status,@CreateBy)"; int headId = Convert.ToInt32(SqlHelper.ExecuteScalar(tran, sqlHead, new SqlParameter("@BillNo", head.BillNo), new SqlParameter("@SupplierId", head.SupplierId), new SqlParameter("@InDate", head.InDate), new SqlParameter("@Total", details.Sum(d => d.Qty * d.Price)), new SqlParameter("@Status", 0), new SqlParameter("@CreateBy", head.CreateBy))); foreach (var d in details) { // 2. 写明细 SqlHelper.ExecuteNonQuery(tran, @"INSERT INTO InStockDetail(HeadId,GoodsId,BatchNo,Qty,Price) VALUES(@HeadId,@GoodsId,@BatchNo,@Qty,@Price)", new SqlParameter("@HeadId", headId), new SqlParameter("@GoodsId", d.GoodsId), new SqlParameter("@BatchNo", (object)d.BatchNo ?? ""), new SqlParameter("@Qty", d.Qty), new SqlParameter("@Price", d.Price)); // 3. 库存累加:先更新,影响行数为 0 才插入 int rows = SqlHelper.ExecuteNonQuery(tran, @"UPDATE Stock SET Qty = Qty + @Qty, UpdateTime = GETDATE() WHERE GoodsId=@GoodsId AND WarehouseId=@WhId AND BatchNo=@BatchNo", new SqlParameter("@Qty", d.Qty), new SqlParameter("@GoodsId", d.GoodsId), new SqlParameter("@WhId", head.WarehouseId), new SqlParameter("@BatchNo", (object)d.BatchNo ?? "")); if (rows == 0) { SqlHelper.ExecuteNonQuery(tran, @"INSERT INTO Stock(GoodsId,WarehouseId,BatchNo,Qty) VALUES(@GoodsId,@WhId,@BatchNo,@Qty)", new SqlParameter("@GoodsId", d.GoodsId), new SqlParameter("@WhId", head.WarehouseId), new SqlParameter("@BatchNo", (object)d.BatchNo ?? ""), new SqlParameter("@Qty", d.Qty)); } } tran.Commit(); return true; } catch (Exception ex) { tran.Rollback(); // 往上抛,界面层统一弹提示,这里不打日志以免重复 throw new Exception("入库保存失败,单据已回滚:" + ex.Message, ex); } } } }

OUTPUT INSERTED.Id是拿自增主键最干净的办法,比SELECT @@IDENTITY安全,触发器场景下不会取错值。库存更新采用「先 UPDATE、看影响行数、为 0 再 INSERT」的写法,配合Stock表上的唯一约束,即使两个客户端同时插同一商品,也只有一个能成功,另一个会撞约束然后整单回滚——这比先查后插少一次往返,也少了查完到插入之间的空窗期。

catch里tran.Rollback()之后必须throw,别吞异常。吞掉的结果是界面显示「保存成功」,实际上什么都没写进去,这种黑匣子问题排查起来最费劲。

3.4 界面层:DataGridView 录入、校验与绑定

界面这块最容易写崩的不是控件本身,是「用户点保存时只填了一半」。校验要放在提交之前,而且要一次性把问题指出来,别让人改一条报一条。

private void btnSave_Click(object sender, EventArgs e) { // 1. 表头校验 if (cmbSupplier.SelectedValue == null) { Tip("请选择供应商"); return; } if (dtpInDate.Value.Date > DateTime.Today) { Tip("入库日期不能晚于今天"); return; } // 2. 明细校验,收集所有错误一次提示 var errors = new List<string>(); var details = new List<InStockDetail>(); foreach (DataGridViewRow row in dgvDetail.Rows) { if (row.IsNewRow) continue; int goodsId; decimal qty; // 单元格可能是 null,也可能填的是非数字,两层都得防 if (!int.TryParse(Convert.ToString(row.Cells["colGoods"].Value), out goodsId)) { errors.Add($"第{row.Index + 1}行:商品未选择"); continue; } if (!decimal.TryParse(Convert.ToString(row.Cells["colQty"].Value), out qty) || qty <= 0) { errors.Add($"第{row.Index + 1}行:数量必须是大于 0 的数字"); continue; } details.Add(new InStockDetail { GoodsId = goodsId, Qty = qty, /* ... */ }); } if (errors.Count > 0) { Tip(string.Join("\n", errors)); return; } if (details.Count == 0) { Tip("至少录入一行明细"); return; } // 3. 提交,异常在业务层抛出,这里统一兜住 try { if (new InStockBll().SaveInStock(BuildHead(), details)) { Tip("保存成功"); dgvDetail.Rows.Clear(); txtBillNo.Text = BillNoGenerator.Next(); // 单号重新生成 } } catch (Exception ex) { Tip(ex.Message); } }

int.TryParse和decimal.TryParse双层防御是必需的,DataGridView 的单元格值在用户编辑未提交时可能是null,直接int.Parse会抛NullReferenceException。错误信息里带行号,仓管一眼就能定位是哪一行填错了,比「数据不合法」这种提示有用得多。保存成功后清空明细而不是刷新整个窗体,能保住表头已填的供应商和日期,连续录单时省事。

4. 查询、预警与导出:把「查得到、看得懂、导得出」补齐

录入是入口,查询和报表才是仓管天天要点的功能。这三件事做好了,系统才算能日常用起来。

4.1 分页查询:别把十万行一次拉进 DataGridView

新手最容易犯的错是SELECT * FROM Stock然后全塞给表格。几千行还行,上十万行界面直接卡住,内存也顶不住。正确做法是服务端分页,用ROW_NUMBER()只取当前页那几十条。

-- @Keyword 为空时查全部,否则按商品名/编码模糊匹配 SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY s.Id DESC) AS rn, s.Id, g.Code, g.Name, s.BatchNo, s.Qty, s.SafeQty, s.UpdateTime FROM Stock s JOIN Goods g ON g.Id = s.GoodsId WHERE (@Keyword = '' OR g.Name LIKE '%' + @Keyword + '%' OR g.Code LIKE '%' + @Keyword + '%') ) t WHERE t.rn BETWEEN @Start AND @End;

@Start和@End由界面算:第 N 页、每页 50 条,就是(N-1)*50 + 1到N*50。总页数单独一条COUNT(*)查,别和分页语句混在一起。

SELECT COUNT(1) FROM Stock s JOIN Goods g ON g.Id = s.GoodsId WHERE (@Keyword = '' OR g.Name LIKE '%' + @Keyword + '%' OR g.Code LIKE '%' + @Keyword + '%');

LIKE '%' + @Keyword + '%'写法允许走参数,但前置通配符会导致索引失效。数据量真上百万了,就得换成全文索引或者Code加前缀匹配。中小仓库这个量级,几十万行以内这么写完全够用,别过度设计。

4.2 库存预警:定时器 + 阈值配置

库存预警的逻辑很简单:某商品的当前数量低于SafeQty,就在主界面弹个提示。阈值放在Stock.SafeQty里,每个商品、每个仓库可以单独设,比全局统一阈值合理。

private System.Windows.Forms.Timer _alarmTimer; private void InitAlarm() { _alarmTimer = new System.Windows.Forms.Timer { Interval = 5 * 60 * 1000 }; // 5 分钟 _alarmTimer.Tick += async (s, e) => await RefreshAlarmAsync(); _alarmTimer.Start(); RefreshAlarmAsync(); // 启动时先查一次 } private async Task RefreshAlarmAsync() { try { // 查询放线程池,避免大数据量时卡住界面 var list = await Task.Run(() => new StockBll().GetBelowSafeList()); dgvAlarm.DataSource = list; lblAlarmCount.Text = $"低于安全库存:{list.Count} 项"; // 只对最紧急的几项标红,数据量大时全标红反而看不出重点 foreach (DataGridViewRow row in dgvAlarm.Rows) { if (Convert.ToDecimal(row.Cells["Qty"].Value) == 0) row.DefaultCellStyle.BackColor = Color.LightCoral; } } catch (Exception ex) { lblAlarmCount.Text = "预警加载失败:" + ex.Message; } }

System.Windows.Forms.Timer在 UI 线程触发,直接在里面查数据库会卡界面,所以查询部分必须Task.Run包一层,await回来再更新控件。async void只用在事件处理器上,别在业务方法里用,异常没人接。

预警查询本身一条 SQL:

SELECT g.Code, g.Name, s.Qty, s.SafeQty FROM Stock s JOIN Goods g ON g.Id = s.GoodsId WHERE s.SafeQty > 0 AND s.Qty <= s.SafeQty ORDER BY (s.Qty - s.SafeQty) ASC; -- 缺口最大的排前面

SafeQty > 0这个条件是过滤掉没设阈值的商品,否则所有零阈值的行都会报出来,预警就废了。

注意:定时器间隔别设太短。5 分钟对仓库业务足够,设成 5 秒会让数据库连接池一直有查询在跑,长期下来影响其他操作。

4.3 Excel 导出:NPOI 免装 Office 的做法

仓管月底要交台账,导出 Excel 几乎是必提需求。别用Microsoft.Office.Interop.Excel,那玩意儿要求机器上装 Office,而且 Excel 进程残留是经典坑。用 NPOI,纯托管库,服务器和客户端都不需要装 Office。

public void ExportStock(string filePath, List<StockView> list) { var wb = new XSSFWorkbook(); // .xlsx 格式 var sheet = wb.CreateSheet("库存台账"); // 表头 var head = sheet.CreateRow(0); string[] titles = { "商品编码", "商品名称", "批次", "数量", "安全库存", "更新时间" }; for (int i = 0; i < titles.Length; i++) { var cell = head.CreateCell(i); cell.SetCellValue(titles[i]); // 表头加粗,导出后一眼能分清 var font = wb.CreateFont(); font.IsBold = true; var style = wb.CreateCellStyle(); style.SetFont(font); cell.CellStyle = style; } // 数据行,从 1 开始 for (int r = 0; r < list.Count; r++) { var row = sheet.CreateRow(r + 1); row.CreateCell(0).SetCellValue(list[r].Code); row.CreateCell(1).SetCellValue(list[r].Name); row.CreateCell(2).SetCellValue(list[r].BatchNo ?? ""); row.CreateCell(3).SetCellValue((double)list[r].Qty); // NPOI 只认 double row.CreateCell(4).SetCellValue((double)list[r].SafeQty); row.CreateCell(5).SetCellValue(list[r].UpdateTime.ToString("yyyy-MM-dd HH:mm")); } sheet.SetColumnWidth(1, 30 * 256); // 宽度单位是 1/256 字符,商品名给宽一点 // 直接写磁盘,写完流必须关,否则文件被占用打不开 using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write)) { wb.Write(fs); } }

数字类型的坑要留意:NPOI 的SetCellValue只接受double,decimal必须显式转,否则编译不过。日期建议先转成格式化字符串再写,避免 Excel 把它识别成错误日期。SetColumnWidth的单位是1/256个字符宽度,乘 256 才是能看懂的字符数。

导出时先让用户选路径,SaveFileDialog拿到路径再调方法。文件被 Excel 打开时写入会抛IOException,界面上提示「文件正在被占用,请关闭后重试」就行,不用做额外处理。

5. 常见问题排查:上线后最容易翻车的五类情况

这套系统在局域网里跑,出问题的场景高度集中。下面这几条是实际遇到最多的,每条按现象、原因、解决写,照着排能省不少时间。

5.1 连不上库、登录进不去这类环境问题

现象一:程序启动就弹「未将对象引用设置到对象的实例」,点确定直接退出。

原因基本是ConfigurationManager.ConnectionStrings["StockDb"]返回了 null,也就是 App.config 里的name和代码里取的不一致,或者配置文件根本没被编译进输出目录。

解决:打开bin\Debug下的StockSys.Win.exe.config,确认里面有StockDb这个节点。没有的话检查 App.config 是否被误删,或者项目的「生成操作」被改成了None。取连接串时加个判空,报错信息里直接写「找不到连接字符串 StockDb」,比空引用好排查十倍。

现象二:提示「用户 'sa' 登录失败」,账号密码明明是对的。

原因是 SQL Server 默认只开了 Windows 身份验证,sa账号被禁用。

解决:SSMS 里右键实例属性 → 安全性 → 改成「SQL Server 和 Windows 身份验证模式」,然后到「安全性 → 登录名 → sa」上右键属性,把「强制实施密码策略」和对钩解除、账号启用,最后重启 SQL 服务。这一步不改,连接字符串写对也白搭。

5.2 库存对不上:并发与重复提交

现象三:两个仓管同时给同一个商品入库,最终库存少了其中一单的量。

原因是两个人几乎同时点保存,A 查到库存是 100,B 也查到 100,A 写回 150,B 写回 130,B 覆盖了 A 的结果。

解决:别在代码里「先查再算再写」,把累加交给数据库。UPDATE Stock SET Qty = Qty + @Qty WHERE ...这种写法本身是原子的,SQL Server 会在行上加锁。这就是 3.3 里不用「读出 Qty 再赋值」的原因。另外Stock表的唯一约束必须留着,它是并发场景下最后一道闸。

现象四:网络卡了一下,用户以为没点中,连点三次保存,结果库存加了三次。

原因是按钮没有防重复提交,每次点击都新开事务。

解决:点击后立刻btnSave.Enabled = false,在finally里恢复。同时在InStockHead.BillNo上加唯一约束,配合单号生成规则(日期 + 流水号),重复提交会撞唯一键导致整单回滚,不会产生重复数据。

private void btnSave_Click(object sender, EventArgs e) { btnSave.Enabled = false; try { DoSave(); } catch (Exception ex) { Tip(ex.Message); } finally { btnSave.Enabled = true; } }

现象五:出库时能出成负库存。

原因是出库的UPDATE只做了减法,没判断剩余量是否足够。

解决:把条件写进WHERE,让数据库替你判断:

UPDATE Stock SET Qty = Qty - @Qty WHERE GoodsId=@GoodsId AND WarehouseId=@WhId AND BatchNo=@BatchNo AND Qty >= @Qty;

影响行数为 0 就说明库存不足,业务层直接抛异常、整单回滚,界面提示「库存不足」。这种「条件更新 + 判断影响行数」的写法,比先SELECT判断再UPDATE少一次往返,也没有并发窗口。

5.3 界面卡死与数据绑定问题

现象六:查库存的时候界面白屏几秒,鼠标点不动。

原因是查询在 UI 线程同步执行,数据量大或者数据库响应慢就直接假死。

解决:查询方法改成async,用Task.Run包住 DAL 调用,await拿到结果再赋值给DataSource。如果项目还是 .NET Framework 4.x,记得把界面方法写成async void(只限事件处理器),内部await才能在查询期间让界面保持响应。

现象七:DataGridView绑定后,用户改了单元格,数据库却没变。

原因是把List<T>直接赋给DataSource,这种绑定是只读的,改动只留在内存里。

解决:需要双向编辑就换BindingList<T>或者DataTable;如果只是展示,就把ReadOnly = true,把编辑入口统一收到「编辑」按钮弹窗里。两种方式都行,最怕的是一半能改一半不能改,用户以为改完了其实没存。

6. 二次开发:把单仓库版本改成多门店多货位,改哪几处就够

单仓版本跑顺了,最常见的需求就是「我们开了第二家店,能不能一起管」。改动看着大,其实集中在三处:表结构加维度、查询加过滤、界面加切换。

数据库层面,Stock表已经有WarehouseId,如果当初没偷懒把它硬编码成 1,那基本不用动结构,只要在Warehouse表里加几条门店记录,再给Users表加一个DefaultWarehouseId表示这个人默认看哪个店的库存。真正要改的是所有查询的过滤条件:

改动点单仓版本多门店版本
库存唯一约束GoodsId + BatchNoGoodsId + WarehouseId + BatchNo
查询条件无仓库过滤加WarehouseId = @WhId
界面无仓库下拉框顶部加仓库切换,切换即刷新
权限管理员 / 普通管理员可跨店,普通只能看本店

如果当初偷懒把WarehouseId写死了,先跑一遍ALTER TABLE Stock ADD WarehouseId INT NOT NULL DEFAULT(1),把历史数据全归到默认仓,再补上唯一约束,改动量也不算大。

界面上的仓库切换不要做成「每打开一个窗体都重新选」,把它放在主窗体的状态栏,用单例或者静态属性存当前选中仓,各业务窗体读取时拿这个值:

// 当前登录上下文,全局唯一,切换仓库时更新它 public static class AppContext { public static int CurrentWarehouseId { get; set; } public static string CurrentUser { get; set; } public static string Role { get; set; } // 每次查询前调一下,避免业务层到处传 warehouseId 参数 public static int RequireWarehouse() { if (CurrentWarehouseId <= 0) throw new Exception("请先在顶部选择仓库"); return CurrentWarehouseId; } }

这个写法省事,但要注意切换仓库时把已经打开的业务窗体数据刷新一遍,否则用户看着旧店的库存去下单,就是实打实的错账。我的做法是在主窗体切换事件里遍历MdiChildren,对实现了IRefreshable接口的子窗体统一调RefreshData()。

改动顺序建议这样走:先在测试库上把唯一约束和WarehouseId补齐,跑一遍历史数据的库存汇总,和改之前对得上再动代码;然后改 DAL 层的查询加过滤条件,这一步可以用单元测试或者几条固定 SQL 验证;最后才动界面。顺序反了,出现了对不上账的情况,你根本分不清是数据结构的问题还是界面传参的问题。

提示:多门店上线前,务必用「A 店入库、B 店查询」的方式验证隔离性。我见过只改了查询语句但漏了某张报表的,结果 A 店能看到 B 店的成本价,这种问题上线后才被发现,处理起来很被动。

从那以后我每次改库存相关的表结构,都强制先在备份库上把全量数据跑一遍汇总对账,确认改前改后总量一致才敢往生产推。这套笨办法救过我两次,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询