C#库存管理系统源码解析:C/S架构、三层设计与库存流水防坑指南 简介这份资源是一套基于C#与C/S架构实现的库存管理系统面向正在学习C#桌面开发、希望积累完整项目经验的学生与自学者。系统围绕商品入库、出库、库存盘点与预警等核心业务展开客户端负责界面交互服务端处理数据与业务逻辑适合作为课程设计或练手项目的参考。压缩包共309个文件约4.74MB包含62个cs源码文件、25个resx与resources资源文件、83张jpg与50张bmp界面素材、18个ico图标以及sln解决方案、csproj工程文件、数据库文件与一份数据库表注释文档覆盖从源码到素材的完整工程结构。目前已有350人学习下载。读者可借助数据库表注释理解表结构与字段含义通过解决方案文件直接打开并继续开发结合源码学习C#数据操作与界面设计思路对提升桌面应用开发能力有实际帮助。1. 从一张Excel表说起这套C#库存管理系统到底能接住什么活如果你管过仓库大概率经历过这种场面月底盘点Excel 里同一个物料三个名字A 表说库存 120B 表说 98实际货架上数出来 105。对不上就开始翻聊天记录、翻纸质单据一晚上就没了。这套用 C# 写的库存管理系统解决的就是这类「账实不符、多人协作靠吼」的问题。它是典型的 C/S 架构桌面程序客户端跑在 Windows 上数据库放服务器适合中小仓库、门店后台、工厂备件库这种十几到几十个人同时操作的场景。技术栈是 C# WinForm/WPF SQL Server或 MySQL核心功能围绕入库、出库、盘点、库存预警、报表查询展开。你如果是刚学完 C# 基础想找个能跑通的项目练手或者公司里正缺一套轻量库存工具这份源码值得拆开看。它不追求大而全胜在结构清晰、能直接改。2. 拆开这套C/S库存系统三层结构、数据表和登录流程2.1 为什么是C/S而不是B/S选型理由得先讲清很多人第一反应是「都什么年代了还做桌面端」。但库存这个场景有它的特殊性操作员往往在仓库现场网络不一定稳扫码枪、标签打印机这类外设走本地接口比浏览器调 USB 省心得多。C/S 架构下客户端负责界面和本地校验服务端只管数据存取断网时至少界面不卡死重连后补提交。这套系统就是按这个思路分的层。具体分三层表示层WinForm 窗体负责输入输出和基础校验、业务逻辑层BLL处理入库出库的规则比如出库数量不能大于当前库存、数据访问层DAL拼 SQL 或调用存储过程。三层之间通过实体类Model传递数据。这么分的好处是你改数据库从 SQL Server 换到 MySQL理论上只动 DAL界面和业务规则不用碰。常见做法是用 ADO.NET 直接写也有用 Dapper 或 EF 的这份源码我看到的偏 ADO.NET 手写 SQL对新手反而友好能看清每条语句在干什么。选型上还有一个点库存系统对事务一致性要求高。出库要同时扣库存、写流水、更新单据状态这三步必须在一个事务里。C/S 下用SqlTransaction或TransactionScope控制起来比在 Web 层跨请求简单。这也是它比 B/S 更适合强一致库存场景的原因之一。2.2 核心数据表长什么样字段别拍脑袋定拿到源码先别急着跑把表结构看明白后面改需求才不慌。库存系统的表不用多但几个关键字段设计错了后期对账能把你逼疯。下面是我整理的核心表清单字段名按常见命名习惯给表名作用关键字段注意点Product物料/商品主档ProductID, Code, Name, Unit, SafetyStockCode 要唯一索引别用 Name 当业务主键Stock实时库存ProductID, WarehouseID, Quantity联合唯一索引防同一物料多仓重复行InboundOrder入库单主表OrderNo, SupplierID, InDate, StatusStatus 用枚举值别存中文InboundDetail入库单明细OrderID, ProductID, Qty, Price明细和主表用外键关联OutboundOrder出库单主表OrderNo, DeptID, OutDate, Status同上OutboundDetail出库单明细OrderID, ProductID, Qty出库数量要在业务层校验StockFlow库存流水ProductID, ChangeQty, Type, RefOrderNo只增不改对账全靠它这里有个血泪经验Stock表的Quantity不要直接让用户改所有变动必须走流水。也就是说任何一次库存增减都要在StockFlow里留一条记录Stock只是流水累加的结果。这样一旦账实不符你能顺着流水倒查是哪一笔出的问题。很多新手图省事直接UPDATE Stock SET Quantity 100出了事就是黑匣子查都没法查。2.3 登录与权限别把校验只放在客户端登录流程看着简单坑不少。典型实现是用户输入账号密码客户端把密码做一次 MD5 或 SHA256 加盐哈希发给服务端比对。注意加盐的盐值要存在用户表里每个用户不同。别用固定盐更别明文存密码。// 登录校验的常见写法简化版 public User Login(string account, string password) { // 1. 先按账号查出用户记录拿到盐值 var user userDal.GetByAccount(account); if (user null) return null; // 2. 用该用户的盐值 输入密码 计算哈希 string inputHash HashHelper.Compute(password, user.Salt); // 3. 比对哈希而不是比对明文 if (inputHash ! user.PasswordHash) return null; // 4. 登录成功记录登录日志可选 logDal.Write(user.UserID, Login, DateTime.Now); return user; }逻辑说明第一步先查用户是为了拿到该用户专属的盐值第二步用同样的算法和盐重新计算第三步只比哈希。参数上Salt建议 16 位以上随机字符串PasswordHash字段长度留够SHA256 是 64 位十六进制。失败时不要提示「账号不存在」还是「密码错误」统一提示「账号或密码错误」避免被人枚举账号。权限这块常见做法是角色 菜单权限表。登录成功后把该用户能访问的菜单和按钮权限查出来客户端根据权限决定哪些按钮置灰。但注意客户端置灰只是体验真正的校验必须在 BLL 或 DAL 再做一次。我见过有人只在前端判断「这个用户不能删除」结果直接调接口照样删这就是典型的翻车点。3. 把源码跑起来环境、数据库脚本和第一个入库单3.1 开发环境与依赖版本对不上先别怀疑人生这套 C/S 项目跑起来需要的东西不多但版本对不上会报一堆莫名其妙的错。我一般按下面这个清单准备操作系统Windows 10/11 都行服务器端建议 Windows Server开发工具Visual Studio 2019 或 2022社区版够用.NET 框架看源码用的是 .NET Framework 4.7.2 还是 .NET 6/8别混数据库SQL Server 2016 以上或 MySQL 5.7/8.0数据库管理工具SSMS 或 Navicat用来执行建表脚本打开解决方案后先看App.config或appsettings.json里的连接字符串。这是最容易出错的地方。常见格式!-- App.config 里的连接字符串示例 -- connectionStrings add nameStockDB connectionStringServer127.0.0.1;DatabaseStockDB;User Idsa;Password你的密码; providerNameSystem.Data.SqlClient / /connectionStrings参数说明Server是本机就写127.0.0.1或localhost如果是命名实例要写localhost\SQLEXPRESSDatabase是建表脚本里创建的库名User Id和Password用你数据库的实际账号。如果用 Windows 身份验证把后面两段换成Integrated SecurityTrue。改完连接字符串先别急着 F5去数据库里把脚本跑完。3.2 建库建表脚本怎么执行顺序不能乱源码包里一般有个SQL或Database文件夹里面是.sql脚本。执行顺序通常是先建库再建表最后插初始数据比如默认管理员账号、基础物料分类。我一般会在 SSMS 里新建一个查询窗口把脚本按顺序贴进去执行而不是双击文件让它自己跑这样哪一步报错看得清楚。-- 1. 建库如果不存在 IF NOT EXISTS (SELECT * FROM sys.databases WHERE name StockDB) BEGIN CREATE DATABASE StockDB; END GO -- 2. 切到该库 USE StockDB; GO -- 3. 建物料主档表 CREATE TABLE Product ( ProductID INT IDENTITY(1,1) PRIMARY KEY, Code NVARCHAR(50) NOT NULL UNIQUE, -- 物料编码唯一 Name NVARCHAR(100) NOT NULL, Unit NVARCHAR(20) NOT NULL, SafetyStock DECIMAL(18,2) DEFAULT 0 -- 安全库存低于它要预警 ); GO -- 4. 建库存表注意联合唯一 CREATE TABLE Stock ( StockID INT IDENTITY(1,1) PRIMARY KEY, ProductID INT NOT NULL, WarehouseID INT NOT NULL, Quantity DECIMAL(18,2) DEFAULT 0, CONSTRAINT UQ_Stock UNIQUE (ProductID, WarehouseID), CONSTRAINT FK_Stock_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID) ); GO逻辑说明IDENTITY(1,1)让主键自增UNIQUE约束保证物料编码不重复Stock表的联合唯一约束是关键防止同一物料在同一仓库出现两行否则你查库存时SUM出来就是错的。DECIMAL(18,2)用于数量别用FLOAT浮点误差在库存里是灾难。执行完这些再跑初始数据脚本把默认管理员插进去。3.3 第一张入库单从界面到数据库走了哪几步环境通了就跑一遍完整流程。点「入库管理」→「新建入库单」选供应商、加明细、填数量单价、保存。这背后走了这么几步界面收集数据封装成InboundOrder和ListInboundDetail对象BLL 校验单据号是否重复、明细是否为空、数量是否大于 0DAL 开启事务先插主表拿到OrderID再批量插明细遍历明细更新Stock表对应物料的Quantity同时写StockFlow提交事务返回成功// 入库保存的核心事务逻辑简化 public bool SaveInbound(InboundOrder order, ListInboundDetail details) { using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tran conn.BeginTransaction()) // 开启事务 { try { // 1. 插主表拿回自增 OrderID int orderId inboundDal.InsertOrder(conn, tran, order); foreach (var d in details) { d.OrderID orderId; // 2. 插明细 inboundDal.InsertDetail(conn, tran, d); // 3. 更新库存累加 stockDal.Increase(conn, tran, d.ProductID, d.WarehouseID, d.Qty); // 4. 写流水Type1 表示入库 flowDal.Insert(conn, tran, d.ProductID, d.Qty, 1, order.OrderNo); } tran.Commit(); // 全部成功才提交 return true; } catch (Exception ex) { tran.Rollback(); // 任何一步失败全部回滚 logHelper.Error(入库保存失败, ex); return false; } } } }逻辑说明所有数据库操作共用同一个conn和tran这是事务生效的前提。Increase方法内部是UPDATE Stock SET Quantity Quantity qty用累加而不是直接赋值避免并发覆盖。StockFlow的Type字段用数字区分业务类型1 入库、2 出库、3 盘点调整别存中文。失败时Rollback保证不会出现「主表插了、库存没加」的脏数据。参数上Qty建议在 BLL 就校验大于 0DAL 只负责执行。4. 出库、盘点与库存预警业务规则写在哪一层4.1 出库校验为什么不能只靠数据库约束出库比入库多一层风险库存不够怎么办。有人想用数据库的CHECK (Quantity 0)约束来兜底这只能防住「变成负数」但防不住并发。两个操作员同时出库都读到库存 10各出 8数据库约束会让第二笔失败但用户体验很差而且失败原因不明确。正确做法是在 BLL 里做「先查后扣」并且把查询和扣减放在同一个事务里配合行锁。常见写法// 出库前的库存校验在事务内执行 public bool CheckAndDeduct(int productId, int warehouseId, decimal qty, string orderNo) { // 1. 在事务内用 UPDLOCK 锁住该行防止并发读脏 decimal current stockDal.GetQuantityWithLock(productId, warehouseId); if (current qty) { throw new BizException($物料库存不足当前 {current}需要 {qty}); } // 2. 扣减库存 stockDal.Decrease(productId, warehouseId, qty); // 3. 写流水Type2 表示出库 flowDal.Insert(productId, -qty, 2, orderNo); return true; }逻辑说明GetQuantityWithLock内部 SQL 带WITH (UPDLOCK)在事务未提交前锁住该行第二个请求会等待等第一个扣完再读就不会出现超卖。参数上qty传正数写流水时转成负数方便后面SUM对账。抛出的BizException是自定义业务异常界面层捕获后弹提示而不是弹一堆堆栈。4.2 盘点单把「账实差异」变成一笔可追溯的调整盘点是库存系统里最容易被做烂的功能。很多实现是让用户直接改Stock表的数量改完就完事结果差异原因查不到。规范做法是盘点单记录「账面数」和「实盘数」差异部分生成一笔调整流水Type3备注里写清盘点单号。流程是新建盘点单 → 选择仓库和物料范围 → 系统带出当前账面数 → 用户填实盘数 → 保存时计算差异 → 差异不为零的写一条StockFlow同时把Stock调整到实盘数。这样任何一次库存变动都有流水对应对账时SUM(StockFlow.ChangeQty)应该等于Stock.Quantity不等就说明有地方绕过流水直接改了库存。我一般会加一个对账查询定期跑一遍-- 对账流水累加 vs 实时库存找出不一致的物料 SELECT s.ProductID, p.Name, s.Quantity AS 实时库存, ISNULL(f.TotalChange, 0) AS 流水累加, s.Quantity - ISNULL(f.TotalChange, 0) AS 差异 FROM Stock s JOIN Product p ON p.ProductID s.ProductID LEFT JOIN ( SELECT ProductID, WarehouseID, SUM(ChangeQty) AS TotalChange FROM StockFlow GROUP BY ProductID, WarehouseID ) f ON f.ProductID s.ProductID AND f.WarehouseID s.WarehouseID WHERE s.Quantity ISNULL(f.TotalChange, 0);这条 SQL 跑出来有行就说明有物料被绕过流水改了得去查是谁、什么时候改的。参数上没什么要调的直接执行即可。建议做成一个菜单项让管理员能随时点。4.3 库存预警安全库存怎么定预警怎么触发安全库存不是拍脑袋定的但系统层面要做的很简单每个物料有个SafetyStock字段当Stock.Quantity SafetyStock时在首页或预警列表里标红。触发时机有两个一是每次出库后检查二是定时任务每天扫一遍。实现上出库后检查最直接// 出库后检查是否低于安全库存 public void CheckSafetyStock(int productId, int warehouseId) { var stock stockDal.Get(productId, warehouseId); var product productDal.Get(productId); if (stock.Quantity product.SafetyStock) { // 写入预警表或直接推消息 warningDal.Insert(productId, warehouseId, stock.Quantity, product.SafetyStock); } }逻辑说明SafetyStock为 0 表示不预警所以判断前可以先过滤掉 0。预警记录建议单独一张表带「是否已处理」字段避免重复提醒。参数上SafetyStock的设定属于业务决策系统只负责执行别在代码里写死阈值。5. 避坑与排查这套库存系统最容易翻车的五个地方5.1 并发出库导致库存变负现象两个操作员同时出库库存 10各出 8最后库存变成 -6。原因BLL 里先查后扣但查询和扣减不在同一事务或者查询没加锁。解决把查询和扣减放进同一事务查询时用UPDLOCK锁行或者直接用UPDATE Stock SET Quantity Quantity - qty WHERE Quantity qty根据受影响行数判断是否成功。5.2 连接字符串写错报「未找到或无法访问服务器」现象程序一启动就弹数据库连接失败。原因Server写成了机器名但 SQL Server 没开 TCP/IP或者实例名写错。解决先用 SSMS 用同样的参数连一次能连上再抄到配置文件。命名实例要写主机名\实例名端口非默认要加,端口号。5.3 中文乱码物料名称变成问号现象入库时物料名称存进去是???。原因数据库字段用了VARCHAR而不是NVARCHAR或者连接字符串没设字符集。解决所有存中文的字段一律NVARCHARC# 里参数用SqlDbType.NVarChar连接字符串加Charsetutf8MySQL 场景。5.4 事务没回滚主表有单明细是空的现象入库单列表能看到单号点进去明细为空。原因插主表和插明细之间抛了异常但事务没包住或者Rollback写在catch外面没执行到。解决确认所有相关操作共用同一个SqlTransactionRollback放在catch块里并且Commit之前不要提前return。5.5 直接改库存表对账永远对不上现象月底对账流水累加和实时库存差一大截。原因有人图省事直接在数据库里UPDATE Stock改数量没写流水。解决数据库层面收回UPDATE Stock的权限只允许程序通过存储过程或特定账号改同时加对账 SQL 定期跑发现差异立即查。6. 进阶技巧用触发器兜底和把库存变动做成可回放日志前面说的都是「靠代码自觉」但人总会犯错代码也可能有漏网之鱼。我一般会加一层数据库触发器兜底在Stock表上建AFTER UPDATE触发器一旦Quantity被修改就自动往StockFlow写一条Type9系统调整的记录备注写「触发器捕获」。这样即使有人绕过程序直接改库流水里也有痕迹。触发器写法不复杂但要注意别和程序里的流水重复所以程序写流水时用一个特殊Type触发器只捕获「没有对应流水」的改动——实现上可以比对时间戳或者干脆让程序改库存时也走一个带标记的存储过程触发器只对非存储过程的修改生效。另一个技巧是把StockFlow做成可回放日志。每条流水记录ProductID、ChangeQty、Type、RefOrderNo、CreateTime理论上从任意一个时间点开始按时间顺序累加所有流水就能还原出当前库存。这意味着你可以做「库存快照」每天凌晨把当前库存和最大流水 ID 存一张快照表之后查历史库存就不用从头算直接从快照加增量。对数据量大的仓库这个优化能把查询从几十秒降到毫秒级。验证方法也简单随便挑一个物料把它的流水按时间排序手动累加看结果是否等于Stock.Quantity。我习惯在每次发版前跑一遍这个校验全库对账 SQL 返回空才允许上线。从那以后我每次改库存相关代码都强制走一遍「流水累加 实时库存」的校验再也没出现过对不上账的情况。希望帮到你。本文还有配套的精品资源点击获取