
简介基于C#的超市收银管理系统源码.zip是一套面向毕业设计及课程项目的完整超市收银系统源码采用C#与.NET Framework开发覆盖商品管理、库存控制、销售记录、会员管理、支付方式等核心模块并区分前台收银与后台管理两大操作入口。压缩包共271个文件约2.56MB其中106个.cs文件为业务逻辑与窗体代码27个.dll为运行所需的类库依赖17个.resx保存界面资源另含配置文件、解决方案及SQL数据库脚本整体结构清晰可编译调试后直接运行。已有118人学习浏览适合需要可运行示例的C#初学者与毕业设计者。源码遵循面向对象设计附有SQL数据库脚本与Word文档重点展示了WinForms界面开发、三层架构搭建、SQL Server增删改查、异常处理及打印报表等实用技能便于对照学习与二次扩展。1. 这套 C# 超市收银管理系统源码先定用途再谈代码这是一套用 C# 写的桌面版超市收银管理系统跑在 .NET Framework 上典型的 WinForms 项目结构商品管理、库存控制、销售记录、会员积分、多种支付方式、报表打印全都覆盖所以最常被拿去做毕业设计或 C# 课程设计的底稿。拿到手别急着双击 exe先把它当成学习样本来拆看三层结构怎么切、看数据库为什么按第三范式设计、看一次扫码到结账落库之间要处理多少个边界条件。对刚入门 C# 的开发者和准备答辩的学生来说它比空讲语法更落地对在职开发者来说这套代码里收银、库存、会员的模块边界也能直接借去做进销存或上位机的小工具。下面我按「骨架 → 收银主流程 → 数据库 → 避坑 → 答辩前改造」的顺序把它拆开讲。2. 把项目跑起来从 .csproj 和设计时缓存看分层再到连接数据库2.1 先看目录缓存文件暴露了项目分层线索解压这套源码包后别急着打开某个 .cs 文件就看。先在目录层面停留一分钟你会发现同时存在多个 .csproj 文件还有一堆 *.cache 文件。很多新手看到.cache就以为是源码其实那是 Visual Studio 的设计时缓存比如AssemblyReference.cache和DesignTimeResolveAssemblyReferencesInput.cache是编辑器在解析项目引用时生成的临时文件删掉后重新编译会自动生成对工程没有任何影响。反而这几个 .csproj 值得读SMartStorageManager.csproj是主项目WinForms 窗体和收银界面都在这里Model.csproj放实体类BLLUtility.csproj是业务逻辑层加公共工具类。这种划分在答辩里特别好讲表示层、业务逻辑层、数据访问层切开了后面改需求时不用把整个窗体翻个底朝天。文件/目录在系统中的角色说明SMartStorageManager.csproj表示层 / 主项目登录窗口、收银台界面、后台管理窗体Model.csproj实体层Product、Member、SaleOrder 等数据模型只存属性BLLUtility.csproj业务逻辑层 工具收银、库存、订单处理以及 DBHelper、日志等*.cache构建缓存VS 自动生成不要提交到毕设压缩包里理解这一层之后你会发现这套写法和很多 C# 上位机项目同源窗体负责输入输出业务逻辑放到独立类库数据访问收在公共方法里。能把这套分层讲明白比背一堆语法在面试里更加分。2.2 从 App.config 改连接字符串开始不管代码写得再分层能不能跑起来的第一关是数据库连接。.NET Framework 的 WinForms 项目里连接字符串通常放在App.config的connectionStrings节点中。找到它确认指向哪个数据库实例。connectionStrings add nameSuperMarketConn connectionStringData Source.;Initial CatalogSuperMarketDB;User IDsa;Password123456;MultipleActiveResultSetstrue providerNameSystem.Data.SqlClient / /connectionStrings这里几个参数要重点说。Data Source.表示本机默认的 SQL Server 实例如果是 Express 版就得写成.\SQLEXPRESS或(localdb)\MSSQLLocalDBInitial Catalog是数据库名User ID和Password是登录账号。MultipleActiveResultSetstrue是我个人习惯加的它允许同一个连接上同时打开多个 DataReader在收银台这种连续扫码的界面里能少一点打开连接的次数。改完之后不要直接跑。先把数据库建好把初始化 SQL 脚本执行一遍。多数这类系统会在项目里带一个.sql文件或者要求手动还原数据库备份。如果脚本里存的是中文商品名执行前先确认数据库排序规则能存中文否则后面查出来的全是???。2.3 还原、改目标框架、初始化数据库固定三步拿到项目我一般按这个顺序来顺序乱掉就会出现各种奇怪的编译错误。# 第一步还原 NuGet 包 nuget restore SMartStorageManager.sln # 第二步用 msbuild 编译 msbuild SMartStorageManager.sln /p:ConfigurationDebug /p:PlatformAnyCPU第一步是为了拉取第三方依赖比如用到实体框架或报表组件时包没还原就会出现一堆CS0246 找不到类型的报错。第二步编译如果报错说目标框架版本不对右键项目打开属性页把.NET Framework目标版本改成你本机已安装的版本。改目标框架后最好清理一遍解决方案再重新生成否则旧缓存会干扰编译。数据库初始化时先确认 SQL Server 服务已经启动。如果系统里带的是.mdf数据库文件而不是 SQL 脚本连接字符串里通常会写成AttachDbFilename|DataDirectory|\SuperMarketDB.mdf的形式。这里的|DataDirectory|是 .NET 里一个特殊目录标记会指向程序运行目录下的数据文件夹别把它当成普通路径。数据库起来后用系统的初始账号登录能进到主界面才算跑通全链路。3. 收银主流程拆解扫码查询、购物车、结账扣库存的代码走读3.1 条形码查询参数化 SQL 与扫码枪输入处理收银台第一步是扫商品扫码枪本质上是键盘输入设备扫出来一串条码程序拿它去数据库查对应商品。这里最容易踩的第一个坑是有人为了省事直接用字符串拼接 SQL比如SELECT * FROM Product WHERE Barcode barcode 。一旦条码里混入特殊字符轻则查询报错重则暴露 SQL 注入点。虽然收银系统是内网桌面程序但毕业设计答辩时老师专挑这种地方问。public Product GetProductByBarcode(string barcode) { string sql SELECT ProductId, Name, Price, Stock, Barcode FROM Product WHERE Barcode barcode; using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { // 用 Add 指定长度比 AddWithValue 更稳 cmd.Parameters.Add(barcode, SqlDbType.VarChar, 13).Value barcode; conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { if (reader.Read()) { return new Product { ProductId Convert.ToInt32(reader[ProductId]), ProductName reader[Name].ToString(), Price Convert.ToDecimal(reader[Price]), Stock Convert.ToInt32(reader[Stock]), Barcode reader[Barcode].ToString() }; } } } return null; }这段代码里有几个细节值得琢磨。cmd.Parameters.Add(barcode, SqlDbType.VarChar, 13)指定了长度 13对应 EAN-13 这种常见条码长度如果你用AddWithValue在某些 SQL Server 场景下可能因为参数类型推断不准导致索引失效虽然平时感觉不到但这是面试官爱问的点。另外using写在SqlConnection和SqlCommand上确保连接和命令用完就释放WinForms 收银台这种长时间运行的程序连接泄漏是最隐蔽的内存问题。扫码枪还有一个小毛病部分型号扫完会自动追加回车键如果文本框里收的是KeyPress事件容易触发两次查询。我一般会先对输入做Trim()再判断长度必要时用PadLeft(13, 0)把缺位条码补全确保查出来的商品不会因为格式不统一而漏查。3.2 购物车数据结构List 和数组的取舍查到了商品下一步是把它加入当前购物车。这个场景不固定条数所以用数组就很别扭C# 里数组一旦声明长度就不能变添加一件商品还得重新Array.Resize麻烦且容易出错。正确做法是用集合类型比如ListT或BindingListT。public class SaleItem { public int ProductId { get; set; } public string Barcode { get; set; } public string ProductName { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } // 表达式属性小计随数量和单价实时计算 public decimal Subtotal Price * Quantity; }BindingListT比ListT多的一个优点是自动通知界面刷新绑定到DataGridView后数据源里增删项界面会跟着变。用数组和集合的区别在答辩里也常被问到数组适合固定长度的数据集合比如每天固定三个收银班次购物车这种动态增删的场景必须用集合。这里顺便也能把 C# 的泛型和委托概念一并讲清楚。我实际操作时会维护一个BindingListSaleItem作为收银界面的当前购物车扫码查到的商品先检查购物车中是否已存在同一条码如果已存在就把数量加一而不是新增一行这样界面更真实。var existing cart.FirstOrDefault(i i.Barcode barcode); if (existing ! null) { existing.Quantity; } else { cart.Add(new SaleItem { ProductId product.ProductId, Barcode product.Barcode, ProductName product.ProductName, Price product.Price, Quantity 1 }); }FirstOrDefault是 LINQ 里很常用的方法找不到元素时返回null而不是抛异常配合匿名表达式代码短一截。但要注意FirstOrDefault走的是谓词匹配购物车数据量大了之后比如几百行商品明细性能不如图字典不过普通超市一次结账最多几十项完全够用。3.3 结账落单数据库事务里一次完成销售订单和库存扣减购物车确认后点结账是整个系统业务逻辑最重的一段。很多失败的实现是这样的先生成销售订单再扣库存或者先查库存再扣减中间任何一步出错数据就对不上。正确做法是把「写销售主表 写销售明细 扣库存」放在一个数据库事务里要么全部成功要么失败回滚。using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlTransaction tx conn.BeginTransaction(); try { // 1. 写销售主表利用 OUTPUT 拿到新订单号 SqlCommand cmd new SqlCommand( INSERT INTO SaleOrder(OrderTime, TotalAmount, MemberId, PayType) OUTPUT INSERTED.OrderId VALUES(time, total, memberId, payType), conn, tx); cmd.Parameters.Add(time, SqlDbType.DateTime).Value DateTime.Now; cmd.Parameters.Add(total, SqlDbType.Decimal).Value totalAmount; cmd.Parameters.Add(memberId, SqlDbType.Int).Value memberId ?? (object)DBNull.Value; cmd.Parameters.Add(payType, SqlDbType.Int).Value payType; int orderId (int)cmd.ExecuteScalar(); // 2. 扣库存 写明细逐条处理 foreach (SaleItem item in cart) { cmd.CommandText UPDATE Product SET Stock Stock - qty WHERE ProductId pid AND Stock qty; cmd.Parameters.Clear(); cmd.Parameters.Add(qty, SqlDbType.Int).Value item.Quantity; cmd.Parameters.Add(pid, SqlDbType.Int).Value item.ProductId; int rows cmd.ExecuteNonQuery(); if (rows 0) { throw new Exception(库存不足 item.ProductName); } cmd.CommandText INSERT INTO SaleOrderDetail(OrderId, ProductId, Qty, Price) VALUES(oid, pid, qty, price); cmd.Parameters.Clear(); cmd.Parameters.Add(oid, SqlDbType.Int).Value orderId; cmd.Parameters.Add(pid, SqlDbType.Int).Value item.ProductId; cmd.Parameters.Add(qty, SqlDbType.Int).Value item.Quantity; cmd.Parameters.Add(price, SqlDbType.Decimal).Value item.Price; cmd.ExecuteNonQuery(); } tx.Commit(); } catch { tx.Rollback(); throw; } }这段代码的关键点有三处。第一OUTPUT INSERTED.OrderId可以在插入主表的同时把自增主键返回省掉再查一次IDENT_CURRENT的麻烦。第二扣库存的UPDATE语句带了Stock qty条件更新不了就说明库存不足直接抛异常回滚这比先SELECT再UPDATE要安全。第三事务对象tx传给每条SqlCommand保证全部操作在同一个连接和事务里。注意循环里cmd.CommandText和cmd.Parameters.Clear()是配合使用的因为同一个SqlCommand对象被复用不清理参数会残留上次的值。WinForms 的事件驱动模型在这个环节也体现得最明显点击「结账」按钮触发Click事件事件处理函数里调用业务层方法。这也是 C# 里事件与委托概念最具体的落地场景——按钮会触发某个事件系统委托给它对应的处理方法。答辩时能把这个关系从代码里指出来比干背定义强得多。4. 数据库设计复盘3NF 表结构、典型 SQL 与查询边界4.1 表结构与第三范式销售主表和明细表为什么要分开这套系统的基础是关系型数据库常用 SQL Server 或 MySQL。超市收银场景里一张单子对应多个商品如果全部塞进一张表商品名称、单价、数量只能在同一行里用逗号拼字符串查询时要拆字符串统计销售额时根本没法汇总。所以表结构要按关系模型拆开销售订单拆成主表和明细表。表名用途关键字段Product商品表ProductId、CategoryId、Barcode、Name、Price、StockCategory分类表CategoryId、CategoryNameMember会员表MemberId、Name、Phone、PointsSaleOrder销售主表OrderId、OrderTime、TotalAmount、MemberId、PayTypeSaleOrderDetail销售明细表DetailId、OrderId、ProductId、Qty、PriceStockLog库存流水表LogId、ProductId、ChangeQty、ActionType、CreateTime商品表里有分类主键CategoryId指向分类表销售明细表里ProductId指向商品表。按第三范式去检查商品名称、单价只存在于商品表销售明细表只存Qty和当时的成交价Price不冗余商品名称订单总金额可以算出来但为了查询方便放在主表也说得过去。这里有个设计取舍值得在答辩时主动提订单明细里的单价必须保留因为商品表价格会变历史账单要能还原当时的成交价。建表脚本是这类系统最值钱的部分我贴一个核心的商品表。CREATE TABLE Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, CategoryId INT NOT NULL, Barcode VARCHAR(13) NOT NULL UNIQUE, Name NVARCHAR(50) NOT NULL, Price DECIMAL(10,2) NOT NULL CHECK (Price 0), Stock INT NOT NULL DEFAULT 0 CHECK (Stock 0), CreatedAt DATETIME DEFAULT GETDATE() );几个字段类型要说明一下。Barcode用VARCHAR(13)而不是NVARCHAR因为条码是纯英文数字不带中文用NVARCHAR存储会多占一倍空间Name用NVARCHAR商品名要存中文VARCHAR在中文环境下容易乱码Price用DECIMAL(10,2)带两位小数不能用FLOAT浮点数算总额会有精度误差这在财务场景是大忌。CHECK约束确保价格和库存不为负数等于在数据库层面拦了一道。4.2 销售统计的典型 SQL日期范围、分组汇总后台管理里最常用的是销售报表——输入起止日期按天分组看销售额。这种查询看起来简单但日期处理的细节最容易出错。如果用OrderTime today这种写法因为OrderTime带时间部分查出来的数据永远是空的。SELECT CONVERT(varchar(10), OrderTime, 120) AS sale_date, COUNT(*) AS order_count, SUM(TotalAmount) AS total_amount FROM SaleOrder WHERE OrderTime start AND OrderTime DATEADD(day, 1, end) GROUP BY CONVERT(varchar(10), OrderTime, 120) ORDER BY sale_date DESC;日期范围的条件用 start和 DATEADD(day, 1, end)这样能完整覆盖结束日当天所有订单避免漏掉当天最后一笔。CONVERT(varchar(10), OrderTime, 120)是 SQL Server 里把时间转成yyyy-MM-dd格式的常见写法格式码120对应 ODBC 标准格式。如果换成 MySQL就得用DATE_FORMAT(OrderTime, %Y-%m-%d)两种数据库语法不一样切换前要先确认底层是什么。4.3 数据访问层要注意的边界脏读与隐式转换收银系统多线程操作数据库一个常见的理论问题是脏读。SQL Server 默认隔离级别是Read Committed读的时候不会拿到别人未提交的数据但如果你在某些代码里手动改了隔离级别或者用了NOLOCK提示就可能读到正在回滚的中间状态。对收银系统来说宁可查询稍慢也不要在销售额统计里掺入脏数据。另外要注意隐式转换。比如Product表的ProductId是INT但查询时传入的是字符串123SQL Server 会做隐式转换表单数据量大了之后索引可能失效。在 C# 侧参数该用Int32就用Int32不要图省事全部用AddWithValue传字符串这是数据访问层最常见的性能隐坑。5. 编译与运行避坑五个最容易翻车的现场5.1 登录即闪退连接字符串与数据库版本对不上现象程序能启动输入账号密码点登录直接弹未处理异常退出或者报「初始化登录失败」。原因最常见是App.config里Data Source.指向本机默认 SQL Server 实例但你的机器只装了 SQL Server Express 或 LocalDB另一个原因是 SQL Server 服务根本没启动。解决先打开 SQL Server 配置管理器确认服务状态再改连接字符串。Express 实例用.\SQLEXPRESSLocalDB 用(localdb)\MSSQLLocalDB。如果数据库是.mdf附加文件改成AttachDbFilename|DataDirectory|\SuperMarketDB.mdf。这种问题 90% 不是代码逻辑问题是环境不对。提示改完连接字符串后最好在Main方法入口处加一个数据库连通性测试哪怕只是一个conn.Open()也能把连接错误提前暴露出来。5.2 中文乱码数据库存进去全是「???」现象界面 TextBox 里输入中文商品名没问题点保存后重新查出来变成???或者某些机器上运行整个界面都是乱码。原因数据库表字段用了VARCHAR而不是NVARCHAR或者 SQL 脚本执行时数据库排序规则不是中文排序规则还有一种情况是.cs源文件被另存为了带 BOM 的 ANSI 编码读取时中文注释全乱。解决商品名这种带中文的字段统一用NVARCHAR执行初始化脚本前先确认库的排序规则是Chinese_PRC_CI_AS。如果已经存了乱码数据只能清掉重建ALTER DATABASE SuperMarketDB COLLATE Chinese_PRC_CI_AS; ALTER TABLE Product ALTER COLUMN Name NVARCHAR(50) NOT NULL;COLLATE这个命令只改数据库默认排序规则已存在的VARCHAR字段要单独ALTER COLUMN才能转换过来。另外给字符串参数赋值时SQL 里字符串前加N前缀也是一种兜底比如N可乐它会明确告诉数据库按 Unicode 处理。5.3 库存扣成负数查询判断和更新之间存在空隙现象两个收银台同时卖出同一件商品日志里库存变成-1你明明在扣库存前判断过if (stock quantity)。原因判断和扣减之间有时间差。第一台电脑先查库存为 10还没执行扣减第二台电脑也查到库存为 10两边都判断「够卖」然后各扣了 7 件库存变成 -4。这就是典型的并发问题靠程序里的if判断保不住数据。解决把判断条件放进 SQL 里让数据库来决定是否允许扣减。用UPDATE Product SET Stock Stock - qty WHERE ProductId pid AND Stock qty影响行数为 0 就说明库存不够。如果还要更强的保证可以在事务里加锁提示BEGIN TRANSACTION; UPDATE Product WITH (UPDLOCK, ROWLOCK) SET Stock Stock - qty WHERE ProductId productId AND Stock qty; IF ROWCOUNT 0 BEGIN ROLLBACK; RETURN; END INSERT INTO SaleOrderDetail(OrderId, ProductId, Qty, Price) VALUES(orderId, productId, qty, price); COMMIT;UPDLOCK告诉数据库在更新期间锁住这行ROWLOCK把锁粒度限制在当前行而不是整张表避免收银高峰期互相等待。这个写法在课程设计里完全可以展示但要注意加锁时间不要过长事务里不要写耗时的文件操作。出现负库存时优先查是不是还有旧的先查后改的代码没清理干净。5.4 报表打印没反应HasMorePages 忘了设置现象点「打印报表」按钮打印预览一片空白或者打印机只出一张白纸收银小票只打了半截。原因WinForms 的PrintDocument打印多页时依赖PrintPage事件里的e.HasMorePages属性告诉系统「后面还有没有下一页」。很多人在事件处理函数里画完当前页就返回没管HasMorePages导致要么不打下一页要么多打一张空白页。另一个常见原因是报表数据源是空的订单里没有任何明细记录。解决在PrintPage事件里维护一个行号索引画完一页后用rowIndex rows.Count判断是否继续private void printDoc_PrintPage(object sender, PrintPageEventArgs e) { float y e.MarginBounds.Top; while (rowIndex rows.Count y e.MarginBounds.Bottom) { e.Graphics.DrawString(rows[rowIndex], printFont, Brushes.Black, e.MarginBounds.Left, y); y rowHeight; rowIndex; } // 关键还有数据就继续打下一页否则结束 e.HasMorePages rowIndex rows.Count; }HasMorePages是整个打印事件的边界条件true时 Framework 会再次触发PrintPage直到你返回false。每次开始打印前要把rowIndex重置为 0否则第二次打印时数据少了。调试时期建议先用PrintPreviewDialog看预览别一遍遍往打印机送纸。5.5 .NET Framework 版本不匹配CS0246 和解决方案清理现象编译报一堆CS0246 找不到类型或命名空间或者提示缺少System.Configuration引用。原因项目是用某个更高版本的 .NET Framework 写的比如4.7.2而你机器上是4.5或者 NuGet 包还原失败引用程序集缺失。这类报错在一些包下载一半时也会出现表现为一半类型能找到、一半找不到。解决先打开项目属性页看目标框架把它改成你机器上已安装的版本然后清理解决方案再重新生成。注意清理不是只清理项目要右键解决方案点「清理解决方案」把bin和obj目录删干净避免旧版本的程序集残留干扰编译。# 删除所有文件夹里的 bin/obj再重新编译 for /d /r . %i in (bin obj) do rd /s /q %ibin和obj是编译输出目录obj存中间编译文件bin存最终输出删除后不影响源码。执行完再msbuild一次大部分版本问题都能暴露出来。如果机器上确实没装对应框架版本去安装高版本而不是在代码里手动改一堆条件编译符号。6. 答辩前再加一层保险操作日志与演示数据脚本6.1 加一个简单的操作日志让系统「有据可查」很多毕设做出来功能都有但一被问「系统怎么追踪谁改过商品价格」就卡壳。这一层用来做日志能力非常快不用加框架建一张日志表再写一个静态写入方法即可。public static void WriteLog(int userId, string actionType, string detail) { string sql INSERT INTO OperationLog(UserId, ActionType, Detail, CreateTime) VALUES(userId, actionType, detail, GETDATE()); // 参数化执行逻辑和前面商品查询相同 using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(userId, SqlDbType.Int).Value userId; cmd.Parameters.Add(actionType, SqlDbType.NVarChar, 20).Value actionType; cmd.Parameters.Add(detail, SqlDbType.NVarChar, 200).Value detail; conn.Open(); cmd.ExecuteNonQuery(); } }日志写入属于次要业务不要塞到销售事务里否则日志表故障会影响主流程。在改商品、删除订单、调整库存这些操作的业务方法里各调一行就能捕捉痕迹。就算把日志写失败了也不应该影响正常收银更稳妥的方案是包一层try-catch静默处理。6.2 造一轮能扛住答辩的演示数据很多毕设翻车不是代码跑不动是演示时数据库空空如也点开销售报表没有数据可看。提前造一批演示数据效果完全不一样。下面这个脚本可以在商品表里批量插入 50 个演示商品DECLARE i INT 1; WHILE i 50 BEGIN INSERT INTO Product(CategoryId, Barcode, Name, Price, Stock) VALUES (i % 5 1, 690 RIGHT(0000000000 CAST(i AS VARCHAR(10)), 10), 演示商品 CAST(i AS NVARCHAR(10)), i * 1.5 2, 100); SET i i 1; END;条码部分的RIGHT(0000000000 CAST(i AS VARCHAR(10)), 10)是把编号左补齐到 10 位拼接后的条码长度固定商品名直接用中文拼接方便答辩时展示中文编码处理能力。造完商品再插入几笔当天订单销售报表里立刻有内容可讲。从那以后我每次拿到新源码包都会强制先跑一遍数据库脚本再开 UI顺手把连接字符串和目标框架版本写进项目里的README省得换个机器又要重新排查环境问题。希望这篇拆解能帮你少踩几个坑把精力留到真正需要吃透的业务逻辑上祝你的毕设一次通过。本文还有配套的精品资源点击获取