☰
C#超市管理系统开发指南:WinForms+SQL Server实现进销存与库存管理
2026/10/9 12:42:08 网站建设 项目流程

简介:基于C#开发的超市管理系统源码与数据库压缩包,面向超市管理者、C#初学者及毕业设计人员,提供一套完整的信息化解决方案。系统涵盖商品管理、采购管理、销售管理、会员管理、库存预警与报表生成等核心模块,配合SQL Server 2008数据库实现数据持久化,可帮助理解WinForm界面设计与ADO.NET数据交互流程。压缩包共59个文件,大小1.97MB,主要包含16个.cs源码文件、6个.resx与6个.resources资源文件、3个.exe可执行程序、3个.dll库文件,以及.mdf/.ldf数据库文件和.sln/.csproj项目文件,结构清晰便于直接打开运行和学习。资源内附数据库DATA文件与项目源码,适合在Visual Studio 2010环境中导入使用。目前已有119人学习下载,对于想要快速上手C#桌面应用开发和超市业务建模的读者具有实际参考价值。

1. C#超市管理系统:除了买现成收银软件,自己写一套要多久

一家小超市的老板每天用Excel记库存,月底对不上账;想上个商业收银软件,一年订阅费比利润还好看。基于C#的超市管理系统(源码+数据库),就是用WinForms窗口程序加上SQL Server或MySQL,把商品档案、进货、收银、库存这些事自己落地。它是一个典型的C#入门到进阶练手项目,也是一套能真拿去给便利店用的内部工具。这篇文章不聊虚的,直接按“源码该怎么拆”来讲:从建表、连接数据库,到登录、商品增删改查、收银扣库存,最后把最容易让人栽跟头的几个坑列清楚。

2. 系统拆解:先把功能边界圈清楚,再定五张核心表

2.1 功能边界:为什么“登录+商品+进销存”就能支撑一家便利店

我见过太多项目死在“功能全”。一上来就规划会员储值、促销返券、电子小票、线上商城,结果数据库设计了十几个表,写到一半发现互相牵制,连登录都没跑通。超市管理系统的本质是进销存,控制住成本和库存,其他的都是加分项。一套能真正用的雏形只需要五块功能:

  • 登录与权限:区分管理员和收银员,收银员看不到进货成本和利润,这是最朴素的权限要求。
  • 商品档案:条码、品名、分类、单位、售价、库存上下限。货架上的每个单品都能在这里查到。
  • 进货入库:给供应商送来的货登记入库,增加库存,同时记录进价。
  • 收银销售:前台录入商品、计算总价、扣减库存、生成销售单。
  • 库存查询与预警:低于库存下限的商品要能一眼看出来,否则就是缺货断档。

这五个模块串起来就是一条完整的主链路:录商品 → 进货 → 收银 → 查库存。第一版能把这四条路走通,系统就已经具备上线条件。供应商、会员、统计报表都是在这条主链路上长出来的枝叶,可以放到第二个迭代再做。练习阶段最忌讳的,是拿一张“大而全”的模块图开刀,最后连一张销售单都生成不出来。

我一般会先用一张纸画出主链路,再沿着主链路去设计数据表。表跟着业务走,不是先建一堆表再想怎么凑业务。

2.2 五张核心表:主从表设计与字段取舍

超市系统的数据表设计,核心不是多,而是主从关系清楚。商品、用户、供应商这三张是基础档案表;销售和进货这两组必须拆成“主表 + 明细表”,这是进销存系统的命根子。很多新手把一次购买的多件商品拼成逗号分隔字符串塞进一个字段,查询时再Split,这种设计会导致统计和补货完全没法做。

下面是我在类似C#超市管理系统项目里常用的表结构,入门阶段照这个建不会走弯路:

