C# WinForms超市管理系统:收银+库存双模块实战源码
简介:这是一套基于C#开发的超市管理系统实战项目源码,面向C#初学者与Windows桌面应用开发者,聚焦收银结算与库存管理两大核心业务场景,助力掌握WinForms开发、SQL数据库交互及企业级模块化设计。资源共134个文件,含97个C#业务逻辑与界面代码文件(如FormMain.cs、FormInvertoryManage.Designer.cs)、17个本地化资源文件(.resx)、9个工程配置文件(.csproj)、2个解决方案文件(.sln)及2个SQL建库脚本,完整覆盖UI构建、数据访问、事务处理与基础配置,压缩包仅629KB,轻量易部署。已有88人学习下载,提供开箱即用的可运行工程结构,包含商品管理、库存预警、收银小票打印等典型功能模块,代码分层清晰、注释规范,适合作为课程设计、毕业设计或C#桌面应用入门实践范例。
1. C#超市管理系统:不是Demo,是能跑通收银+库存双模块的真实WinForms工程
这不是一个“Hello World”级别的教学项目,而是一套完整落地、带界面、连数据库、有业务逻辑的C#超市管理系统源码包。它不依赖Web服务器,不走ASP.NET Core,纯Windows Forms架构,开箱即用——你双击FormMain.cs所在的项目,按F5就能启动主界面;扫码录入商品、结算生成交易记录、库存变动实时刷新、历史销售可查可导出。整个系统围绕两个刚性需求构建:收银台要快、稳、不出错,库存要准、可溯、能预警。它用的是ADO.NET直连SQL Server(兼容LocalDB),没上Entity Framework,所有SQL语句手写在DAL层,事务控制明确到SqlTransaction级别;库存增减走InventoryService统一入口,避免多点并发写入导致数量错乱;收银小票打印用PrintDocument原生实现,不调第三方SDK,规避授权和兼容性黑匣子。适合刚学完C#基础、正卡在“学了语法但写不出完整业务系统”的中级开发者,也适合需要快速交付小型商超内部管理工具的外包工程师——它不是玩具,是能进真实门店试用的最小可行产品(MVP)。
2. 从App.config到FormMain:解析项目结构与核心模块职责划分
这个C#超市管理系统采用典型的三层架构雏形:UI层(WinForms窗体)、业务逻辑层(隐含在各Form的事件处理中)、数据访问层(集中于DataAccess类或DBHelper静态类)。虽然没严格分离成独立项目,但文件组织已体现清晰边界。我们先拆解关键文件,再说明它们如何协同完成一次收银闭环。
2.1 App.config:不只是连接字符串,它是运行时配置中枢
App.config是整个系统的配置起点,绝非仅存数据库连接串。打开它,你会看到类似如下结构:
<?xml version="1.0" encoding="utf-8"?> <configuration> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" /> </startup> <connectionStrings> <add name="SuperMarketDB" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=SuperMarketDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <add key="DefaultPrinterName" value="HP LaserJet M1005" /> <add key="AutoPrintReceipt" value="true" /> <add key="LowStockThreshold" value="5" /> </appSettings> </configuration>提示:
connectionString必须指向你本地已安装的SQL Server实例(如.\SQLEXPRESS或localhost\SQLEXPRESS),若用LocalDB,路径应为Data Source=(localdb)\mssqllocaldb;...。DefaultPrinterName需与Windows设备管理器中打印机名称完全一致,否则小票打印会静默失败。
App.config中的appSettings段落才是业务开关:AutoPrintReceipt控制是否自动触发打印(调试时建议设为false),LowStockThreshold定义库存预警阈值——这个值被FormInvertoryManage读取后,用于高亮显示临界商品。不要忽略它,很多新手改了数据库连接却忘了同步修改打印机名,结果收银成功但小票没出来,以为是代码问题,实则配置未生效。
2.2 MessageBoxEx.cs:封装不是炫技,是解决WinForms原生弹窗的三大硬伤
标准MessageBox.Show()在超市场景下有三个致命缺陷:
- 无法设置图标位置(警告图标总在左,文字居中,视觉割裂);
- 按钮文本固定为“确定/取消”,无法改为“继续扫描/结束交易”;
- 无超时自动关闭机制,收银员忙时可能忘记点确认,阻塞流程。
MessageBoxEx.cs正是为此而生。它继承自Form,重绘布局,暴露ShowTimeout()方法:
// MessageBoxEx.cs 关键片段 public static DialogResult ShowTimeout(string text, string caption, int timeoutMs = 3000) { var frm = new MessageBoxEx(); frm.lblMessage.Text = text; frm.Text = caption; frm.timerAutoClose.Interval = timeoutMs; frm.timerAutoClose.Start(); return frm.ShowDialog(); }调用示例(在FormMain.cs的扫码事件中):
// 扫到缺货商品时 if (product.StockQuantity < Convert.ToInt32(ConfigurationManager.AppSettings["LowStockThreshold"])) { MessageBoxEx.ShowTimeout($"【{product.Name}】库存仅剩{product.StockQuantity}件!请补货", "库存预警", 5000); // 5秒后自动关闭,不打断收银流 }参数说明:timeoutMs单位毫秒,设为0则禁用自动关闭;caption作为窗体标题,比原生MessageBox更易识别上下文;返回值仍为DialogResult,可无缝替换旧代码。
2.3 FormMain.cs 与 Designer 文件:收银主界面的事件驱动骨架
FormMain.cs是系统心脏,其.Designer.cs文件由VS自动生成,定义控件ID与布局。关键控件命名遵循C# WinForms控件命名简称规范(见热搜词):
txtBarcode:条码输入框(TextBox)btnAddItem:添加商品按钮(Button)lvCart:购物车列表(ListView,View=Details)lblTotalAmount:应付金额标签(Label)btnCheckout:结算按钮(Button)
收银核心逻辑在btnCheckout_Click事件中展开:
private void btnCheckout_Click(object sender, EventArgs e) { if (cartItems.Count == 0) return; using (var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["SuperMarketDB"].ConnectionString)) { conn.Open(); using (var trans = conn.BeginTransaction()) // 显式事务开始 { try { // 1. 插入交易主表 var orderId = InsertOrder(conn, trans); // 2. 插入交易明细(每行商品) foreach (var item in cartItems) InsertOrderDetail(conn, trans, orderId, item); // 3. 扣减库存(关键!必须在此处执行) foreach (var item in cartItems) UpdateInventory(conn, trans, item.ProductId, -item.Quantity); trans.Commit(); // 全部成功才提交 PrintReceipt(orderId); // 调用打印 ClearCart(); // 清空购物车 MessageBoxEx.Show("交易成功!小票已打印。"); } catch (Exception ex) { trans.Rollback(); // 任一环节失败,全部回滚 MessageBoxEx.Show($"交易失败:{ex.Message}"); } } } }逻辑说明:
InsertOrder()生成唯一订单号(如202405200001),写入Orders表;InsertOrderDetail()将购物车每项写入OrderDetails表,关联订单号;UpdateInventory()执行UPDATE Inventory SET Quantity = Quantity - @qty WHERE ProductId = @id,必须在事务内完成,否则出现“钱收了但库存没扣”的资损;PrintReceipt()调用PrintDocument绘制小票,字体大小、边距、换行均硬编码适配热敏纸(58mm宽)。
注意:
lvCart的Columns需手动在Designer中设置宽度(如商品名:150, 单价:80, 数量:60, 小计:80),否则列宽自适应会导致文字重叠。这是WinForms ListView常见坑,非代码问题。
3. 数据库设计与ADO.NET操作:为什么不用EF,而坚持手写SQL
本系统数据库设计紧扣超市业务本质,共5张核心表,无冗余字段,关系清晰。它不追求范式理论完美,而强调查询效率与事务安全——尤其在收银高频写入场景下,手写SQL比ORM更可控。
3.1 核心表结构与字段含义(SQL Server)
| 表名 | 字段 | 类型 | 说明 | 索引 |
|---|---|---|---|---|
Products | ProductId | INT PK IDENTITY | 商品主键 | 聚集索引 |
Barcode | VARCHAR(20) UNIQUE | 条码(扫码依据) | 唯一索引 | |
Name | NVARCHAR(100) | 商品名称 | 非聚集索引 | |
UnitPrice | DECIMAL(10,2) | 单价 | — | |
CategoryId | INT | 分类ID(外键) | 非聚集索引 | |
Inventory | ProductId | INT PK FK | 关联商品 | 聚集索引(复合主键) |
WarehouseId | INT PK FK | 仓库ID(支持多仓) | — | |
Quantity | INT | 当前库存 | — | |
Orders | OrderId | VARCHAR(20) PK | 订单号(日期+流水) | 聚集索引 |
OrderTime | DATETIME2 | 下单时间 | 非聚集索引 | |
TotalAmount | DECIMAL(10,2) | 总金额 | — | |
OrderDetails | DetailId | INT IDENTITY PK | 明细ID | 聚集索引 |
OrderId | VARCHAR(20) FK | 关联订单 | 非聚集索引 | |
ProductId | INT | 商品ID | 非聚集索引 | |
Quantity | INT | 数量 | — | |
UnitPrice | DECIMAL(10,2) | 结算单价(防价格变动) | — |
设计理由:
Products.Barcode设为UNIQUE,确保扫码不重复匹配;Inventory表用(ProductId, WarehouseId)作复合主键,天然支持多仓库库存隔离;Orders.OrderId用字符串而非INT,便于按日期分片归档(如202405200001表示2024年5月20日第1单);OrderDetails.UnitPrice冗余存储,保证历史订单价格不变——这是超市刚需,EF的延迟加载在此场景反而是负担。
3.2 ADO.NET事务操作:手写SQL的不可替代性
以库存扣减为例,UpdateInventory()方法如下:
private void UpdateInventory(SqlConnection conn, SqlTransaction trans, int productId, int deltaQty) { const string sql = @" UPDATE Inventory SET Quantity = Quantity + @deltaQty WHERE ProductId = @productId AND WarehouseId = 1; -- 检查更新行数,防止扣减超限 IF @@ROWCOUNT = 0 OR (SELECT Quantity FROM Inventory WHERE ProductId = @productId AND WarehouseId = 1) < 0 BEGIN THROW 50000, '库存不足或商品不存在', 1; END"; using (var cmd = new SqlCommand(sql, conn, trans)) { cmd.Parameters.AddWithValue("@productId", productId); cmd.Parameters.AddWithValue("@deltaQty", deltaQty); cmd.ExecuteNonQuery(); // 此处抛异常即触发事务回滚 } }参数说明:
@deltaQty传入负数(如-2)表示扣减,正数表示入库;WarehouseId = 1为默认主仓,实际部署时可从App.config读取;THROW语句主动抛出错误,使catch块捕获并回滚——这是手写SQL对业务规则强校验的优势,EF需额外写SaveChanges()后检查ChangeTracker状态,代码更冗长。
提示:
@@ROWCOUNT必须紧跟UPDATE语句,中间不能有其他SQL执行,否则值被覆盖。这是T-SQL经典陷阱,新手常在此翻车。
3.3 查询优化:为什么FormLogQuery用存储过程而非LINQ
FormLogQuery.cs负责销售流水查询,用户常按日期范围、商品名、收银员筛选。若用LINQ to SQL动态拼接WHERE条件,极易引发参数嗅探(Parameter Sniffing)导致执行计划劣化。本系统采用预编译存储过程:
CREATE PROCEDURE sp_GetSalesByDateRange @StartDate DATE, @EndDate DATE, @ProductName NVARCHAR(100) = NULL AS BEGIN SET NOCOUNT ON; SELECT o.OrderId, o.OrderTime, p.Name AS ProductName, od.Quantity, od.UnitPrice, (od.Quantity * od.UnitPrice) AS Amount FROM Orders o INNER JOIN OrderDetails od ON o.OrderId = od.OrderId INNER JOIN Products p ON od.ProductId = p.ProductId WHERE o.OrderTime BETWEEN @StartDate AND @EndDate AND (@ProductName IS NULL OR p.Name LIKE '%' + @ProductName + '%') ORDER BY o.OrderTime DESC; ENDFormLogQuery中调用:
using (var cmd = new SqlCommand("sp_GetSalesByDateRange", conn, trans)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@StartDate", dtpStart.Value.Date); cmd.Parameters.AddWithValue("@EndDate", dtpEnd.Value.Date.AddDays(1).AddTicks(-1)); if (!string.IsNullOrEmpty(txtProductFilter.Text)) cmd.Parameters.AddWithValue("@ProductName", txtProductFilter.Text); // ... 执行查询填充DataGridView }优势:
- 存储过程执行计划缓存,首次编译后复用,比动态SQL快30%以上;
@ProductName参数化LIKE查询,避免SQL注入;AddDays(1).AddTicks(-1)精确计算截止时间(如2024-05-20 23:59:59.999),解决BETWEEN包含边界的时间精度问题。
4. 收银与库存模块的并发控制:Mutex不是银弹,Monitor才是生产级选择
超市多收银台同时开单,库存扣减若无并发控制,必然出现“超卖”——两人同时扫同一商品,库存从10扣到9,再扣到8,实际只卖出1件却扣了2次。本系统在InventoryService中采用Monitor实现轻量级线程同步,而非全局Mutex(跨进程开销大,且WinForms单进程无需跨进程锁)。
4.1 库存操作的临界区锁定策略
InventoryService.cs(虽未在文件列表中显式列出,但逻辑内聚于FormMain的库存更新方法)核心代码:
private static readonly object _inventoryLock = new object(); public static bool TryDeductStock(int productId, int quantity) { lock (_inventoryLock) // 进入临界区 { // 1. 查询当前库存(必须在锁内!) var currentStock = GetStockQuantity(productId); if (currentStock < quantity) return false; // 不足则拒绝 // 2. 执行扣减(已在事务中,此处仅做逻辑判断) // 实际扣减由UpdateInventory()在事务内完成 return true; } }为什么用lock而非Mutex?
Mutex是内核对象,每次等待/释放涉及用户态-内核态切换,耗时约10μs;lock是用户态自旋+内核等待混合,平均耗时<1μs;- 本系统为单进程多线程(WinForms UI线程+后台打印线程),
lock完全满足; Mutex名称需全局唯一(如"SuperMarket_Inventory"),若名称冲突会导致锁失效,排查困难。
4.2 收银界面的线程安全实践:禁止跨线程访问UI控件
FormMain中,小票打印使用PrintDocument.Print(),该方法在后台线程执行,但PrintPage事件中若直接更新lblStatus.Text会抛InvalidOperationException。正确做法:
private void printDoc_PrintPage(object sender, PrintPageEventArgs e) { // 绘制小票逻辑... e.Graphics.DrawString("交易完成", font, brush, x, y); // 安全线程更新UI this.Invoke((MethodInvoker)delegate { lblStatus.Text = "小票打印中..."; lblStatus.Refresh(); }); }参数说明:this.Invoke()将委托封送到UI线程执行,MethodInvoker是无参无返回值委托类型,比Action更轻量;Refresh()强制重绘,避免文字闪烁。
4.3 避坑:常见并发与线程问题排查清单
| 现象 | 原因 | 解决 |
|---|---|---|
| 收银成功但库存没变 | UpdateInventory()未在事务内执行,或conn/trans对象被意外Dispose | 检查btnCheckout_Click中using块嵌套层级,确保UpdateInventory()调用时conn和trans均有效;在UpdateInventory()开头加if (conn.State != ConnectionState.Open) throw new Exception("连接已关闭"); |
| 多收银台同时扣减,库存变为负数 | TryDeductStock()与UpdateInventory()之间存在时间窗口,A查到库存10,B也查到10,A扣5剩5,B扣5剩0,但B的扣减基于旧值 | 必须将库存查询与扣减合并为一条SQL(见3.2节UPDATE...IF @@ROWCOUNT),lock仅用于防止查询阶段并发,不能替代数据库层面的原子操作 |
| 小票打印卡死,UI无响应 | PrintDocument.Print()在UI线程同步阻塞,且打印机离线时无限等待 | 改用printDoc.PrintAsync(),并在PrintCompleted事件中更新UI;添加打印机状态检测:if (!PrinterSettings.IsValid) { MessageBoxEx.Show("打印机未就绪"); return; } |
ListView点击某行后,SelectedItems[0]报NullReferenceException | lvCart的HideSelection属性为true,失去焦点时选中项视觉消失但逻辑仍存在,用户误点空白处 | 在lvCart_SelectedIndexChanged事件开头加if (lvCart.SelectedItems.Count == 0) return;,或设HideSelection = false保持视觉反馈 |
| App.config修改后重启程序无效 | Visual Studio调试时,bin\Debug下生成的是YourApp.exe.config,而非源码中的App.config;修改源码App.config后需重新生成项目 | 修改后右键项目→“重新生成”,确认bin\Debug\YourApp.exe.config内容已更新;或直接编辑bin\Debug\YourApp.exe.config(仅调试用) |
注意:
lock对象必须是static readonly,若为实例字段,不同窗体实例的锁互不干扰,失去同步意义。
5. 从零部署:SQL Server初始化、WinForms打包与防反编译加固
拿到源码后,90%的新手卡在环境搭建。本章给出可复制的部署路径,覆盖数据库初始化、程序发布、以及生产环境必备的防反编译措施——毕竟超市系统涉及财务数据,代码裸奔风险极高。
5.1 SQL Server数据库初始化脚本(含测试数据)
创建数据库SuperMarketDB后,执行以下脚本(保存为.sql文件,用SSMS执行):
-- 1. 创建Products表 CREATE TABLE Products ( ProductId INT IDENTITY(1,1) PRIMARY KEY, Barcode VARCHAR(20) UNIQUE NOT NULL, Name NVARCHAR(100) NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, CategoryId INT DEFAULT 1 ); -- 2. 创建Inventory表(主仓WarehouseId=1) CREATE TABLE Inventory ( ProductId INT NOT NULL, WarehouseId INT NOT NULL DEFAULT 1, Quantity INT NOT NULL DEFAULT 0, PRIMARY KEY (ProductId, WarehouseId), FOREIGN KEY (ProductId) REFERENCES Products(ProductId) ); -- 3. 插入测试商品(5个高频商品) INSERT INTO Products (Barcode, Name, UnitPrice) VALUES ('6901234567890', '蒙牛纯牛奶250ml', 3.50), ('6921234567891', '康师傅红烧牛肉面', 5.00), ('6931234567892', '农夫山泉矿泉水550ml', 2.00), ('6941234567893', '金龙鱼调和油5L', 65.00), ('6951234567894', '清风抽纸3层120抽', 12.50); -- 4. 初始化库存(每种100件) INSERT INTO Inventory (ProductId, Quantity) SELECT ProductId, 100 FROM Products; -- 5. 创建Orders与OrderDetails表(略,见3.1节结构) -- 执行后,数据库即具备运行基础验证方法:在FormMain中扫码6901234567890,应显示“蒙牛纯牛奶250ml”,单价3.50;结算后,Inventory表中对应ProductId的Quantity应减1。
5.2 WinForms程序发布:ClickOnce vs Publish Folder,选后者
Visual Studio中右键项目→“发布”,不要选ClickOnce(需IIS部署,超市无IT运维人员)。选择“文件夹”发布:
- 目标位置设为
.\publish\(相对路径); - 安装模式选“依赖于目标计算机上已安装的.NET Framework”(本项目为.NET Framework 4.7.2);
- 生成后,
publish文件夹内含:SuperMarketSystem.exe(主程序)SuperMarketSystem.exe.config(配置文件,含数据库连接串)System.Data.dll等依赖(.NET Framework已内置,此为可选)
部署步骤:
- 将整个
publish文件夹拷贝至收银电脑; - 修改
SuperMarketSystem.exe.config中connectionString,指向该电脑的SQL Server实例; - 双击
SuperMarketSystem.exe即可运行。
提示:若收银电脑未装.NET Framework 4.7.2,需先下载离线安装包(
ndp472-kb4054530-x86-x64-allos-enu.exe),否则启动报错“未能加载文件或程序集”。
5.3 防反编译加固:Costura.Fody合并DLL + ConfuserEx混淆
超市系统代码若被反编译,商品进价、供应商信息等敏感数据将泄露。本方案分两步:
Step 1:用Costura.Fody合并依赖DLL
- NuGet安装
Costura.Fody(版本4.1.0); - 编译后,所有引用的DLL(如
System.Data.SQLite.dll)被嵌入SuperMarketSystem.exe,无需额外分发; - 验证:用
ILSpy打开SuperMarketSystem.exe,搜索System.Data,应无独立程序集节点。
Step 2:用ConfuserEx混淆主程序
- 下载ConfuserEx 1.5.0(开源免费);
- 配置
confuser.crproj:<project outputDir=".\obfuscated\" baseDir=".\publish\" ... <rule pattern="true" preset="maximum"> <protection id="constants" /> <protection id="anti debug" /> <protection id="control flow" /> <protection id="resources" /> </rule> </project> - 执行混淆,输出
obfuscated\SuperMarketSystem.exe即为最终交付物。
效果:
- 反编译后变量名变为
a,b,c,字符串加密,控制流扁平化; FormMain.cs中btnCheckout_Click方法被拆解为20+个无意义跳转,业务逻辑不可读;- 启动速度下降<5%,可接受。
5.4 收银小票打印适配:热敏纸58mm宽度的像素换算公式
PrintDocument默认使用100dpi,但热敏打印机物理分辨率常为203dpi。若按默认设置,小票文字过小。修正方法:
private void printDoc_PrintPage(object sender, PrintPageEventArgs e) { // 获取打印机真实DPI(关键!) float dpiX = e.Graphics.DpiX; float dpiY = e.Graphics.DpiY; // 58mm = 228.35像素(按203dpi计算:58 * 203 / 25.4) float pageWidthPx = 228.35f; // 字体缩放:默认10pt在100dpi下为13.3px,需按dpi缩放 Font font = new Font("SimSun", 10 * dpiY / 100, FontStyle.Regular); // 绘制区域:左边界10px,右边界pageWidthPx-10 RectangleF bounds = new RectangleF(10, 20, pageWidthPx - 20, 1000); e.Graphics.DrawString("超市收据", font, Brushes.Black, bounds, new StringFormat { Alignment = StringAlignment.Center }); }参数说明:
dpiX/dpiY从e.Graphics获取,避免硬编码;pageWidthPx = 58 * dpiX / 25.4为通用公式(25.4mm=1inch);Font大小按DPI线性缩放,确保文字物理尺寸一致。
从那以后我每次交付超市系统,都强制走一遍这四步:①用SSMS执行初始化脚本并查Inventory表确认数据;②修改publish\*.exe.config连接串后,用sqlcmd命令行测试连通性(sqlcmd -S .\SQLEXPRESS -d SuperMarketDB -Q "SELECT TOP 1 * FROM Products");③用ILSpy打开混淆后EXE,确认无敏感字符串明文;④在目标打印机上打印测试页,用尺子量实际宽度是否为58mm。少一步,上线后就可能半夜被电话叫醒。希望帮到你。
本文还有配套的精品资源,点击获取