简介:这是一份基于C#语言与C/S架构实现的库存管理系统源码项目,面向正在学习C#桌面开发、希望积累完整项目经验的初学者与进阶开发者。系统围绕商品入库、出库、库存盘点与预警等核心业务展开,客户端负责界面交互,服务端处理数据与业务逻辑,可帮助读者理解数据库设计与WinForm界面开发的完整流程。压缩包共309个文件,约4.74MB,包含62个cs源代码文件、25个resx资源文件、10个dll类库、3个exe可执行程序及数据库文件等,另附数据库表注释文档与Visual Studio解决方案文件,便于直接打开运行与二次开发。目前已有350人学习下载。项目结构完整、注释清晰,读者可从中掌握库存管理系统的数据库建模、数据操作与界面搭建思路,适合作为课程设计或自学练手参考。
1. 从一张Excel表到一套C/S库存系统:这个项目能帮你省下多少加班
如果你在中小制造、贸易或零售公司待过,大概率见过这样的场景:仓库用一张共享Excel表记出入库,采购、销售、仓管三个人同时打开,改完保存互相覆盖,月底盘点永远对不上账。这个用C#写的库存管理系统,就是冲着这个场景去的——它把商品档案、入库、出库、库存查询、预警和报表拆成独立的C/S客户端,数据统一落到数据库,谁改了哪条记录都有据可查。技术栈上走的是经典WinForms + ADO.NET + SQL Server组合,没有花哨的前端框架,装完就能在内网跑,适合两类人:一是想拿一个完整业务系统练手C#桌面开发的初学者,二是需要一套能直接改字段、加模块的中小团队内部工具。它不追求高并发,追求的是“看得懂、跑得起来、改得动”。
2. 技术选型与整体架构:为什么是WinForms而不是Web
2.1 三层结构在桌面端的落地方式
这个项目没有用MVC或MVVM那套重框架,而是老老实实按表现层、业务逻辑层、数据访问层来切。表现层是各个Form窗体,业务逻辑层放在BLL文件夹里,数据访问层在DAL文件夹,实体类单独放Model。这种分层在WinForms项目里属于“够用且不绕”的做法,新手打开解决方案就能顺着文件夹找到对应代码,不会一上来就被依赖注入和接口抽象劝退。
具体调用链是这样的:用户在FrmProduct里点“新增”,窗体收集TextBox的值,组装成Product实体,调用ProductBLL.Add(product);BLL做非空校验和编码唯一性检查,再转给ProductDAL.Insert(product);DAL拼SQL参数,走SqlHelper执行。每一层职责清晰,改库存扣减逻辑只需要动BLL,不用碰界面。
// BLL/ProductBLL.cs public class ProductBLL { private ProductDAL dal = new ProductDAL(); public bool Add(Product product) { // 业务规则:商品编码不能为空且不能重复 if (string.IsNullOrEmpty(product.ProductCode)) throw new Exception("商品编码不能为空"); if (dal.ExistsCode(product.ProductCode)) throw new Exception("商品编码已存在"); // 初始库存为0,后续通过入库单累加 product.StockQty = 0; product.CreateTime = DateTime.Now; return dal.Insert(product) > 0; } }这段代码里ExistsCode是DAL层的方法,用SELECT COUNT(1)判断编码是否重复。把校验放在BLL而不是窗体里,是为了以后如果加一个批量导入功能,能复用同一套规则。参数product.StockQty强制置零,是因为库存必须由入库单驱动,不允许建档时手工填——这是库存系统最容易翻车的地方,很多新手版本就是这里没卡住,导致账实不符。
2.2 数据库表设计的几个关键字段
项目默认用SQL Server,核心表就五张:Product(商品档案)、StockIn(入库主表)、StockInDetail(入库明细)、StockOut(出库主表)、StockOutDetail(出库明细)。主表和明细拆开,是因为一张入库单可能包含多个商品,拆开后库存计算逻辑更干净。
Product表里除了常规的编码、名称、规格、单位,有两个字段值得注意:StockQty(当前库存)和WarnQty(预警下限)。StockQty不是让用户直接改的,而是每次入库审核通过后UPDATE Product SET StockQty = StockQty + @qty,出库则减。WarnQty用于库存查询时标红,低于这个值就提示补货。
-- 入库审核时更新库存的存储过程核心逻辑 UPDATE Product SET StockQty = StockQty + (SELECT SUM(Qty) FROM StockInDetail WHERE InId = @InId AND ProductId = Product.Id) WHERE Id IN (SELECT ProductId FROM StockInDetail WHERE InId = @InId);用一条SQL批量更新而不是在C#里循环,是为了避免多次往返数据库。注意这里没有用触发器,而是放在业务层显式调用,原因是触发器调试困难,出问题不好排查,放在代码里至少能打断点。
2.3 为什么C/S在这个场景下比B/S更合适
有人会问,现在都2025年了,为什么不用Web?答案在部署环境。很多中小仓库的网络条件一般,只有内网,没有公网IP,装IIS配域名对非技术人员是负担。C/S客户端只要共享一个数据库连接字符串,双击exe就能用,打印入库单直接调PrintDocument,不需要考虑浏览器兼容和打印样式。代价是每台机器都要装.NET Framework,但项目用的是.NET Framework 4.7.2,Win10以上系统自带,实际部署时基本零配置。
3. 核心模块拆解:入库、出库与库存计算
3.1 入库单的录入与审核流程
入库模块分两步:录入和审核。录入时只写StockIn和StockInDetail,状态字段Status设为0(待审核),此时不影响库存。审核通过后状态改为1,同时触发库存累加。这样设计是为了防止录错单直接污染库存,给仓管一个反悔的机会。
// BLL/StockInBLL.cs public bool Audit(int inId) { // 开启事务,保证主表状态和库存更新要么都成功要么都回滚 using (SqlConnection conn = SqlHelper.GetConnection()) { conn.Open(); SqlTransaction tran = conn.BeginTransaction(); try { // 1. 检查单据状态,防止重复审核 string checkSql = "SELECT Status FROM StockIn WHERE Id=@Id"; SqlCommand checkCmd = new SqlCommand(checkSql, conn, tran); checkCmd.Parameters.AddWithValue("@Id", inId); int status = Convert.ToInt32(checkCmd.ExecuteScalar()); if (status != 0) throw new Exception("该单据已审核或已作废"); // 2. 更新主表状态 string updateSql = "UPDATE StockIn SET Status=1, AuditTime=GETDATE() WHERE Id=@Id"; SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue("@Id", inId); updateCmd.ExecuteNonQuery(); // 3. 累加库存 string stockSql = @"UPDATE Product SET StockQty = StockQty + (SELECT Qty FROM StockInDetail WHERE InId=@InId AND ProductId=Product.Id) WHERE Id IN (SELECT ProductId FROM StockInDetail WHERE InId=@InId)"; SqlCommand stockCmd = new SqlCommand(stockSql, conn, tran); stockCmd.Parameters.AddWithValue("@InId", inId); stockCmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } }事务是这段代码的核心。库存系统的血泪经验就是:任何影响库存的操作必须包在事务里,否则一旦中间步骤失败,就会出现“单据审核了但库存没加”的玄学问题。checkCmd先查状态再更新,是为了防止两个仓管同时点审核导致重复累加。参数inId是入库单主键,从界面的DataGridView选中行获取。
3.2 出库时的库存校验与负库存拦截
出库比入库多一层校验:出库数量不能超过当前库存。这个校验必须在BLL层做,而且要在事务内做,否则并发出库时可能两个请求都读到足够的库存,扣减后变成负数。
// BLL/StockOutBLL.cs public bool Audit(int outId) { using (SqlConnection conn = SqlHelper.GetConnection()) { conn.Open(); SqlTransaction tran = conn.BeginTransaction(); try { // 逐条检查明细库存是否充足 string checkSql = @"SELECT d.ProductId, d.Qty, p.StockQty, p.ProductName FROM StockOutDetail d INNER JOIN Product p ON d.ProductId = p.Id WHERE d.OutId = @OutId"; SqlCommand checkCmd = new SqlCommand(checkSql, conn, tran); checkCmd.Parameters.AddWithValue("@OutId", outId); SqlDataReader reader = checkCmd.ExecuteReader(); while (reader.Read()) { int qty = Convert.ToInt32(reader["Qty"]); int stock = Convert.ToInt32(reader["StockQty"]); if (qty > stock) { string name = reader["ProductName"].ToString(); reader.Close(); throw new Exception($"商品【{name}】库存不足,当前库存{stock},出库{qty}"); } } reader.Close(); // 扣减库存并更新单据状态 string updateSql = @"UPDATE Product SET StockQty = StockQty - (SELECT Qty FROM StockOutDetail WHERE OutId=@OutId AND ProductId=Product.Id) WHERE Id IN (SELECT ProductId FROM StockOutDetail WHERE OutId=@OutId); UPDATE StockOut SET Status=1, AuditTime=GETDATE() WHERE Id=@OutId;"; SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue("@OutId", outId); updateCmd.ExecuteNonQuery(); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } }注意checkSql用了INNER JOIN一次性把明细和当前库存查出来,而不是在循环里逐条查数据库。这是性能上的常见优化,一张出库单几十条明细,逐条查就是几十次往返,内网再快也扛不住频繁操作。reader.Close()在抛异常前必须调用,否则连接会被占用,后续操作直接卡死——这个坑我见过不止一次。
3.3 库存查询与预警的实时计算
库存查询界面支持按商品编码、名称、分类过滤,并且把低于预警线的商品标红。这里没有用缓存,每次查询都实时读Product表,因为库存变动频繁,缓存反而容易读到脏数据。
// DAL/ProductDAL.cs public DataTable GetStockList(string keyword) { string sql = @"SELECT ProductCode, ProductName, Spec, Unit, StockQty, WarnQty, CASE WHEN StockQty <= WarnQty THEN '预警' ELSE '正常' END AS StockStatus FROM Product WHERE 1=1"; if (!string.IsNullOrEmpty(keyword)) { sql += " AND (ProductCode LIKE @kw OR ProductName LIKE @kw)"; } sql += " ORDER BY StockQty ASC"; SqlParameter[] paras = { new SqlParameter("@kw", "%" + keyword + "%") }; return SqlHelper.ExecuteDataTable(sql, paras); }CASE WHEN直接在SQL里算预警状态,比在C#里循环判断再改DataGridView单元格颜色更省事。ORDER BY StockQty ASC让库存最少的排最前面,仓管打开就能看到该补什么货。参数keyword做了模糊匹配,注意这里用了参数化查询而不是字符串拼接,防注入是基本要求。
4. 避坑与常见问题:部署和运行时的真实故障
4.1 连接字符串写错导致“登录失败”
现象:客户端启动后点任何按钮都弹“用户‘sa’登录失败”。原因通常是App.config里的连接字符串还留着模板值,或者SQL Server没开混合验证模式。解决:打开App.config,确认connectionString里的服务器地址、数据库名、账号密码与实际一致;如果用的是Windows验证,把Integrated Security=True加上,去掉User ID和Password。注意SQL Server默认只允许Windows验证,需要在SSMS里右键服务器属性→安全性→改成“SQL Server和Windows身份验证模式”,然后重启服务。
4.2 数据库脚本没执行导致“对象名无效”
现象:程序能启动,但一点查询就报“对象名‘Product’无效”。原因是项目附带的.sql建表脚本没有在目标数据库执行。解决:用SSMS打开脚本,先确认当前数据库选的是你配置的那个,再执行。脚本里包含建库、建表、插入初始管理员账号,顺序不能乱。如果已经建过表,重复执行会报“已存在”,可以先删库再跑,或者手动改脚本加IF NOT EXISTS判断。
4.3 并发审核导致库存重复累加
现象:两个仓管同时审核同一张入库单,库存加了两次。原因:审核逻辑里先查状态再更新,但两个请求可能同时查到状态为0。解决:在UPDATE StockIn SET Status=1后面加AND Status=0,然后判断ExecuteNonQuery返回的影响行数,如果为0说明已被别人审核,直接抛异常回滚。这是乐观锁的简单实现,比锁表轻量。
string updateSql = "UPDATE StockIn SET Status=1, AuditTime=GETDATE() WHERE Id=@Id AND Status=0"; SqlCommand updateCmd = new SqlCommand(updateSql, conn, tran); updateCmd.Parameters.AddWithValue("@Id", inId); if (updateCmd.ExecuteNonQuery() == 0) throw new Exception("单据已被审核,请刷新后重试");4.4 打印入库单时格式错位
现象:入库单打印出来条码对不齐,或者分页时表头丢失。原因:WinForms的PrintDocument默认按屏幕像素换算,不同打印机DPI不一样。解决:在PrintPage事件里用e.Graphics.DpiX和DpiY做缩放,表头在每页的PrintPage里重绘,不要指望HasMorePages自动处理。如果嫌麻烦,可以导出Excel再打印,项目里预留了ExportToExcel方法,用的是NPOI库。
5. 二次开发与改造:从能跑到好用
5.1 加一个“库存调拨”模块的完整思路
现有系统只有入库和出库,如果两个仓库之间要调货,直接做出库再入库会生成两张单,对账麻烦。改造思路是加Transfer主表和TransferDetail明细,字段和StockOut类似,但审核时同时扣减调出仓库存、增加调入仓库存。这需要先在Product表加WarehouseId字段,或者单独建Stock表按仓库维度存库存。我一般会选后者,因为一个商品在多个仓库有库存是常态,把StockQty放在Product表里迟早要重构。
改造步骤:第一步,建Warehouse表和Stock表(ProductId, WarehouseId, Qty);第二步,把现有Product.StockQty的数据迁移到Stock表,默认仓库Id为1;第三步,改入库审核逻辑,从更新Product改为更新Stock;第四步,加调拨界面和BLL。迁移时注意用事务,先备份数据库。
5.2 用Entity Framework替换手写DAL的取舍
项目现在用SqlHelper手写SQL,好处是透明、好调试,坏处是字段一多,增删改查代码重复。如果想换成EF,建议只替换DAL层,BLL和界面不动。用EF的Code First根据现有表反向生成实体,然后ProductDAL里的方法改成LINQ查询。但要注意:EF默认延迟加载,库存查询这种需要实时性的场景,记得用AsNoTracking(),否则查出来的实体被上下文跟踪,反而更慢。另外事务写法要改成DbContext.Database.BeginTransaction(),和现在的SqlTransaction不通用。
5.3 一个验证改造是否成功的笨办法
每次改完库存相关逻辑,不要只点界面看数字对不对。我习惯在数据库里建一张StockLog表,每次库存变动都插一条记录,字段包括ProductId、ChangeQty、BeforeQty、AfterQty、SourceType、SourceId、CreateTime。然后写一条校验SQL:SELECT * FROM Product p WHERE p.StockQty != (SELECT ISNULL(SUM(ChangeQty),0) FROM StockLog WHERE ProductId=p.Id)。如果这条SQL查出来有记录,说明库存和流水对不上,一定有地方漏了写日志或者事务回滚不干净。这个习惯帮我抓过好几次隐蔽的并发问题——界面显示正常,但流水对不上,迟早出大事。
从那以后我每次动库存逻辑,都强制走一遍“改代码→跑流水校验SQL→模拟并发审核→再校验”的流程,宁可多花十分钟,也不想半夜被仓管电话叫醒说账乱了。希望这套拆解能帮你少走点弯路,源码里的注释我尽量写细了,遇到卡住的地方,先看数据库状态,再看事务边界,多半能定位到。
本文还有配套的精品资源,点击获取