表名一句话职责需要重点关注的字段
UserInfo登录账号与员工信息UserName设唯一约束,Password存哈希,RoleID区分权限
Supplier供应商档案SupplierName唯一,Contact和Phone可空
Product商品档案 + 当前库存快照ProductNo唯一(条码),Price用DECIMAL,Stock用INT
PurchaseOrder / PurchaseDetail进货主从表主表记单号和总金额,明细记商品、数量、进价
SalesOrder / SalesDetail销售主从表主表记收银员和总金额,明细记商品、数量、售价
StockLog库存变动流水ChangeType标识IN/OUT,BeforeStock和AfterStock用于对账

销售单为什么要拆两张表?因为一次收银会产生一个订单头和N行商品明细。主表存OrderNo、CashierId、TotalAmount、SaleTime,明细表存OrderId、ProductId、Quantity、Price、SubTotal。两张表通过OrderId关联,主表一条记录对应明细表多条记录。进货同理。这样拆,统计“今天卖了多少瓶可乐”就是一条带WHERE的SQL,而不是在字符串里做文章。

字段类型的选择也直接影响后面好不好写代码。价格一律用DECIMAL(10,2),数量用INT,条码用NVARCHAR(20)而不是VARCHAR。金额用float/double存,是后续对账差一分钱的万恶之源,这一点在避坑章节还会展开讲。

2.3 库存的两种记录方式:快照与流水,缺一不可

很多简单系统的库存就只放在Product.Stock字段里。进货加、收银减,界面展示很方便,但它只是一个“结果快照”。如果某天库存对不上账,你只知道“现在不对”,却不知道“从哪一步开始不对”。所以稍微认真一点的项目,都会加一张StockLog库存流水表。

流水表记录每一次库存变动:哪个商品、入库还是出库、变动数量、变动前库存、变动后库存、操作人、操作时间。这样一来,库存有了审计链路。发现账实不符时,按时间把流水拉出来,就能定位到是哪一笔销售或者哪一次进货出了问题,而不是对着Product表瞎猜。

就我自己的做法而言,Product.Stock负责“当前还有多少”,StockLog负责“为什么会变成现在这个数”,两张表配合才能既好用又好查。等到你要做盘点功能时,流水表就是盘点差异的依据,它不是一个可有可无的装饰,而是进销存系统里真正值钱的部分。

3. 用C# WinForms把核心功能跑起来:登录、商品维护与增删改查

3.1 项目结构:至少分成UI和DAL两层,别把SQL全塞进按钮事件

拿到一个C#超市管理系统源码包,第一件事不是双击sln,而是先看项目结构。常见做法是把解决方案拆成两个项目:一个WinForms的UI层,一个类库形式的DAL数据访问层。UI层负责窗体交互,DAL层负责所有SQL和数据访问。这样做的直接好处是:以后换数据库连接,或者把界面从WinForms换成WPF,业务逻辑不用重写。

如果你是从空项目开始建,我建议先写一个通用的数据库帮助类,后面所有窗体的数据操作都走它。下面是我常用的DbHelper精简版,核心是三个方法:

