简介:一套基于C#与MySQL开发的仓库管理系统完整项目,面向C#初学者和数据库学习者,适合课程设计、毕业设计或项目实训。系统包含商品管理、库存管理、出入库记录、报表统计等功能,并采用数据访问层、业务逻辑层、表示层的分层结构,便于理解真实项目的组织方式。压缩包共135个文件,大小约1.2MB,主要包含49个C#源文件、资源文件、界面截图、SQL脚本、配置文件和可执行程序等,且附带数据库文件,可省去手动建表。项目工程文件齐全,可直接用Visual Studio打开调试。已有266人学习下载。通过分析源码,可掌握ADO.NET访问MySQL、异常与事务处理、WinForm界面设计,以及进销存类系统的常见模块划分,为后续开发提供可参考的代码模板。
1. 为什么C#+MySQL的仓库管理系统还能成为今天的常青项目
实际开发里,仓库管理系统常以压缩包形式在开发者和毕业生之间流传,大多是C#的WinForms或WPF项目,数据库脚本挂一个.sql文件,连上就能跑。这类项目最大的价值不是开箱即用,而是提供一套成熟的数据表结构和业务闭环——从MySQL里的商品档案、入库单、出库单到库存台账,每一步都有参照。拿到的压缩包里通常是一个Visual Studio工程加一个数据库文件,改改连接字符串就能登录后台,接下来是把别人逻辑变成自己的。
适合刚接手内部工具开发、要给工厂做进销存,或准备毕业设计的人。MySQL是地基,C#负责界面交互,连接、事务、字符集是后续坑的源头。下边按先看表、再跑通、再改造、再避坑的顺序讲。
2. 先拆仓库管理系统的表结构和数据流:从MySQL文件里读出业务
2.1 从数据库文件反推业务:核心表与字段设计
大多数C#版WMS压缩包的数据库文件里,表结构绕不开这几张:User(用户)、Product(商品档案)、Supplier(供应商)、InStock与InStockDetail(入库单及明细)、OutStock与OutStockDetail(出库单及明细)、Stock(实时库存)、Log(操作日志)。主外键关系很好认:入库主表对明细表一对多,明细表通过ProductId关联商品表,通过InStockId关联主表;库存表以商品ID为唯一索引,入库累加、出库扣减。
为什么要先看懂表再动代码?因为很多流传的项目业务逻辑写得随意,直接在C#代码里拼接SQL。我接手过一套压缩包,入库明细表里连“操作员”字段都没有,库存表干脆没有期初字段,导致上线第一天就不知道账面对不对。接手的第一步不是改界面,而是把每张表的主键、外键、索引过一遍,确认哪张表是库存快照、哪张表只做流水记录。
| 表名 | 关键字段 | 职责 |
|---|---|---|
| User | Id, UserName, Password, DisplayName | 登录与权限 |
| Product | Id, ProductCode, ProductName, Spec, Unit, Price | 商品档案 |
| Supplier | Id, SupplierCode, SupplierName, Contact | 供应商 |
| InStock | Id, InStockNo, SupplierId, Operator, InStockDate, Status | 入库单主表 |
| InStockDetail | Id, InStockId, ProductId, Quantity, Price, TotalAmount | 入库明细 |
| OutStock | Id, OutStockNo, CustomerId, Operator, OutStockDate, Status | 出库单主表 |
| OutStockDetail | Id, OutStockId, ProductId, Quantity, Price, TotalAmount | 出库明细 |
| Stock | ProductId, Quantity, UpdateTime | 实时库存 |
| OperationLog | Id, Operator, Action, TargetId, CreateTime | 操作留痕 |
表设计里最容易翻车的是Quantity字段类型。用int做库存数量,小仓库看着够用,一旦出现负库存或大批次盘点就溢出;凡是涉及数量、金额的列,建议统一decimal(18,2)。金额列绝对不要用float,等对账单里出现0.30000000000000004这种数,你就明白为什么了。另外Stock表里的Quantity默认值要设置成0而不是NULL,否则登录后看到的库存列表全是一堆空值,这是MySQL里很小却很烦的一个点。
2.2 数据流闭环:入库单、出库单、库存表怎么协作
业务主流程是个闭环:创建入库单→审核→写明细→累加库存→写日志;出库则反过来:创建出库单→校验库存→扣减库存→写明细→写日志。很多压缩包代码跳过了“审核”这一步,点保存就落库,录错数据没有后悔药。我改造时习惯加一个Status字段:0草稿、1已审核、2已作废,这才有挽回空间。
在这个闭环里,Stock不是业务流水表,而是快照表。期初数量只初始化一次,之后每次业务都通过更新来变化。写入库时要特别注意:不要在插入明细后先“SELECT Quantity FROM Stock WHERE ProductId=@pid”,再用取到的值在C#里算好新库存去UPDATE,这会在并发下丢更新。正确做法是让MySQL自己原子加减:
UPDATE Stock SET Quantity = Quantity + @delta, UpdateTime = NOW() WHERE ProductId = @pid;逻辑说明:这条语句让数据库在锁行状态下完成增量更新,而不是应用程序先读再写。只要Stock表里已经存在该商品的期初行,每次入库都会在此基础上做加法,天然避免两个窗口互相覆盖。
整个闭环的核心是事务。一次入库涉及到主表插入、明细多行插入、库存更新、日志插入四件事,任何一步失败都要整体回滚,否则就会出现“单据没了、库存多了”的灵异现象。在C#里用MySqlConnection.BeginTransaction()包裹所有命令,最后统一Commit,catch里Rollback,第4章会给完整写法。这个做法不只在入库,出库、盘点、调拨都适用。
2.3 C#连接MySQL:驱动选型与连接字符串的关键参数
C#连MySQL最常见的是MySql.Data(官方Connector/NET),NuGet包名就是MySql.Data;另一条路是MySqlConnector,API几乎兼容、异步支持更好。压缩包自带的项目如果引用了MySql.Data.dll,优先保留它,改动最小;如果要新写连接层,我更倾向MySqlConnector,线程安全性和异步场景表现更好。
连接字符串是第一个卡点,最常见问题集中在SSL、字符集和版本匹配上。仓库系统部署在局域网,我常用这样一段:
Server=localhost;Port=3306;Database=wms;Uid=root;Pwd=123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;逻辑说明:Server和Port指向MySQL地址,本机部署用localhost:3306;Database填导入后的库名;Uid/Pwd是MySQL账号;CharSet=utf8mb4是为了中文不乱码;SslMode=None跳过握手期的SSL协商,适合纯内网部署;AllowPublicKeyRetrieval=True专治MySQL 8.0下“Public Key Retrieval is not allowed”的报错。
参数说明里最值得记的两项是CharSet和SslMode。很多项目跑起来中文显示成问号,不是代码问题,是连接串没声明utf8mb4;而MySQL 5.7的默认字符集可能是utf8,导入的sql文件头部如果没有SET NAMES、表结构还是老字符集,两边不一致就会出现乱码。驱动版本上,MySQL 5.7配MySql.Data 8.0.x通常没问题;反过来MySQL 8.0配很老的6.x驱动,会报“Character set 'utf8mb4' is not supported”,优先升驱动再排查代码。网上mysql安装教程很多,这里只看两个决策点:版本选5.7还是8.0,认证插件是否兼容——8.0的默认认证方式,第5章会专门说。
3. 从压缩包到能点开的登录框:初始化MySQL、导入数据库文件与还原C#工程
3.1 环境准备:MySQL 5.7/8.0选型和Visual Studio框架核实
动手前先确认两件事:MySQL版本和.NET框架版本。压缩包里的数据库文件,通常是用mysqldump导出的.sql,或者用Navicat for MySQL备份出来的备份包。前者直接命令行导入,后者需要用Navicat的还原功能。如果是.sql,MySQL 5.7和8.0基本都能导,但要注意sql文件里有没有MySQL 8.0特有的排序规则,比如utf8mb4_0900_ai_ci,5.7不认识,会报1273错误。我开发时习惯用5.7.44做测试,交付到客户那边再按其现有版本决定,避免导出文件自带新语法。
Visual Studio与.NET框架也要对齐。老压缩包大多是.NET Framework 4.5或4.6.2的WinForms工程,用Visual Studio 2019/2022打开时通常会自动重新定位到4.7.2。如果提示目标框架不受支持,就在项目属性里改成4.7.2再还原NuGet包。另一个隐藏变量是平台位数:MySQL驱动是32位的,生成平台要选x86;如果项目里引用了64位报表组件,则选x64。这个设置错了不会编译报错,而是运行时抛“未能加载文件或程序集”,是最迷惑人的错误之一。
3.2 导入数据库文件:命令行建库、导入与常见报错
拿到.sql文件后,我习惯先建空库、再重定向导入,不直接用source。这样不需要关心sql里有没有CREATE DATABASE,也能避免导入到错误的库。
# 先建库,再导入,字符集统一utf8mb4 mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS wms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p wms < D:\project\wms\db\wms.sql逻辑说明:第一步建库时显式声明字符集和排序规则,避免继承MySQL默认的latin1;第二步把.sql文件重定向进wms库。如果sql文件内部带有USE wms或CREATE DATABASE语句,也不冲突,只是最终库名要跟连接串保持一致。
参数说明:-u指定用户,-p提示输密码,避免把密码留在cmd历史里;Windows下路径不要带中文,否则cmd代码页容易解析失败。导入遇到ERROR 1064时,先检查文件开头有没有BOM——用记事本另存为UTF-8无BOM再试;遇到ERROR 1366是字符集不匹配,字段里的中文无法转成目标字符集,解决思路是把库、表、字段三级字符集统一成utf8mb4。
3.3 修改连接字符串:App.config配置与第一次编译
导入成功后,打开Visual Studio里的App.config,把连接串填进connectionStrings节点。老项目常用这样的配置:
<!-- wms数据库连接,字符集utf8mb4,内网可关SSL --> <connectionStrings> <add name="WMSConnString" connectionString="Server=localhost;Port=3306;Database=wms;Uid=root;Pwd=123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;" providerName="MySql.Data.MySqlClient"/> </connectionStrings>逻辑说明:name是代码里读取时的键,用ConfigurationManager.ConnectionStrings["WMSConnString"].ConnectionString拿连接串。providerName要和实际引用驱动匹配;如果老项目用的是ODBC方式,连接串要换成Driver={MySQL ODBC 8.0 Unicode Driver};Server=...;Database=...的形式,两者不能混。
参数说明:Persist Security Info默认不开;连接池参数Connection Lifetime、Min Pool Size这些,等真出现连接数过多再调,不要一上来就堆一堆配置。编译期最常见的三类错误:一是找不到MySql.Data命名空间,在NuGet里重新安装MySql.Data;二是ConfigurationManager未引用,添加System.Configuration引用;三是SQL里的表名不存在,回第2章核对表名。第一次启动若能弹出登录框但点登录报错,多在Navicat里用同一账号测一次,先排除MySQL侧问题。
4. 把入库、出库、库存查询改成能上线的代码:事务、参数化与界面刷新
4.1 入库单保存逻辑:事务包裹主从表、原子更新库存
入库动作是仓库系统的第一个核心,也是坏代码重灾区。常见做法是用户在主界面填单头,DataGridView里录明细,点保存一次性写主表和明细表。我会把整个过程包进事务,确保主表和明细要么都在、要么都不在。这段代码可以直接照着改:
using (var conn = new MySqlConnection(connString)) { conn.Open(); var tx = conn.BeginTransaction(); try { // 主表插入,同时取回自增ID作为明细外键 string sqlMain = @"INSERT INTO InStock(InStockNo, SupplierId, Operator, InStockDate, Status) VALUES(@no, @supplierId, @operator, @date, @status); SELECT LAST_INSERT_ID();"; var cmdMain = new MySqlCommand(sqlMain, conn, tx); cmdMain.Parameters.AddWithValue("@no", "RK" + DateTime.Now.ToString("yyyyMMddHHmmss")); cmdMain.Parameters.AddWithValue("@supplierId", supplierId); cmdMain.Parameters.AddWithValue("@operator", currentUser); cmdMain.Parameters.Add("@date", MySqlDbType.DateTime).Value = inStockDate; cmdMain.Parameters.AddWithValue("@status", 1); int mainId = Convert.ToInt32(cmdMain.ExecuteScalar()); foreach (var item in details) { // 明细插入并原子更新库存,不依赖期初行是否存在 string sqlDetail = @"INSERT INTO InStockDetail(InStockId, ProductId, Quantity, Price, TotalAmount) VALUES(@inStockId, @productId, @quantity, @price, @totalAmount); INSERT INTO Stock(ProductId, Quantity, UpdateTime) VALUES(@productId, @quantity, NOW()) ON DUPLICATE KEY UPDATE Quantity = Quantity + @quantity, UpdateTime = NOW();"; var cmdDetail = new MySqlCommand(sqlDetail, conn, tx); cmdDetail.Parameters.AddWithValue("@inStockId", mainId); cmdDetail.Parameters.AddWithValue("@productId", item.ProductId); cmdDetail.Parameters.AddWithValue("@quantity", item.Quantity); cmdDetail.Parameters.AddWithValue("@price", item.Price); cmdDetail.Parameters.AddWithValue("@totalAmount", item.Price * item.Quantity); cmdDetail.ExecuteNonQuery(); } tx.Commit(); } catch { tx.Rollback(); throw; } finally { tx.Dispose(); } }逻辑说明:两条SQL在一个事务里,先取主表自增ID作为明细外键,再逐行写入明细并更新库存。更新库存用了INSERT...ON DUPLICATE KEY UPDATE,不依赖期初数据是否存在;即使Stock表里没这商品的行,也会自动建行。事务对象单独声明而不放进using,确保Rollback和Commit的时序可控。
参数说明:日期参数显式指定了MySqlDbType.DateTime,而不是用AddWithValue靠驱动推断,避免“string到datetime转换失败”。单号用“RK+时间戳”生成,简单但会撞单,若同一秒保存两单,需要加随机后缀或使用数据库自增序列。这段代码里最值得记住的是:主表、明细、库存的写入全部挂在同一个tx上,任何一行抛异常,catch里的Rollback会把前面的操作全部撤销。
4.2 库存扣减的两种做法:先查再改是最容易翻车的写法
出库单的核心是扣库存。行业内对“扣减”有两条路线:先查再改、原子更新。先查再改是网上最常见也最容易翻车的写法:
string sqlCheck = "SELECT Quantity FROM Stock WHERE ProductId=@pid"; int qty = Convert.ToInt32(ExecuteScalar(sqlCheck, pid)); if (qty >= need) { ExecuteNonQuery("UPDATE Stock SET Quantity = Quantity - @need WHERE ProductId=@pid", pid, need); }逻辑说明:这段逻辑在单用户试跑时完全正常,但两个操作员同时录出库单时,两人都查到库存够,然后都执行扣减,最后库存变负,账面全乱。原因是SELECT和UPDATE之间存在时间窗口,既没有事务保护,也没有行锁。
参数说明:需要need、pid两个参数,但无论怎么传参,这个写法本质上就是非原子的“检查-更新”。正确的替代方案是把条件写进UPDATE:UPDATE Stock SET Quantity = Quantity - @need WHERE ProductId = @pid AND Quantity >= @need,然后检查ExecuteNonQuery的影响行数,返回1才表示扣减成功;如果返回0,说明库存不足,整体回滚。更严格的做法是SELECT ... FOR UPDATE加事务,但对小仓库来说,带条件的UPDATE配合事务已经够用,代码也容易让别人看懂。
4.3 DataGridView刷新、跨线程更新与列名绑定的坑
界面层最常见的问题是:保存后列表不刷新、列头是英文、后台查询一卡界面就假死。我习惯把库存查询结果放进DataTable,再赋值给dataGridView.DataSource。保存结束必须重新查询,不要自己在界面上改单元格——那是在骗自己,下一次查询又会把旧数据盖回来。
绑定前处理列头有三条路:SQL别名写中文、DataGridView列头写中文、动态生成列。我倾向保留英文列名,在ColumnHeaderText里设置中文,这样SQL变动不影响界面。还有一个高频坑:AutoGenerateColumns默认打开时,SQL里只要多select一个字段,界面就会多出一列,排查界面多了列时先看SQL返回了哪些列。
跨线程更新控件是另一个让人抓狂的问题。一旦在BackgroundWorker或Task里执行查询,回填DataGridView必须用Invoke回到UI线程。很多做上位机开发的在这翻过车:后台线程直接操作控件,抛“跨线程操作无效”。仓库系统数据量虽小,但查询超过几千行就可能卡界面,提前写好Invoke封装比晚点补要省事得多。我一般会把“设置DataSource”和“刷新显示”封装成两个方法,后台线程统一调Invoke版本,界面就再没因为刷新闪退过。
5. 部署到内网后的高频避坑记录:现象、原因、处理
5.1 中文乱码:登录正常,商品名显示成问号
现象:功能都能用,但所有中文显示为??,或Navicat里看正常、软件里看乱码。
原因:字符集链路有一环不一致就会乱码:数据库表用utf8mb4、连接串CharSet用utf8、sql文件内容又是latin1,三个环节只要有一个错位,结果必乱。
处理:先确认三处:SHOW VARIABLES LIKE 'character_set_database';、sql文件头部的SET NAMES、连接串的CharSet。然后统一成utf8mb4:
ALTER DATABASE wms CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; ALTER TABLE Product CONVERT TO CHARACTER SET utf8mb4;改完重连,再查一次,不要改了数据库忘了改连接串,也不能反过来。
5.2 MySQL 8.0认证插件导致C#连接失败
现象:命令行、Navicat都能连,唯独C#程序报“Authentication method 'caching_sha2_password' not supported”,或者“Public Key Retrieval is not allowed”。
原因:MySQL 8.0默认用户认证插件是caching_sha2_password,老驱动不认。
处理:两条路。优先升级驱动并加AllowPublicKeyRetrieval=True;无法升级驱动时,把应用账号改回老认证方式:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456'; FLUSH PRIVILEGES;这里建议单独建一个wms_app账号,只授权wms库,不给整个实例权限,别拿root裸奔。
5.3 导入.sql文件报错:BOM、1064、1273
现象:命令行导入抛ERROR 1064 (42000),或者ERROR 1273 (HY000) Unknown collation。
原因:1064多半是文件被文本编辑器加过BOM,首条语句解析失败;1273是sql文件里的排序规则在低版本MySQL不存在。
处理:1273把utf8mb4_0900_ai_ci替换成utf8mb4_general_ci再导入;1064先把文件转成UTF-8无BOM。用Navicat备份出来的.bak文件不能命令行导入,用Navicat的备份还原功能,路径不要带中文。
5.4 杀毒软件把编译好的exe当木马
现象:Debug或Release目录下的exe一生成就被隔离,换台电脑拷贝过去同样报。
原因:.NET编译产物体积小、结构固定,容易被启发式引擎误判;某些旧版第三方dll被标记过威胁特征,整个exe连坐。
处理:先全量编译一次,关掉杀毒看是否只有exe被隔离;再把第三方dll逐个移除排查特征;最后给发布目录加白名单。发布时用强签名能降低误报率但不是100%杜绝。确认代码是自己的,再决定是否点信任,建议不要让杀毒软件长期关闭。
5.5 重复扣减:单据一张、库存少了两次
现象:用户连点两次保存,或网络卡顿后重试,数据库里出现两条相同单据,库存被扣两次。
原因:按钮没有防重复提交,事务各自独立,第一次事务还没提交完成时第二次操作又进来了。
处理:界面层保存时把按钮置灰,finally中恢复;更硬的办法是业务层加幂等控制,在单据主表加BusinessNo唯一索引,重复插入直接顺事务回滚。两个办法同时上,不要只靠界面置灰,因为异常抛太快时置灰不一定来得及恢复。日志表里记录每次保存的请求ID,排查时能顺着它把重复动作定位到秒级。
6. 上线前我最后会做的三处小改动:索引、备份和操作留痕
第一处是给高频查询加索引。库存查询页通常一打开就查全表,商品名模糊查询在上千行数据后会明显拖慢响应。我会在Product表ProductName上加普通索引,在出入库明细表的(InStockId, ProductId)上建联合索引,Stock表ProductId上建唯一索引。改完用EXPLAIN SELECT验证是否走索引。这个改动不动代码,但对体验提升最明显。
第二处是定备份策略。mysqldump每天凌晨导一次,保留近7天。Windows任务计划里可以放这样一条命令:
mysqldump -uroot -p --default-character-set=utf8mb4 --single-transaction --quick wms > D:\backup\wms_%date:~0,4%%date:~5,2%%date:~8,2%.sql参数说明:--single-transaction在InnoDB下用一致性快照,不锁业务表;--quick逐行输出,减少内存占用;后面的%date:~0,4%是在cmd里截取系统日期的年、月、日,避免日期带斜杠导致文件名非法。恢复时用mysql -uroot -p wms < 备份文件,库名要和连接串一致,正好衔接第3章的导入步骤。
第三处是补操作留痕。很多压缩包项目只有登录日志,没有业务日志。我习惯补一张OperationLog,字段只要Id、Operator、Action、TargetId、CreateTime,在每个写操作后面插一行。这个习惯救过我一次:用户说库存对不上账,查到最后是误删了期初记录,没有日志根本无从定位。日志不用复杂,重在“每个写操作都记”,而不是只在登录时记一下。
说到底,这类压缩包项目的价值在表结构和业务闭环,不在那几行界面代码。先按第2章读懂表,第3章把它跑通,再谈改造。MySQL版本、字符集、驱动版本是三个隐藏变量,任何一个不对,代码再好也白搭。我自己的排查口令是“先查版本、后查连接、再查事务”,这套口令帮我把无数玄学问题变成了具体报错。希望帮到你。
本文还有配套的精品资源,点击获取