
简介这是一套基于C#与SQLite开发的轻量级仓库管理系统源码面向.NET初学者及中小型仓储业务场景开发者解决货品出入库、信息维护与数据查询等核心管理需求。资源包共171个文件含32个C#源文件.cs构成完整业务逻辑层5个可执行程序.exe与1个SQLite数据库文件.db确保开箱即用辅以32个动态链接库.dll和4个工程配置文件.csproj整体结构清晰、模块划分明确适合学习WinForm桌面应用开发与本地数据库集成实践。压缩包大小为4.14MB编译环境为Visual Studio 2017兼容.NET Framework 2.0代码简洁规范涵盖入库、出库、增删改查及明细浏览等全流程功能。目前已有884人学习下载读者可直接运行调试、理解分层架构设计如DAL数据访问层、掌握SQLite在C#中的CRUD操作及界面事件驱动逻辑是入门仓储类管理系统开发的优质实操范例。1. 为什么一个用 C# SQLite 写的仓库管理系统反而比“高大上”架构更扛得住产线真实压力你见过凌晨三点还在跑的 WMS 吗不是云原生微服务集群而是一台工控机上跑着的 WinForms 程序数据库文件就躺在C:\WMS\Data\warehouse.db里没装 SQL Server没配 Redis 缓存没走 RabbitMQ 消息队列——但它连续 18 个月没重启过扫码入库、盘点出库、批次追溯全在线PLC 传来的托盘号一进来300ms 内完成校验写库回传状态。这不是玄学是 C# SQLite 在中小制造/物流现场的真实生存逻辑轻量、嵌入、零部署依赖、事务原子性强、IO 可控、调试透明。它不解决“亿级并发”但完美覆盖 92% 的中小仓库场景——日均单据 ≤ 5000 条、终端 ≤ 20 台、网络带宽 ≤ 100Mbps、运维人员只会点“开始菜单”。本篇不讲 ORM 抽象层怎么设计不堆砌 Entity Framework Core 的 fluent API只带你从零拉起一个可交付、可维护、能进车间、能接扫码枪、能导 Excel、能防误操作的 C# SQLite 仓库管理系统源码骨架。所有代码基于 .NET 6跨平台兼容、SQLitePCLRaw.bundle_e_sqlite3真原生驱动、Dapper极简数据访问拒绝任何“玩具级 demo”套路。如果你正被 Docker 部署卡在内网防火墙、被 EF Core 迁移脚本搞崩生产库、或被 Java Web 项目启动慢到不敢重启服务——这篇就是你的后悔药。2. 从空解决方案到可运行窗体搭建最小可行仓库管理骨架2.1 创建项目结构与关键 NuGet 包选型理由新建一个 .NET 6 Windows Forms App目标框架net6.0-windows不要选 .NET Core 或 .NET 7——车间电脑普遍装的是 .NET 6 Runtime装新版本需管理员权限而产线 IT 往往拒绝给你开。项目结构按职责分层非 DDD 复杂分层是能一眼看懂的物理隔离WarehouseSystem/ ├── WarehouseSystem.UI/ # WinForms 窗体 主入口 ├── WarehouseSystem.Core/ # 实体类、业务规则、DTO ├── WarehouseSystem.Data/ # 数据访问层Dapper 封装 SQLite 连接管理 └── WarehouseSystem.Models/ # 仅含实体类避免循环引用关键 NuGet 包必须手动指定版本避坑点见第 4 章SQLitePCLRaw.bundle_e_sqlite3v2.1.8这是目前最稳定的原生 SQLite 绑定bundle_green在某些工控机显卡驱动下会触发 GDI 崩溃e_sqlite3是微软官方推荐的嵌入式首选。Dapperv2.1.24轻量、无反射魔法、SQL 完全可控比 EF Core 快 3.2 倍实测 10 万条插入耗时对比且QueryFirstOrDefaultT返回 null 而非异常适合仓储场景的“查无结果即合法”逻辑。Microsoft.Data.Sqlitev6.0.26禁用它内部封装了连接池和异步线程调度在 WinForms UI 线程中极易引发InvalidOperationException: Connection is already in use——这是产线翻车第一高频原因。提示SQLitePCLRaw.bundle_e_sqlite3安装后会自动引入SQLitePCLRaw.core和SQLitePCLRaw.provider.e_sqlite3无需额外引用。若编译报错Could not load file or assembly SQLitePCLRaw.core右键项目 → 属性 → 发布 → 设置“生成时复制本地”为True。2.2 初始化 SQLite 数据库用迁移脚本而非 Code-FirstSQLite 不支持ALTER COLUMN也不该用 EF Core Migration 生成.sql脚本再执行——因为产线升级必须“一键静默”不能弹窗让用户选“是否执行迁移”。我们采用预置 SQL 脚本 版本号校验方式在WarehouseSystem.Data项目中新建Resources/Migrations/文件夹放入V1__Init.sql-- V1__Init.sql CREATE TABLE IF NOT EXISTS Products ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Code TEXT NOT NULL UNIQUE COLLATE NOCASE, Name TEXT NOT NULL, Category TEXT, Unit TEXT DEFAULT 件, CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS Inventory ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ProductId INTEGER NOT NULL, Location TEXT NOT NULL, -- 库位编码如 A-01-01 Quantity INTEGER NOT NULL DEFAULT 0, BatchNo TEXT, ExpireDate DATE, UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_inventory_product_location ON Inventory(ProductId, Location); CREATE INDEX IF NOT EXISTS idx_inventory_batch ON Inventory(BatchNo);在WarehouseSystem.Data/DatabaseInitializer.cs中实现初始化逻辑using System; using System.IO; using System.Reflection; using System.Text; using Dapper; using SQLitePCL; public static class DatabaseInitializer { private const string DbPath Data\warehouse.db; private const string VersionTable SchemaVersion; public static void Initialize() { // 确保 Data 目录存在 Directory.CreateDirectory(Path.GetDirectoryName(DbPath)); // 若数据库文件不存在创建并执行 V1 脚本 if (!File.Exists(DbPath)) { using var conn new SQLiteConnection(DbPath); conn.Open(); // 创建版本表 conn.Execute($ CREATE TABLE IF NOT EXISTS {VersionTable} ( Version TEXT PRIMARY KEY, AppliedAt DATETIME DEFAULT CURRENT_TIMESTAMP )); // 执行 V1 脚本 var script GetEmbeddedSql(V1__Init.sql); conn.Execute(script); // 记录版本 conn.Execute($INSERT INTO {VersionTable} (Version) VALUES (v), new { v V1 }); } else { // 检查当前版本此处简化为只支持 V1 → V2 升级实际项目加 switch using var conn new SQLiteConnection(DbPath); conn.Open(); var current conn.QueryFirstOrDefaultstring($SELECT Version FROM {VersionTable} ORDER BY AppliedAt DESC LIMIT 1); if (current V1) { // 执行 V2 脚本示例增加字段 var v2Script GetEmbeddedSql(V2__AddSupplierColumn.sql); conn.Execute(v2Script); conn.Execute($INSERT INTO {VersionTable} (Version) VALUES (v), new { v V2 }); } } } private static string GetEmbeddedSql(string fileName) { var resourceName $WarehouseSystem.Data.Resources.Migrations.{fileName}; using var stream Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); using var reader new StreamReader(stream, Encoding.UTF8); return reader.ReadToEnd(); } }逻辑说明GetEmbeddedSql从资源文件读取 SQL避免部署时漏掉.sql文件COLLATE NOCASE让Code字段查询不区分大小写扫码枪扫出的ABC123和abc123视为同一物料ON DELETE CASCADE保证删除商品时库存自动清理避免脏数据idx_inventory_product_location索引是性能核心——盘点时按库位查所有商品此索引让WHERE Location A-01-01查询从 1200ms 降到 18ms实测 5 万条记录。2.3 构建第一个业务窗体扫码入库界面与事务控制在WarehouseSystem.UI中新建FrmInbound.cs拖放控件TextBox txtScanCode扫码枪输入框、Label lblProductName、NumericUpDown nudQuantity、ComboBox cmbLocation、Button btnConfirm。关键逻辑扫码即查、查到才允许提交、提交必事务、失败必回滚、UI 必反馈private async void btnConfirm_Click(object sender, EventArgs e) { // 1. 输入校验前端拦截 if (string.IsNullOrWhiteSpace(txtScanCode.Text)) { MessageBox.Show(请先扫描物料编码, 提示, MessageBoxButtons.OK, MessageBoxIcon.Information); return; } if (nudQuantity.Value 0) { MessageBox.Show(数量必须大于0, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); return; } // 2. 开启事务Dapper 不自动开事务必须显式 using var conn new SQLiteConnection(DatabaseConfig.ConnectionString); conn.Open(); using var transaction conn.BeginTransaction(); try { // 3. 查商品用 Dapper QuerySingle查不到抛异常 var product conn.QuerySingleOrDefaultProduct( SELECT * FROM Products WHERE Code code COLLATE NOCASE, new { code txtScanCode.Text.Trim() }, transaction); if (product null) { throw new InvalidOperationException($未找到物料编码{txtScanCode.Text}); } // 4. 查当前库位库存用于后续更新 var existing conn.QuerySingleOrDefaultInventory( SELECT * FROM Inventory WHERE ProductId pid AND Location loc, new { pid product.Id, loc cmbLocation.Text.Trim() }, transaction); // 5. 插入或更新库存 if (existing null) { conn.Execute( INSERT INTO Inventory (ProductId, Location, Quantity, BatchNo, ExpireDate) VALUES (pid, loc, qty, batch, expire), new { pid product.Id, loc cmbLocation.Text.Trim(), qty (int)nudQuantity.Value, batch DateTime.Now.ToString(yyyyMMdd), // 简化批次号 expire DateTime.Now.AddYears(2).ToString(yyyy-MM-dd) }, transaction); } else { conn.Execute( UPDATE Inventory SET Quantity Quantity qty, UpdatedAt CURRENT_TIMESTAMP WHERE Id id, new { qty (int)nudQuantity.Value, id existing.Id }, transaction); } // 6. 记录操作日志独立表不参与库存事务 conn.Execute( INSERT INTO OperationLog (Type, TargetId, Operator, Remark, CreatedAt) VALUES (type, tid, op, rmk, CURRENT_TIMESTAMP), new { type INBOUND, tid product.Id, op Environment.UserName, rmk $扫码入库{txtScanCode.Text} × {(int)nudQuantity.Value} 件库位 {cmbLocation.Text} }); transaction.Commit(); MessageBox.Show($入库成功{product.Name} × {(int)nudQuantity.Value} 件, 成功, MessageBoxButtons.OK, MessageBoxIcon.Information); // 7. 清空界面准备下一次扫描 txtScanCode.Clear(); txtScanCode.Focus(); } catch (Exception ex) { transaction.Rollback(); MessageBox.Show($入库失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } }参数说明COLLATE NOCASE再次出现确保扫码枪大小写不敏感transaction显式传入每个Execute/Query否则 Dapper 默认不走事务OperationLog表独立于事务因日志失败不应影响主业务符合仓储审计要求txtScanCode.Clear()后调用Focus()让扫码枪连续扫无需点窗体——这是产线效率刚需。3. 核心业务闭环库存盘点、出库、批次追溯与 Excel 导出3.1 盘点功能离线比对 差异高亮 一键修正盘点不是“查总数”而是“人走到库位 A-01-01扫实物标签系统显示理论库存人确认是否一致”。因此界面需支持左侧显示系统理论库存来自Inventory表右侧输入实际扫码数量差异行红色高亮“修正为实际值”按钮FrmStocktake.cs关键代码private void LoadInventoryByLocation(string location) { using var conn new SQLiteConnection(DatabaseConfig.ConnectionString); var sql SELECT i.Id, p.Code, p.Name, i.Quantity AS SystemQty, 0 AS ActualQty, -- 初始为0由用户输入 i.Location, i.BatchNo, i.ExpireDate FROM Inventory i JOIN Products p ON i.ProductId p.Id WHERE i.Location loc ORDER BY p.Code; dgvStocktake.DataSource conn.QueryStocktakeItem(sql, new { loc location }).ToList(); } private void btnApplyActual_Click(object sender, EventArgs e) { var rows dgvStocktake.Rows.CastDataGridViewRow() .Where(r r.Cells[ActualQty].Value ! null int.TryParse(r.Cells[ActualQty].Value.ToString(), out _)) .ToList(); using var conn new SQLiteConnection(DatabaseConfig.ConnectionString); conn.Open(); using var trans conn.BeginTransaction(); try { foreach (DataGridViewRow row in rows) { var id Convert.ToInt32(row.Cells[Id].Value); var actual Convert.ToInt32(row.Cells[ActualQty].Value); // 更新库存注意此处是覆盖非累加 conn.Execute( UPDATE Inventory SET Quantity qty, UpdatedAt CURRENT_TIMESTAMP WHERE Id id, new { qty actual, id }, trans); } trans.Commit(); MessageBox.Show($已修正 {rows.Count} 条盘点差异, 成功, MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { trans.Rollback(); MessageBox.Show($修正失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } }注意StocktakeItem类需标记[Table(Inventory)]并定义属性映射Dapper 才能正确绑定。DataGridView的AutoGenerateColumns false手动绑定列并设置ReadOnly true除ActualQty列外防止误编辑。3.2 出库流程先进先出FIFO批次选择与防超发校验出库不是简单减库存必须按批次有效期排序优先出最早入库的批次并校验“要出的数量 ≤ 当前批次可用量”// 获取某商品所有可出库批次按 ExpireDate ASC, UpdatedAt ASC var batches conn.QueryInventory( SELECT * FROM Inventory WHERE ProductId pid AND Quantity 0 ORDER BY ExpireDate ASC, UpdatedAt ASC, new { pid productId }, transaction).ToList(); int remaining needQty; foreach (var batch in batches) { if (remaining 0) break; int canTake Math.Min(batch.Quantity, remaining); // 更新该批次库存 conn.Execute( UPDATE Inventory SET Quantity Quantity - take WHERE Id id, new { take canTake, id batch.Id }, transaction); // 记录出库明细独立表 conn.Execute( INSERT INTO OutboundDetail (InventoryId, Qty, OutboundTime) VALUES (iid, q, CURRENT_TIMESTAMP), new { iid batch.Id, q canTake }, transaction); remaining - canTake; } if (remaining 0) { throw new InvalidOperationException($库存不足还需 {remaining} 件无法完成出库); }3.3 批次追溯从销售单反查原料批次链这是 GMP/ISO 要求的核心能力。假设销售单号SO20240501-001对应出库明细我们要查销售单 → 出库明细 → 库存记录 → 商品 → 原料采购单SQLite 支持递归 CTE但深度有限。我们用三层 JOIN 参数化查询实现-- 追溯 SQL传入 outboundId WITH RECURSIVE Trace AS ( -- 第一层出库明细 SELECT od.Id as DetailId, i.Location, i.BatchNo, i.ExpireDate, p.Code as ProductCode, p.Name as ProductName, 1 as Level FROM OutboundDetail od JOIN Inventory i ON od.InventoryId i.Id JOIN Products p ON i.ProductId p.Id WHERE od.OutboundId outboundId UNION ALL -- 第二层采购入库假设存在 PurchaseDetail 表关联 Inventory SELECT pd.Id, PURCHASE as Location, pd.BatchNo, pd.ReceiveDate as ExpireDate, pr.Code, pr.Name, 2 FROM PurchaseDetail pd JOIN Products pr ON pd.ProductId pr.Id WHERE pd.InventoryId IN (SELECT DetailId FROM Trace) ) SELECT * FROM Trace ORDER BY Level;实际项目中PurchaseDetail表需有InventoryId外键。若无此表则追溯止于Inventory符合中小仓库现实。3.4 Excel 导出用 ClosedXML 避免 COM 依赖与 Excel 进程残留Microsoft.Office.Interop.Excel在无 Office 的工控机上必崩且会残留EXCEL.EXE进程。改用ClosedXMLNuGetClosedXMLv0.102.3private void ExportToExcel(ListInventory data, string fileName) { using var wb new XLWorkbook(); var ws wb.Worksheets.Add(库存清单); // 表头 ws.Cell(1, 1).Value 物料编码; ws.Cell(1, 2).Value 物料名称; ws.Cell(1, 3).Value 库位; ws.Cell(1, 4).Value 数量; ws.Cell(1, 5).Value 批次号; ws.Cell(1, 6).Value 有效期; // 数据 for (int i 0; i data.Count; i) { var item data[i]; ws.Cell(i 2, 1).Value item.ProductCode; // 需在 Inventory 类加 ProductCode 属性 ws.Cell(i 2, 2).Value item.ProductName; ws.Cell(i 2, 3).Value item.Location; ws.Cell(i 2, 4).Value item.Quantity; ws.Cell(i 2, 5).Value item.BatchNo; ws.Cell(i 2, 6).Value item.ExpireDate?.ToString(yyyy-MM-dd) ?? ; } // 自动列宽 ws.Columns().AdjustToContents(); wb.SaveAs(fileName); MessageBox.Show($已导出至{fileName}, 完成, MessageBoxButtons.OK, MessageBoxIcon.Information); }参数说明ws.Columns().AdjustToContents()比手动设列宽更可靠适配不同长度文本item.ProductCode需在Inventory类中通过JOIN查询时赋值或用 Dapper 的QueryInventory, Product, Inventory多映射导出路径建议用Environment.GetFolderPath(Environment.SpecialFolder.Desktop)避免权限问题。4. 避坑C# SQLite 仓库系统上线前必须绕开的 5 个血泪雷区4.1 现象程序启动时报错Unable to load DLL e_sqlite3原因SQLitePCLRaw.bundle_e_sqlite3的 native dll 未随程序发布。x64/x86 平台不匹配或runtimes文件夹未复制到输出目录。解决在.csproj中添加CopyLocalLockFileAssembliestrue/CopyLocalLockFileAssemblies确保发布时选择目标平台为x64工控机基本全是 64 位并在项目属性 → 生成 → 平台目标设为x64检查bin\x64\Release\runtimes\win-x64\native\e_sqlite3.dll是否存在不存在则手动复制SQLitePCLRaw.native_library包中的对应 dll。4.2 现象多用户同时操作时库存数量错乱如 100 件被两人各减 50结果剩 0 而非 50原因未使用BEGIN IMMEDIATE或BEGIN EXCLUSIVESQLite 默认DEFERRED事务读写分离导致幻读。解决所有写操作前加conn.Execute(BEGIN IMMEDIATE);或封装成using var trans conn.BeginTransaction(IsolationLevel.Serializable);SQLite 实际只支持Serializable等价于BEGIN EXCLUSIVE。4.3 现象扫码枪输入后窗体卡死CPU 占用 100%原因TextBox.TextChanged事件中直接查数据库扫码枪每扫一位触发一次10 位编码触发 10 次查询。解决改用KeyDown事件监听Enter键或加防抖Task.Delay(300).ContinueWith(_ SearchProduct())300ms 内连续输入只查最后一次。4.4 现象导出 Excel 后再次导出报错The process cannot access the file because it is being used by another process.原因ClosedXML未释放FileStream或XLWorkbook对象未Dispose。解决必须用using包裹XLWorkbook导出路径避免用DateTime.Now.ToString(yyyyMMddHHmmss)作文件名——高并发时可能重名改用Guid.NewGuid().ToString(N)。4.5 现象SQLite 数据库文件越来越大即使删了 90% 数据文件体积不变原因SQLite 删除数据只标记为“可复用”不自动收缩文件。解决每日凌晨执行VACUUM;需在连接关闭后执行using var conn new SQLiteConnection(DbPath); conn.Open(); conn.Execute(VACUUM;);或在DatabaseInitializer.Initialize()结尾加conn.Execute(PRAGMA auto_vacuum FULL;);首次建库时设置后续自动回收。5. 进阶技巧让 SQLite 仓库系统真正“进车间”的 3 个硬核实践5.1 用 SQLite FTS5 实现模糊搜索替代 LIKE %关键词% 的慢查询仓库人员常搜“电机”“轴承”“不锈钢”LIKE全表扫描在 10 万商品时达 2.3 秒。FTS5全文检索可压到 80ms-- 创建虚拟表在 V1__Init.sql 中追加 CREATE VIRTUAL TABLE Products_fts USING fts5( Code, Name, Category, contentProducts, content_rowidId ); -- 创建触发器自动同步 CREATE TRIGGER products_ai AFTER INSERT ON Products BEGIN INSERT INTO Products_fts(rowid, Code, Name, Category) VALUES (new.Id, new.Code, new.Name, new.Category); END; CREATE TRIGGER products_ad AFTER DELETE ON Products BEGIN INSERT INTO Products_fts(products_fts, rowid, Code, Name, Category) VALUES(delete, old.Id, old.Code, old.Name, old.Category); END; CREATE TRIGGER products_au AFTER UPDATE ON Products BEGIN INSERT INTO Products_fts(products_fts, rowid, Code, Name, Category) VALUES(delete, old.Id, old.Code, old.Name, old.Category); INSERT INTO Products_fts(rowid, Code, Name, Category) VALUES (new.Id, new.Code, new.Name, new.Category); END;C# 查询代码var results conn.QueryProduct( SELECT p.* FROM Products p JOIN Products_fts f ON p.Id f.rowid WHERE f MATCH keyword, new { keyword $\{searchText}*\ }); // 注意fts5 用双引号星号支持前缀匹配提示MATCH语法支持电机*前缀、轴承 NEAR/3 不锈钢邻近比LIKE强 30 倍。但 FTS5 表不支持ORDER BY需在 C# 中二次排序。5.2 用 WAL 模式提升并发写入吞吐从 12 QPS 到 210 QPS默认DELETE模式下写操作会锁整个数据库。开启 WALWrite-Ahead Logging后读写可并发// 在 DatabaseConfig.ConnectionString 后追加 // Data SourceData\warehouse.db;Journal ModeWAL;SynchronousNormal; // 注意SynchronousNormal 比 Full 快 3 倍断电可能丢最后 1 条但仓库场景可接受实测对比i5-8250U SSD场景DELETE 模式WAL 模式100 条并发入库12 QPS210 QPS读写混合5读5写8 QPS185 QPS数据库文件锁等待时间142ms/次1.2ms/次注意WAL 模式会产生-wal和-shm临时文件必须与.db文件同目录且不能被备份软件误删。5.3 用 SQLite 加密扩展SEE保护敏感数据防U盘拷走数据库SQLite 官方不提供加密但sqlcipher是工业级方案。我们用SQLitePCLRaw.bundle_e_sqlcipher替换e_sqlite3安装SQLitePCLRaw.bundle_e_sqlcipherv2.1.8修改连接字符串Data SourceData\warehouse.db;PasswordMySecureKey123!;首次创建库时conn.Execute(PRAGMA key MySecureKey123!;);后续每次连接都需带Password参数否则报file is encrypted or is not a database。提示密钥硬编码在配置文件中不安全应从 Windows DPAPI 加密的注册表读取或用ProtectedData.Protect()加密密钥字符串。但产线环境往往接受明文密钥——毕竟物理接触数据库文件的人已经能拿到全部权限。我干这行八年亲手把三个厂的纸质台账换成 C# SQLite 系统最深的体会是别跟产线谈“架构优雅”要谈“扫码不卡顿、断网能干活、重启不丢单、IT 不用教”。SQLite 不是退而求其次它是用最朴素的工具把最脏最累的活干得最稳。每次看到老师傅用扫码枪“嘀”一声就完成入库笑着拍拍我肩膀说“这玩意儿比以前快多了”我就知道那些为兼容 Win7 写的try-catch、为防误触加的MessageBox确认、为省电关掉的动画效果——全都值了。希望帮到你。本文还有配套的精品资源点击获取