WinForm扫码枪出入库系统:从条码模式到业务事务的完整实践 简介Windows窗体扫码枪货物出入库与订单管理系统是一套桌面应用程序工程面向仓库、门店及小型企业解决货物收发和订单处理依赖人工录入、效率低且容易出错的问题。系统利用扫码枪自动扫描条码或二维码通过正则表达式匹配扫描结果并更新数据库匹配规则集中在MyPatternStr类中用户可根据实际条码格式进行调整支持的码制取决于所连接的扫码枪设备。程序启动时会自动将输入法切换为英文并把光标定位到扫描结果输入框确保扫码枪输出的内容能被正确捕获覆盖出入库登记、订单维护等核心业务流程。资源包共一百二十四个文件压缩后约九百三十五KB包含C#源程序、窗体界面资源、可执行文件、调试文件、配置文件、数据库文件以及图标和声音等辅助素材结构较为完整。目前已有134人浏览学习附有完整解决方案与项目源码可直接编译运行也便于二次开发可作为同类扫码枪管理系统的参考实现。1. 扫码枪自动扫描的货物出入库与订单管理这套系统到底解决什么问题一套基于 C# WinForm、由扫码枪自动扫描驱动的货物出入库和订单管理系统说直白点就是仓库文员不再拿笔抄货号、再回电脑前一个个敲键盘而是拿起扫码枪对着条码“哔”一下单据明细自动累加、库存自动增减、订单行状态自动推进。它解决的痛点非常明确——手工录入慢、抄错货号、入库和出库对不上账、订单发到哪一步没人说得清。适合中小型仓库、工厂线边仓、门店收发和电商打包台这类场景数据量一天几百到几千笔不需要上 WMS 或 ERP 的大型架构但也不想靠 Excel 硬扛。这类系统的核心难点不在“扫码枪怎么读条码”而在“读到的条码如何正确地驱动业务”这正是本文想讲清楚的事。2. 选对扫码枪工作模式USB键盘模式还是虚拟串口模式扫码枪接入 Windows 这件事看起来是插上 USB 就能用但真正决定 WinForm 程序怎么写、稳不稳的是它工作在哪种模式下。市面上的霍尼韦尔、新大陆、得力等主流扫码枪几乎都同时支持几种模式切换最常见的是 USB 键盘模拟HID和虚拟串口VCP。2.1 两种模式的工作原理与选型依据USB 键盘模式把扫码枪模拟成一个标准键盘扫到的条码会以“键盘击键”的方式逐字符敲进当前焦点控件最后再补一个回车键。它的优势是零驱动、零配置插上就认换电脑也基本不用装东西所以很适合固定工位、随手一插就能用的场景。缺点是你必须保证焦点落在正确的输入框上如果焦点跑到别的按钮上扫码内容会直接“敲”到按钮上触发意外操作中文输入法开启时字母和数字可能被输入法吞掉或转换成拼音处理不好就是一连串玄学乱码。虚拟串口模式则是扫码枪自带的驱动把设备虚拟成一个 COM 口程序通过 SerialPort 主动读数据。它不依赖焦点、不受输入法影响数据以字节流到达后台线程就能接收稳定性明显更好适合需要长时间连续扫描、扫码枪不固定插在同一台电脑上的场景。代价是要装驱动、要配置波特率换电脑后串口号可能漂移。我一般的选型标准是操作员固定坐班、速度要求不高选键盘模式省事如果扫码枪会被拿着来回走、或者要接在工控机/上位机环境里直接上虚拟串口模式。做货物出入库和订单管理这种连续扫描场景我倾向直接推荐串口模式后台接收数据、按条解析比跟输入法抢焦点踏实得多。2.2 用 WinForm 捕获键盘模式扫码完整实现与参数说明键盘模式下最忌讳在 TextBox 的 KeyDown 里写死逻辑因为只要焦点挪到 DataGridView 或按钮上整套代码就失效了。更稳妥的做法是给整个 Form 装一个消息过滤器在 Windows 消息层拦截击键也就是实现 IMessageFilter 接口。public partial class MainForm : Form, IMessageFilter { private StringBuilder _scanBuffer new StringBuilder(); public MainForm() { InitializeComponent(); Application.AddMessageFilter(this); // 全局预过滤键盘消息 } public bool PreFilterMessage(ref Message m) { // 0x0100 是 WM_KEYDOWN也就是按下键盘键 if (m.Msg ! 0x0100) return false; Keys key (Keys)(m.WParam.ToInt32()); // 扫码枪默认以回车结束回车是完整条码的结束符 if (key Keys.Enter) { string barcode _scanBuffer.ToString().Trim(); _scanBuffer.Clear(); if (barcode.Length 0) { HandleScannedBarcode(barcode); // 将扫码内容交给业务层 } return true; // 吃掉回车避免触发按钮默认行为 } // 只收集可见字符数字、字母、常用符号其余按键忽略 if ((key Keys.D0 key Keys.D9) || (key Keys.A key Keys.Z) || key Keys.OemMinus || key Keys.OemPeriod) { char ch (char)key; _scanBuffer.Append(ch); } return false; // 让消息继续走界面上的焦点控件不干扰 } }这段代码的核心思路是不让扫码内容进入任何焦点控件直接在窗口层拦截并累积到 StringBuilder 里直到遇到回车再整体交给业务处理函数。参数上有两点值得注意Keys.OemMinus 和 OemPeriod 是按键盘上减号和句点对应的 OEM 键条码里如果带横杠、点号这类字符必须放行回车必须 return true 吃掉否则当你把焦点停在“提交”按钮上时扫码枪的结束回车会直接把按钮“按”下去。这个方案绕开了输入法问题吗没有如果 Windows 当前处于中文输入法状态PreFilterMessage 里拿到的可能是输入法翻译后的按键消息字符照样会丢。所以键盘模式下有一个硬性配套动作把所有可输入控件的 ImeMode 设置为 Disable或者在程序启动时强制切换到英文输入法否则就等着踩第 5 章的坑。2.3 用 WinForm 读取虚拟串口模式后台接收与 UI 线程安全虚拟串口模式的程序相对干净扫码枪插好、驱动装好Windows 会分配一个 COM 口SerialPort 打开后就能等数据。数据到达时触发 DataReceived 事件但注意这个事件是在后台线程触发的不能直接碰界面控件必须通过 BeginInvoke 或 Invoke 回到 UI 线程再处理。private SerialPort _sp; private StringBuilder _rxBuffer new StringBuilder(); private void OpenScannerPort(string portName, int baudRate 9600) { _sp new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _sp.DataReceived Sp_DataReceived; _sp.Open(); } private void Sp_DataReceived(object sender, SerialDataReceivedEventArgs e) { string chunk _sp.ReadExisting(); if (_rxBuffer.Length 0 chunk.Length 0) return; _rxBuffer.Append(chunk); // 扫码枪发送的结尾通常是回车或换行以换行为完整条码边界 string current _rxBuffer.ToString(); int newlineIdx current.IndexOfAny(new[] { \r, \n }); if (newlineIdx 0) return; // 还没收到完整条码继续等 string barcode current.Substring(0, newlineIdx).Trim(); _rxBuffer.Clear(); // 把剩下的内容放回缓冲区防止一次读到多条时丢失 if (newlineIdx 1 current.Length) { _rxBuffer.Append(current.Substring(newlineIdx 1)); } if (barcode.Length 0) { string finalBarcode barcode; BeginInvoke(new Action(() HandleScannedBarcode(finalBarcode))); } }这段代码处理了两个高频坑拆包和粘包。扫码枪一次可能只发半个条码也可能一次把两条码连着发过来如果收到什么就处理什么必然出错。参数方面波特率大多数扫码枪出厂是 9600但也有 115200 的新型号不确定时查看扫码枪说明书或驱动配置工具里的默认值数据位 8、停止位 1、无校验是通用默认扫码枪不特别设置就不会变。还有一点ReadExisting 读出来的是字符串但如果扫码枪配置成输出 ASCII 十六进制那读出来就是一堆 HEX 文本需要在驱动或配置码里改回“普通字符串”输出模式。2.4 模式切换与驱动配置的常见手段扫码枪本身不会同时工作在键盘模式和串口模式切换方式各家略有区别。常见做法是扫描说明书上的“切换 USB 键盘模式”“切换虚拟串口模式”配置码扫一下即完成切换部分品牌需要在驱动配置工具里改。判断当前处于什么模式最简单——插上后打开设备管理器看“键盘”或“人体学输入设备”里多出来的那一项就是键盘模式看到“端口 (COM 和 LPT)”里多出的 COM 号就是串口模式。无论用哪种模式连接好后第一件事是打开记事本扫一张条码如果记事本能正确打出字符并换行说明枪本身没问题再排查程序。3. 出入库业务是怎么在扫码里“自动”跑起来的扫码枪接进来只是第一步真正让货物出入库系统成立的是扫码后的业务逻辑。很多新手把扫码和库存放在一个事件处理函数里堆代码第一版能跑第二版就乱成一团。这里要先把对象理清楚。3.1 先理清业务对象入库单、出库单、库存流水货物出入库这个标题下至少存在四个核心对象单据头、单据明细、库存、流水。单据头描述“谁在什么时候做了什么事”比如入库单号、供应商、创建时间、操作员单据明细描述“具体是哪些物料、各多少数量”一行一个物料库存描述“当前某种物料还剩多少”流水描述“每一次变动是加了还是减了、对应哪张单”。扫码枪在这个模型里承担的角色是“明细的快速录入器”扫一个条码等于告诉系统“这一行物料又收到/发出了一件”。所以扫码处理函数的核心工作不是改库存而是先匹配单据明细再累加数量最后才在提交环节统一动库存和流水。匹配的依据是条码和物料编码的对应关系要么条码本身就是物料编码要么通过 t_item 表把条码映射到物料编码。对象关键字段说明入库单头入库单号、供应商、入库日期、状态状态标记待收货/部分收货/完成入库明细单号、物料编码、应入数量、已收数量已收数量由扫码累加出库单头出库单号、领料部门/客户、出库日期、状态同理库存表物料编码、当前数量、更新时间每次变动必须事务保护流水表物料编码、变动类型、变动数量、关联单号、时间用于追溯和盘点对账这里的关键认知是扫码枪只负责增加“已收/已发数量”真正的库存更新应当在整张单据提交或完成时结算。如果你每扫一件就立刻直改库存中间扫错、取消、撤销时库存就乱套了。3.2 扫码入库的处理流程匹配明细、累加数量、刷新界面看一个入库扫码处理函数的实际骨架。这里设定用户先选择或录入一个入库单号然后开始扫物料条码。private void HandleScannedBarcode(string barcode) { string docNo txtDocNo.Text.Trim(); if (string.IsNullOrEmpty(docNo)) { lbTip.Text 请先选择入库单号; return; } DataRow row FindOrderLine(docNo, barcode); if (row null) { lbTip.Text $条码 {barcode} 不属于当前入库单; return; } int shouldQty row.Fieldint(qty); int receivedQty row.Fieldint(received_qty) 1; if (receivedQty shouldQty) { lbTip.Text $物料 {barcode} 已超收当前应收 {shouldQty} 件; return; } row[received_qty] receivedQty; row[status] receivedQty shouldQty ? 完成 : 部分收货; UpdateLineState(row); // 把已收数量和状态写回数据库 RefreshGrid(); lbTip.Text $已收 {barcode}{receivedQty}/{shouldQty}; }这个函数的核心策略是“先查后改”。FindOrderLine 按入库单号和条码查明细查不到就说明扫错了单子或条码不属于当前单直接提示并放弃本条避免无效数据写入。receivedQty 累加后先判断是否超过应入数量超收了就拦截这是超收保护。UpdateLineState 负责把状态写回数据库RefreshGrid 刷新 DataGridView 让操作员看到当前进度。注意一个设计细节状态在界面上显示为中文但数据库里建议存整数码。0待收货1部分收货2完成9关闭DataGridView 里再用列转换显示成中文文本便于 SQL 统计和后续流转判断。3.3 出库扫码先查可用库存再扣减事务保护不能省出库和入库最大的不同在于出库要面对“库存不够”的现实问题。如果每扫一件就直接把库存 UPDATE 减一减成负数也不会报错最后盘点时账面库存对不上就很尴尬。正确的做法是把“库存足够才允许扣减”这个条件写进 SQL 里。private bool TryReduceStock(string itemNo, int qty, string orderNo) { using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { var cmd new SqlCommand( UPDATE t_stock SET qty qty - qty, updated_time GETDATE() WHERE item_no itemNo AND qty qty, conn, tx); cmd.Parameters.AddWithValue(itemNo, itemNo); cmd.Parameters.AddWithValue(qty, qty); int affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); return false; // 库存不足或物料不存在 } var logCmd new SqlCommand( INSERT INTO t_stock_log (item_no, doc_type, order_no, qty_change, log_time, operator) VALUES (itemNo, OUT, orderNo, -qty, GETDATE(), operator), conn, tx); logCmd.Parameters.AddWithValue(operator, Environment.UserName); logCmd.ExecuteNonQuery(); tx.Commit(); return true; } } }这段 SQL 的精髓在 WHERE 条件里的 qty qtyUPDATE 语句自带库存校验不满足条件时影响行数为 0直接回滚。这比先 SELECT 查库存再 UPDATE 更稳因为两个并发出库请求同时到达时SELECT 查到都是足够的但 UPDATE 条件能保证只有一个成功。扣减成功后必须同事务插入流水流水写失败则整单回滚确保“账面变了但没记录”这种事永远不会发生。3.4 扫码结果与 DataGridView 实时联动扫码入库过程中操作员最关心的是“哪个物料扫了多少、还差多少”。最简单的做法是用 BindingSource 绑定一个 DataTable每次扫码后修改行数据再 ResetBindings。这里有个实际经验不要让整个表 ResetBindings那样滚动位置会跳回顶部操作员扫着扫着就不知道扫到哪一行了。更友好的做法是定位到刚更新那一行、选中它让界面跟随扫码进度滚动。private void RefreshGrid() { bindingSource.ResetBindings(false); // 让更新行保持在视野内 if (currentRowIndex 0 dgvDetail.Rows.Count currentRowIndex) { int rowIdx dgvDetail.Rows.Count - 1; // 实际项目中按匹配到的行定位 dgvDetail.ClearSelection(); dgvDetail.Rows[rowIdx].Selected true; dgvDetail.CurrentCell dgvDetail.Rows[rowIdx].Cells[item_no]; } }这里的 currentRowIndex 需要结合扫码匹配到的明细行去算实践中我一般返回匹配行的行号而不是写死最后一行。DataGridView 的 CurrentCell 定位等于告诉用户“刚刚扫的那个物料在这里”连续扫不同物料时眼睛不用在表格里来回找。还有一个小经验对频繁刷新的列不要用 DataGridViewComboBoxColumn 去做状态显示直接绑定文本列刷新成本低得多。4. 订单管理系统与出入库怎么连成一条线入库出库跑通以后订单管理是自然延伸。订单系统在 WinForm 里无非是“订单列表 订单明细 进度状态”但和扫码联动后逻辑就变成订单的每一行物料分别收到多少、什么时候收完、哪些行阻碍了整单完成。4.1 数据模型订单、订单明细、库存流水如何落表设计表结构时我建议把订单头、订单明细、库存、流水分成四张表而不是把所有东西塞进一张大宽表。宽表查询方便但更新时锁竞争严重、字段冗余、状态容易不一致。下面是 SQL Server 的建表参考。CREATE TABLE t_order ( order_no varchar(32) PRIMARY KEY, -- 订单号 customer varchar(64) NOT NULL, -- 客户/部门 status tinyint NOT NULL DEFAULT 0, -- 0待收货 1部分收货 2完成 9关闭 create_time datetime NOT NULL DEFAULT GETDATE() ); CREATE TABLE t_order_line ( id int IDENTITY(1,1) PRIMARY KEY, order_no varchar(32) NOT NULL, item_no varchar(32) NOT NULL, -- 物料编码 item_name varchar(64) NOT NULL, qty int NOT NULL, -- 应发/应入数量 received_qty int NOT NULL DEFAULT 0, -- 已扫码数量 CONSTRAINT uq_order_line UNIQUE (order_no, item_no), CONSTRAINT fk_order_line_order FOREIGN KEY (order_no) REFERENCES t_order(order_no) ); CREATE TABLE t_stock ( item_no varchar(32) PRIMARY KEY, qty int NOT NULL DEFAULT 0, updated_time datetime ); CREATE TABLE t_stock_log ( log_id bigint IDENTITY(1,1) PRIMARY KEY, item_no varchar(32) NOT NULL, doc_type varchar(8) NOT NULL, -- IN 入库 / OUT 出库 order_no varchar(32) NOT NULL, qty_change int NOT NULL, -- 正数入库负数出库 log_time datetime NOT NULL DEFAULT GETDATE(), operator varchar(32) NOT NULL ); CREATE INDEX idx_stock_log_time ON t_stock_log(log_time); CREATE INDEX idx_order_line_item ON t_order_line(item_no);订单行设置联合国唯一约束 uq_order_line这是防重关键同一张订单里同一个物料编码不允许出现两行扫码匹配时才能做到一行已收数量一直累加。外键约束建议仅保留 t_order_line 到 t_order 这一层库存表不建任何外键因为库存和订单是不同生命周期的事物外键会让扫码这种高频快速写入被数据库的引用检查拖慢。索引方面流水表按 log_time 建索引是为了盘点对账时按时间段检索订单行表按 item_no 建索引是为了扫码时按条码查物料快速命中。4.2 订单行状态流转从待收货到完成订单行的状态不应该是手工改的而应当由扫码逻辑自动推导。推导规则很简单private string CalcLineStatus(DataRow line) { int should line.Fieldint(qty); int received line.Fieldint(received_qty); if (received 0) return 待收货; if (received should) return 部分收货; return 完成; }这段规则放在业务层每次扫码累加后调用并写回数据库。与头部订单状态的关系是当订单所有明细行的状态都变成“完成”时订单头状态才是“完成”有一行完成即是“部分收货”全部未扫则是“待收货”。写代码时注意别反了——很多人只更新行状态却忘了推进头状态最后订单列表里一直显示待收货看起来就像系统把单子丢了。实现头状态推进不复杂扫码更新行状态后查一下该订单是否还有未完成的行没有了就把 t_order.status 改成 2。如果中途发现扫错了货提供“冲销已扫码”功能将 received_qty 减回去、状态回退、同时插入一条 qty_change 为负数的流水库存表不需要动因为此时库存还没结算。这就是第 3 章说的“先记账、后结算”的好处冲销只改单据状态不碰库存风险小得多。4.3 同一事务里更新订单行、库存与流水不要在每个扫码事件里分散提交 SQL而要以“整批次提交”为单位开启事务。看这个批次提交的代码结构private void CommitInbound(string orderNo, ListScannedLine lines) { using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { try { foreach (var line in lines) { // 1. 更新订单行已收数量 // 2. 增加库存 // 3. 插入流水 // 三条 SQL 共用同一个 tx 对象 } tx.Commit(); } catch { tx.Rollback(); throw; } } } }事务边界为什么必须这样圈因为扫码枪是连续事件操作员可能一口气扫 20 件你每扫一件就单独提交一次等于 20 次磁盘写入且每一次都可能成功一半失败一半。批量收集、一次提交既能减少数据库连接开销又能保证一批数据要么全进账、要么全回滚。这里还需要配合防重操作每一条流水记录插入前检查同一扫码内容是否已经被处理过。常见做法是在内存里维护一个 HashSet 存最近处理过的条码条码重复时直接忽略避免同一次扫码因为消息重发或双击被记两次。4.4 订单列表界面怎么和扫码联动订单列表在 WinForm 里用 DataGridView 展示时最实用的展示方式是第一列显示单号第二列显示客户第三列显示进度条。进度不一定要用第三方控件最简单的是在 DataGridView 里加一列文本格式化成“已收 35/40”同时用 RowState 的背景色区分完成行绿色部分收货黄色待收货白色。这个效果比任何花哨仪表盘都直观。实际操作中还可以给 DataGridView 加一个双击事件双击某一行订单后弹出该订单的明细窗口聚焦到扫码输入框扫码枪直接开始录明细。整个交互闭环就是选单号 → 扫条码 → 看进度 → 完成自动提示。5. 扫码枪接入与出入库的高频坑现象、原因、解决这个环节积累的都是真正会让系统翻车的细节。如果只照着功能清单开发上线第一天就会在某个意想不到的地方卡住。5.1 中文输入法吞字符扫码内容变成拼音或直接丢失现象键盘模式下扫描数字字母混合的条码界面收到的内容缺字母、多出拼音字符或者完全为空。原因Windows 处于中文输入法状态时键盘消息会被输入法先拦截翻译PreFilterMessage 里拿不到原始按键导致字符丢失或被转换。串口模式下不存在这个问题。解决键盘模式程序启动时强制切换英文输入法同时把界面所有可能获得焦点的输入控件 ImeMode 设为 Disable。代码上可以用 InputLanguage.CurrentInputLanguage InputLanguage.FromCulture(new CultureInfo(en-US)) 在 MainForm 构造函数里执行一次。如果客户机装的是精简版系统没有英文输入法就用 LoadKeyboardLayout 这类 API 强制加载再不行就劝客户直接切到虚拟串口模式一劳永逸。5.2 串口数据粘包拆包扫码内容一条变两条或半条现象串口模式下快速连扫时偶尔一条完整的码变成半条或者两条码粘在一起。原因DataReceived 事件触发时机是按缓冲区非空判断的无法保证每次刚好读到一个完整条码。驱动层一次可能收到半个条码也可能一次收到两个完整条码加一个换行符。解决必须做缓冲拼接和换行符拆分。第 2 章代码里已经实现的 StringBuilder 缓冲区 按 \r\n 截断的写法是从串口读取的基本功。需要注意的是拆分时最后一段如果没有换行符结尾要保留在缓冲区里等下一次数据不能直接丢弃。5.3 快速连扫丢单或重复入库现象连续扫 10 个条码数据库最终只记录了 8 个或者同一个条码被记录两次。原因两种原因叠加一是键盘模式下焦点短暂丢失消息过滤器的缓冲区被覆盖二是界面刷新逻辑阻塞了 UI 线程后续扫码事件被 Windows 丢弃。更隐蔽的是同一批数据提交时没有防重机制网络抖动导致 SQL 执行超时重试就写了两遍。解决把扫码接收和处理分离。扫码事件只负责把条码放入队列后台线程或定时器批量从队列取数据写入数据库同时数据库层面给流水表加联合唯一索引或业务唯一键比如 (order_no, item_no, scan_seq) 这个组合重复写入会被数据库直接拒绝。经验上讲WinForm 里做连续扫码界面线程绝不能在扫码处理器里执行数据库操作否则闪烁、卡顿、丢数据会一起来了。5.4 出库扣库存扣成负数现象库存明明只有 10 件出库单扫了 12 件还能继续账面变成 -2。原因写的扣减 SQL 没有把库存充足作为条件先 SELECT 后 UPDATE 也会因为并发请求而产生超卖。解决续第 3 章的写法扣减时用 UPDATE t_stock SET qty qty - qty WHERE item_no itemNo AND qty qty影响行数为 0 就回滚并提示“库存不足”。这一步是防呆的关键比在 C# 代码里做 if 判断更可靠因为数据库行锁能处理并发。5.5 换一台电脑就识别不了扫码枪现象扫码枪在本机正常拿到另一台电脑上毫无反应设备管理器里看不到任何新设备。原因要么是枪的驱动只在原先那台机器上装过要么是枪被配置成了虚拟串口模式新机器没有对应 COM 口驱动。一些品牌扫码枪还保留记忆功能恢复出厂设置后才会重新按默认键盘模式工作。解决随身带一份驱动安装包和配置码速查表。到新电脑后第一步看设备管理器里有没有新设备、是不是带感叹号没反应就扫一下说明书上的“恢复出厂设置”配置码再重新插拔一次。还有一种务实的做法给程序加一个 COM 口自动枚举功能程序启动时遍历 COM1 到 COM20尝试打开并发送读取指令哪一路能收到枪的应答就自动选中哪个口省得让操作员手动配串口号。6. 从单机到多工位批量连续扫码与业务处理解耦的两个进阶技巧批量连续扫描是出入库系统最真实的操作形态——操作员不会扫一件停一下点一次按钮而是一口气扫十几件再统一提交。此时把扫码事件直接捆绑到数据库操作上UI 卡顿、重复提交都不可避免。我的做法是引入一个待提交缓冲列表扫码只往列表里加内容界面即时展示“已扫待提交”清单操作员确认后点“提交入库”一次性写库。private Liststring _pendingBarcodes new Liststring(); private void HandleScannedBarcode(string barcode) { _pendingBarcodes.Add(barcode); dgvPending.Rows.Add(barcode, DateTime.Now.ToString(HH:mm:ss)); lbPendingCount.Text 待提交 _pendingBarcodes.Count 件; } private void BtnCommit_Click(object sender, EventArgs e) { if (_pendingBarcodes.Count 0) return; // 将 _pendingBarcodes 打包传入事务方法统一写订单行、库存、流水 CommitInbound(txtDocNo.Text.Trim(), _pendingBarcodes); _pendingBarcodes.Clear(); dgvPending.Rows.Clear(); lbPendingCount.Text 待提交0 件; }这个改动让扫码事件和业务持久化解耦扫码只操作内存和界面提交才碰数据库批量写入效率也更高。若未来要升级成多工位网络版只需要把 HandleScannedBarcode 收到的条码通过 TCP、HTTP 或消息队列发给服务端WinForm 界面的扫码体验完全不变。我习惯在项目一开始就把扫码接收和业务处理拆成两个类BarcodeScanner 负责接收和解析条码MainForm 只负责订阅通知并刷新界面。这样即使后期从串口模式换到网络模式、从单机库换成服务端 API改动也被控制在一个类里。做这类系统几年下来最大的感受是扫码枪本身从不骗人骗人的往往是焦点、输入法和随手写的 SQL把这三样管住系统就成功了大半。希望帮到你。本文还有配套的精品资源点击获取