首页 / 资讯中心 / 文章详情

WinForms库存管理系统源码拆解:从数据库附加到事务防超卖

WinForms库存管理系统源码拆解:从数据库附加到事务防超卖 ★ FEATURED ARTICLE
简介这是一套基于C#语言与Winform框架的库存管理系统完整源码和数据库包面向初、中级.NET开发者可用于学习桌面端管理系统的分层开发与SQLite数据库应用。系统使用.NET Framework 4.7.2开发框架和SQLite3数据库包含界面展示层、业务逻辑层、数据访问层和实体模型层并提供SMSUI、BLL、SMSDAL、DAL、SMSModels等工程文件模块划分清楚便于按模块阅读、测试和二次开发适合作为课程设计、毕业设计或日常练手的参考项目。压缩包内共290个文件以C#源代码、动态链接库、界面图片和资源配置文件为主同时包含数据库文件与可执行程序整个压缩包约196.91MB可在Visual Studio 2022中打开、编辑和编译运行入口位于SMSUI项目下。资源默认提供管理员账户和密码登录后即可操作商品入库、出库、查询等功能数据库文件需按说明放到Debug目录下才能正常启动也可用SQLiteStudio打开查看商品表、库存表等结构配合各层源代码理解Winform界面与业务数据交互和常见增删改查实现。目前已有45人学习下载想快速上手此类管理系统的开发者可将其作为完整实战参考。1. 为什么 WinForms 做库存管理仍然能打一套带数据库文件的源码包拆解很多人一听到 C# WinForms 就觉得是老古董但真到了给中小门店、小工厂或者课程设计交差的时候WinForms 反而是最靠谱的选项。库存管理系统这种内部业务工具核心诉求不是技术多新而是「能改、能跑、能讲清楚」——老板要今天加个字段明天就能加验收老师要看你把登录、增删改查、入库出库这条线走通。这套带源码和数据库文件的 WinForms 库存管理系统走的正是最典型的三段式结构WinForms 界面 ADO.NET 数据访问 SQL Server 数据库文件适合正在做 C# 课程设计的学生也适合接小外包项目想快速搭一个可交付骨架的开发者。我拆这类项目有一个固定习惯先把数据库文件挂起来再跑起来看登录最后去追入库出库的事务逻辑。这套资源里源码和数据库文件是配套的只要按顺序做半小时内就能在本地跑出完整业务流。下面按这个顺序把每一个环节的写法和坑都说清楚。2. 先把数据库文件挂起来MDF 附加、连接串与 App.config 的三种写法2.1 先分清拿到的是 MDF 还是 SQL 脚本解压资源包之后先看数据库文件的后缀这决定了你接下来走哪条路。最常见的是.mdf加.ldf的组合这是 SQL Server 的数据库文件和日志文件需要用附加的方式挂到 SQL Server 实例上。另一种可能是单个.sql文件这种是建库建表的脚本用 SSMS 打开执行一遍就行不需要附加。判断方法很简单.mdf文件右键属性看不到数据库版本直接打开 SSMS连接到本地实例后在「数据库」节点上右键选「附加」把.mdf选进去。如果版本不匹配SSMS 会立刻弹错误错误信息里的「数据库版本 661 / 706 / 782」这类数字就是版本线索对照你本机SELECT VERSION的结果就能看出差距。我一般建议直接装一个 SQL Server Express用比文件版本更高或同版本的实例去附加别费劲去降级文件。如果拿到的是.sql脚本执行方式更简单但要注意脚本里如果有USE [master]或者CREATE DATABASE语句执行时不要选中某个用户库再跑直接在 master 库上下文执行即可。命令行方式也可以sqlcmd -S . -E -i D:\Inventory\init.sql-S .表示本机默认实例-E表示用 Windows 身份验证-i指定脚本文件路径。如果本机实例是命名实例比如.\SQLEXPRESS就把-S参数换成对应名字。执行完之后用SELECT name FROM sys.databases确认库起来了再做下一步。2.2 附加数据库的两种姿势与对应的连接串SSMS 图形界面附加是最直观的右键「数据库」→「附加」→「添加」→ 选中.mdf如果.ldf和.mdf在同目录会自动带上。如果你更喜欢用命令也可以用CREATE DATABASE ... FOR ATTACH的写法适合在部署脚本里复用CREATE DATABASE Inventory ON (FILENAME ND:\Inventory\Inventory.mdf), (FILENAME ND:\Inventory\Inventory_log.ldf) FOR ATTACH;路径必须写绝对路径而且 SQL Server 服务账号要有该目录的读写权限否则会报操作系统错误 5拒绝访问。附加成功后在 SSMS 里展开库看看有没有Users、Product、StockIn、StockOut这几张表这是库存系统最基础的几类表用户表、商品表、入库流水表、出库流水表。表齐了说明文件没坏。连接串是接下来所有代码能跑起来的前提三种典型写法列一下场景连接串本机默认实例Windows 身份Server.;DatabaseInventory;Integrated SecurityTrue;本机 Express 实例Server.\SQLEXPRESS;DatabaseInventory;Integrated SecurityTrue;远程服务器SQL 账号Server192.168.1.10,1433;DatabaseInventory;User IDsa;Passwordxxx;注意第三种写法里端口写在服务器地址后面用逗号分隔SQL Server 默认是 1433如果你装了命名实例且开了动态端口这个写法很可能连不上要用 SSMS 的配置工具查实际端口。这个细节是排查连接问题的第一站。2.3 连接串放进 App.config别写死在代码里我拆过不少课设源码最痛恨的就是在Form1.cs里写死一长串连接字符串换台机器就要重新编译。正确做法是把连接串放到App.config的appSettings里运行时用ConfigurationManager读取。项目默认会带App.config没有就右键添加「应用程序配置文件」内容写成这样?xml version1.0 encodingutf-8 ? configuration startup useLegacyV2RuntimeActivationPolicytrue supportedRuntime versionv4.0 / /startup appSettings add keyconnStr valueServer.;DatabaseInventory;Integrated SecurityTrue; / /appSettings /configuration读取时在代码里加一个DbHelper之类的静态类所有数据访问都从同一个入口拿连接using System.Configuration; using System.Data.SqlClient; public static class DbHelper { public static string ConnStr { get { return ConfigurationManager.AppSettings[connStr]; } } public static SqlConnection GetConnection() { var conn new SqlConnection(ConnStr); return conn; } }这里要注意用了ConfigurationManager就必须在项目引用里加上System.Configuration这个程序集不然编译直接报「缺少 using 指令或程序集引用」。另外改App.config后要重新生成项目bin\Debug目录下的项目名.exe.config才会同步更新很多人改了配置文件发现没生效就是忘了重新编译这个坑我后面还会提到。3. 登录与商品维护参数化 SQL、DataGridView 绑定与 ComboBox 联动3.1 登录验证用参数化 SQL 替代字符串拼接这套系统里登录窗体LoginForm是入口逻辑很直白输入用户名密码查Users表匹配上了就打开主窗体。但这里有一个非常重要的写法分水岭——用字符串拼接 SQL 还是参数化 SQL。很多课设源码喜欢写成SELECT * FROM Users WHERE UserName txtUser.Text 这种写法在演示时没问题上真实环境就是注入靶子。我一般会写成下面这样private bool ValidateUser(string userName, string password) { string sql SELECT COUNT(*) FROM Users WHERE UserName name AND Password pwd; using (var conn DbHelper.GetConnection()) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(name, userName); cmd.Parameters.AddWithValue(pwd, password); conn.Open(); int count (int)cmd.ExecuteScalar(); return count 0; } }AddWithValue有两个细节要说。第一参数名必须和 SQL 里的占位符完全一致SQL 里写name代码里写name少一个符号就会报「必须声明标量变量」。第二ExecuteScalar返回的是objectCOUNT(*)的结果是int直接强转没问题但如果 SQL 改成查别的聚合值强转类型就要对应调整。密码存储这边多说一句如果Users表里是明文密码趁项目还没验收赶紧改成哈希。常见做法是存 SHA256 的十六进制串校验时把用户输入也哈希一遍再比较。虽然短小但能避免系统上线后被问「为什么数据库里能看到所有人的密码」这种尴尬——等被问到再补就是吃后悔药了。using System.Security.Cryptography; using System.Text; public static string HashPassword(string raw) { using (var sha SHA256.Create()) { byte[] bytes sha.ComputeHash(Encoding.UTF8.GetBytes(raw)); StringBuilder sb new StringBuilder(); foreach (byte b in bytes) sb.Append(b.ToString(x2)); return sb.ToString(); } }3.2 主窗体数据加载与 DataGridView 绑定登录成功进入主窗体MainForm核心控件是DataGridView商品列表、库存列表都靠它展示。WinForms 里 DataGridView 的常规做法是用SqlDataAdapter把查询结果填进DataTable再把DataTable赋给DataSource这样数据是断开式的操作完再通过适配器更新回数据库。private DataTable LoadProduct() { string sql SELECT p.ProductId, p.ProductName, c.CategoryName, p.UnitPrice, p.Quantity, p.MinStock FROM Product p INNER JOIN Category c ON p.CategoryId c.CategoryId ORDER BY p.ProductId; using (var conn DbHelper.GetConnection()) using (var da new SqlDataAdapter(sql, conn)) { DataTable dt new DataTable(); da.Fill(dt); return dt; } }DataGridView的体验好坏基本由几个属性决定ReadOnly true防止误编辑SelectionMode FullRowSelect让整行高亮AutoSizeColumnsMode Fill让列宽撑满窗体AllowUserToAddRows false去掉表格底部的空行RowHeadersVisible false隐藏行头。这几个属性是 WinForms 控件属性里最常用的几个调完之后表格瞬间从「默认丑样式」变成「像回事的样子」这就是为什么很多人说界面美化不一定要换第三方控件。商品新增是另一个典型入口通常是弹一个小窗体收集信息然后参数化 INSERTprivate void AddProduct(string name, int categoryId, decimal price, int minStock) { string sql INSERT INTO Product(ProductName, CategoryId, UnitPrice, MinStock, Quantity) VALUES(name, catId, price, minStock, 0); SELECT SCOPE_IDENTITY();; using (var conn DbHelper.GetConnection()) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(name, name); cmd.Parameters.AddWithValue(catId, categoryId); cmd.Parameters.AddWithValue(price, price); cmd.Parameters.AddWithValue(minStock, minStock); conn.Open(); int newId Convert.ToInt32(cmd.ExecuteScalar()); // 用 newId 继续做后续操作比如初始化库存记录 } }这里两条 SQL 用分号隔开执行SCOPE_IDENTITY()拿到的是本连接刚插入的自增 ID。注意不能用SELECT IDENTITY那个变量在多表触发器存在时会拿到错误的值这是老生常谈但几乎每个项目都会有人踩。插入成功后主窗体的LoadProduct()要重新调用一次否则表格里看不到新数据——刷新时机是 DataGridView 用的最常见的坑后面避坑章节专门写。3.3 分类下拉ComboBox 的 DisplayMember 与 SelectedValue商品分类和商品明细之间是一对多的关系界面上通常有一个ComboBox放分类列表选中某个分类后商品表格只显示该分类的数据。ComboBox 绑定的核心是三个属性DataSource、DisplayMember、ValueMember。DataSource放一个DataTable或List实体DisplayMember指定用户看到的列分类名ValueMember指定实际取值的列分类 ID。private void LoadCategory() { DataTable dt GetCategoryTable(); // SELECT CategoryId, CategoryName FROM Category cboCategory.DataSource dt; cboCategory.DisplayMember CategoryName; cboCategory.ValueMember CategoryId; cboCategory.SelectedIndex 0; }这段代码的坑在细节里DisplayMember和ValueMember写的是列名字符串如果列名写错ComboBox 不会编译报错而是显示成DataRowView这种类型名看着像见鬼了其实只是字符串匹配不上。SelectedValue一定要在DataSource赋值之后才能取到有效值在InitializeComponent完就去读SelectedValue拿到的永远是 null。分类切换联动商品列表就简单了在SelectedIndexChanged事件里重新查一次商品表把分类 ID 作为参数拼进 SQL 条件再给表格重新赋值数据源。这个联动模式在 WinForms 项目里出现频率极高学会一次客户管理、供应商管理都是同一个套路。4. 入库出库的库存事务流水表设计、扣减防超卖与预警高亮4.1 为什么必须拆库存表与流水表很多库存系统的初级实现是商品表里一个Quantity字段入库就加几出库就减几。这样做演示没问题但一遇到「上个月三号的入库记录是谁做的」「这个月总共进了多少货」这种追溯性查询就完全抓瞎。正确做法是把快照和流水分开商品表只存当前库存快照入库流水表和出库流水表各存各的每一次变动。这套资源的表结构设计是典型的三件套字段大概像下面这样表名关键字段职责ProductProductId, ProductName, CategoryId, Quantity, MinStock当前库存快照StockInStockInId, ProductId, Quantity, UnitPrice, InTime, Operator入库流水StockOutStockOutId, ProductId, Quantity, UnitPrice, OutTime, Operator出库流水流水表的每一条记录都对应一次真实操作带操作人和时间库存表的Quantity是流水累计的结果。这样设计的最大好处是库存数和流水对不上时可以用两张表的数据做核对而不是对着一个数字猜。建表脚本核心部分长这样CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductName NVARCHAR(100) NOT NULL, CategoryId INT NOT NULL, UnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0, Quantity INT NOT NULL DEFAULT 0, MinStock INT NOT NULL DEFAULT 0 ); CREATE TABLE StockIn ( StockInId INT IDENTITY(1,1) PRIMARY KEY, ProductId INT NOT NULL, Quantity INT NOT NULL CHECK (Quantity 0), UnitPrice DECIMAL(18,2) NOT NULL, InTime DATETIME NOT NULL DEFAULT GETDATE(), Operator NVARCHAR(50) NOT NULL );CHECK (Quantity 0)这个约束是数据库层面的一道保险防止程序逻辑漏了导致入库数量为负。类似地出库表也应该加同样的约束。程序可以错但数据库约束能拦住一部分明显不合法的数据这是我认为课设项目里最值得加的一层防线。4.2 入库操作事务里先插流水再更库存入库的本质是「写一条流水 更新一次库存快照」这两件事必须同时成功或同时失败。如果你先执行 INSERT 流水成功紧接着 UPDATE 库存时数据库连接断了结果就是库存没变但流水多了对账永远对不上。解决办法是包在一个事务里public void DoStockIn(int productId, int qty, decimal price, string operatorName) { string sqlIn INSERT INTO StockIn(ProductId, Quantity, UnitPrice, Operator) VALUES(pid, qty, price, op);; string sqlUpdate UPDATE Product SET Quantity Quantity qty WHERE ProductId pid;; using (var conn DbHelper.GetConnection()) { conn.Open(); using (var tx conn.BeginTransaction(IsolationLevel.ReadCommitted)) using (var cmd new SqlCommand()) { cmd.Connection conn; cmd.Transaction tx; try { cmd.CommandText sqlIn; cmd.Parameters.AddWithValue(pid, productId); cmd.Parameters.AddWithValue(qty, qty); cmd.Parameters.AddWithValue(price, price); cmd.Parameters.AddWithValue(op, operatorName); cmd.ExecuteNonQuery(); cmd.CommandText sqlUpdate; cmd.ExecuteNonQuery(); tx.Commit(); } catch { tx.Rollback(); throw; } } } }这里我给BeginTransaction传了IsolationLevel.ReadCommitted这是大多数业务系统的默认隔离级别——读不到未提交的数据同时并发压力不至于像Serializable那样大。事务内的两条命令必须绑定同一个SqlConnection和同一个SqlTransaction对象漏掉cmd.Transaction tx会直接报「ExecuteNonQuery 要求命令拥有事务」的错。先插流水再更新库存的顺序是出于一个朴素理由流水是事实记录库存是派生结果事实先落库万一第二步失败回滚也干净。4.3 出库扣减与防超卖一条 UPDATE 顶掉三段判断出库的难点是防止库存扣成负数。最朴素的写法是先 SELECT 查当前库存在 C# 里判断够不够够了再 UPDATE。这个写法在单机单窗口时能跑但两个窗口同时开、或者将来接个 Web 端就会面临同一个库存被两个请求同时读到 10然后都判断「够」最后都执行扣减库存变成负数。我一般会给出库表操作写成一条带条件的 UPDATE让数据库自己判断string sql UPDATE Product SET Quantity Quantity - qty WHERE ProductId pid AND Quantity qty;; using (var conn DbHelper.GetConnection()) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(qty, qty); cmd.Parameters.AddWithValue(pid, productId); conn.Open(); int affected cmd.ExecuteNonQuery(); if (affected 0) { throw new Exception(库存不足出库失败); } }WHERE Quantity qty是关键它让扣减操作成为一个原子判断数据库行锁保证同一时间只有一个会话能更新这一行后到的事务会因为不满足条件更新到 0 行从而被识别为库存不足。ExecuteNonQuery返回的是受影响行数0 就说明条件没满足。这也是我认为这批资源里最值得抄走的一段代码——大多数课程设计和初级外包项目出库扣减都栽在「先读后写」这个并发漏洞上。库存预警的展示方式我习惯在DataGridView的CellFormatting事件里做行着色当Quantity MinStock时把整行背景色改成浅橙或浅红用户一眼扫过去就知道哪些商品要补货了。private void dgvProduct_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dgvProduct.Rows[e.RowIndex].DataBoundItem is DataRowView row) { int qty Convert.ToInt32(row[Quantity]); int min Convert.ToInt32(row[MinStock]); if (qty min) { dgvProduct.Rows[e.RowIndex].DefaultCellStyle.BackColor Color.LightSalmon; } } }预警阈值放在表里的MinStock字段而不是写死在代码里这样业务人员改预警线不用重新编译程序。至于大批量导入或导出时的界面卡顿WinForms 的标准答案是BackgroundWorker加ToolStripProgressBar更新状态栏进度条在后台线程执行时更新 UI 要用ReportProgress直接在线程里操作控件会抛跨线程访问异常这个以后单独写一篇细说。5. 避坑记录从附加失败到负库存的六个常见翻车点5.1 环境与连接类附加失败、换机连不上、引擎未注册附加 MDF 报版本不匹配。现象SSMS 附加数据库时弹出错误提示「数据库版本 782 或 706」之类的数字后续无法继续。原因本机 SQL Server 实例版本低于数据库文件原本的版本。解决根据报错版本号安装对应或更高版本的 SQL Server Express然后把文件附加到新实例上。不要尝试手动改 MDF 文件的十六进制版本号那个操作轻则文件损坏重则整个项目数据打不开。连接串本地能跑换台机器就连不上报错集中在错误 26 或 40。现象代码没改数据库也确定存在但conn.Open()抛「在建立与服务器的连接时出错」或「找不到服务器实例」。原因多半是连接串里Server.\SQLEXPRESS写死了命名实例而目标机器只装了默认实例或实例名不同。解决连接串外置到App.config换机器只改配置或者统一装 SQL Server Express 并保持实例名一致。这个错误 80% 不是玄学就是实例名或端口对不上。Access 数据库引擎报错或 AnyCPU 翻车。现象如果资源里附带的是.accdb文件在 64 位系统上运行报「Microsoft.ACE.OLEDB.12.0 未注册」。原因Access 数据库引擎有 32 位和 64 位两个版本项目生成目标AnyCPU在 64 位系统上默认按 64 位跑驱动对不上。解决把项目的「平台目标」改成x86或者安装与项目位数匹配的 Access Database Engine。这个坑不只存在于 Access 场景任何依赖原生 COM 组件的 WinForms 项目都会遇到。5.2 数据与运行类负库存、数据不刷新、登录窗体关闭出库把库存扣成负数。现象库存显示 0 之后还能继续出库甚至变成负数且没有报错。原因程序逻辑是先 SELECT 查库存再用 C# 判断是否充足最后 UPDATE。多窗口或多用户并发时两个请求读到同一库存值都通过了判断。解决改成 4.3 里的带条件 UPDATEWHERE Quantity qty靠受影响行数判断是否成功。这是并发环境下最便宜的后悔药——不需要加锁也不需要改架构一行 WHERE 就解决了。新增或修改商品后 DataGridView 不刷新。现象操作提示成功但表格里看不到新数据。原因LoadProduct()只在窗体初始化时调用了一次或者虽然调用了但直接把新的DataTable赋值给了同一个DataSourceDataGridView没有感知数据变化。解决每次增删改操作完成后重新执行一次LoadProduct()赋值之前先dgv.DataSource null;再赋新表强制表格重建绑定。刷新时机永远放在数据库操作 commit 之后别放在按钮点击一开始。登录窗体一关闭整个程序就退出。现象输入账号密码点登录主窗体闪了一下就消失了或者登录窗体一Close()整个进程结束。原因Program.cs里Application.Run(new LoginForm())入口窗体的消息循环跟着登录窗体一起结束主窗体还没来得及接管。解决改成Application.Run(new MainForm())作为入口登录窗体用ShowDialog()模态弹出验证通过后再打开主窗体或者登录窗体里用Hide()替代Close()保证进程消息循环不中断。课设里最常见的改法是后者因为改动最小但我会建议前者逻辑更干净// Program.cs [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); using (var login new LoginForm()) { if (login.ShowDialog() ! DialogResult.OK) return; } Application.Run(new MainForm()); }登录窗体里验证成功后把this.DialogResult DialogResult.OK;主窗体的Load事件再加载基础数据。这样登录和主窗体的生命周期彻底分开不会再出现「登录窗一关程序全没」的诡异行为。6. 进阶收尾日志、存储过程与换机部署的一次到位拿到一套能跑的 WinForms 库存系统只是第一步真正的分水岭在于「能不能在别人机器上跑起来」和「出问题能不能查」。我建议先补一个最廉价的日志组件在DbHelper里加一个Log(string msg)的静态方法把每次数据库操作的异常信息和关键参数写入logs目录下的文本文件。这个动作看起来土但实际排障价值极高——客户打电话说「点出库报错了」你没法远程看到他的屏幕这时日志文件里记录的操作人、商品 ID、数量和时间就是唯一的线索。没有日志的系统就是个黑匣子出了问题只能靠客户截图猜。然后是存储过程。如果入库出库的事务逻辑要复用或者将来要接入其他客户端把事务逻辑写进存储过程比写在 C# 里更合适因为数据库层面的原子性由数据库自己保证应用程序再也不用担心事务对象绑错连接CREATE PROCEDURE usp_StockOut ProductId INT, Qty INT, Operator NVARCHAR(50) AS BEGIN BEGIN TRANSACTION; UPDATE Product SET Quantity Quantity - Qty WHERE ProductId ProductId AND Quantity Qty; IF ROWCOUNT 0 BEGIN ROLLBACK TRANSACTION; RAISERROR(库存不足, 16, 1); RETURN; END; INSERT INTO StockOut(ProductId, Quantity, Operator) VALUES(ProductId, Qty, Operator); COMMIT TRANSACTION; END;存储过程里同样用Quantity Qty防超卖ROWCOUNT判断更新行数不满足就回滚并抛错。这样 C# 端只负责传入参数和接收错误业务规则收口在数据库层。C# 调用时把CommandType设为StoredProcedure参数名与存储过程参数一致即可。最后是交付时的部署检查。WinForms 项目打包成安装程序后在客户机器上最常见的翻车是「装上打不开」或「打开就白屏」原因无非三个目标机器没装对应版本的 .NET Framework、没装 SQL Server 实例或实例名不匹配、连接串里写死了开发机路径。我的习惯是用 Visual Studio 自带或 Inno Setup 生成安装包把App.config里的连接串留成占位符部署时现场改成客户环境的实际值。如果客户没有预算装独立 SQL Server部署 SQL Server Express 并附加 MDF 文件是一条代价最低的路。那次我因为没检查目标机的 .NET 版本白白浪费半天反复重装从那以后我每次交付 WinForms 项目都强制走一遍「换机清单」目标框架、数据库实例、连接串外置、日志目录可写四项全过才签字交付。这套流程也建议你收下希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站