简介基于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用DECIMALStock用INTPurchaseOrder / PurchaseDetail进货主从表主表记单号和总金额明细记商品、数量、进价SalesOrder / SalesDetail销售主从表主表记收银员和总金额明细记商品、数量、售价StockLog库存变动流水ChangeType标识IN/OUTBeforeStock和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 %关键字% 会让索引失效商品表数据到上万条以后会有明显卡顿届时应改成条码精确匹配为主、模糊搜索为辅。新增和更新操作则直接走ExecuteNonQueryprivate 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 versionv4.0 sku.NETFramework,Versionv4.8 / /startup connectionStrings add nameSuperMarket connectionStringData Source.;Initial CatalogSuperMarketDB;User IDsa;Password123456; providerNameSystem.Data.SqlClient / /connectionStrings /configurationData Source里的“.”代表本机默认SQL Server实例。如果装的是SQL Server Express实例名通常是.\SQLEXPRESS要写完整。Initial Catalog对应刚才建的SuperMarketDB。账号密码用的是SQL Server混合验证下的sa也可以改成Integrated SecurityTrue走Windows验证那样部署到同事机器时不用管数据库密码但要保证运行程序的账号有权限。我一般建议练习阶段用Integrated SecurityTrue少踩一个密码相关的坑因为很多人建库时没开启sa账号或者密码策略记不住最后卡在登录这一步。MySQL用户注意驱动换成MySql.Data.MySqlClient连接串格式是Serverlocalhost;DatabaseSuperMarketDB;Uidroot;Pwd123456;CharSetutf8mb4其中CharSetutf8mb4对应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 11 --”直接登录进去现象在用户名输入框里输入 OR 11 --再随便填个密码居然登录成功了。这不是段子是真实出现过的劣质登录代码。原因登录SQL是字符串拼接出来的比如SELECT * FROM UserInfo WHERE UserName txtUserName.Text 。用户输入的单引号把SQL语句提前闭合后面的OR 11让条件永远成立--注释掉了剩余部分。核心问题在于把用户输入当成了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交账。这些都是同一个方向上的自然延伸事务和流水的习惯养成了后面加什么功能都不慌。我一直的习惯是凡是涉及“减库存”“扣钱”这类不可逆操作的代码一律显式事务包住宁可多写几行不留脏账。希望帮到你。本文还有配套的精品资源点击获取