ARTICLE DETAIL

资讯详情

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

C# WinForm扫码枪出入库系统实战:HID与串口接入、SQLite事务与订单状态机

C# WinForm扫码枪出入库系统实战:HID与串口接入、SQLite事务与订单状态机 简介这是一套基于 C# Winform 开发的货物出入库与订单管理系统源码面向需要处理仓储物流业务的开发者与中小企业信息化人员。系统通过扫码枪自动扫描条形码或二维码将结果经正则表达式匹配后写入数据库替代人工录入减少错误并提升处理速度启动时自动切换英文输入法并将光标定位到输入框扫描类型与匹配规则可在 MyPatternStr 类中调整灵活性较高。资源包共 124 个文件以 27 个 cs 源码、14 个 png 界面截图、11 个 resources 资源、9 个 exe 可执行文件及 dll、config、resx、mdb 数据库等为主压缩包约 935KB结构完整可直接编译运行。目前已有 136 人学习下载。读者可借此掌握扫码枪对接、正则解析、Winform 界面与数据库联动等完整实现思路并在此基础上扩展库存查询、订单跟踪与报表功能。1. 扫码枪一响WinForm 里那套出入库系统到底怎么跑起来仓库现场最常见的画面操作员拿着扫码枪对着货物条码扣一下扳机PC 端 WinForm 界面立刻弹出这条货的信息入库还是出库、数量多少、对应哪张订单几秒钟内落库完成。这套「C# WinForm 扫码枪」的货物出入库与订单管理系统本质是把扫码枪当成一个高速键盘输入设备用 WinForm 做业务界面用本地或轻量数据库做数据落地把人工敲单变成扫一下就走。它适合谁中小仓储、门店后仓、生产车间线边库这类场景单机或局域网几台机器就够用不需要上 WMS 大系统。核心诉求就三个扫得准、录得快、查得到。下面按「扫码枪怎么接进来 → 数据怎么设计 → 出入库和订单怎么串 → 坑在哪 → 怎么验证」这条线把能直接抄的代码和参数讲清楚。2. 扫码枪接入 WinForm从 HID 键盘模式到串口模式的选择2.1 先搞清楚你的扫码枪是哪种输出模式市面上扫码枪绝大多数默认是 HID 键盘模式也叫键盘仿真扣一下扳机它就把条码字符一个个「敲」进当前焦点控件末尾补一个回车。这种模式对 WinForm 最友好因为不需要写任何通信代码只要保证焦点在正确的 TextBox 上监听 KeyDown 或 TextChanged 就能拿到完整条码。另一类是串口模式RS232 或 USB 虚拟串口扫码枪通过 COM 口发数据需要SerialPort类去读。什么时候用串口当你的扫码枪要接工控机、要走长距离、或者现场有多个扫码枪需要区分来源时。串口模式的好处是数据来源明确不会和人工键盘输入混淆。还有一种是 USB-HID 但走厂商 SDK 的这种一般用于工业扫码器能读到图像、质量评分等额外信息普通仓储用不上。选型建议单机、单枪、操作员就在电脑前直接用 HID 键盘模式开发量最小。多枪、远距离、需要区分哪个工位扫的用串口模式。2.2 HID 键盘模式的最小可用代码HID 模式的关键是扫码枪输入速度极快通常 5~20ms 一个字符人工键盘不可能这么快。所以可以用「输入间隔」来区分扫码枪和人工输入避免误触发。// 在 Form 里放一个 txtScan 文本框设其 KeyDown 事件 private StringBuilder _scanBuffer new StringBuilder(); private DateTime _lastKeyTime DateTime.Now; private void txtScan_KeyDown(object sender, KeyEventArgs e) { // 回车表示一次扫码结束 if (e.KeyCode Keys.Enter) { string code _scanBuffer.ToString().Trim(); _scanBuffer.Clear(); if (!string.IsNullOrEmpty(code)) { HandleScanCode(code); // 业务处理入口 } e.SuppressKeyPress true; // 屏蔽回车提示音 return; } // 判断是否为扫码枪输入两次按键间隔小于 50ms var now DateTime.Now; double gap (now - _lastKeyTime).TotalMilliseconds; _lastKeyTime now; if (gap 50 _scanBuffer.Length 0) { // 间隔过大视为人工输入清空缓冲重新开始 _scanBuffer.Clear(); } // 普通字符追加数字、字母 if (e.KeyCode Keys.D0 e.KeyCode Keys.Z) { _scanBuffer.Append((char)e.KeyValue); } }逻辑说明_scanBuffer累积字符回车时提交。gap 50ms判断是为了防止人工慢慢敲键盘被误当成扫码。参数50是经验值扫码枪一般 5~20ms人工最快也要 80ms 以上取 50 比较稳。如果你的扫码枪特别快或特别慢可以调到 30 或 80。e.SuppressKeyPress true是必须的否则回车会触发 WinForm 默认的按钮点击或发出「叮」的提示音现场很吵。2.3 串口模式读取条码串口模式需要先确认扫码枪的 COM 口号和波特率一般在扫码枪说明书或配置码里设置常见 9600、8、N、1。private SerialPort _port; private void InitScanner(string comPort) { _port new SerialPort(comPort, 9600, Parity.None, 8, StopBits.One); _port.DataReceived Port_DataReceived; _port.Open(); } private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e) { // 串口回调在非 UI 线程必须 Invoke 回主线程 string data _port.ReadLine().Trim(); this.Invoke(new Action(() { if (!string.IsNullOrEmpty(data)) HandleScanCode(data); })); }逻辑说明ReadLine()依赖扫码枪末尾的回车换行符如果扫码枪没配结束符会一直阻塞。参数9600是波特率要和扫码枪一致不一致会读到乱码。Invoke是必须的WinForm 控件只能主线程访问直接在回调里改 TextBox 会抛跨线程异常。提示串口打开前先枚举SerialPort.GetPortNames()确认口号存在插拔 USB 转串口后口号可能变化现场最好固定口号或在配置里让用户选。3. 数据表设计货物、订单、出入库流水怎么建3.1 四张核心表的最小结构不管用 SQL Server、SQLite 还是 Access表结构逻辑是一样的。核心四张表货物主表、订单表、订单明细、出入库流水。表名关键字段说明GoodsId, BarCode, Name, Spec, Unit, Stock货物主表BarCode 唯一索引OrdersId, OrderNo, Customer, CreateTime, Status订单主表Status 0待处理 1部分 2完成OrderItemsId, OrderId, GoodsId, Qty, ScannedQty订单明细ScannedQty 记录已扫数量StockLogId, GoodsId, Type, Qty, OrderNo, ScanTime, Operator出入库流水Type 0入库 1出库Goods.BarCode一定要建唯一索引这是扫码枪能快速定位货物的前提。OrderItems.ScannedQty是防重复扫的关键字段每扫一次加一和Qty比对判断是否扫完。3.2 用 SQLite 建库的代码中小场景我一般直接用 SQLite免安装、单文件、备份就是拷文件。// 需要引用 System.Data.SQLite string connStr Data Sourcewms.db;Version3;; using (var conn new SQLiteConnection(connStr)) { conn.Open(); string sql CREATE TABLE IF NOT EXISTS Goods( Id INTEGER PRIMARY KEY AUTOINCREMENT, BarCode TEXT NOT NULL UNIQUE, Name TEXT NOT NULL, Spec TEXT, Unit TEXT, Stock INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS StockLog( Id INTEGER PRIMARY KEY AUTOINCREMENT, GoodsId INTEGER NOT NULL, Type INTEGER NOT NULL, Qty INTEGER NOT NULL, OrderNo TEXT, ScanTime TEXT NOT NULL, Operator TEXT );; using (var cmd new SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } }逻辑说明UNIQUE约束保证条码不重复插入重复条码会抛异常业务层要捕获并提示「该条码已存在」。ScanTime用 TEXT 存 ISO 格式时间字符串SQLite 没有原生日期类型这样存方便排序和查询。参数说明Data Sourcewms.db是数据库文件路径相对路径会落在程序运行目录正式部署建议用绝对路径或放到AppDomain.CurrentDomain.BaseDirectory下。3.3 扫码入库的事务处理入库不是简单加库存要同时写流水、更新库存、更新订单已扫数量必须放在一个事务里否则中途失败会出现库存和流水对不上。private void HandleScanCode(string barCode) { using (var conn new SQLiteConnection(connStr)) { conn.Open(); using (var tran conn.BeginTransaction()) { try { // 1. 查货物 var goods GetGoodsByBarCode(conn, barCode); if (goods null) { MessageBox.Show($条码 {barCode} 未登记); return; } // 2. 写流水 InsertLog(conn, goods.Id, 0, 1, _currentOrderNo); // 3. 更新库存 UpdateStock(conn, goods.Id, 1); // 4. 更新订单已扫数量 UpdateOrderScanned(conn, _currentOrderNo, goods.Id, 1); tran.Commit(); RefreshGrid(); // 刷新界面 } catch (Exception ex) { tran.Rollback(); MessageBox.Show(入库失败 ex.Message); } } } }逻辑说明BeginTransaction到Commit之间任何一步抛异常都Rollback保证数据一致。UpdateStock用UPDATE Goods SET Stock Stock 1 WHERE Id id不要先查再算再写并发下会丢更新。参数说明Type0表示入库出库传 1。Qty1是单件扫码如果一次扫一箱把 Qty 改成箱规数量。4. 订单管理与出入库联动状态机怎么设计才不乱4.1 订单状态用状态机管别用一堆 bool订单从创建到完成状态流转是固定的待处理 → 部分扫描 → 已完成。用整数枚举表示每次扫码后重新计算状态而不是散落一堆isFinished、isPartial布尔字段。private void UpdateOrderStatus(SQLiteConnection conn, string orderNo) { string sql UPDATE Orders SET Status ( SELECT CASE WHEN SUM(Qty) SUM(ScannedQty) THEN 2 WHEN SUM(ScannedQty) 0 THEN 1 ELSE 0 END FROM OrderItems WHERE OrderId Orders.Id ) WHERE OrderNo no; using (var cmd new SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue(no, orderNo); cmd.ExecuteNonQuery(); } }逻辑说明每次扫码后调用一次状态由明细数据推导出来不依赖人工设置永远不会出现「明明扫完了还显示待处理」的玄学问题。SUM(Qty) SUM(ScannedQty)判断全部扫完SUM(ScannedQty) 0判断部分扫完。4.2 防重复扫和超扫现场最容易翻车的就是同一箱货被扫两次或者订单只要 10 件却扫了 12 件。防重复扫有两个思路一是靠ScannedQty和Qty比对超了就拒绝二是记录已扫条码同一订单同一条码不允许重复。private bool CanScan(SQLiteConnection conn, string orderNo, int goodsId) { string sql SELECT Qty, ScannedQty FROM OrderItems WHERE OrderId (SELECT Id FROM Orders WHERE OrderNo no) AND GoodsId gid; using (var cmd new SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue(no, orderNo); cmd.Parameters.AddWithValue(gid, goodsId); using (var reader cmd.ExecuteReader()) { if (!reader.Read()) return false; // 订单里没这个货 int qty reader.GetInt32(0); int scanned reader.GetInt32(1); return scanned qty; // 已扫小于应扫才允许 } } }逻辑说明scanned qty是硬约束扫满就拒绝并提示「该货物已扫完」。如果业务允许超扫比如散装货物把判断改成scanned qty * 1.1之类但一般不建议。参数说明no是订单号gid是货物 Id。这个方法在HandleScanCode里事务开始后、写流水前调用返回 false 直接提示并 return。4.3 出库和入库的差异处理入库是「货到了加库存」出库是「货走了减库存」但出库多一层校验库存不够不能出。private bool CheckStockEnough(SQLiteConnection conn, int goodsId, int needQty) { string sql SELECT Stock FROM Goods WHERE Id id; using (var cmd new SQLiteCommand(sql, conn)) { cmd.Parameters.AddWithValue(id, goodsId); object result cmd.ExecuteScalar(); if (result null) return false; return Convert.ToInt32(result) needQty; } }逻辑说明出库前先查库存不够就拒绝并提示「库存不足当前 X 件」。入库不需要这个校验。ExecuteScalar返回第一行第一列比ExecuteReader轻量。参数说明needQty是本次出库数量单件扫传 1。如果是批量出库在事务里先校验再扣减避免扣成负数。5. 避坑与排查扫码枪项目现场最容易翻车的 5 个点5.1 扫码枪扫了没反应焦点跑丢了现象操作员扣扳机界面没任何反应但扫码枪指示灯正常。原因HID 模式下扫码枪是把字符敲进当前焦点控件如果焦点在 DataGridView 或按钮上字符进不去 TextBox自然拿不到。解决在 Form 的KeyPreview true把 KeyDown 事件提到 Form 级别处理不依赖具体控件焦点。或者在每次业务操作后主动txtScan.Focus()。更稳的做法是放一个隐藏的 TextBox 专门接扫码始终让它保持焦点。5.2 条码读出来少一位或多一位现象扫出来的条码和实际不符偶尔少最后一位。原因HID 模式下如果KeyDown里对回车处理不当或者扫码枪结束符配置成了 Tab 而不是回车缓冲区没正确提交。解决先确认扫码枪的结束符配置扫说明书上的配置码设成 CR 回车。然后在代码里同时监听Keys.Enter和Keys.Tab两个都当结束符处理。另外检查_scanBuffer清空逻辑确保每次提交后彻底清空。5.3 串口模式读到的全是乱码现象SerialPort.ReadLine()返回一堆问号或乱码。原因波特率、数据位、校验位、停止位和扫码枪不一致最常见的是波特率设错。解决查扫码枪说明书确认串口参数一般是 9600/8/N/1。用串口调试助手先验证能正常读到条码再写进代码。如果扫码枪是 USB 虚拟串口确认驱动装好、口号正确。5.4 并发扫码导致库存对不上现象两台电脑同时扫同一批货最后库存比实际少。原因UPDATE Goods SET Stock Stock 1本身是原子的但如果业务逻辑是「先查库存再算再写」两步之间被另一个线程插入就会丢更新。解决所有库存变更用Stock Stock delta这种数据库层面的原子操作不要在 C# 里查出来加完再写回去。事务隔离级别用默认的即可SQLite 写操作本身会加锁。5.5 程序打包后扫码枪不工作现象开发机上正常打包成安装程序装到现场电脑后扫码没反应。原因一是目标机器没装 .NET Framework 对应版本二是 SQLite 的System.Data.SQLite.dll和SQLite.Interop.dll没一起打包x86/x64 架构不匹配。解决用 Costura.Fody 把依赖 DLL 合并进主程序或者确保安装包里包含x64和x86两个子目录的 Interop DLL。目标机器 .NET 版本在安装程序里检测并提示。打包前在干净的虚拟机上测一遍别只在自己开发机上测。6. 用模拟扫码和日志回放验证整套流程系统做完怎么确认它真的可靠我的习惯是写一个模拟扫码的测试入口不依赖真实扫码枪直接调HandleScanCode用一批测试条码跑完整流程再看数据库结果对不对。// 测试用模拟连续扫码 private void SimulateScan() { string[] testCodes { 6901234567890, 6901234567891, 6901234567890 }; foreach (var code in testCodes) { HandleScanCode(code); Application.DoEvents(); // 让界面刷新测试时用 } // 跑完后查数据库核对 VerifyResult(); } private void VerifyResult() { using (var conn new SQLiteConnection(connStr)) { conn.Open(); string sql SELECT g.BarCode, g.Stock, COUNT(l.Id) as LogCount FROM Goods g LEFT JOIN StockLog l ON g.Id l.GoodsId GROUP BY g.Id; using (var cmd new SQLiteCommand(sql, conn)) using (var reader cmd.ExecuteReader()) { while (reader.Read()) { Console.WriteLine($条码:{reader[0]} 库存:{reader[1]} 流水数:{reader[2]}); } } } }逻辑说明testCodes里故意放一个重复条码验证防重复逻辑是否生效。VerifyResult用LEFT JOIN把货物和流水关联检查库存数和流水条数是否匹配。正常情况下扫了 3 次其中 1 次重复被拒库存应该是 2流水也是 2。参数说明Application.DoEvents()只在测试时用正式代码里不要用会导致重入问题。测试条码用真实的或符合你条码规则的假数据都行关键是覆盖「正常扫、重复扫、不存在条码」三种情况。再进一步把每次扫码的原始输入和业务处理结果都写一份文本日志现场出问题时直接看日志回放比问操作员「你当时怎么操作的」靠谱得多。日志格式我一般用时间 | 条码 | 结果 | 订单号一行一条出问题 grep 一下就知道是扫码枪没读到还是业务逻辑拒绝了。这套东西我前后在几个小仓库落地过最大的教训是别在开发机上测通了就上线一定要拿真实扫码枪、在真实网络和真实电脑上跑一遍完整流程。HID 焦点、串口口号、打包依赖这三个坑几乎每个项目都会踩一次。把模拟测试和日志回放做进流程里现场排查能省一半时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表