
简介这份C#进销存管理系统源码面向具备一定C#与WinForms基础的开发者适合用于课程设计、毕业设计或中小型商贸企业进销存业务的二次开发参考。资源采用经典三层架构组织代码涵盖登录、主窗体、客户与仓库资料管理、商品入库出库、库存查询、销售单据及权限管理等模块并附带水晶报表相关文件便于理解业务数据流转与报表输出。压缩包共49个文件以19个cs源码文件为核心配合13个resx窗体资源、3个bmp图标、2个rpt报表模板以及sql建库脚本、mdf与ldf数据库文件、bak备份和sln、csproj工程文件整体约360KB结构紧凑、便于直接打开运行。已有556人学习下载读者可据此快速搭建数据库环境、梳理三层调用关系并在此基础上扩展功能或替换界面节省从零搭建进销存框架的时间。1. 一套能跑起来的 C# 进销存源码到底该长什么样很多做企业信息化的朋友第一次拿到「C#进销存 源码」这个关键词时脑子里想的是一份能直接编译、点开就能用的完整系统。现实往往相反网上流传的所谓源码要么是只有几个窗体的课程作业要么是数据库脚本缺失的半成品要么是三层架构写得漂亮但业务逻辑全是空壳。我见过太多人下载了十几个 G 的压缩包最后连登录都进不去这就是典型的「源码幻觉」。进销存的核心其实不复杂采购进来、库存管住、销售出去再加上往来账和报表。但真正落地时麻烦全在细节里——并发扣库存、批次与保质期、退货冲红、多单位换算、权限到按钮级。一套值得拿来复现的 C# 进销存源码必须能让你看清这些细节是怎么用代码表达的而不是只给你一个漂亮的登录界面。这篇文章面向两类人想自己搭一套内部用的开发者以及需要基于现成源码二次开发的实施人员。我会按「先立住架构、再跑通主流程、最后填坑」的顺序把一套典型方案拆开讲。2. 从数据库表结构到三层架构进销存源码的骨架怎么搭2.1 先定表结构再谈代码分层进销存系统的地基是表结构不是窗体。我一般会先把下面这几组核心表定下来再动手写 C# 代码。表结构一旦反复改后面所有实体类、DAL、BLL 都要跟着重写血泪经验就是宁可花两天画 ER 图也别花两周改字段。表名作用关键字段商品表存商品基础信息商品编码、名称、规格、单位、默认进价、默认售价仓库表多仓库支持仓库编码、名称、负责人库存表实时库存商品ID、仓库ID、数量、锁定数量采购单主表采购头单号、供应商、日期、状态采购单明细采购行主表ID、商品ID、数量、单价、金额销售单主表销售头单号、客户、日期、状态销售单明细销售行主表ID、商品ID、数量、单价、金额库存流水表所有出入库记录商品ID、仓库ID、变动数量、业务类型、关联单号库存表只存当前结存库存流水表存每一次变动。这两张表必须同时更新且要在同一个事务里。很多新手只更新库存表结果对账时发现少了三件货查都没法查。2.2 三层架构在 C# 里的最小落地C# 进销存源码常见的是 UI BLL DAL 三层配合实体类 Model。下面是一个最小可运行的库存扣减示例用 ADO.NET 直接写不依赖任何 ORM方便你看清每一步。// DAL 层库存扣减带事务和行锁 public class StockDAL { public bool ReduceStock(int goodsId, int warehouseId, int qty, string billNo, SqlConnection conn, SqlTransaction tran) { // 1. 先查当前库存加 UPDLOCK 防止并发超卖 string sqlCheck SELECT Quantity FROM Stock WITH (UPDLOCK, ROWLOCK) WHERE GoodsIdGoodsId AND WarehouseIdWarehouseId; SqlCommand cmdCheck new SqlCommand(sqlCheck, conn, tran); cmdCheck.Parameters.AddWithValue(GoodsId, goodsId); cmdCheck.Parameters.AddWithValue(WarehouseId, warehouseId); object result cmdCheck.ExecuteScalar(); if (result null) throw new Exception(库存记录不存在); int current Convert.ToInt32(result); if (current qty) throw new Exception(库存不足当前可用 current); // 2. 扣减库存 string sqlUpdate UPDATE Stock SET Quantity Quantity - Qty WHERE GoodsIdGoodsId AND WarehouseIdWarehouseId; SqlCommand cmdUpdate new SqlCommand(sqlUpdate, conn, tran); cmdUpdate.Parameters.AddWithValue(Qty, qty); cmdUpdate.Parameters.AddWithValue(GoodsId, goodsId); cmdUpdate.Parameters.AddWithValue(WarehouseId, warehouseId); cmdUpdate.ExecuteNonQuery(); // 3. 写库存流水负数表示出库 string sqlLog INSERT INTO StockLog(GoodsId,WarehouseId,ChangeQty,BizType,BillNo,CreateTime) VALUES(GoodsId,WarehouseId,ChangeQty,销售出库,BillNo,GETDATE()); SqlCommand cmdLog new SqlCommand(sqlLog, conn, tran); cmdLog.Parameters.AddWithValue(GoodsId, goodsId); cmdLog.Parameters.AddWithValue(WarehouseId, warehouseId); cmdLog.Parameters.AddWithValue(ChangeQty, -qty); cmdLog.Parameters.AddWithValue(BillNo, billNo); cmdLog.ExecuteNonQuery(); return true; } }这段代码的关键在WITH (UPDLOCK, ROWLOCK)。没有它两个收银员同时卖最后一件货系统会双双扣减成功库存变成负数。参数Qty是本次出库数量BillNo是关联的销售单号方便日后追溯。事务由 BLL 层统一控制DAL 只负责执行不自己提交。2.3 BLL 层怎么组织业务规则BLL 层是进销存源码里最容易被写乱的地方。我的习惯是按业务单据建类一个单据一个类类里只放这个单据相关的校验和流程。比如销售出库单BLL 要做四件事校验单据状态、校验库存、写主从表、扣库存。顺序不能乱先写单再扣库存还是先扣库存再写单取决于你的业务容忍度。我一般先扣库存再写单因为库存是硬约束写单失败可以回滚库存扣错了就是生产事故。// BLL 层销售出库主流程 public class SaleBLL { public void SaveSaleOrder(SaleOrder order) { using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { // 1. 写销售主表 int saleId saleDAL.InsertMaster(order, conn, tran); // 2. 逐行写明细并扣库存 foreach (var item in order.Items) { saleDAL.InsertDetail(saleId, item, conn, tran); stockDAL.ReduceStock(item.GoodsId, item.WarehouseId, item.Qty, order.BillNo, conn, tran); } tran.Commit(); } catch (Exception ex) { tran.Rollback(); throw new Exception(保存销售单失败 ex.Message); } } } }connStr是数据库连接字符串通常放在配置文件中。tran保证主表、明细、库存三者要么全成功要么全回滚。这里有个细节InsertMaster返回自增主键saleId明细表用它做外键。如果数据库用的是 GUID 主键这一步可以提前生成。3. 采购入库与销售出库把两条主流程跑通3.1 采购入库的批次与保质期处理采购入库比销售出库多一层麻烦批次。食品、药品、化妆品行业必须记录批号和保质期出库时要按先进先出。如果你的进销存源码没有批次表后期加这个功能等于重做。我建议一开始就在库存表里加BatchNo和ExpireDate两个字段库存表的主键变成「商品仓库批次」。-- 库存表增加批次维度 ALTER TABLE Stock ADD BatchNo NVARCHAR(50) NULL; ALTER TABLE Stock ADD ExpireDate DATETIME NULL; -- 查询可用库存时按保质期升序先出临期批次 SELECT TOP 1 * FROM Stock WHERE GoodsIdGoodsId AND WarehouseIdWarehouseId AND Quantity 0 ORDER BY ExpireDate ASC;采购入库时同一商品不同批次要分开插入库存记录。出库时按ExpireDate升序取批次这就是最简单的先进先出。如果客户不要求批次BatchNo可以统一填DEFAULT不影响主流程。3.2 销售出库的并发与超卖防护销售出库最怕超卖。除了前面 DAL 里的UPDLOCKBLL 层还要做一次前置校验把大部分并发挡在事务外面。做法是在进入事务前先查一次可用库存如果不够直接返回错误不开启事务。这样能减少锁竞争提升吞吐。// 事务外预校验减少锁等待 public bool CheckStock(int goodsId, int warehouseId, int qty) { string sql SELECT Quantity FROM Stock WHERE GoodsIdGoodsId AND WarehouseIdWarehouseId; SqlCommand cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(GoodsId, goodsId); cmd.Parameters.AddWithValue(WarehouseId, warehouseId); object result cmd.ExecuteScalar(); if (result null) return false; return Convert.ToInt32(result) qty; }预校验通过后再进事务事务里仍然要再查一次并加锁。预校验只是优化不能替代事务内的锁。参数qty是本次出库数量如果单据有多行要逐行校验。3.3 退货与冲红怎么在源码里表达退货分两种采购退货和销售退货。采购退货是出库销售退货是入库。冲红则是把原单标记为作废再生成一张反向单。我一般用「红字单」方案原单状态改为「已冲红」同时插入一张数量为负的单据库存流水也记负数。这样报表统计时正负相加自然抵消不用改查询逻辑。// 销售退货库存增加流水记正数 public void ReturnSale(int saleId, int goodsId, int warehouseId, int qty) { // 库存增加 string sql UPDATE Stock SET Quantity Quantity Qty WHERE GoodsIdGoodsId AND WarehouseIdWarehouseId; // 流水记录 string log INSERT INTO StockLog(GoodsId,WarehouseId,ChangeQty,BizType,BillNo,CreateTime) VALUES(GoodsId,WarehouseId,ChangeQty,销售退货,BillNo,GETDATE()); // 执行时 ChangeQty 传正数 qty }退货单必须关联原销售单号方便财务对账。如果原单已经跨月还要考虑是否影响已结账期间这个在 BLL 层加一个「财务期间」校验即可。4. 权限、报表与打印进销存源码里最容易被忽视的三块4.1 按钮级权限的轻量实现很多 C# 进销存源码的权限只做到菜单级用户能打开销售单界面就能点删除。实际业务里删除、改价、查看成本价这些操作必须单独控制。我的做法是在用户角色表里存一个权限码字符串用逗号分隔窗体加载时遍历控件按权限码设置Enabled。// 权限码格式Sale.Delete,Sale.EditPrice,Stock.ViewCost public void ApplyPermission(Form form, string permissionCodes) { string[] codes permissionCodes.Split(,); foreach (Control ctrl in form.Controls) { if (ctrl.Tag ! null ctrl.Tag.ToString().StartsWith(Perm:)) { string need ctrl.Tag.ToString().Substring(5); ctrl.Enabled codes.Contains(need); } } }控件的Tag属性填Perm:Sale.Delete加载时自动匹配。这种方式不用改数据库新增权限只要加一个码。缺点是控件多了要逐个设置Tag但一次性的工作量可以接受。4.2 库存报表的 SQL 怎么写才不慢库存报表最怕全表扫描。库存流水表随着时间推移会变得很大查询某段时间的出入库汇总时必须走索引。我一般会在StockLog表的CreateTime和GoodsId上建联合索引查询时强制走索引。-- 建索引 CREATE INDEX IX_StockLog_Time_Goods ON StockLog(CreateTime, GoodsId); -- 查询某商品某月的出入库汇总 SELECT GoodsId, SUM(CASE WHEN ChangeQty 0 THEN ChangeQty ELSE 0 END) AS InQty, SUM(CASE WHEN ChangeQty 0 THEN -ChangeQty ELSE 0 END) AS OutQty FROM StockLog WHERE CreateTime Start AND CreateTime End GROUP BY GoodsId;Start和End是月初和下月初用而不是可以避免月末最后一秒的边界问题。如果数据量超过千万行考虑按月分表或者把历史流水归档到另一张表。4.3 打印模板与导出 Excel 的取舍打印单据一般用PrintDocument或第三方报表工具。如果源码里用的是PrintDocument你要自己算坐标改一个字段位置就要调半天。我建议把打印模板做成 HTML用WebBrowser控件加载后打印改样式跟改网页一样简单。导出 Excel 则用NPOI或EPPlus不要用Office.Interop后者在服务器上部署时经常因为权限问题翻车。// 用 NPOI 导出库存报表 using NPOI.HSSF.UserModel; HSSFWorkbook workbook new HSSFWorkbook(); HSSFSheet sheet workbook.CreateSheet(库存报表); HSSFRow header sheet.CreateRow(0); header.CreateCell(0).SetCellValue(商品编码); header.CreateCell(1).SetCellValue(商品名称); header.CreateCell(2).SetCellValue(库存数量); // 后续逐行填充数据最后写入文件流NPOI不依赖 Office部署时只要拷贝 DLL 即可。参数方面CreateSheet的名称不要超过 31 个字符否则会抛异常。5. 避坑与排查C# 进销存源码落地时的五个常见问题5.1 库存对不上流水和结存不一致现象月底盘点发现库存表数量与流水汇总差了几件。原因通常是某次出库只更新了库存表没写流水或者事务回滚时流水写了但库存没回滚。解决写一个对账存储过程每天定时跑发现不一致就报警。对账逻辑是库存表.数量 期初 SUM(流水.变动数量)期初取上期结存。5.2 并发时死锁两个事务互相等锁现象高峰期保存销售单时偶尔报「事务被死锁牺牲」。原因是两个事务以不同顺序更新同一批商品。解决在 BLL 层对单据明细按GoodsId排序后再逐行扣库存保证所有事务的加锁顺序一致。另外事务里不要做查询以外的耗时操作比如调用外部接口。5.3 单据号重复多台客户端同时开单现象两台收银机同时保存生成相同的单号。原因是单号用MAX(单号)1生成并发时读到同一个值。解决用数据库序列或单独的单号表加UPDLOCK取号。如果单号规则是「前缀日期流水」流水号必须从数据库取不能在内存里自增。5.4 报表查询超时流水表太大现象查上季度库存汇总时转圈几十秒。原因是StockLog表没有按时间过滤或者索引失效。解决先确认查询条件里CreateTime有没有用到索引再看执行计划有没有全表扫描。如果数据量确实大把一年前的流水归档到StockLog_History报表只查当前表加历史表的联合视图。5.5 部署到客户电脑后连不上数据库现象开发机正常客户机报「连接超时」。原因通常是连接字符串写死了localhost或者客户机没装 SQL Server 客户端协议。解决连接字符串放App.config用Data SourceIP,端口格式客户机安装对应的数据库驱动并检查防火墙是否放行端口。6. 从能跑到好用给进销存源码加一层缓存与异步一套进销存源码能跑通主流程只是及格真正让客户觉得「好用」的往往是那些不起眼的优化。我自己的习惯是在 BLL 和 DAL 之间加一层轻量缓存把商品资料、仓库资料、客户资料这些读多写少的数据缓存起来减少数据库往返。用MemoryCache就够了不用上 Redis除非是多台应用服务器。// 商品资料缓存5 分钟过期 public class GoodsCache { private static MemoryCache cache MemoryCache.Default; public static ListGoods GetGoodsList() { string key GoodsList; var list cache.Get(key) as ListGoods; if (list null) { list goodsDAL.GetAll(); // 从数据库读 cache.Set(key, list, DateTimeOffset.Now.AddMinutes(5)); } return list; } // 商品资料变更时调用立即失效 public static void Clear() { cache.Remove(GoodsList); } }缓存的关键是失效策略。商品改了价格必须立刻清缓存否则收银员会用旧价格开单。我一般在商品保存的 BLL 方法最后调一次GoodsCache.Clear()。参数AddMinutes(5)是兜底过期时间防止某次忘记清缓存导致长期脏数据。另一个提升体验的点是异步保存。销售单保存时主流程只写数据库打印小票和发送通知可以放到后台线程。用Task.Run包一下但要注意异常捕获后台线程抛异常不会弹窗必须写日志。// 保存后异步打印不阻塞收银 Task.Run(() { try { PrintTicket(saleId); } catch (Exception ex) { LogHelper.Write(打印失败 ex.Message); } });这里有个坑Task.Run里的数据库连接不能复用主线程的SqlConnection必须新开连接。我见过有人把conn传进去结果主线程提交事务后连接关闭后台线程报「连接已关闭」。验证缓存和异步有没有生效我一般看两个指标保存销售单的响应时间是否稳定在 200 毫秒以内以及数据库的 QPS 是否下降。如果响应时间忽高忽低多半是缓存没命中或者锁竞争。这时候用 SQL Server Profiler 抓一下慢查询基本能定位到问题。最后说一个我自己的教训早期做进销存时我总想把所有功能都塞进一个窗体结果代码超过五千行改一个字段要翻半天。后来强制自己按单据拆窗体每个窗体不超过八百行公共逻辑抽到基类维护成本直接降了一半。源码这东西能跑只是起点能改才是价值。希望帮到你。本文还有配套的精品资源点击获取