ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# WinForms库存管理系统实战:扫码枪、事务与Dapper核心解析

C# WinForms库存管理系统实战:扫码枪、事务与Dapper核心解析 简介基于C#与C/S架构实现的库存管理系统面向正在学习桌面应用开发或需要库存管理参考的C#开发者。系统围绕商品入库、出库、库存盘点等典型功能组织代码代码由开发者依据参考书自行编写完整保留了项目脉络配合数据库表注释文档可帮助读者理解从数据库设计到界面交互的实现思路适合作为课程设计或自学练手的参考项目。资源包共309个文件、4.74MB包含62个.cs源码文件、25个.resx资源文件以及jpg/bmp图片、ico图标、dll库等界面与运行所需组件另有3个exe可执行程序可直接体验功能数据库备份与说明文档便于本地部署调试。项目包含完整的解决方案配置与目录结构从数据库表注释到界面资源一应俱全。目前已有347人学习浏览适合希望在C/S项目中快速掌握数据库操作、业务分层与WinForms界面开发的学习者。1. 项目总览为什么用C#做桌面端库存管理1.1 核心需求拆解这不是一张CRUD表那么简单很多人一听“库存管理系统”第一反应就是“这不就是增删改查嘛”真正上手做就会发现这个项目藏着不少硬骨头。我这次用C#做的库存管理系统表面上要解决的是商品入库、出库、盘点、库存查询和低库存预警这五类基本业务但实际动工之前得先把需求一层层剥开来看。比如说“入库”这件事不是往数据库里插一行记录就完事。仓库里常见的场景是采购到货后扫码枪对着条码“嘀”一声系统要自动识别商品、判断是否已存在、记录批次、更新库存数同时还要写一条库存流水方便以后追溯“这批货什么时候进的、谁经手的、进价是多少”。出库也一样不仅要扣减库存还要防止超卖、防止负数库存。低库存预警也不简单——是低于安全库存就弹窗还是按商品类别分别设置阈值这些细节如果不在一开始梳理明白做到一半再改功能那返工量是相当吓人的。我当时是先画了一遍业务流程图把“入库单—入库明细—库存流水—库存汇总”这条数据链路理清楚才敢动手写代码。1.2 技术栈选型逻辑WinForms MySQL Dapper 的理由这个项目我选了C# WinForms MySQL Dapper这套组合而不是一上来就用WPF、EF Core、SQL Server这些“更高级”的搭配。原因很实际给中小型仓库用的管理系统部署环境大多是Windows电脑配置不高、甚至还在用Win7WinForms启动快、占用资源少双击exe就能跑运维成本几乎为零。数据访问层选Dapper而不是EF Core主要考虑到查询性能和可控性。库存系统最核心的交互就是查库存、找商品、看流水Dapper走原生SQL性能损耗极小写起动态查询条件来也灵活。而且库存系统的逻辑本身就复杂用EF Core那种强映射反而容易在事务边界、并发控制上出一些隐性问题。数据库选MySQL而非SQL Server纯粹是从成本出发——很多小公司不想额外掏SQL Server的授权费MySQL免费、跨平台、备份也方便配上Navicat管理工具基本够用了。这套组合的另一个好处是它本身就是C#后端开发面试里被问得最多的一套技术栈。做这个项目的过程中我相当于把ADO.NET、事务、反射、事件驱动、委托这些都过了一遍后面去聊C#面试题的时候脑子里全是实际项目里的场景而不是背概念。2. 核心技术点拆解扫码、事务、主从表与扩展性的落地2.1 扫码枪“触发事件”怎么处理热搜词里反复出现“C#扫码枪触发事件”这绝对是库存系统开发里最容易让新手卡住的上手门槛。扫码枪本质上就是个键盘输入设备扫一下条码它会把条码内容“噼里啪啦”地输入到当前焦点所在的文本框之后默认再补一个回车键。所以你要做的不是去调什么扫码枪SDK而是处理键盘输入事件。我当时的做法是界面上放一个TextBox设置属性让其始终聚焦然后在KeyDown事件里监听回车键。当用户扫码后文本框里会拿到完整的条码字符串紧接着触发回车这时候就说明一整个条码已经扫完了。代码逻辑大概是这样的private void txtBarcode_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Enter) { string barcode txtBarcode.Text.Trim(); if (string.IsNullOrEmpty(barcode)) return; // 根据条码查询商品并加入列表 var product _productService.GetByBarcode(barcode); if (product ! null) { AddToCart(product); } else { MessageBox.Show(未找到该条码对应的商品); } // 清空输入框准备下一次扫描 txtBarcode.Clear(); e.SuppressKeyPress true; // 禁止回车键产生“叮”的声音 } }这里有几个细节容易踩坑。第一个是e.SuppressKeyPress true这句如果不加每次扫码回车之后系统会播放Windows默认的错误提示音仓库里几十把扫码枪一起响那噪音真的让人抓狂。第二个是光标焦点的问题如果扫码的时候用户不小心点了别的按钮焦点跑到ListView或者按钮上去了扫码内容就会乱掉所以要用txtBarcode.Focus()在多个关键操作结束后把焦点重新拉回来。第三个是条码规则有些场景下条码是“前缀关键编号后缀”的格式需要用Substring去截取中间的有效部分这也是为什么“C#语言怎样截取字符串”这类问题在相关热搜里特别高频。2.2 库存变动的灵魂事务与流水设计如果整个系统只允许我保留一个设计原则我一定选这一条任何库存数值都不能直接改只能通过流水记录来推导。也说是说商品表里那个“当前库存”只是一个冗余的汇总值真正权威的数据源是库存变动流水表stock_record。每次入库、出库、盘点调整都先往流水表里插入一条记录再去更新商品表的库存数字。这么做的好处太多了——出问题的时候能回溯对账的时候有依据统计某段时间的出入库情况直接查流水就行。因为涉及“先写流水、再改库存”两步操作必须用数据库事务包起来否则第二步失败会导致账实不符。我直接用MySQL的事务using (var connection new MySqlConnection(_connectionString)) { connection.Open(); using (var transaction connection.BeginTransaction()) { try { // 1. 插入库存流水 var insertSql INSERT INTO stock_record (product_id, change_type, quantity_before, quantity_change, quantity_after, remark, create_time) VALUES (ProductId, ChangeType, Before, Change, After, Remark, NOW()); connection.Execute(insertSql, record, transaction); // 2. 更新商品库存 var updateSql UPDATE product SET stock_quantity stock_quantity Change WHERE id ProductId; connection.Execute(updateSql, new { Change record.QuantityChange, ProductId record.ProductId }, transaction); transaction.Commit(); } catch { transaction.Rollback(); throw; } } }注意这里我用的不是库存数 库存数 - 出库数这种写死方向的SQL而是stock_quantity stock_quantity ChangeChange正数就是入库、负数就是出库。这样一套代码同时支持入库和出库场景逻辑统一不容易出分支错误。2.3 主从表展示Dev GridControl 还是 DataGridView做仓库管理界面必然会遇到主从表展示的需求——左边是入库单列表点一下某张单子右边显示这张单的明细商品。如果用的是DevExpress的GridControl它内置了Master-Detail模式可以在一行主记录下直接展开子表。我在实际项目里用过效果确实好看但问题是DevExpress是收费控件很多公司不愿意为这种内部系统额外掏钱而且它的数据绑定写法跟原生控件差别很大新手接手时理解成本高。所以我这个项目选择了更务实的方式主表用DataGridView展示单据列表下面再放一个DataGridView展示当前选中单据的商品明细两者通过SelectionChanged事件联动。说白了就是手写一个“伪主从表”private void dgvOrders_SelectionChanged(object sender, EventArgs e) { if (dgvOrders.CurrentRow null) return; int orderId Convert.ToInt32(dgvOrders.CurrentRow.Cells[Id].Value); var details _orderService.GetDetailsByOrderId(orderId); dgvOrderDetails.DataSource details; }这种方式灵活度高而且如果后面要加“选中单据后显示汇总金额”“统计商品总数量”等功能直接在事件里扩展就行。说到底界面控件的选择还是要以“业务场景匹配度”为主不是越炫越好。对库存系统来说操作员每天要盯着这个界面连续工作好几个小时清晰、响应快、不容易误操作才是第一位。2.4 用反射做菜单和权限少写一堆if elseC#里“反射”这个词看起来很高深但放到库存系统里其实有一个非常实用的落地场景根据用户角色动态生成菜单。不同角色管理员、仓管员、财务、普通员工看到的菜单是不同的。新手做法一般是判断角色然后一个个拼接菜单项角色一多代码就成了意大利面条。我用反射做了一套简单的权限菜单方案每个窗体类上标注一个MenuAttribute注明菜单名称和需要的权限码。在系统启动时通过反射扫描程序集里所有标注了该特性的窗体类。根据当前登录用户的角色权限码过滤出能访问的窗体动态加载到菜单栏。这样以后新增一个功能窗体只需要写窗体类、标一个特性菜单就自动出现了完全不碰原来的逻辑代码。C#反射这个点在很多面试里都会被问到如果你能聊出“我用它做过动态菜单和插件式模块加载”这种实际案例面试官会很感兴趣。3. 实操解析从建表到出入库核心功能的完整实现3.1 建表SQL与字段设计要点库存系统的表结构本身不复杂但设计不好很容易出隐患。我最终定的核心表如下简化版-- 商品表 CREATE TABLE product ( id int NOT NULL AUTO_INCREMENT, barcode varchar(64) NOT NULL COMMENT 条码, name varchar(128) NOT NULL COMMENT 商品名称, spec varchar(64) DEFAULT NULL COMMENT 规格型号, unit varchar(16) DEFAULT NULL COMMENT 计量单位, stock_quantity decimal(12,3) NOT NULL DEFAULT 0 COMMENT 当前库存, safety_stock decimal(12,3) NOT NULL DEFAULT 0 COMMENT 安全库存, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_barcode (barcode) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存流水表 CREATE TABLE stock_record ( id bigint NOT NULL AUTO_INCREMENT, product_id int NOT NULL, change_type tinyint NOT NULL COMMENT 1入库 2出库 3盘点, quantity_before decimal(12,3) NOT NULL, quantity_change decimal(12,3) NOT NULL, quantity_after decimal(12,3) NOT NULL, remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_product_time (product_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;有几个字段设计细节我想重点强调。第一是库存数量的类型一定要用decimal而且别选太大的精度——decimal(12,3)足够绝大多数场景用既有三位小数又不会因为高精度导致计算变慢。第二是quantity_change这个字段它存的是“变化量”正负号代表方向这样通过SQL的SUM()函数就能直接算出某段时间的总入库/总出库非常灵活。第三是必加联合索引库存流水表的数据量增长很快没有索引的话月底做统计报表时查询会慢到让人怀疑人生。3.2 仓储层代码Dapper 连接与仓储封装Dapper用起来非常简单核心就是Connection.Query和Connection.Execute。但为了不让业务层代码到处散落SQL字符串我做了个简单的仓储层封装public class ProductRepository { private readonly string _connStr; public ProductRepository(string connStr) { _connStr connStr; } public Product GetByBarcode(string barcode) { using var conn new MySqlConnection(_connStr); var sql SELECT id, barcode, name, spec, unit, stock_quantity, safety_stock FROM product WHERE barcode barcode; return conn.QueryFirstOrDefaultProduct(sql, new { barcode }); } public IEnumerableProduct Search(string keyword, int page, int pageSize) { using var conn new MySqlConnection(_connStr); var sql SELECT * FROM product WHERE name LIKE kw OR barcode LIKE kw OR spec LIKE kw ORDER BY id DESC LIMIT offset, pageSize; return conn.QueryProduct(sql, new { kw $%{keyword}%, offset (page - 1) * pageSize, pageSize }); } }这里有个注意事项LIMIT offset, pageSize这种写法在MySQL里直接用Dapper参数是没问题的但如果你用的是Oracle或者有些老版本数据库分页语法完全不同迁移的时候要专门改。另外不建议把连接字符串硬编码在代码里我给的是放到App.config里面再按环境做切换。如果公司内部有配置中心那就更好了。3.3 入库/出库的核心事务代码入库单保存是整个系统最核心的一段逻辑涉及的操作包括往入库单主表插入记录、往入库单明细表插入多条记录、往库存流水表写流水、更新商品库存、记录操作人。这五步必须在一个事务里完成。我写的过程大致如下public bool CreateInboundOrder(InboundOrder order, ListInboundOrderDetail details, string operatorName) { using var conn new MySqlConnection(_connStr); conn.Open(); using var tx conn.BeginTransaction(); try { // 1. 主表 var orderId conn.ExecuteScalarint( INSERT INTO inbound_order (order_no, operator_name, remark, create_time) VALUES (OrderNo, OperatorName, Remark, NOW()); SELECT LAST_INSERT_ID();, order, tx); foreach (var d in details) { var product conn.QueryFirstOrDefaultProduct( SELECT * FROM product WHERE id ProductId FOR UPDATE, new { d.ProductId }, tx); if (product null) throw new Exception($商品ID {d.ProductId} 不存在); // 2. 更新库存 conn.Execute( UPDATE product SET stock_quantity stock_quantity Qty WHERE id ProductId, new { Qty d.Quantity, d.ProductId }, tx); // 3. 写流水 conn.Execute( INSERT INTO stock_record (product_id, change_type, quantity_before, quantity_change, quantity_after, remark, create_time) VALUES (ProductId, 1, Before, Change, After, Remark, NOW()), new { d.ProductId, Before product.StockQuantity, Change d.Quantity, After product.StockQuantity d.Quantity, Remark $入库单号:{order.OrderNo} }, tx); } tx.Commit(); return true; } catch (Exception ex) { tx.Rollback(); _logger.Error(ex, 入库单保存失败); return false; } }为什么这里要在查商品的时候加FOR UPDATE这就是应对并发场景的关键了。当两个操作员几乎同时扫码提交出库如果不加锁可能出现两个线程都读到库存是10然后各自扣了8最后库存变成2而不是-6——听起来还能接受但如果同时提交的是10笔那库存直接变成负数了。加上FOR UPDATE后事务期间这一行数据被锁定第二个请求必须等第一个事务提交或回滚之后才能继续这就从源头杜绝了超卖。3.4 条码截取与本地时间更新的细节处理热搜词里有“C#语言怎样截取字符串”和“C#更新本地时间”这两个在库存系统里是非常合理的需求。先说条码截取。有些仓库用的条码长得很复杂比如“6901234567890”这种标准EAN-13或者“供应商编码产品编码批次号”拼起来的长码。我遇到的情况是客户要求只识别条码中的某一段作为商品唯一标识比如取第8到第15位那就在逻辑层统一处理public static string ParseBarcode(string rawBarcode) { if (string.IsNullOrEmpty(rawBarcode)) return string.Empty; // 按约定的规则截取例如取第1位到第13位 if (rawBarcode.Length 13) { return rawBarcode.Substring(0, 13); } return rawBarcode.Trim(); }注意Substring的边界判断一定要做否则越界异常会导致扫码枪扫到超短条码时程序直接崩溃。至于“更新本地时间”这个其实是指显示时间和从服务器同步时间。仓库管理员的电脑系统时间经常不准会影响单据的create_time记录。我的处理方式是系统启动时从MySQL执行SELECT NOW()获取数据库时间作为本机参考时间但所有写入操作的时间统一用数据库时间不许用本地DateTime.Now。这样做的好处是哪怕操作员电脑时间快慢十几分钟单据流水的时间戳始终以服务器为准月底对账不会出现时间错乱。4. 常见问题与排查技巧实录4.1 连接MySQL总是报错或乱码这个项目是用MySQL 8.0连接串必须写清楚字符集。我一开始没注意从数据库查出来的中文全是乱码排查了半天发现是连接串漏了CharSetutf8mb4。正确的连接串类似这样Serverlocalhost;Databasestockdb;Uidroot;Pwd123456;CharSetutf8mb4;另外MySQL 8.0默认的认证插件是caching_sha2_password如果驱动版本太低会连不上。我的经验是直接使用MySql.Data 8.0以上的NuGet包别用以前遗留的6.x版本省得折腾。4.2 扫码枪多了一个回车或者输入法干扰如果用了中文输入法扫码枪输入的一长串数字有可能进入输入法组词状态导致条码被截断查询不到商品。处理办法很粗暴但有效让条码输入框把输入法禁掉。WinForms里可以在控件的ImeMode属性设置为Disable这样扫码内容永远以英文状态输入就不会出现中文状态下的输入法干扰。另外如果扫码枪设置了“追加回车”但偶尔有设备因为驱动原因回车没有触发可以在KeyPress事件里监听回车再加一个兜底逻辑或者在TextBox的LostFocus事件里做处理——当条码框失焦但内容不为空时也当作一次扫码结果处理。4.3 库存扣减成功但流水没写进去这种问题只在事务没用好时才会出现。如果更新库存和写流水不是包在同一个事务里就很可能出现“库存变了、流水缺失”这种灾难性数据问题。排查思路是先看代码中是否所有写库存的入口都走了同一个Service方法再看数据库日志模拟一次出入库操作检查stock_record表是否多了一条记录且quantity_after字段值是否正确。我实测下来的经验是只要发现任何算术值不一致优先怀疑事务边界没包住其次怀疑有多个地方都能直接改库存字段绕过了流水记录。4.4 数值精度与四舍五入的坑库存数量用decimal(12,3)后如果界面上给用户直接输入数值必须做输入校验。用decimal.TryParse处理的是“用户输入了非数字”的情况但还有一个更隐蔽的问题浮点数计算。如果你在前端用double去做商品数量累加比如0.1加0.2得到的结果可能是0.30000000000000004一顿操作下来库存数就变成了一长串奇怪的小数。C#里处理这类业务数值从一开始就要全部使用decimal类型不要在中间环节转成double或float。4.5 数据库连接数被打满WinForms客户端如果不注意释放连接长时间运行后会出现“Too many connections”错误。Dapper本身会自动释放连接但前提是你每次查询都正确用了using且连接字符串里没有把连接池开得太大。我的习惯是在App.config里设置PoolingTrue;Max Pool Size50;同时保证仓储层方法短小精悍一把连接用完就关。再配合NLog记日志检查连接泄漏时直接看日志里是否有超时异常定位到具体方法看看是不是忘了using。最后分享一点我的实际体会做这个C#库存管理系统对我来说收获最大的不是“会用WinForms拉控件”而是真正理解了事务、并发和事件驱动在现实业务里的分量。有些东西看书和做Demo根本感受不到比如第一次碰到库存超卖、第一次被扫码枪的输入法弄得焦头烂额、第一次因为少了FOR UPDATE导致月底对账对不上——每一个坑踩过去之后你对这个系统的掌控感才会真正建立起来。如果你正准备拿这个项目练手或者面试我强烈建议你多花时间把库存流水表和事务逻辑想透这是整个系统的灵魂所在。后面如果想往深了做还可以给它加网络版Socket同步、对接一下上位机采集的数据或者用ONNX模型做图像识别辅助盘点这个项目的扩展空间比你想的要大得多。本文还有配套的精品资源点击获取
返回列表