using System.Data; using System.Data.SqlClient; namespace SuperMarket.DAL { public static class DbHelper { private static readonly string ConnString = System.Configuration.ConfigurationManager.ConnectionStrings["SuperMarket"].ConnectionString; // 查询首行首列,常用于判断账号是否存在、取自增ID public static object ExecuteScalar(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnString)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteScalar(); } } // 查询返回DataTable,常用于DataGridView的数据绑定 public static DataTable ExecuteQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnString)) using (var da = new SqlDataAdapter(sql, conn)) { if (parameters != null) { da.SelectCommand.Parameters.AddRange(parameters); } var dt = new DataTable(); da.Fill(dt); return dt; } } // 执行增删改,返回受影响行数,大于0说明成功 public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnString)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteNonQuery(); } } } }

三个方法对应三种最常见的数据库操作场景:查单个值、查表格、增删改。关键点有两个:一是所有连接和命令都用using包住,让资源随作用域释放,否则反复开关窗口会把连接池占满;二是参数必须通过SqlParameter传递,而不是拼进SQL字符串。上面的代码在WinForms项目里需要添加System.Configuration.dll的引用,否则ConfigurationManager用不了,这是新手最容易卡住的一个细节。

3.2 登录验证:参数化查询代替字符串拼接

登录是所有管理系统都逃不掉的第一步。C#超市管理系统的登录逻辑其实很清楚:拿用户名和密码去UserInfo表查记录,查到就放行,并且把角色ID存到全局变量里,供后面窗体判断权限。下面是登录按钮的点击事件代码:

private void btnLogin_Click(object sender, EventArgs e) { string userName = txtUserName.Text.Trim(); string password = txtPassword.Text; if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password)) { MessageBox.Show("用户名和密码不能为空"); return; } string pwdHash = GetMd5Hash(password); string sql = @"SELECT UserId, RoleID, RealName FROM UserInfo WHERE UserName = @name AND Password = @pwd AND IsEnabled = 1"; DataTable dt = DbHelper.ExecuteQuery(sql, new SqlParameter("@name", userName), new SqlParameter("@pwd", pwdHash)); if (dt.Rows.Count == 1) { CurrentUser.UserId = Convert.ToInt32(dt.Rows[0]["UserId"]); CurrentUser.RoleId = dt.Rows[0]["RoleID"].ToString(); CurrentUser.RealName = dt.Rows[0]["RealName"].ToString(); this.DialogResult = DialogResult.OK; } else { MessageBox.Show("用户名或密码错误,或账号已停用"); } } private string GetMd5Hash(string input) { using (var md5 = System.Security.Cryptography.MD5.Create()) { byte[] bytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); var sb = new System.Text.StringBuilder(); foreach (byte b in bytes) { sb.Append(b.ToString("x2")); } return sb.ToString(); } }

登录条件里带了IsEnabled = 1,这样停用的账号即使密码正确也进不来,比只判断账号密码多一层状态控制。密码用MD5哈希后入库和比对,数据库里不存明文,避免数据库文件泄露后密码被直接看到。需要说明的是,MD5在真正高安全场景不够强,但对本地超市系统这个级别,它已经是合格的“比明文强得多”的方案,进阶再换BCrypt不迟。

数据库里存的是哈希值,所以你无法用“忘记密码”找回原密码,只能重置为一个新密码再改成哈希。我给这种系统的管理界面都会加一个“重置密码”按钮,而不是“查看密码”按钮,这是设计习惯。

3.3 商品增删改查:DataGridView的绑定和刷新时机

商品管理是超市系统最典型的增删改查界面。左边是条件,右边是DataGridView表格,下方是几个维护按钮。关键写法集中在两点:查询方法怎么复用,以及新增删除之后怎么让表格刷新。

private void LoadProductData(string keyword = "") { string sql = @"SELECT p.ProductId, p.ProductNo, p.ProductName, p.Category, p.Unit, p.Price, p.Stock, p.WarnStock, s.SupplierName FROM Product p LEFT JOIN Supplier s ON p.SupplierId = s.SupplierId WHERE @keyword = '' OR p.ProductName LIKE '%' + @keyword + '%' OR p.ProductNo LIKE '%' + @keyword + '%' ORDER BY p.ProductId DESC"; DataTable dt = DbHelper.ExecuteQuery(sql, new SqlParameter("@keyword", keyword)); dgvProduct.DataSource = dt; }

这段查询使用LEFT JOIN把供应商名称带出来,即使商品没有关联供应商,也不会因为Join而丢行。WHERE里用@keyword = ''做判断,当关键字为空时,条件整体为真,返回全部商品;不为空时按品名或条码模糊匹配。这里有个性能边界:LIKE '%关键字%' 会让索引失效,商品表数据到上万条以后会有明显卡顿,届时应改成条码精确匹配为主、模糊搜索为辅。

新增和更新操作则直接走ExecuteNonQuery:

