C# WPF超市收银系统源码解析:从MVVM到扫码结账实战

发布时间:2026/9/29 18:56:50
C# WPF超市收银系统源码解析:从MVVM到扫码结账实战
简介基于C#与WPF框架构建的超市收银系统完整源码适合桌面应用开发者、计算机专业课程设计者以及需要参考进销存与零售结算流程的技术人员。项目模拟真实收银场景覆盖商品资料维护、库存同步、订单结算、会员管理等核心模块可直观了解WPF界面层与C#业务逻辑如何协同工作。压缩包共564个文件总大小约57.43MB其中198个cs源码文件为业务实现主体32个xaml负责界面布局与样式64个baml是已编译的界面资源此外还包含72个dll运行库、8个exe程序及sqlite数据库文件具备较好的完整性与可运行性。目前已有241人学习下载深入阅读源码可学习XAML数据绑定、命令绑定、MVVM模式拆分、ADO.NET数据库访问等WPF关键技术也能借鉴订单流水、库存联动、会员积分等模块的设计思路对理解商业桌面软件工程化开发具有实际参考价值。1. 打开这份C# WPF超市收银系统源码前台收银与后台管理一次讲清社区超市收银台最常见的画面是扫码枪扫完商品收银员还要手动敲键盘改价格、翻本子记会员欠账晚上对账时账实不符成了家常便饭。这份基于C# WPF的超市收银系统就是冲着这个场景去的——前台收银台负责扫码、购物车、折扣与小票打印后台管理负责商品、库存、会员和订单流水前后台用MVVM结构串起来。对刚把C#语法过完一遍、想找个完整项目拆着练的人来说它比单纯刷题更有参考价值对准备给自家门店或小客户做信息化改造的开发者它也是一个可以少走弯路的起步底子。我拆这套源码时重点看的是三层界面层和业务层怎么解耦、扣库存的事务到底写没写对、以及打印和扫码这类外设交互有没有踩坑。2. 把架子先搭对MVVM分层、数据访问与界面库选型2.1 为什么收银界面用WPF而不是WinForm先回答一个很现实的选型问题做收银台这种偏工具类的界面为什么不用WinFormWinForm开发快、控件直接拖但真把商品列表、结算面板、会员信息放在一个窗口里联动时WinForm的事件驱动写法会让代码迅速膨胀。WPF的XAML声明式布局和DataBinding更适合收银台这种“数据变化驱动界面刷新”的场景。比如购物车数量一改小计和总价跟着变在WPF里只需要让TotalPrice这个属性响应集合变化就行代码量比WinForm少一截。从设备交互角度看收银台本质是个接外设的上位机程序扫码枪输入条码、小票打印机走串口或驱动打印、钱箱由打印指令控制弹开。这些外设事件通常来自独立线程WPF的Dispatcher机制处理跨线程更新UI比WinForm更顺手界面不会因为一次串口接收就整个卡住。另外WPF对触摸屏操作也友好收银员直接点屏幕改数量、选支付方式按钮和列表的响应区域可以做得很大。综合下来这套源码选WPF是合理的它不是炫技而是收银这个场景确实吃绑定和异步交互。2.2 解决方案的四个工程与依赖关系打开这套源码首先会看到它按职责拆成了四个工程这是一个值得学下来的分层习惯。View层放窗口和用户控件ViewModel层放界面状态和命令Model层放商品、订单、会员实体DataAccess层放所有数据库读写。依赖关系是单向的View引用ViewModelViewModel引用Model和DataAccess谁都不准反向引用。为什么强制单向依赖因为收银系统的界面改动频率非常高——今天加会员积分入口明天改支付方式按钮后天换小票模板。如果这些改动都需要连带改业务代码维护成本就上去了。有了ViewModel做中间层换界面时业务逻辑可以原样保留。我习惯先看MainWindowViewModel它是整个收银台的导航中枢里面通常会暴露几个关键命令打开收银台、打开商品管理、打开订单查询、打开会员管理。在源码里可以看到它的构造函数里注入了数据访问服务和一些共享状态这种写法可以保证切换页面时购物车状态、当前登录用户这类信息不丢。2.3 数据库访问SQLite与SQL Server并存的好处源码的数据访问层值得多花点时间看因为收银系统的存盘点常常是噩梦。单店收银机用SQLite最省事一个文件搞定不用装数据库服务断电也不会把数据搞烂。多台收银机联网的超市则需要SQL Server这类中心化数据库否则多个前台同时卖货库存数据各说各话。好的源码会把这层抽象开让你在两种数据库之间切换时不碰界面代码。我拆这份源码时看到它用了类似Dapper这种轻量ORM。说实话在收银这种查询模式固定、表结构清晰、对性能和可控性要求高的场景里Dapper比EF Core更合适。EF Core的功能强但映射约定、变更追踪带来的开销和心智负担对一个中小型收银系统来说有些重。Dapper手写SQL出了问题直接用SSMS去验SQL排查链路短。看一段商品查询的示例public Product GetProductByBarcode(string barcode) { using var connection _dbFactory.CreateConnection(); const string sql SELECT Id, Barcode, ProductName, UnitPrice, Stock, CategoryId FROM Products WHERE Barcode Barcode AND Status 1; return connection.QueryFirstOrDefaultProduct(sql, new { Barcode barcode }); }这段SQL只查上架状态的商品Status 1这个条件很重要下架商品不该出现在收银台扫码结果里。Barcode参数化传值防止拼SQL带来的注入问题收银台的条码输入框如果直接拼字符串碰到特殊字符时会翻车。QueryFirstOrDefault在没有匹配项时返回null调用方要做空判断提示“商品不存在或已下架”。2.4 界面库HandyControl与FontAwesome.Sharp的实际用途收银台的UI想要做出门店能用的水平纯靠原生控件工作量不小。源码里大概率引入了HandyControl和FontAwesome.Sharp这类库这不是凑包而是省时间。HandyControl提供了一套现成的控件样式和窗口模板比如收银台左侧的功能导航菜单、右侧的DataGrid商品列表用它的样式库能直接获得干净的视觉效果不用自己重画模板。FontAwesome.Sharp则是给WPF用的图标字体库把FontAwesome图标渲染成按钮图标。收银界面的按钮多但每个按钮都用图片素材太费事用图标字体只需要指定一个Unicode编码就能显示图标。例如侧边栏的“商品管理”按钮加一个Cube图标“订单查询”加一个ClipboardList图标两行XAML就完成清晰度比位图高。需要提醒的是引入字体图标库后按钮的FontFamily属性要指向对应字体名很多人图标不显示都是这个没设置对。3. 收银台核心链路落地扫码检索、购物车与折扣结算3.1 扫码枪的输入机制与商品检索先说一个基础知识超市用的扫码枪在系统层面就是个键盘设备扫到条码后像打字一样快速输入一串数字最后自动带一个回车键。所以收银台的条码输入框只需要接收文本并处理回车事件不需要写任何串口代码。常见做法是让条码Box常驻焦点或者干脆在整个窗口层拦截PreviewKeyDown只要判断回车和条码格式就触发查询。扫码检索牵扯到界面刷新WPF里最合适的集合类型是ObservableCollection。购物车每加一件商品界面列表就自动多一行不需要手动NotifyPropertyChanged。下段代码演示了购物车条目怎么组织public class CartItemViewModel : ObservableObject { public string Barcode { get; set; } public string ProductName { get; set; } public decimal UnitPrice { get; set; } private int _quantity; public int Quantity { get _quantity; set { _quantity value; OnPropertyChanged(nameof(Quantity)); OnPropertyChanged(nameof(SubTotal)); } } public decimal SubTotal UnitPrice * Quantity; }集合本身用ObservableCollection 绑定到DataGrid的ItemsSource增删会自动通知界面。Quantity里再次触发SubTotal的变更通知这是“数量改、小计跟着改”的关键。注意SubTotal是只读属性不能在ViewModel层手动给它赋值否则会出现赋值了但界面不刷新的问题。扫码查询后第一件事是判断商品是否已在购物车里已经在的话直接Quantity加1不要重复插行。这个逻辑通常在ViewModel里用一个字典按条码索引购物车项时间复杂度是常数如果每次都遍历集合购物车商品多了以后收银高峰期会感觉到卡顿。3.2 折扣策略与金额计算decimal的固执收银系统的折扣规则五花八门大多数中小超市用阶梯折扣比如满100打9折、满200打8.5折。源码里通常会用独立的折扣服务或者配置表来管理规则不会把数字硬编码在界面代码里。这部分最需要较真的是数据类型所有金额字段必须用decimal不能手滑写出double。很多新人会在这翻车。double看起来也能存小数但它是二进制浮点0.1 0.2这种运算会得到0.30000000000000004这样的结果。收银台连续出几十单之后金额微小的偏差会被累积放大对账时账目差几分钱的事件就是这么来的。decimal是十进制浮点专门为货币计算设计。看一段阶梯折扣的实现public decimal ApplyDiscount(decimal totalAmount) { decimal discountRate 1m; if (totalAmount 200m) { discountRate 0.85m; } else if (totalAmount 100m) { discountRate 0.9m; } decimal finalAmount decimal.Round(totalAmount * discountRate, 2); decimal discountMoney totalAmount - finalAmount; if (discountMoney 0m) { _logService.WriteOperationLog($订单折扣金额: {discountMoney}); } return finalAmount; }这里每一处金额都带m后缀强制走decimal别让编译器和运行时自己猜。decimal.Round的第二个参数2表示保留两位小数四舍五入。折扣金额先算出来再记日志这样后面的日结报表可以对得上账。也要留意边界条件负金额、超过原价的折扣率、购物车为空时点结算这些都要在入口处拦截。3.3 小票打印串口指令与模板小票打印是收银系统里最容易让人烦躁的环节它不像界面和数据库出问题不是弹异常而是“打了但格式不对”或“打一半卡纸”。常见做法是两种走Windows打印驱动程序直接把排版好的文档发给打印机或者走串口指令用小票机的ESC/POS命令控制内容、走纸和切纸。后者可控性更强也是这份源码里能看到的主要方式。串口打印前先要确认小票机的通信参数我在这里吃过亏直接用系统默认值去连结果乱码。常见的参数是波特率9600、数据位8、停止位1、无校验有些新机器的波特率是115200以设备背面标签为准。打印内容拼装时按行写入字节流用代码示意一下核心动作private void SendToPrinter(string content) { using var port new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); port.Open(); // 初始化打印机清空缓冲 byte[] init { 0x1B, 0x40 }; port.Write(init, 0, init.Length); // 选择中文字符集避免中文变问号 byte[] charset { 0x1B, 0x74, 0x0F }; port.Write(charset, 0, charset.Length); byte[] contentBytes Encoding.Default.GetBytes(content \n); port.Write(contentBytes, 0, contentBytes.Length); // 走纸两行并切纸 byte[] cut { 0x1B, 0x64, 0x02, 0x1D, 0x56, 0x42, 0x00 }; port.Write(cut, 0, cut.Length); }这个发送方法的关键约束是内容拼装和指令发送都需要保持编码一致。Encoding.Default在中文Windows下是GB2312可以兼容大多数国产小票机如果打印机要求UTF-8要把编码明确指出来。模板方面我的习惯是内容用一个List 拼每行是一个元素然后统一用Environment.NewLine连接。商品行、合计行、折扣行都各自成段这样改模板时只需要改字符串拼接不会动到底层发送逻辑。4. 商品、订单与会员数据库设计与防超卖事务4.1 三张核心表的设计思路数据库设计是收银系统的地基。这套源码里最核心的是商品表、订单表、订单明细表再加一张会员表。设计上需要提前想清楚几个细节商品价格用decimal(10,2)库存用int订单状态用tinyint0未支付、1已支付、2已退款时间字段统一用datetime2。金额字段不要省精度库存字段不要用浮点数这些都是血泪教训换来的。订单头和订单明细为什么要拆两张表因为一张订单可能包含多个商品而商品的价格、数量是下单时的快照不能完全依赖商品表。比如某个商品今天调价了历史订单里的信息还得是当时的售价。订单明细表存储下单时的ProductName、UnitPrice、Quantity、SubTotal这样日后查报表、打退款单都不会因为商品表变化而失真。索引设计也很关键。商品表的Barcode要建唯一索引因为扫码枪每次查询都走条码。订单明细表的OrderId字段建普通索引按单查明细时避免全表扫描。订单表上的PaymentTime建索引因为日结报表基本都按支付时间筛选。索引不是越多越好但收银系统里这几个查询路径几乎是固定的该建的索引建上高峰期查询体验会好很多。4.2 扣库存必须用事务防止超卖的最底线收银系统最怕超卖库存显示还有5件结果卖出去了6件。原因通常是“先读库存、判断够不够、再扣减”这三步没有放在同一事务里。两台收银机同时处理同一个商品时两个进程都读到库存5都判断可以卖都去扣减库存会变成3但实际卖出2件账就乱了。正确的写法是让数据库来守护库存在一条更新语句里做条件判断。下面是SQLite和SQL Server都能接受的通用思路BEGIN TRANSACTION; UPDATE Products SET Stock Stock - Quantity WHERE Id ProductId AND Stock Quantity; -- 如果影响行数为0说明库存不足回滚并停止 IF changes() 0 BEGIN ROLLBACK; -- 返回错误库存不足 END ELSE BEGIN -- 写入订单表和订单明细表 COMMIT; END;这里有个设计选择真正的成品代码不应该在SQL脚本里写业务判断因为收银程序还要同时写订单表、可能扣会员积分、记录支付流水。把这些都放进一个事务里在C#代码层面用IDbTransaction更直观。Dapper的事务写法是这样using var connection _dbFactory.CreateConnection(); using var transaction connection.BeginTransaction(); try { string updateSql UPDATE Products SET Stock Stock - Quantity WHERE Id ProductId AND Stock Quantity; int rowsAffected connection.Execute(updateSql, new { Quantity item.Quantity, ProductId item.ProductId }, transaction); if (rowsAffected 0) { transaction.Rollback(); throw new InsufficientStockException(item.Barcode); } // 此处继续插入订单头、订单明细、流水记录 transaction.Commit(); } catch { transaction.Rollback(); throw; }写这套逻辑的关键有两点一是UPDATE语句里的Stock Quantity条件它把“判断库存”和“扣减库存”压缩进同一原子操作避免并发超卖二是事务范围要覆盖所有写操作不能只包住扣库存否则扣了库存但订单没写成功数据还是会错位。最后不要忘了在事务提交前用try-catch包住发生异常立即回滚这是收银数据的后悔药。4.3 会员积分与储值的设计约束会员模块在中小超市里主要干两件事积分和储值。积分规则一般是消费1元积累1分有些店会根据会员等级加倍储值则是预存金额结账时优先扣储值余额。源码里会员表至少要包含MemberId、Phone、Balance、Points、Level字段其中Balance和Points都是频繁变更的字段。储值扣款也怕并发比如会员在两个柜台同时结账余额100元两边都判断够扣最后余额变成负数。解决办法跟库存一样UPDATE Members SET Balance Balance - Amount WHERE Id Id AND Balance Amount并且和订单写入放在同一个事务里。积分同理但积分允许负数的可能性更小加个积分流水表记录每次变动来源消费赠送、手动调整、过期清零以后对账时有据可查。任何直接改余额或积分的操作都应该记流水这是财务类系统的底线也是我刚入行时被老师傅教育过很多次的地方。5. 实战避坑扫码枪、小票打印与金额对账的常见排查5.1 扫码枪商品加不进去扫完没反应现象收银员扫码后收银台界面没有任何反应但光标跳到别的输入框里去了。原因扫码枪本质是键盘它把条码“打”到当前焦点所在的控件上。如果收银台的条码TextBox不在主窗口的默认焦点位置或窗口里某个按钮获得了焦点扫码内容就到别处去了。再一个常见原因是全局快捷键或别的事件处理把回车键消耗了。解决不要只依赖单控件焦点而是在主窗口的PreviewKeyDown事件里统一拦截。protected override void OnPreviewKeyDown(KeyEventArgs e) { if (e.Key Key.Return _scannerInput.Length 6) { _viewModel.SearchProductByBarcode(_scannerInput); _scannerInput string.Empty; e.Handled true; } else if (e.Key Key.D0 e.Key Key.D9) { _scannerInput e.Key.ToString().Replace(D, ); } else if (e.Key Key.NumPad0 e.Key Key.NumPad9) { _scannerInput (e.Key - Key.NumPad0).ToString(); } }窗口级拦截的好处是不管焦点在哪条码都能被收进来。限制是只能处理键盘型扫码枪USB串口型的扫码枪要走DataReceived事件那是另一套逻辑。判断条码长度大于6是为了防止普通数字输入被当成条码减少误触。5.2 打印中文变成问号或乱码现象小票上的商品名全是问号或者一行字支离破碎。原因串口打印时发送的字节流和打印机固件期望的字符集不一致。很多国产热敏打印机的默认字符集不是UTF-8而是GB18030或者GBK。直接用UTF-8编码发中文打印机的字库表里找不到对应关系就打出了问号。解决发送内容前先向打印机发送选择字符集的指令再用匹配的Encoding类编码内容。// 设定GBK字符集0x0F一般表示汉字字符集 port.Write(new byte[] { 0x1B, 0x74, 0x0F }, 0, 3); // 用GBK编码转换中文内容 Encoding gbk Encoding.GetEncoding(GB18030); byte[] data gbk.GetBytes(receiptContent); port.Write(data, 0, data.Length);遇到乱码时先确认一件小事直接用打印机厂商的调试工具打印一条中文如果厂商工具也乱码是打印机字符集设置问题和你的代码无关只有厂商工具正常而你的程序乱码才需要调整代码里的编码方式。5.3 日结对账差了一分钱现象销售明细加总后的金额和收款合计对不上账面差一分钱或几分钱。原因大部分是计算精度问题某个环节用了double累计了一天的舍入误差后暴露出来。还有一部分是退货未落账、抹零金额没记录、或者折扣计算时先四舍五入了金额后续又按未四舍五入金额参与运算。解决全面检查所有金额计算路径强制decimal类型并且统一舍入时机。我的习惯是每一行明细只存最终的小计金额折扣在整单层面记录折扣金额不再回写明细。舍入只发生在最终保存时刻中间过程一律保留原始小数。用代码检查时搜索全部double字面量看到0.1、0.05、0.85这些数字逐一确认它们是否被隐式转换成了decimal。5.4 收银高峰期界面卡住按一下按钮半天才响应现象每天下午两三点客流高峰期收银员按键后界面明显延迟点击结算后要等好几秒。原因数据库查询或者文件写入操作直接跑在UI线程上。扫码枪扫一个商品程序同步查一次数据库界面渲染就被阻塞。WPF的UI线程一旦被阻塞占用所有界面操作都得排队表现为卡顿。解决所有数据库查询都走async/await同时把耗时的文件写入、报表生成操作放到后台任务。需要注意async不是万灵药如果同步调用了.Result或.Wait()还是会死锁或阻塞。另外购物车集合如果用ObservableCollection大批量更新时要临时切换到IList操作再整体刷新UI避免每加一行都触发一次界面变化这也是高峰期卡顿的一个隐藏来源。6. 进阶改造离线单、报表导出与角色权限6.1 断网容灾离线单先写本地再同步服务器门店断网是常见故障收银台不能因为路由器重启就停摆。给收银系统加一个离线模式并不复杂本地数据库SQLite作为临时存储订单状态标记为PendingUpload收银员可以正常结算收银程序把订单写入本地库序列化保存网络恢复后后台服务定期扫描本地库里未上传的订单逐单上传到服务器成功后把状态改成Uploaded。设计上要注意两件事本地库的表结构和服务器端保持一致至少保证OrderId相关字段可追溯因为离线期间没有库存实时扣减同步时要重新校验库存库存不足的订单要标记为异常不能静默成功。我一般会在订单表加一个SyncStatus字段0代表待上传、1代表已上传、2代表库存异常方便排查。6.2 日结报表导出不依赖Excel互操作很多老板看报表就要Excel文件。最省事的做法是让程序导出CSVExcel直接双击就能打开。用代码生成CSV比引用Excel COM组件稳定得多不会因为Excel版本或权限问题报错同一段逻辑也方便扩展成导出选定时段的所有订单明细public void ExportOrdersToCsv(ListOrderHeader orders, string filePath) { var sb new StringBuilder(); sb.AppendLine(订单号,支付时间,商品数,应收金额,实收金额,抹零金额); foreach (var order in orders) { sb.AppendLine(string.Join(,, order.OrderNo, order.PaymentTime.ToString(yyyy-MM-dd HH:mm:ss), order.ItemCount, order.TotalAmount, order.ActualAmount, order.DiscountAmount)); } File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8); }CSV的一个细节是单元格内容里如果包含逗号或换行必须用双引号包裹否则导出后的列会错位。商品明细里如果导出含换行的备注字段最好提前做转义处理。报表导出的动作放到后台线程执行界面只显示“正在导出”的状态提示。6.3 角色权限按登录身份控制敏感操作中小超市的收银机不是只有收银员用店长要能改价格、退单、查报表老板要能看到各门店销售汇总。源码里的权限做法一般是在登录窗口选择角色登录成功后把角色信息写进全局SessionViewModel里的各个按钮通过CanExecute控制可用性。我习惯用继承自ICommand的RelayCommand给它传入一个CanExecute委托比如“改价”按钮的CanExecute判断当前用户是否拥有PriceOverridePermission。这样一来权限逻辑集中在ViewModel里按钮自动置灰比在XAML里写Visibility判断要清爽得多。退单、手动调整库存这些更敏感的操作还要在操作日志里记录操作员ID出了问题能追溯到具体人。再者改源码前一定先备份原工程至少在本地打个压缩包。收银系统的数据库文件也要定期备份SQLite可以直接复制文件备份SQL Server用定时备份任务。开发时可以在代码里留一个Debug模式下可用的“跳过登录”开关但发布版本必须强制走登录流程这个开关我吃过亏上线时没关店员直接就能进后台乱改价账单乱了一周才查出来。从那以后每次发版我都会把登录、精度、事务这三个地方强制走一遍检查清单确认没有捷径和盲区才敢交给门店用。希望帮到你。本文还有配套的精品资源点击获取