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)

表名字段类型说明索引
ProductsProductIdINT PK IDENTITY商品主键聚集索引
BarcodeVARCHAR(20) UNIQUE条码(扫码依据)唯一索引
NameNVARCHAR(100)商品名称非聚集索引
UnitPriceDECIMAL(10,2)单价—
CategoryIdINT分类ID(外键)非聚集索引
InventoryProductIdINT PK FK关联商品聚集索引(复合主键)
WarehouseIdINT PK FK仓库ID(支持多仓)—
QuantityINT当前库存—
OrdersOrderIdVARCHAR(20) PK订单号(日期+流水)聚集索引
OrderTimeDATETIME2下单时间非聚集索引
TotalAmountDECIMAL(10,2)总金额—
OrderDetailsDetailIdINT IDENTITY PK明细ID聚集索引
OrderIdVARCHAR(20) FK关联订单非聚集索引
ProductIdINT商品ID非聚集索引
QuantityINT数量—
UnitPriceDECIMAL(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; END

FormLogQuery中调用:

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]报NullReferenceExceptionlvCart的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运维人员)。选择“文件夹”发布:

  1. 目标位置设为.\publish\(相对路径);
  2. 安装模式选“依赖于目标计算机上已安装的.NET Framework”(本项目为.NET Framework 4.7.2);
  3. 生成后,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。少一步,上线后就可能半夜被电话叫醒。希望帮到你。

本文还有配套的精品资源,点击获取