private void btnAdd_Click(object sender, EventArgs e) { string sql = @"INSERT INTO Product (ProductNo, ProductName, Category, Unit, Price, Stock, WarnStock, SupplierId) VALUES (@no, @name, @category, @unit, @price, @stock, @warn, @supplierId)"; int rows = DbHelper.ExecuteNonQuery(sql, new SqlParameter("@no", txtNo.Text.Trim()), new SqlParameter("@name", txtName.Text.Trim()), new SqlParameter("@category", cboCategory.SelectedItem?.ToString() ?? ""), new SqlParameter("@unit", cboUnit.SelectedItem?.ToString() ?? ""), new SqlParameter("@price", decimal.Parse(txtPrice.Text)), new SqlParameter("@stock", int.Parse(txtStock.Text)), new SqlParameter("@warn", int.Parse(txtWarn.Text)), new SqlParameter("@supplierId", GetSelectedSupplierId())); if (rows > 0) { MessageBox.Show("新增成功"); LoadProductData(); } }

新增成功后必须立刻调用LoadProductData()重新绑定数据源,否则DataGridView不会感知数据库变化,界面上还是老数据。这是新手踩得最多的地方,很多人以为DataGridView绑定后会“自动更新”,实际上不会。价格用decimal.Parse转,因为价格字段是DECIMAL类型,用int.Parse会直接报错;数字格式非法时decimal.Parse会抛异常,更稳妥的写法是decimal.TryParse并在失败时给出提示,这段逻辑在后面避坑章节还会讲。商品条码由文本控件录入,模拟扫码枪时,扫码枪本质上就是一个快速键盘输入设备,焦点停在条码输入框里扫一下就完成录入,然后自动跳到下一个框或回车触发新增。

4. 数据库脚本与连接配置:从零初始化一个能跑的库

4.1 完整建库建表脚本:SQL Server版,反复执行不报错

源码包里如果没有SQL脚本,光有源码是跑不起来的。下面这套脚本以SQL Server为例,覆盖第八张核心表,并且可以在学习阶段反复执行而不报错。MySQL用户只需要把自增语法IDENTITY(1,1)换成AUTO_INCREMENT,其余结构基本一致。

USE master; GO -- 如果库已存在,先强制断开连接并删除,学习阶段重建最省心 IF DB_ID('SuperMarketDB') IS NOT NULL BEGIN ALTER DATABASE SuperMarketDB SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE SuperMarketDB; END GO CREATE DATABASE SuperMarketDB COLLATE Chinese_PRC_CI_AS; GO USE SuperMarketDB; GO CREATE TABLE UserInfo ( UserId INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(30) NOT NULL UNIQUE, Password NVARCHAR(64) NOT NULL, RoleID NVARCHAR(10) NOT NULL DEFAULT 'Clerk', RealName NVARCHAR(20) NOT NULL, IsEnabled BIT NOT NULL DEFAULT 1, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE TABLE Supplier ( SupplierId INT IDENTITY(1,1) PRIMARY KEY, SupplierName NVARCHAR(50) NOT NULL UNIQUE, Contact NVARCHAR(20), Phone NVARCHAR(20) ); GO CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductNo NVARCHAR(20) NOT NULL UNIQUE, ProductName NVARCHAR(100) NOT NULL, Category NVARCHAR(30), Unit NVARCHAR(10), Price DECIMAL(10,2) NOT NULL, Stock INT NOT NULL DEFAULT 0, WarnStock INT NOT NULL DEFAULT 10, SupplierId INT NULL REFERENCES Supplier(SupplierId) ); GO CREATE TABLE SalesOrder ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(20) NOT NULL UNIQUE, CashierId INT NOT NULL, TotalAmount DECIMAL(10,2) NOT NULL DEFAULT 0, SaleTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE TABLE SalesDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL REFERENCES SalesOrder(OrderId), ProductId INT NOT NULL REFERENCES Product(ProductId), Quantity INT NOT NULL, Price DECIMAL(10,2) NOT NULL, SubTotal DECIMAL(10,2) NOT NULL ); GO CREATE TABLE PurchaseOrder ( PurchaseId INT IDENTITY(1,1) PRIMARY KEY, PurchaseNo NVARCHAR(20) NOT NULL UNIQUE, OperatorId INT NOT NULL, SupplierId INT NULL REFERENCES Supplier(SupplierId), TotalAmount DECIMAL(10,2) NOT NULL DEFAULT 0, PurchaseTime DATETIME NOT NULL DEFAULT GETDATE() ); GO CREATE TABLE PurchaseDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, PurchaseId INT NOT NULL REFERENCES PurchaseOrder(PurchaseId), ProductId INT NOT NULL REFERENCES Product(ProductId), Quantity INT NOT NULL, CostPrice DECIMAL(10,2) NOT NULL, SubTotal DECIMAL(10,2) NOT NULL ); GO

