简介基于C#的超市收银管理系统源码是一份面向计算机相关专业毕业设计及C#初学者的完整项目资源。系统覆盖商品管理、库存控制、销售记录、会员管理、支付方式等核心模块并包含权限登录、报表打印与数据库备份等功能适合用于学习WinForms界面开发、三层架构及ADO.NET操作。资源包共271个文件压缩后约2.56MB其中包含106个.cs源代码文件、27个dll引用库、17个resx界面资源文件、17个resources资源文件以及配置文件、SQL脚本和Visual Studio解决方案文件目录结构清晰便于直接打开和二次开发。已有118人学习下载。通过研读这份源码不仅可以掌握C#面向对象编程、事件驱动模型和异常处理机制还能了解如何基于SQL Server设计三范式数据库、使用参数化查询保障数据安全以及实现销售报表生成和打印。对于需要快速完成毕业设计或希望提升企业级应用开发能力的读者是一份实用且有参考价值的实战资料。1. 基于C#的超市收银管理系统这套源码解决的不只是“收钱”用C#写一个超市收银管理系统在学校里是课程设计的经典题目到了真实的小超市里它就是收银台的全部工作扫码、算钱、打折、找零、扣库存、打小票。很多人拿到这样的源码压缩包双击打开.sln跑起来能录入商品也能结账但真放到店里用几天就会翻车——要么库存对不上要么金额冒出0.30000000000000004这种鬼数字。这套系统的难点从来不在界面漂亮不漂亮、按钮多不多而在按下“结算”那一刻金额计算、库存扣减、流水落库必须同时成功任何一步失败都要能回滚否则就是实打实赔钱。适合用它的是三类人交课程设计的学生、想给自家小店搭收银系统的经营者、想用C#把WinForms和数据库操作完整练一遍的入门者。下面按我实际做过的方案把这个方向从架构到上线拆开讲。2. 先把架构定下来WinForms分层、数据库选型与命名约定拿到源码包先别急着双击.sln。标题写着“基于C#”但按C#教程里那种控制台程序或者ASP.NET的读法去看它会发现思路对不上——收银系统源码绝大多数是WinForms桌面程序入口在Program.cs后面跟着一堆Form类。很多人读过C#高级编程面对具体源码时第一步还是应该从入口往回推看清项目里有哪些工程、各自干什么。这一章先把架子和选型讲清楚后面所有代码都在这套骨架上跑。2.1 为什么收银系统用WinForms而不是WPF或Web收银台这个场景有四个固定特征单机或小局域网、操作员长期盯着一块屏幕、输入主要靠扫码枪、打印靠小票机。WPF的绑定和样式确实现代但同样一个表格WinForms用BindingSource绑DataTable代码量比MVVM少一半而且很多小店的收银机还是老配置WinForms启动更快、内存占用更低。Web方案要额外部署IIS或Kestrel还要处理浏览器调打印控件这种麻烦事为了一个两三平方米的收银台去上Web架构纯粹是给自己找活。所以无论是课程设计还是商用源码WinForms都是这类系统最常见的选择这也解释了为什么压缩包里全是Form类而不是Page类。选WinForms还有一个现实理由打印和串口。收银系统经常要跟扫码枪、小票打印机、钱箱打交道WinForms程序直接就能操作串口和USB设备权限和路径问题都比Web环境少。作为入门者用WinForms能把注意力放在业务逻辑上而不是先学一套前端框架再回来处理桌面设备。如果你在评估一套源码先看它的入口是不是Application.Run基本就能判断它是不是WinForms体系。2.2 数据库选型Access、SQLite、SQL Server三选一收银系统的数据库选择几乎决定了你后面要踩多少坑。三类数据库的差别可以用一张表说清维度AccessSQLiteSQL Server部署成本复制.mdb文件即可复制.db文件即可需要安装数据库实例并发能力差多连接容易锁库较好适合单机小并发强支持多收银台事务支持可用但限制多完整ACID完整ACIDC#连接方式OleDbConnectionSystem.Data.SQLite / Microsoft.Data.SqliteSystem.Data.SqlClient适合场景课程设计交差真实单机小店连锁或多收银台C#与Access是老搭配不少课程设计源码都用它因为不用装任何服务考完删掉就行。但Access的锁库问题会在你联调时让人怀疑人生明明程序单机跑第二次打开就报文件正被占用。SQLite也是单文件事务支持完整单机收银场景下我是最推荐它的。SQL Server只在你确实有服务器环境、需要多个收银台同时写库时才有必要上否则就为了一个小店装一个数据库实例运维成本直接拉满。选型建议一句话交作业用Access真开店用SQLite连锁再谈SQL Server。2.3 项目分层与命名约定别把上千行代码塞进Form1.cs看过太多“源码”是单窗体结构Form1.cs里塞了商品管理、收银、会员、报表所有方法窗体一多就互相引用改一个地方炸一片。我做这类系统时无论源码本身怎么样第一件事就是按功能拆工程。常见的做法是下面这种分层SuperMarket.sln ├─ SuperMarket.Model 实体类Product、Member、SaleOrder ├─ SuperMarket.DAL 数据访问ProductDAL、MemberDAL、SaleDAL ├─ SuperMarket.BLL 业务逻辑SaleManager、StockManager、PriceCalculator ├─ SuperMarket.UI WinForms窗体frmLogin、frmMain、frmSale、frmProduct └─ SuperMarket.Common 通用工具打印辅助、单号生成、扩展方法Model层放最干净的C#类每个类对应一张表属性名和列名保持一致DAL层只做SQL和参数化不写任何界面判断BLL层做校验、折扣计算、事务控制UI层只负责把用户操作转成BLL调用把结果显示到控件上。引用方向必须是单向的UI→BLL→DAL→ModelCommon随便引用严禁UI直接调DAL也禁止BLL反过去引用UI否则项目一编译就提示循环依赖。命名约定上类名用PascalCase窗体文件用“frm”前缀控件采用类型简称txt是文本框、btn是按钮、dgv是DataGridView、cbo是下拉框数据库表名单数形式。这套约定不单是写起来好看几百行代码里找控件、找方法时光看名字就能定位排查问题省一半时间。3. 核心交易模块怎么落地商品管理、购物车结算与会员折扣收银系统的核心链路是商品→购物车→结算这三个模块的代码写法决定了这套源码是能真正营业还是只能演示。下面按我常用的实现方式贴三个关键片段每段都说明为什么这么写、参数怎么调、失败时看哪里。3.1 商品管理DataGridView配合BindingSource做增删改查商品列表是所有操作的起点。最省事的做法是把查询结果放进DataTable通过BindingSource绑定到DataGridView这样界面上的修改会记录在行状态里不需要你手写同步逻辑。代码如下// frmProductList.cs —— 商品列表加载 private void LoadProducts() { string connStr ConfigurationManager.ConnectionStrings[SuperMarket].ConnectionString; using (SqlConnection conn new SqlConnection(connStr)) { string sql SELECT ProductId, ProductName, Barcode, UnitPrice, StockQty, CategoryId FROM Products; using (SqlDataAdapter da new SqlDataAdapter(sql, conn)) { DataTable dt new DataTable(); da.Fill(dt); // 绑定到BindingSourceDataGridView只跟BindingSource对话 this.bsProducts.DataSource dt; this.dgvProducts.DataSource this.bsProducts; } } }这段代码用了SqlDataAdapter而不是SqlDataReader逐行读取原因在于DataTable自带行状态跟踪用户在DataGridView里改了单元格DataRowState会变成Modified之后调用SqlCommandBuilder或手写UpdateCommand就能批量回写不用写几十行Update语句。连接串放在App.config里别在代码里硬编码否则换数据库环境要重新编译。注意Barcode字段在数据库里一定要建唯一索引扫码枪扫出来重复商品时数据库直接报错比程序排查半天强得多。商品新增的SQL必须参数化这是所有收银源码最容易翻车的地方。用字符串拼接SQL一旦商品名里带单引号整条语句直接报语法错误更别提SQL注入风险private void AddProduct() { string sql INSERT INTO Products(ProductName, Barcode, UnitPrice, StockQty, CategoryId) VALUES(name, barcode, price, stock, category); using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(name, txtName.Text.Trim()); cmd.Parameters.AddWithValue(barcode, txtBarcode.Text.Trim()); cmd.Parameters.AddWithValue(price, Convert.ToDecimal(txtPrice.Text)); cmd.Parameters.AddWithValue(stock, Convert.ToInt32(txtStock.Text)); cmd.Parameters.AddWithValue(category, cboCategory.SelectedValue); cmd.ExecuteNonQuery(); } }AddWithValue在SQL Server下有时会推断错参数类型比如把DECIMAL推断成NVARCHAR导致索引失效或精度丢失。稳妥做法是用new SqlParameter(price, SqlDbType.Decimal)并显式指定Precision和Scale。文本转数字统一用decimal.TryParse而不是Convert.ToDecimal因为不同系统的区域设置可能把“12.3”解析出不同结果收银台上一分钱都不能差。3.2 购物车与结算从扫码进车到实收找零的完整链路购物车在WinForms里最常见的实现是DataTable临时表列结构跟SaleOrderItems对齐ProductId、ProductName、UnitPrice、Qty、SubTotal。扫码枪扫一下条码程序先去Products表查商品如果购物车DataTable里已有该ProductId就把Qty加1否则新增一行。这样操作简单还能直接作为DataGridView的DataSource显示在界面上。结算时先遍历购物车汇总应收金额再拿实收金额做校验private void CheckOut(decimal realPaid) { DataTable cart (DataTable)dgvCart.DataSource; decimal total 0m; foreach (DataRow row in cart.Rows) { // SubTotal在加入购物车时就算好结算只做加总避免两个地方重复计算不一致 total Convert.ToDecimal(row[SubTotal]); } if (realPaid total) { MessageBox.Show(实收金额小于应收请核对); return; } decimal change realPaid - total; lblChange.Text change.ToString(F2); }这里必须用decimal不能用double所有金额字面量都带m后缀total也要用0m初始化这是上一章说的0.1精度问题的根源所在。SubTotal在加入购物车时就算好并存入DataTable结算时只做累加这样界面显示的小计和实际结算金额永远一致。拿到的realPaid是用户在文本框输入、用decimal.TryParse转出来的收银员有可能输错所以实收小于应收时一定要弹窗阻止而不是直接找零成负数。结算金额确认后把购物车DataTable和实收、找零金额一起传给BLL层的SaleManager由它开启事务写库、扣库存。UI层不要再碰任何SQL否则日结对账时你会发现有的订单写了主表没写明细追溯起来极其痛苦。3.3 会员折扣规则写一个价格计算类而不是到处散落if会员折扣是收银系统里最容易写烂的地方。常见错误是每个窗体里各自处理VIP打9折、金卡打8折的代码散落在frmSale、frmMember等好几个类里改规则要翻遍整个项目。正确做法是建一个独立的价格计算类折扣规则只存在一个地方public enum MemberLevel { Normal 0, Silver 1, Gold 2 } public class PriceCalculator { // 折扣率字典新增会员等级只改这里调用方完全不用动 private static readonly DictionaryMemberLevel, decimal DiscountMap new DictionaryMemberLevel, decimal { { MemberLevel.Normal, 1.00m }, { MemberLevel.Silver, 0.90m }, { MemberLevel.Gold, 0.80m } }; // 返回商品最终应收单价保留两位小数 public static decimal GetFinalPrice(decimal basePrice, MemberLevel level) { decimal ratio DiscountMap.ContainsKey(level) ? DiscountMap[level] : 1.00m; return decimal.Round(basePrice * ratio, 2, MidpointRounding.AwayFromZero); } }把折扣率放进字典而不是散落在if/else里好处是新增一个铂金卡等级时只需要往DiscountMap加一行所有调用方自动生效。decimal.Round的第三个参数MidpointRounding.AwayFromZero很关键默认的ToEven是“四舍六入五成双”收银台会出现0.005最终变成0.00的争议AwayFromZero才符合日常认知。要特别注意特价商品和会员折扣的互斥如果商品表里IsOnSale字段为trueGetFinalPrice应该直接返回特价而不参与折扣计算。这个规则必须在PriceCalculator里统一处理而不是让收银员手工判断否则促销时段就是漏钱的大口子。4. 数据库设计与事务库存扣减不能只靠一条UPDATE业务逻辑再简单数据库设计错了后面全是返工。收银系统可以有很多表但核心只有四张Products商品表、Members会员表、SaleOrders销售主表、SaleOrderItems销售明细表。这一章讲清楚每张表怎么建、字段为什么这么设以及为什么库存扣减必须放在事务里。4.1 四张核心表字段设计与外键约束下面是SQL Server语法的建表脚本SQLite和Access的写法略有差异但结构一致CREATE TABLE Products ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductName NVARCHAR(50) NOT NULL, Barcode NVARCHAR(20) NOT NULL UNIQUE, UnitPrice DECIMAL(10,2) NOT NULL, StockQty INT NOT NULL DEFAULT 0, CategoryId INT NULL, IsOnSale BIT NOT NULL DEFAULT 0 ); CREATE TABLE Members ( MemberId INT IDENTITY(1,1) PRIMARY KEY, MemberNo NVARCHAR(20) NOT NULL UNIQUE, MemberName NVARCHAR(30) NOT NULL, Level INT NOT NULL DEFAULT 0 ); CREATE TABLE SaleOrders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(20) NOT NULL UNIQUE, MemberId INT NULL, TotalAmount DECIMAL(10,2) NOT NULL, PaidAmount DECIMAL(10,2) NOT NULL, ChangeAmount DECIMAL(10,2) NOT NULL, SaleTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE SaleOrderItems ( ItemId INT IDENTITY(1,1) PRIMARY KEY, OrderId INT NOT NULL FOREIGN KEY REFERENCES SaleOrders(OrderId), ProductId INT NOT NULL, Qty INT NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, SubTotal DECIMAL(10,2) NOT NULL );这四张表的关键设计就三条所有金额用DECIMAL(10,2)绝不用FLOATBarcode、MemberNo、OrderNo三列必须UNIQUE业务编号绝不依赖自增IDSaleOrderItems通过OrderId外键挂到SaleOrders下一张主表对应多张明细表。很多人图省事把购物车明细拼成一个长字符串存进主表日结统计时要把JSON再解析出来性能差是一回事数据一致性完全没法保证。MemberId在SaleOrders里是可空的因为不排除非会员现金结账。SaleTime设置默认值GETDATE()但应用层也要显式传入时间因为收银台机器时钟和数据库服务器时钟可能不一致日结对账以哪个为准要在代码里定死否则统计口径会乱。建议所有写入都由应用层统一指定时间数据库默认值只兜底。4.2 事务扣库存多收银台并发时怎么防超卖假设店里有最后一瓶水A收银台和B收银台几乎同时按结算。如果各自先插入销售明细、再执行UPDATE Products SET StockQty StockQty - 1两个会话都能读到库存为1然后都执行减一数据库最终结果变成-1。这就是超卖。解决办法是两件事一起做UPDATE语句带库存条件写成条件更新主表、明细、库存三步全部包进同一个事务。public bool CreateSaleOrder(SaleOrder order, ListSaleOrderItem items) { using (SqlConnection conn new SqlConnection(_connStr)) { conn.Open(); using (SqlTransaction trans conn.BeginTransaction()) { // 第一步插入销售主表拿到自增OrderId string sqlHead INSERT INTO SaleOrders(OrderNo, MemberId, TotalAmount, PaidAmount, ChangeAmount) VALUES(no, member, total, paid, change); SELECT SCOPE_IDENTITY();; // 第二步循环插入销售明细表注意OrderId用上一步拿到的值 // 第三步条件更新库存影响行数为0说明库存不足 string sqlStock UPDATE Products SET StockQty StockQty - qty WHERE ProductId pid AND StockQty qty; SqlCommand cmd new SqlCommand(sqlStock, conn, trans); cmd.Parameters.AddWithValue(qty, item.Qty); cmd.Parameters.AddWithValue(pid, item.ProductId); int affected cmd.ExecuteNonQuery(); if (affected 0) { trans.Rollback(); return false; // 库存不足刚才的主表和明细一起回滚 } trans.Commit(); return true; } } }这段代码的三个关键点UPDATE语句里的AND StockQty qty是防超卖的核心数据库会在行锁内校验当前库存不满足就不更新所有命令都要带trans参数cmd必须挂在同一个事务对象上否则默认自动提交事务形同虚设任何一个affected为0或抛出异常整单回滚不会出现“主表写了、库存没扣”的半截订单。C#线程在这个场景不是主要矛盾两个收银台是独立进程的并发问题落在数据库层面的行锁和事务上。事务隔离级别保持默认的ReadCommitted就够了。有人担心并发读脏数据直接把隔离级别升到Serializable结果两个收银台互相锁死最直观的故障就是收银台卡住不动。条件更新加行锁已经能保证库存扣减不超卖没必要牺牲并发去换用不到的隔离强度。4.3 单号不是自增ID日结为什么按OrderNo查询自增ID有两个问题删掉一条记录就断号营业笔数直接暴露在单号里。所以SaleOrders的OrderNo要单独生成规则常见的是“日期时间三位序号”public static string CreateOrderNo(DateTime now, int seq) { // 例如20250608153045123在日结统计时可以直接按前缀查 return now.ToString(yyyyMMddHHmmss) seq.ToString(D3); }seq怎么来查询当天已存在的最大OrderNo取末三位加一SQL里加WHERE OrderNo LIKE today %的条件。多条收银台并发生成时靠OrderNo列的UNIQUE约束兜底一旦插入报主键冲突程序捕获后重新生成一次即可。日结对账就变得很简单WHERE OrderNo LIKE 20250608%不用做日期转换也不用担心时区问题。这里要提醒一句OrderNo长度要固定我用的是yyyyMMddHHmmss共14位加3位序号总共17位不要一会儿带横杠一会儿不带否则LIKE查询和排序都会出问题。5. 收银系统避坑指南5个最常踩的翻车点与排查方法下面这五个坑不是在编译期报错而是运行时才暴露的一个比一个隐蔽。每条按现象、原因、解决的顺序说清楚都是我见过真实系统在这些地方栽跟头的记录。5.1 Access数据库提示“文件正在使用”却能单机运行现象程序第一遍运行正常关掉再打开就报“文件正被另一进程使用”或“无法更新数据库或对象为只读”重启电脑又好了。原因OleDbConnection没有被释放。DataGridView绑定DataTable后如果连接没有用using包裹或没有Close进程会一直占着.ldb锁文件。Access对多进程写支持本来就很弱哪怕只是连接没释放也会被当成“占用”。这不是玄学用Process Explorer查看就能看到你的exe进程里挂着那个.ldb文件句柄。解决所有OleDb连接都写成using代码块让连接用后即关同时把连接串里加上“OLE DB Services-1;”之类的选项不如直接换库来得省心。如果这套源码还要扩展建议直接从Access迁到SQLiteSQLite单文件模式不会出现这种“幽灵占用”。判断是否是锁库问题最直接的办法是看程序目录下有没有.ldb后缀文件有就说明连接没释放。5.2 金额显示0.30000000000000004现象顾客买三件单价0.1元的商品购物车小计显示0.30000000000000004打印小票也带一串小数。原因代码里用了double或float存金额。二进制浮点数无法精确表达0.10.1参与乘法后误差累积这是计算机组成原理级别的老问题不是程序随机出错。金额相关字段只要有一个人用double整条链路都会被污染。解决全局搜代码里的double和float凡是和金额沾边的全部改成decimal。C#侧的变量类型用decimal数据库列用DECIMAL(10,2)Access用CURRENCY类型JSON序列化时也配置成小数而不是浮点。改完之后加一条测试用例0.1乘3等于0.3这个断言不过代码不许上线。这类问题在收银源码里最容易被当成“偶尔不准”实际是确定性错误。5.3 扫码枪扫码后条码被拆成两段现象扫啤酒的条码购物车里出现了两行一行是“69”另一行是“0123456789”收银员要手工删掉错误行。原因扫码枪本质上是模拟键盘输入字符会按顺序一个个发送到当前焦点控件。如果扫码瞬间焦点不在商品条码文本框上前几个字符就跑到别的按钮或输入框里了。USB键盘模式的扫码枪发送速度时快时慢焦点一旦切换整串条码就被拆开。解决把条码输入框设为只接收数字并处理LostFocus事件让焦点永远弹回这个框。更稳的做法是捕获KeyDown事件等回车键把整串条码接收完后一并解析成完整字符串再查商品库不要边接收边查。还有一种思路是给扫码枪配置成串口模式用后台线程读串口数据彻底绕开焦点问题但配置成本高一些。排查这类问题先看是每扫必拆还是偶尔拆偶尔拆几乎都是焦点。5.4 库存出现负数但代码逻辑看起来没错现象月末盘点某商品库存是负数但日常销售并没有超卖迹象。从数据库日志查当天也没有对应进货单。原因三种可能。一是扣库存的UPDATE没带StockQty qty条件库存减到负数也不报错二是扣库存根本没放在事务里主表写成功但库存语句失败被前端catch吞掉了三是退单流程只退了金额没退库存顾客退货后商品“凭空消失”。解决扣库存统一走4.2节的事务写法条件UPDATE加事务包裹退款单复用一个反向事务把金额和库存一起恢复。不要相信“单机不会并发”这种话一个收银台也可能因为操作顺序交错而超扣先查后扣总是有窗口期。排查时先查SaleOrders里有没有对应的退款单、再查SaleOrderItems的Qty总和与库存台账差多少两步定位到具体环节。5.5 小票打印乱码或半个汉字现象热敏打印机打出来的小票中文变成“???”或者一段乱码抬头店名显示不全。原因驱动装错或者编码不匹配。代码用ESC/POS指令直接发字节流但驱动装成了Windows文本驱动或者代码拼接字符串时用了Encoding.Default在简体中文Windows上通常没问题但一旦系统区域设置变了就乱。小票机裁半行的“半个汉字”问题通常是因为发送了奇数字节的UTF-8字符。解决确认打印机实际支持的指令集代码侧固定用GBK编码拼接整个小票内容再一次发送不要逐行发送让打印机状态错乱。驱动安装时选具体型号驱动别图省事装通用文本驱动。调试时先把打印机自带的诊断页打出来确认状态再打印一段纯ASCII测试是否正常逐步缩小范围。这类问题一旦上线才暴露收银台前排队等你修打印机的场面相当难忘。6. 上线前的最后一步日结对账与ESC/POS小票打印系统开发完不是结束敢不敢正式开业看两件事日结能不能对平、小票打印快不快。日结对账的核心是一句统计SQL当天所有订单的应收合计和实收合计必须相等再拿SaleOrderItems的销售数量与商品表库存变化量核对两边一致这一天的账才算平。我会把这段对账SQL做成一个“日结”按钮收银员下班前点一下不平就弹出差异明细而不是等到月底盘点才发现问题SELECT SUM(TotalAmount) AS TotalDue, SUM(PaidAmount) AS TotalPaid, COUNT(OrderId) AS OrderCount FROM SaleOrders WHERE SaleTime todayStart AND SaleTime todayEnd;另外一个直接影响顾客体验的是小票打印。我见过第一版用打印对话框打印一单要等两三秒高峰期队伍直接排到门口。后来换成热敏打印机原生ESC/POS指令初始化后直接写字节流扣掉对话框的渲染时间一单不到一秒。初始化指令是ESC 发送时用Encoding.GetEncoding(GBK)把字符串转成字节数组再写端口。定宽小票不用担心边缘截断反而要控制每行字符数58mm小票纸一般每行32个中文字符以内超出部分才是“半个汉字”的真凶。我做这套系统的第一版时没做日结卖了一星期才发现库存对不上最后靠导出Excel手工盘了三天从那以后对账SQL就成了我的标配。搜源码、读源码是起点能把结算那一刻的数据一致性守住才是这套系统真正值钱的地方。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?