简介:这份资源是一套基于C#与MySQL数据库开发的仓库管理系统完整项目包,面向学习C#面向对象编程、数据库设计及企业级应用开发的学生与开发者,可用于课程设计、毕业设计或自学实践。压缩包共163个文件,约1.22MB,以49个cs源代码文件为核心,辅以18个resx资源文件、18个png界面素材、3个exe可执行程序、3个config配置文件,以及sln解决方案、csproj工程文件和sql数据库脚本,结构完整、开箱可研。系统涵盖物品入库、出库、查询、统计等基础功能,数据库包含物品、库存、供应商、客户等基础表,并通过视图与存储过程支持按名称、类别、时间、库存量、供应商、客户等八种查询场景,同时实现了登录验证、权限管理与参数化查询等安全机制。目前已有29人学习下载,配套README.md与查询案例说明文档,便于读者快速理解项目架构、复用代码并掌握C#与MySQL结合开发的实践思路。
1. 基于C#的仓库管理系统:从建库到入库单落地的完整路径
很多做企业信息化的朋友第一次接到仓库管理系统的需求时,脑子里第一反应是“不就是增删改查吗”,真动手才发现坑全在细节里:库存数量对不上、并发扣减超卖、入库单和出库单状态流转混乱、MySQL连接池被打满。基于C#的仓库管理系统(含MySQL数据库文件)这个方向,本质是用C#做上位机/桌面端或ASP.NET服务端,用MySQL存商品、库位、单据、流水,把“收货—上架—拣货—出库—盘点”这条链路跑通。它适合中小型制造、电商仓储、门店后台的从业者,也适合想拿一个完整项目练C#连接MySQL、事务、数据库设计的开发者。下面我按自己实际做过的顺序,把建库、连库、单据落库、并发扣减、避坑和进阶验证一层层拆开讲,新手能照着敲,熟手能直接看参数边界。
2. 先把MySQL库建对:仓库管理系统需要哪几张核心表
2.1 表结构设计:商品、库位、库存、单据、流水五件套
仓库管理系统的数据库设计决定了后面代码好不好写。我一般会先落五张核心表:商品表(product)存SKU基础信息,库位表(location)存仓库内的物理位置,库存表(inventory)存“某商品在某库位有多少”,单据主表(bill)和单据明细表(bill_item)存入库单、出库单,库存流水表(stock_log)记录每一次数量变动。很多新手会把库存直接挂在商品表上,结果多库位场景直接翻车——同一个SKU放在A区10件、B区5件,一个字段根本表达不了。
下面是我常用的建库脚本,字符集用utf8mb4,引擎InnoDB,因为后面要靠事务和行锁。
CREATE DATABASE wms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE wms; CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku VARCHAR(64) NOT NULL UNIQUE COMMENT '商品编码', name VARCHAR(128) NOT NULL, unit VARCHAR(16) DEFAULT '件', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE location ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL UNIQUE COMMENT '库位编码如A-01-01', zone VARCHAR(32) COMMENT '区域' ) ENGINE=InnoDB; CREATE TABLE inventory ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, location_id BIGINT NOT NULL, qty INT NOT NULL DEFAULT 0 COMMENT '库存数量', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', UNIQUE KEY uk_prod_loc (product_id, location_id) ) ENGINE=InnoDB; CREATE TABLE bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL UNIQUE, type TINYINT NOT NULL COMMENT '1入库 2出库', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已审核 2已完成', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE bill_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_id BIGINT NOT NULL, product_id BIGINT NOT NULL, location_id BIGINT NOT NULL, qty INT NOT NULL, KEY idx_bill (bill_id) ) ENGINE=InnoDB; CREATE TABLE stock_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, location_id BIGINT NOT NULL, change_qty INT NOT NULL COMMENT '正数入库负数出库', bill_no VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;逻辑说明:inventory表用(product_id, location_id)做唯一键,保证一个商品在一个库位只有一行,避免重复插入。version字段是给乐观锁用的,后面并发扣减会用到。stock_log只追加不修改,方便对账。参数上qty用INT够中小仓库用,如果做大宗物料建议换DECIMAL(18,3)。字符集必须utf8mb4,否则商品名带生僻字或emoji会报错。
2.2 用C#连MySQL:连接字符串与连接池的三个关键参数
建完库就要在C#里连。常见做法是用MySql.Data或MySqlConnector这两个NuGet包,我一般选MySqlConnector,异步支持更干净。连接字符串里最容易出事的是连接池配置,默认值在高并发下会把MySQL打满。
// 安装:dotnet add package MySqlConnector using MySqlConnector; public class DbFactory { // 连接字符串:Pooling开启连接池,Min/Max控制池大小 private const string ConnStr = "Server=127.0.0.1;Port=3306;Database=wms;Uid=root;Pwd=你的密码;" + "Pooling=true;MinimumPoolSize=2;MaximumPoolSize=20;" + "ConnectionTimeout=5;DefaultCommandTimeout=10;CharSet=utf8mb4;"; public static MySqlConnection Open() { var conn = new MySqlConnection(ConnStr); conn.Open(); return conn; } }逻辑说明:MinimumPoolSize=2保证有热连接,MaximumPoolSize=20是中小系统的经验值,超过这个数MySQL的max_connections容易顶不住。ConnectionTimeout=5秒,避免网络抖动时线程全卡死。DefaultCommandTimeout=10秒,防止慢查询拖垮界面。CharSet=utf8mb4要和建库一致,否则中文乱码。注意别在循环里反复new MySqlConnection,连接池会复用,但用完必须Dispose,否则池会被占满,表现就是“连接超时”这种玄学问题。
3. 入库单落库:C#事务写法与库存流水一致性
3.1 一张入库单从界面到数据库的完整流程
入库单的业务动作是:录单→审核→上架→库存增加→写流水。这四步必须在一个事务里,否则出现“单据完成了但库存没加”这种对不上账的血泪经验。我一般把流程写成:先插bill和bill_item,再更新inventory,最后插stock_log,全部包在同一个事务里。
public void ConfirmInbound(Bill bill, List<BillItem> items) { using var conn = DbFactory.Open(); using var tx = conn.BeginTransaction(); try { // 1. 更新单据状态为已完成 using (var cmd = new MySqlCommand( "UPDATE bill SET status=2 WHERE bill_no=@no AND status=1", conn, tx)) { cmd.Parameters.AddWithValue("@no", bill.BillNo); if (cmd.ExecuteNonQuery() == 0) throw new Exception("单据状态不允许完成或不存在"); } foreach (var it in items) { // 2. 库存增加:存在则累加,不存在则插入 using (var cmd = new MySqlCommand(@" INSERT INTO inventory(product_id, location_id, qty) VALUES(@pid, @lid, @qty) ON DUPLICATE KEY UPDATE qty = qty + @qty", conn, tx)) { cmd.Parameters.AddWithValue("@pid", it.ProductId); cmd.Parameters.AddWithValue("@lid", it.LocationId); cmd.Parameters.AddWithValue("@qty", it.Qty); cmd.ExecuteNonQuery(); } // 3. 写库存流水 using (var cmd = new MySqlCommand(@" INSERT INTO stock_log(product_id, location_id, change_qty, bill_no) VALUES(@pid, @lid, @qty, @no)", conn, tx)) { cmd.Parameters.AddWithValue("@pid", it.ProductId); cmd.Parameters.AddWithValue("@lid", it.LocationId); cmd.Parameters.AddWithValue("@qty", it.Qty); cmd.Parameters.AddWithValue("@no", bill.BillNo); cmd.ExecuteNonQuery(); } } tx.Commit(); } catch { tx.Rollback(); throw; } }逻辑说明:UPDATE bill时带status=1条件,是防止重复审核,这是幂等性的关键。ON DUPLICATE KEY UPDATE利用唯一键实现“有则加、无则插”,比先查后写少一次往返。整个事务里任何一步抛异常都Rollback,保证单据、库存、流水三者一致。参数上@qty入库为正数,出库时传负数即可复用同一套逻辑。注意MySqlConnector里事务对象要显式传给每个Command,漏传就会脱离事务,这是新手最常见的翻车点。
3.2 出库扣减:乐观锁和悲观锁怎么选
出库比入库危险,因为要判断库存够不够。两种常见做法:悲观锁用SELECT ... FOR UPDATE锁行,简单但并发高时容易锁等待;乐观锁用version字段,冲突时重试。中小仓库我一般用乐观锁,因为锁等待会让界面卡顿。
public bool DeductStock(long productId, long locationId, int qty, string billNo) { using var conn = DbFactory.Open(); using var tx = conn.BeginTransaction(); try { // 乐观锁:带version条件更新,影响行数为0说明被别人改过 using (var cmd = new MySqlCommand(@" UPDATE inventory SET qty = qty - @qty, version = version + 1 WHERE product_id=@pid AND location_id=@lid AND qty >= @qty AND version=@ver", conn, tx)) { cmd.Parameters.AddWithValue("@qty", qty); cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@lid", locationId); cmd.Parameters.AddWithValue("@ver", GetVersion(conn, tx, productId, locationId)); if (cmd.ExecuteNonQuery() == 0) { tx.Rollback(); return false; // 库存不足或版本冲突,由上层重试 } } // 写负数流水 using (var cmd = new MySqlCommand(@" INSERT INTO stock_log(product_id, location_id, change_qty, bill_no) VALUES(@pid, @lid, @qty, @no)", conn, tx)) { cmd.Parameters.AddWithValue("@pid", productId); cmd.Parameters.AddWithValue("@lid", locationId); cmd.Parameters.AddWithValue("@qty", -qty); cmd.Parameters.AddWithValue("@no", billNo); cmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); throw; } }逻辑说明:WHERE里qty >= @qty保证不会扣成负数,version=@ver保证没有并发修改。影响行数为0时返回false,上层可以重试2到3次。参数上version要先查出来,查和更新之间如果有别人改了,更新就会失败,这正是乐观锁的机制。如果业务要求强一致且并发不高,把SELECT换成FOR UPDATE就是悲观锁,但要注意加索引,否则会锁表。
4. 避坑与排查:仓库管理系统上线后最容易翻车的五件事
4.1 库存对不上:先查流水再查事务边界
现象:盘点时发现某商品库存比实际多或少。原因:多半是事务没包全,比如库存更新了但流水没写,或者单据状态改了但库存没动。解决:先拿stock_log按product_id和location_id做SUM(change_qty),和inventory.qty对比,差值就是漏记的量。然后检查代码里所有改库存的地方是否都在事务内,尤其注意异步方法里using conn和using tx的作用域,别让tx提前Dispose。
4.2 连接池被打满:MaximumPoolSize不是越大越好
现象:系统跑一段时间后报“Timeout expired... max pool size reached”。原因:MaximumPoolSize设太大(比如200),MySQL的max_connections默认151,直接顶爆;或者代码里连接没Dispose,池被占满。解决:池大小按MySQL的max_connections除以应用实例数来定,单实例20到50足够。用using包住每个连接,或者用依赖注入的Scoped生命周期。排查时在MySQL执行SHOW PROCESSLIST看连接数。
4.3 中文乱码:字符集要三处一致
现象:商品名存进去变成问号或乱码。原因:建库、连接字符串、表字段三处字符集不一致。解决:建库用utf8mb4,连接字符串加CharSet=utf8mb4,表字段默认继承库字符集。如果已经建错,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4修复。注意MySQL 8默认就是utf8mb4,但老版本5.7默认latin1,这是重灾区。
4.4 单据重复审核:幂等性没做
现象:同一张入库单被点了两次“完成”,库存加了两遍。原因:UPDATE bill时没带status条件,或者前端没禁用按钮。解决:后端UPDATE必须带status=1,影响行数为0就拒绝;前端按钮点击后立即置灰。这是最典型的后悔药场景,上线前一定要测。
4.5 慢查询拖垮界面:缺索引
现象:单据列表加载要好几秒。原因:bill_item的bill_id没索引,或者stock_log按时间查没索引。解决:外键字段一律加索引,常用查询条件(created_at、bill_no)加索引。用EXPLAIN看执行计划,出现type=ALL就是全表扫描。参数上索引不是越多越好,写多的表控制在5个以内。
5. 进阶验证:用对账SQL和压力测试确认系统真的可靠
系统写完不算完,得验证。我一般做两件事:一是写对账SQL,每天跑一次,确认inventory和stock_log的汇总一致;二是用并发测试模拟多个人同时出库,看会不会超卖。
对账SQL长这样:
SELECT i.product_id, i.location_id, i.qty AS inv_qty, IFNULL(SUM(s.change_qty), 0) AS log_qty, i.qty - IFNULL(SUM(s.change_qty), 0) AS diff FROM inventory i LEFT JOIN stock_log s ON i.product_id = s.product_id AND i.location_id = s.location_id GROUP BY i.product_id, i.location_id, i.qty HAVING diff <> 0;diff不为0就是有问题,顺着stock_log的时间线查就能定位是哪张单据出的错。这个查询在数据量大时要给stock_log的(product_id, location_id)加联合索引,否则会全表扫。
并发测试我用C#开10个线程同时调DeductStock扣同一个库位的库存,初始100件,每个线程扣10件,跑完看最终库存是不是0,流水SUM是不是-100。如果出现负数或对不上,说明锁没生效。参数上线程数按实际并发峰值的1.5倍设,跑三轮取最差结果。
var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() => { for (int i = 0; i < 10; i++) { bool ok = svc.DeductStock(1, 1, 1, "TEST-" + Guid.NewGuid()); if (!ok) Console.WriteLine("扣减失败,库存不足或冲突"); } })).ToArray(); Task.WaitAll(tasks);跑完执行对账SQL,diff必须为0,inventory.qty必须为0。如果qty是负数,说明WHERE里的qty >= @qty没生效,检查是不是参数传错或SQL拼错。这套验证做完,系统才敢上生产。
我自己踩过最深的一个坑是早期没做流水表,库存错了根本查不出原因,只能全量盘点,那滋味真不好受。后来所有改库存的地方强制写流水,对账SQL每天定时跑,心里才踏实。希望帮到你。
本文还有配套的精品资源,点击获取