这条脚本里有两个常用技巧。一是ALTER DATABASE ... SET SINGLE_USER WITH ROLLBACK IMMEDIATE,它的作用是断开所有现有连接,否则DROP DATABASE时会因为连接占用而失败,这个报错在练习阶段非常常见。二是建库时指定Chinese_PRC_CI_AS排序规则,从根上解决中文乱码问题,比事后改字段类型省事得多。外键约束Product.SupplierId、SalesDetail.OrderId这些,会让数据库自己拒绝“删了有销售记录的商品”这类操作,这是保护而不是麻烦,真遇到删除失败时,应该先处理关联数据,而不是把外键删了。

4.2 连接字符串:App.config里存,别写死在代码里

连接字符串是整个系统能不能跑起来的关键。最糟糕的写法是把连接串直接写在每一个窗体代码里,换一台电脑就要改十几个地方。正确做法是写在App.config里,统一配置,DbHelper启动时自动读取。

<configuration> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" /> </startup> <connectionStrings> <add name="SuperMarket" connectionString="Data Source=.;Initial Catalog=SuperMarketDB;User ID=sa;Password=123456;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

Data Source里的“.”代表本机默认SQL Server实例。如果装的是SQL Server Express,实例名通常是.\SQLEXPRESS,要写完整。Initial Catalog对应刚才建的SuperMarketDB。账号密码用的是SQL Server混合验证下的sa,也可以改成Integrated Security=True走Windows验证,那样部署到同事机器时不用管数据库密码,但要保证运行程序的账号有权限。我一般建议练习阶段用Integrated Security=True,少踩一个密码相关的坑,因为很多人建库时没开启sa账号,或者密码策略记不住,最后卡在登录这一步。

MySQL用户注意,驱动换成MySql.Data.MySqlClient,连接串格式是Server=localhost;Database=SuperMarketDB;Uid=root;Pwd=123456;CharSet=utf8mb4,其中CharSet=utf8mb4对应SQL Server的排序规则,同样是为了中文。

4.3 种子数据:至少给一个管理员账号和一些商品

脚本建完,数据库是空的,这时候直接运行程序连登录都进不去。所以必须给种子数据。最简单的一套:一个admin账号、几个供应商、几个典型商品。

INSERT INTO Supplier (SupplierName, Contact, Phone) VALUES ('本地百货批发部', '李经理', '13800000001'), ('农夫山泉经销商', '王姐', '13800000002'); GO INSERT INTO UserInfo (UserName, Password, RoleID, RealName) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', 'Admin', '系统管理员'); GO INSERT INTO Product (ProductNo, ProductName, Category, Unit, Price, Stock, WarnStock, SupplierId) VALUES ('690000000001', '农夫山泉 550ml', '饮料', '瓶', 2.00, 100, 20, 1), ('690000000002', '可口可乐 330ml', '饮料', '罐', 3.50, 80, 20, 1), ('690000000003', '康师傅红烧牛肉面', '方便食品', '桶', 4.50, 60, 15, 1); GO

管理员密码这里直接用了admin的MD5值,目的就是让你第一次登录时不用想密码。正式给门店部署时,必须让管理员第一次登录后立即改密码。商品的条码字段全部用真实的前缀“690”开头,这是中国境内商品条码的国家代码,扫码枪扫出来的条码就是这种形式。种子数据的作用不只是让程序能跑起来,更重要的是让后面的增删改查、收银测试有真实的操作对象。

