简介:这是一套基于C#的仓库管理系统完整工程,覆盖进货、销售、库存等核心业务模块,采用人机交互界面,并对输入数据做严格校验,适合高校学生用于课程设计或毕业设计参考,也适合初学C#与WinForm的开发者模仿练习。压缩包共包含115个文件,以.cs源码、.resx/.resources资源文件与.ico图标为主,辅以.exe可执行程序、.png/.jpg图片、.txt说明及数据库文件(.mdf/.ldf),合计4.34MB,结构清晰,解压后可在Visual Studio中直接打开查看。系统内置了较为完善的数据库与界面层代码,并提供详细的项目配置说明,帮助使用者快速搭建运行环境、理解业务逻辑。资源发布以来已有1467人学习下载,对于需要快速获取可用项目模板的读者来说,是一份实用的参考材料。
1. 从 frmMain 到 frmKcGoods:C# 仓库管理的五条业务主线
拿到这份 C# 仓库管理系统源码时,我先扫了一遍工程文件里的窗体列表。frmJhGoodsInfo、frmSellGoods、frmThGoodsInfo、frmKcGoods、frmCompanyInfo、frmEmpInfo,一整套进货、销售、退货、库存、往来单位、员工管理模块都在,不是只有一个空壳主界面。很多课程设计项目表面上功能齐全,实际把进货单和销售单写成两个互不相干的 DataGridView,库存数完全靠手工维护——这类项目答辩时一问就穿帮。这个包的价值在于它把五条业务主线都串进了 WinForms 窗体里,拿它做课程设计或毕业设计参考,真正要学的不是拖动控件,而是「退货怎么做才能把库存冲减回去」这类状态流转问题。如果你正卡在库存扣减逻辑、多条件查询拼接和事务回滚上,下面这几章可以直接对应到源码里的具体窗体去改。
2. DataSet 与 SqlConnection:数据访问层先立住,再谈业务
2.1 连接字符串的放法,决定了验收时会不会被追问
多数课程设计源码里,连接字符串直接硬编码在每个窗体的 Load 事件里,frmJhGoodsInfo 里写一份,frmSellGoods 里再复制一份。一旦数据库路径变了,要全局替换七八处。这个包的工程结构里有 Chexc.csproj 和 GenerateResource.Cache,遵循的是 VS 老式 WinForms 工程的写法。你拿到手后,第一件事不是打开 frmMain 看界面,而是把所有 SqlConnection 的创建收拢到一个公共类里。最常见的做法是新建一个 DbHelper 静态类,把连接串统一放到 App.config 的 connectionStrings 节点,再用 ConfigurationManager 读取。
using System.Configuration; using System.Data; using System.Data.SqlClient; public static class DbHelper { // 从 App.config 读取连接串,避免散落在各个窗体里 private static readonly string connStr = ConfigurationManager.ConnectionStrings["WarehouseDB"].ConnectionString; public static DataTable ExecuteDataTable(string sql, params SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); SqlDataAdapter adapter = new SqlDataAdapter(cmd); DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } }这段代码的逻辑重点在params SqlParameter[] parameters。所有外部输入都走参数对象,而不是字符串拼接,这是避免 SQL 注入的第一道闸门。using块保证连接和命令对象在方法结束后自动释放,课程设计阶段看不出差别,但连续开单 50 次不释放连接,就会报「连接池已满」的错误。ExecuteDataTable走的是 DataAdapter 的 Fill,适合查询;ExecuteNonQuery走的是直连执行,适合新增、修改、删除。
如果你改成了 Access 数据库,就把 SqlConnection 换成 OleDbConnection,SqlParameter 换成 OleDbParameter,SQL 语句里的参数占位符要从@name改成?,这是两种数据库最明显的差异。这个包的使用说明里如果注明是 SQL Server 数据库,就保持 SqlClient 不动。
2.2 DataSet 用得好不好,看库存查询响应
源码里的 frmKcGoods 是库存窗体,对应的 Designer.cs 里应该有 DataGridView 控件。课程设计里最常见的库存查询写法是:点一次按钮查一次库,再点一次再查。问题在于,库存查询往往是用户反复操作的入口,每次都新建 SqlConnection、Fill DataSet、再绑定 DataGridView,查询慢的原因多半不是 SQL 语句差,而是连接的打开和释放开销占了大头。我一般会把 DataSet 缓存下来,配合 BindingSource 做筛选,而不是每次都回库查。
这里有一个容易被忽略的细节:DataGridView 绑定DataTable.DefaultView后,直接改RowFilter就能在内存里过滤,不需要重新查库。比如按商品名称模糊过滤怎么写:
private void txtKeyword_TextChanged(object sender, EventArgs e) { DataView view = inventoryTable.DefaultView; if (string.IsNullOrWhiteSpace(txtKeyword.Text)) { view.RowFilter = string.Empty; } else { // RowFilter 的 LIKE 语法和 SQL 的 LIKE 略有差异,通配符仍是 % view.RowFilter = string.Format("GoodsName LIKE '%{0}%'", txtKeyword.Text.Replace("'", "''")); } gridInventory.DataSource = view; }这段代码的Replace("'", "''")是为了防止用户输入单引号时破坏 RowFilter 的表达式。注意 RowFilter 不是参数化接口,它本质是表达式字符串,所以必须手动转义。这种写法比每次敲一个字符就查一次数据库要顺滑得多,也正好回答了「C# 显示查找一条记录字段数据」这类搜索需求——先查一次,之后在内存里反复筛。
关于数据库增删改查,这个包的五个业务窗体都离不开四个基本动作:进货窗体是 Insert,销售窗体是 Insert + Update,退货窗体是 Insert + Update + 反向冲减。在 frmEmpInfo 员工管理里把删改权限分开处理就好,但进货和销售单据一旦录入,通常不建议提供物理删除功能,而是加一个「作废」标记,这一点第 3 章继续展开。
3. 进货、销售、退货:用 SqlTransaction 把库存扣减做成闭环
3.1 为什么库存表不能直接「减一」
很多新手写销售功能时,逻辑是「更新库存表的数量字段」。但如果你打开了 frmSellGoods 的源码,会发现销售单录完还要往商品明细表里插一条,再更新库存。这里有个关键的选型问题:库存数到底是存出来的,还是算出来的。课程设计里往往两者都有——有一个库存表存当前数量,但每次销售时都依赖代码里先查再减的顺序。如果两个用户同时卖同一个商品,A 查到库存 5,B 也查到库存 5,A 减成 4,B 也减成 4,实际剩余应该是 3,但数据库里成了 4,这就是丢失更新。
正确的做法是把「扣减库存」和「写入销售明细」放在同一个事务里。库存表是结果,不是源头,源头是流水表。仓库管理系统的核心在这一章:进货增加可用库存,销售减少可用库存,退货则把减少的那部分加回来。frmThGoodsInfo 退货窗体的逻辑本质上和销售相反,但它处理的是已售商品的退回,还涉及退货数量不能大于原销售数量,这个校验必须在事务里一起做。
3.2 一个能跑的销售事务模板
下面是一段可以直接替换到 frmSellGoods 里的核心事务代码。它完成了三件事:检查库存是否充足、写销售明细、扣减库存。任何一步失败全部回滚。
public bool CreateSaleOrder(string orderNo, int goodsId, int quantity) { string checkStockSql = "SELECT StockQty FROM KcGoods WHERE GoodsId = @goodsId"; string insertSaleSql = @" INSERT INTO SellGoods (OrderNo, GoodsId, Quantity, SaleTime) VALUES (@orderNo, @goodsId, @quantity, GETDATE())"; string updateStockSql = @" UPDATE KcGoods SET StockQty = StockQty - @quantity WHERE GoodsId = @goodsId AND StockQty >= @quantity"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnString)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 第一步:查当前库存 using (SqlCommand checkCmd = new SqlCommand(checkStockSql, conn, tran)) { checkCmd.Parameters.AddWithValue("@goodsId", goodsId); int currentQty = (int)checkCmd.ExecuteScalar(); if (currentQty < quantity) return false; // 库存不足,先回滚再返回 } // 第二步:写销售主表和明细表 using (SqlCommand insertCmd = new SqlCommand(insertSaleSql, conn, tran)) { insertCmd.Parameters.AddWithValue("@orderNo", orderNo); insertCmd.Parameters.AddWithValue("@goodsId", goodsId); insertCmd.Parameters.AddWithValue("@quantity", quantity); insertCmd.ExecuteNonQuery(); } // 第三步:扣库存,条件里带 StockQty >= @quantity 做二次保护 using (SqlCommand updateCmd = new SqlCommand(updateStockSql, conn, tran)) { updateCmd.Parameters.AddWithValue("@goodsId", goodsId); updateCmd.Parameters.AddWithValue("@quantity", quantity); int rows = updateCmd.ExecuteNonQuery(); if (rows == 0) throw new Exception("库存扣减失败,可能库存不足"); } tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }这段代码的细节值得逐一拆开讲。库存不足时的 return false 发生在事务提交之前,using块结束时事务默认回滚,不会留下脏数据。第二步和第三步在同一个事务里,明细写成功了但库存扣减因为条件不满足而影响行数为 0 时,抛异常进入回滚,前一步的插入也被撤销。这比先判断再更新安全得多,因为第二步和第三步之间没有其他线程能插进来,事务的隔离性保证了这一点。
3.3 回滚场景对照表
理解事务最直接的方式是看失败后会怎样。把常见的异常场景和回滚行为列成表格,验收时被问到「库存对不上怎么办」可以直接拿出来讲。
| 异常场景 | 表现 | 事务回滚后的状态 |
|---|---|---|
| 库存数量小于销售量 | 检查步骤返回 false | 未开启提交,连接释放时回滚,数据库不变 |
| 销售明细字段超长 | 插入语句抛 SqlException | 明细未写入,库存未扣减 |
| 库存扣减影响行数为 0 | 自定义异常抛出 | 明细回滚,库存不变 |
| 数据库连接中断 | 命令执行抛异常 | 整个事务回滚,数据保持一致 |
这里要特别说明的是第三行。UPDATE KcGoods SET StockQty = StockQty - @quantity WHERE GoodsId = @goodsId AND StockQty >= @quantity中,如果库存不够,这一行受影响的行数是 0,但不会报错。如果不检查这个影响行数,事务照样提交,库存变成负数,这就是很多课程设计里库存对不上账的根源。退货窗体的逻辑就是把这个流程反过来:写退货单,然后把 StockQty 加回去。源码 frmThGoodsInfo 里的思路基本一致,只是把减法换加法,再把检查条件从「库存充足」改成「该商品的售出数量足够」。
4. 参数化查询与模糊检索:frmKcGoods 和 frmCompanyInfo 的查询通用写法
4.1 多条件查询,拼 SQL 还是拼条件?
仓库管理系统的查询模块最容易写出两个极端:一个是所有查询共用一句写死的 SQL,用户想按商品名称查都做不到;另一个是把 SQL 字符串当拼图,用户输入什么就直接拼什么,导致单引号报错或者注入口子。frmKcGoods 库存查询和 frmCompanyInfo 往来单位查询里,最合适的折中是「动态拼接 WHERE 子句 + 参数化传值」。关键点不在拼不拼,在于拼的只是条件和参数名,用户输入的值永远只走参数。
4.2 一个参数化的组合查询方法
以库存查询为例,支持按商品编号、商品名称模糊、库存下限三个条件同时筛选。用户可以不填任何条件,直接列出全部;也可以只填名称。
public DataTable QueryStock(string goodsId, string goodsName, int minQty) { string sql = @" SELECT GoodsId, GoodsName, Spec, StockQty, UnitPrice, LastInTime FROM KcGoods WHERE 1 = 1"; List<SqlParameter> paramList = new List<SqlParameter>(); if (!string.IsNullOrWhiteSpace(goodsId)) { sql += " AND GoodsId = @goodsId"; paramList.Add(new SqlParameter("@goodsId", goodsId)); } if (!string.IsNullOrWhiteSpace(goodsName)) { sql += " AND GoodsName LIKE @goodsName"; paramList.Add(new SqlParameter("@goodsName", "%" + goodsName + "%")); } if (minQty > 0) { sql += " AND StockQty >= @minQty"; paramList.Add(new SqlParameter("@minQty", minQty)); } return DbHelper.ExecuteDataTable(sql, paramList.ToArray()); }这里WHERE 1 = 1不是多余的摆设,它让后续每个AND子句都不需要判断「当前是不是第一个条件」,代码会清爽很多。LIKE 参数的值在 C# 端把%拼进去,而不是在 SQL 语句里写'%' + @goodsName + '%',两者效果一样,但后者如果遇到用户输入里自带%或_会变得不可控。按编号查询用等号,按名称查询用 LIKE,这两个场景都是课程设计里出现频率最高的。
4.3 从 DataRow 里取字段,避免三番五次写 Convert
查询结果绑定进 DataGridView 只是第一步,很多时候还要把选中行的数据回填到文本框里做修改。这时会频繁写Convert.ToInt32(row["StockQty"]),字段一多就很啰嗦。一个更省事的做法是在 DbHelper 里加两个泛型方法,专门处理 DBNull 和类型转换。
public static T GetFieldValue<T>(DataRow row, string columnName) { if (row == null || !row.Table.Columns.Contains(columnName)) return default(T); object value = row[columnName]; if (value == DBNull.Value || value == null) return default(T); // 处理可空类型和普通类型,比如 decimal? 和 decimal Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); return (T)Convert.ChangeType(value, targetType); }这个方法的典型用法是decimal price = DbHelper.GetFieldValue<decimal>(row, "UnitPrice"),它把「判断 DBNull、判空、Convert.ChangeType」三步合一。frmJhGoodsInfo 进货窗体的单价、数量回填,frmEmpInfo 员工信息修改,都可以直接复用。注意Convert.ChangeType对日期类型也能正确处理,但如果数据库里存的是字符串且格式不规范,可以先ToString()再转。
4.4 输入校验:在 UI 层挡掉九成错误
摘要里提到「对用户输入的数据进行严格的数据检验」,这在源码里通常对应窗体的 Validating 事件或者按钮点击时的统一校验。数量、单价这类字段必须用decimal.TryParse而不是(decimal)txt.Text,否则用户输入空字符串直接抛 FormatException。比较实用的规则如下。
| 控件/字段 | 校验规则 | 提示信息 |
|---|---|---|
| 数量 | 整数且大于 0 | 「数量必须为正整数」 |
| 单价 | decimal 且大于等于 0 | 「单价格式不正确」 |
| 日期范围 | 开始日期小于结束日期 | 「开始日期不能晚于结束日期」 |
| 商品名称 | 非空,长度不超过 50 | 「商品名称不能为空」 |
| 手机号 | 非必填,填了要符合格式 | 「手机号格式有误」 |
校验通过后再执行数据库操作,这是 frmSellGoods 和 frmKcGoods 里最常见的代码路径。把校验函数拆成独立的ValidateInput()方法,返回值带错误信息字符串,比散落在按钮事件里的if-else更容易维护。
5. 批量提交与库存快照:把课程设计做成能直接验收的工程
课程设计交完了,如果你还想把它放到简历项目里,或者毕业设计答辩时被追问「数据多了怎么办」,这一章的技巧能拉开档次。核心是两件事:批量写入时别一条条 ExecuteNonQuery,对账时要有库存快照。
frmJhGoodsInfo 进货窗体经常是一次录入多行商品,最常见的学生写法是循环里调 ExecuteNonQuery,每行一次数据库往返。100 行商品就要 100 次连接往返,性能瓶颈全在 I/O 上。更好的做法是用 DataTable + DataAdapter.Update 批处理。先构造一个 DataTable,结构跟数据库表保持一致,把界面上的行都放进去,然后一次性提交。
DataTable dt = new DataTable(); dt.Columns.Add("OrderNo", typeof(string)); dt.Columns.Add("GoodsId", typeof(int)); dt.Columns.Add("Quantity", typeof(int)); dt.Columns.Add("UnitPrice", typeof(decimal)); foreach (DataGridViewRow row in gridPurchase.Rows) { if (row.IsNewRow) continue; dt.Rows.Add( orderNo, Convert.ToInt32(row.Cells["GoodsId"].Value), Convert.ToInt32(row.Cells["Quantity"].Value), Convert.ToDecimal(row.Cells["UnitPrice"].Value)); } using (SqlConnection conn = new SqlConnection(DbHelper.ConnString)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { using (SqlCommand cmd = new SqlCommand( @"INSERT INTO JhGoodsInfo (OrderNo, GoodsId, Quantity, UnitPrice) VALUES (@OrderNo, @GoodsId, @Quantity, @UnitPrice)", conn, tran)) { cmd.Parameters.Add("@OrderNo", SqlDbType.NVarChar, 30, "OrderNo"); cmd.Parameters.Add("@GoodsId", SqlDbType.Int, 0, "GoodsId"); cmd.Parameters.Add("@Quantity", SqlDbType.Int, 0, "Quantity"); cmd.Parameters.Add("@UnitPrice", SqlDbType.Decimal, 0, "UnitPrice"); SqlDataAdapter adapter = new SqlDataAdapter(); adapter.InsertCommand = cmd; adapter.Update(dt); } tran.Commit(); } }这个写法的核心是给 SqlDataAdapter 的 InsertCommand 绑定参数,参数里的第四个参数传的是 DataTable 的列名。adapter.Update(dt)会遍历 DataTable 的每一行,自动把当前行的列值填进参数然后执行,所以执行了 100 次 Insert 命令,但代码只写了一行。从用户界面上看,整批数据要么全部成功,要么全部回滚,比攒一长串 SQL 拼接再一次性执行的方案性能更好,也更不容易被特殊字符打崩。这个模式同样适用于 frmSellGoods 销售明细的批量导入。
第二个技巧是库存快照。课程设计的库存报表往往只显示当前库存,但入库、出库记录一变多,历史某个时点的库存是多少就没法查了。最简单的做法是加一张库存快照表,每天或者每次关账时把当时的商品库存、库存金额记下来。
INSERT INTO KcSnapshot (GoodsId, StockQty, SnapshotTime) SELECT GoodsId, StockQty, GETDATE() FROM KcGoods这行 SQL 不复杂,但它让系统能够回答「上个月月底库存是多少」。对仓库管理系统来说,这个能力是「能跑」和「能用」的分水岭。frmKcGoods 里的库存查询结果可以和快照表做差异对比,找出哪些商品在某个周期内发生过变动,项目说明里写一句「系统支持历史库存对账」,含金量比单纯展示当前库存高得多。
最后提醒一个调试习惯:把数据库操作里实际执行的 SQL 和参数值输出到日志文件,用cmd.CommandText和cmd.Parameters拼接一条带实际值的文本。课程设计阶段的错误多半是 SQL 语法和参数类型不匹配,日志比断点更快地告诉你问题在哪里。
本文还有配套的精品资源,点击获取