5. 避坑与排查:C#超市管理系统最常见的5个翻车现场

5.1 中文乱码:库建对了,界面还是“???”

现象:插入中文商品名后,数据库里存的是问号或者一堆无法辨认的字符,查询出来更是没法看。有时候排除重装系统,换一台机器又正常。

原因:SQL Server的排序规则不是中文,建库用的默认排序规则不支持中文字符。另外,字段用了VARCHAR而不是NVARCHAR,也会导致中文被截断或转成问号。代码层面如果连接字符串的编码和库排序规则对不上,同样会出问题。

解决:建库时显式指定COLLATE Chinese_PRC_CI_AS,字段类型用NVARCHAR,这样从库到字段都支持中文。如果库已经建了,可以用ALTER DATABASE SuperMarketDB COLLATE Chinese_PRC_CI_AS补救,但已经存入乱码的数据要清掉重录。MySQL的对应操作是把库和表的字符集都设为utf8mb4。我的一条血泪经验是:项目启动前先跑一条插入中文的测试SQL,验证环境,别等整个系统写完再排查编码问题。

5.2 DataGridView不刷新:改完数据界面纹丝不动

现象:新增、删除商品后,DataGridView里还是旧列表,重启程序才看到新数据。有人怀疑是自己数据库操作写错了,其实数据已经进去了,只是界面没更新。

原因:DataGridView绑定DataSource之后,它持有的DataTable是数据库某一次查询的内存快照。数据库里的数据变了,但DataTable没有重新Fill,控件自然不知道要刷新。这是数据绑定的“黑匣子”特性,不是控件有bug。

解决:所有增删改操作成功之后,重新调用LoadProductData()方法,重新查询并绑定。如果界面上有其他控件的联动,比如选中的行、筛选项,刷新后要注意恢复状态。频繁操作时可以用BindingSource包装数据源,调用ResetBindings()让界面同步,但最朴素也最可靠的还是“改完就重新查”。

5.3 float存金额:对账差一分钱,查半天是玄学

现象:2.0加0.1算出来不是2.1,小票打印金额和实际收款对不上,有时候差0.01,有时候差0.0000001。

原因:float和double是二进制浮点数,存储十进制小数时本身就有精度损失。超市系统里金额连续运算多次,误差会累计,最后反映在对账上。

解决:C#代码里金额全部用decimal类型,数据库字段用DECIMAL(10,2),中间计算不要转成double再转回decimal。这个原则从建表的第一天就要守住。int.Parse这个问题相对好排查,因为会直接抛异常;float存钱的问题则是不报错、光对不上账,最折磨人。

5.4 SQL注入:文本框里输入“' OR 1=1 --”直接登录进去

现象:在用户名输入框里输入' OR 1=1 --,再随便填个密码,居然登录成功了。这不是段子,是真实出现过的劣质登录代码。

原因:登录SQL是字符串拼接出来的,比如SELECT * FROM UserInfo WHERE UserName = '+ txtUserName.Text +'。用户输入的单引号把SQL语句提前闭合,后面的OR 1=1让条件永远成立,--注释掉了剩余部分。核心问题在于把用户输入当成了SQL代码执行,而不是数据。

解决:没有后悔药,唯一可靠的手段是参数化查询,就是第3章里写的SqlParameter方式。参数化之后,输入里的单引号、注释符都会被当成普通字符传给数据库,而不是参与SQL解析。不要试图自己写函数过滤单引号,那是在和正则表达式搏斗,总有漏网之鱼。这条对新手最重要,教程里的字符串拼接写法直接跳过,不管你多不习惯参数化。

5.5 换台电脑就连不上数据库:连接串写死机器名

现象:在自己机器上一切正常,把项目拷给同事,启动就报“建立到服务器的连接时发生错误”或者“在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误”。

原因:连接字符串里写死了Data Source=你的机器名,甚至写死了sa密码。同事机器上的SQL Server实例名、登录方式很可能完全不一样。SQL Server的防火墙、TCP/IP协议未开启,也会导致局域网内连不上。

解决:连接字符串统一放在App.config里,部署到哪台机器就改哪台机器的配置文件。连不上的时候,先在SSMS的连接对话框里下拉看本机有哪些实例,把Data Source改成正确的实例名。如果数据库跑在另一台服务器上,先ping通,再测试SQL Server端口是否被防火墙拦着。我也会顺手在DbHelper里做一个“连接失败时弹出连接串地址”的提示,方便部署时快速定位是实例名错了还是密码错了。

6. 进阶:事务与库存流水,把系统从“能交作业”做到“能收银”

收银是超市管理系统里最不能出错的地方,因为它同时涉及三件事:生成销售单、生成销售明细、扣减库存。这三件事只要有一件失败,账就乱了。比如销售单写进去了但库存没扣,系统会一直认为货还有,直到哪天盘库发现少了。所以收银的代码必须用显式事务包住,保证要么全部成功,要么全部回滚。

using (var conn = new SqlConnection(DbHelper.ConnString)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { long orderId; string sqlOrder = @"INSERT INTO SalesOrder (OrderNo, CashierId, TotalAmount) VALUES (@no, @cashier, @total); SELECT SCOPE_IDENTITY();"; using (var cmd = new SqlCommand(sqlOrder, conn, tx)) { cmd.Parameters.AddWithValue("@no", orderNo); cmd.Parameters.AddWithValue("@cashier", CurrentUser.UserId); cmd.Parameters.AddWithValue("@total", totalAmount); orderId = Convert.ToInt64(cmd.ExecuteScalar()); } foreach (var item in cart) { string sqlDetail = @"INSERT INTO SalesDetail (OrderId, ProductId, Quantity, Price, SubTotal) VALUES (@orderId, @pid, @qty, @price, @qty * @price)"; using (var cmd = new SqlCommand(sqlDetail, conn, tx)) { cmd.Parameters.AddWithValue("@orderId", orderId); cmd.Parameters.AddWithValue("@pid", item.ProductId); cmd.Parameters.AddWithValue("@qty", item.Quantity); cmd.Parameters.AddWithValue("@price", item.Price); cmd.ExecuteNonQuery(); } // 扣库存:条件里带 Stock >= @qty,影响行数为 0 说明库存不足 string sqlStock = @"UPDATE Product SET Stock = Stock - @qty WHERE ProductId = @pid AND Stock >= @qty"; using (var cmd = new SqlCommand(sqlStock, conn, tx)) { cmd.Parameters.AddWithValue("@pid", item.ProductId); cmd.Parameters.AddWithValue("@qty", item.Quantity); if (cmd.ExecuteNonQuery() == 0) { throw new Exception("库存不足:" + item.ProductName); } } } tx.Commit(); } catch { tx.Rollback(); MessageBox.Show("收银失败,本次操作已全部回滚"); } } }

这段代码最值得注意的不是INSERT,而是扣库存那条UPDATE。它把Stock >= @qty直接写进WHERE条件,用影响行数判断库存是否足够。如果先SELECT再判断,再用UPDATE,两行代码之间数据可能被另一个收银窗口改掉,这就是并发问题。把判断和更新合成一条语句,数据库在原子操作里同时完成检查和扣减,远比两条语句稳妥。至于手写SQL还是用存储过程,这个体量的系统手写完全够用,反而更容易排查。

到这一步,系统已经具备“能收银”的底线能力。再往上走,我会建议在事务里同步写一条StockLog流水,把“扣库存”和“记流水”也放进同一个事务,这样库存变动永远有据可查。接着可以补营业日报:按天聚合SalesOrder的TotalAmount,导成Excel交账。这些都是同一个方向上的自然延伸,事务和流水的习惯养成了,后面加什么功能都不慌。我一直的习惯是,凡是涉及“减库存”“扣钱”这类不可逆操作的代码,一律显式事务包住,宁可多写几行,不留脏账。希望帮到你。

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

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

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

立即咨询