
简介一套C#进销存管理系统源码面向需要快速搭建或学习企业进销存系统的开发人员覆盖客户订单、物料需求、采购入库、销售出库、委外加工、报废单等业务单据以及十多种统计与明细报表并包含物料、BOM、部门、员工、供应商、客户等基础数据维护以及操作员授权、系统备份、操作日志等系统管理模块。资源共1119个文件以538个.cs源码文件、197个.resx界面资源文件、195个.resources资源文件为主另含45个dll库、多个XML配置文件及若干exe程序压缩包大小23.52MB便于直接编译参考。目前已有1572人下载学习适合用于毕业设计、课程项目或小型企业进销存系统的二次开发。整套源码模块划分清晰单据流程完整可帮助读者理解C#架构下的业务逻辑设计与报表实现思路。1. 一套进销存源码真正值得拆的不是增删改查把一套 C# 进销存管理系统的源码完整读一遍你会发现 90% 的代码不是界面上的按钮和 DataGridView 绑定而是销售出库时库存扣减的时机、采购退货后应收应付怎么冲、订单改单后已锁定的库存怎么释放。这类“账不平、数不对”的问题靠改界面是解决不了的必须回到数据结构和单据流转的设计上找答案。进销存进、销、存本质上是“采购单、销售单、仓库调拨单”这几种业务单据的流转系统再配一个库存账和往来账做支撑。C# 在这个领域之所以还很常见一是 Windows 环境的企业内部部署成本低二是 WinForms/WPF 与 SQL Server 配合做单据录入、Excel 导入导出非常顺。对从业者来说看源码的价值不在抄界面而在理解“单据状态机”“库存流水”“事务边界”这三件事是怎么落地的。这篇文章我会从模块划分、数据模型、服务层实现、并发调优、账实核对五个维度展开。新手可以按章节把环境跑通熟悉的人重点看第 4 章的并发扣减和第 5 章的自动对账技巧。整个过程不依赖某个特定开源项目照着做就能在本地搭出一套可运行的进销存骨架。2. 进销存管理系统的模块划分与数据模型先把单据和账分开想2.1 为什么进销存要把“单据表”和“流水表”分开设计很多刚接触这套系统的人会问库存表里减掉数量不就行了吗为什么要多写一张流水表原因在于进销存系统里没有任何一次库存变动是无缘无故的。每次变动都必须能追溯到“哪张采购单入库了”“哪张销售单出库了”否则月底盘点时库存对不上业务部门来问你根本查不出来龙去脉。所以核心设计第一原则是库存余额只是一个读取用的“快照”流水表才是唯一的写入依据。所有的入库、出库、调拨、盘点调整先写流水再更新余额表。这样数据出现异常时可以用流水重算余额来定位是哪个环节出了问题。C# 服务层里对应的就是两个仓储接口一个负责流水追加一个负责余额更新它们必须在同一个数据库事务里完成。核心模块对应关系大致是这样的业务模块主要单据表影响的核心账采购管理PurchaseOrder / PurchaseOrderItem库存余额、应付账款销售管理SaleOrder / SaleOrderItem库存余额、应收账款库存管理StockTransfer / StockCheck各仓库库存余额往来账管理Receivable / Payable应收、应付余额采购入库单会同时影响库存和应付销售出库单会同时影响库存和应收这两类单据在进销存里属于“双账联动”写代码时不要拆成两个独立事务去提交否则就会出现“库存扣了但应收没生成”的中间状态。2.2 库存表与流水表一个写全一个读快库存余额表Inventory保存每个仓库下每个商品的当前数量它只关心“现在有多少”。而库存流水表InventoryTransaction保存每一次变动记录它关心的是“在什么时间、因为哪张单据、变动了多少”。库存表的结构尽量精简。我通常会保留这几个字段仓库 ID、商品 ID、可用数量、锁定数量、累计入库数量、累计出库数量、更新时间。其中“锁定数量”是给销售订单用的预占逻辑后面章节会专门说。流水表则必须包含来源单据类型、来源单据号、变动方向、变动前数量、变动后数量、操作人、操作时间缺一不可。这里有一个容易忽略的细节变动前数量和变动后数量一定要记录。两个都存下来才能在系统出问题时重建出每一笔操作发生时刻的完整库存快照否则只能倒推遇到负数就很难查了。2.2.1 C# 实体与数据库字段的映射示例public class InventoryTransaction { public long Id { get; set; } public int WarehouseId { get; set; } public int ProductId { get; set; } public int SourceType { get; set; } // 枚举1采购入库 2销售出库 3调拨 4盘点调整 public string SourceNo { get; set; } // 来源单据号例如 PO20250202-001 public int Direction { get; set; } // 1入库 -1出库 public decimal Quantity { get; set; } // 变动数量正数 public decimal BeforeQuantity { get; set; } public decimal AfterQuantity { get; set; } public DateTime CreateTime { get; set; } public string CreateUser { get; set; } }这段代码里面几个字段值得单独说明。SourceType 和 SourceNo 是“溯源”的钥匙任何一条流水必须能通过这两个字段找到来源单据Direction 用 1 和 -1 而不是字符串“入/出”这样求某个时间段的净变动时一条 SUM 语句就够了BeforeQuantity 和 AfterQuantity 可以在异常排查时用来判断是不是存在并发覆盖。如果你看到一套源码里的流水表没有存变动前数量那它的审计能力基本是要打折扣的。2.3 单据头与单据体为什么一定是一对多进销存的业务单据几乎全都是“一张单子对应多行商品明细”典型的就是采购单里会有几十个商品共用一个供应商、一个到货仓库、一个审核状态。如果在一张表里每个商品一行、供应商信息每行重复存后期统计和状态维护都会很痛苦。所以标准设计是两张表单据主表存公共信息单据明细表存每个商品行的数量、单价、金额。两个表通过单据号关联。这在 C# 里对应的就是主表实体带一个明细集合属性 主表属性比如 Name、OrderNo、SupplierId 在明细表里不要重复明细表的金额分摊规则要单独处理第 5 章的 Excel 导出部分会再提到。3. C# 实现核心逻辑从源码里最应该抄走的三个服务3.1 服务层组织按领域划分还是按“操作”划分看源码时建议先看 Services 或 BLL 目录的组织方式。常见的做法是把 Service 按领域分而不是按数据库表分。比如 StockService 里面既调用 InventoryRepository也会调用 InventoryTransactionRepository因为一次出库操作必然同时影响两张表。如果看到按表拆服务、服务器里互相调用那这套代码在事务控制上大概率会出问题。我用得比较顺手的组织方式是一个业务操作对应一个公开方法事务从方法入口开启在方法内部完成所有仓储调用和业务校验。这样的好处是方便写单元测试也方便把事务边界画在方法上而不需要在 Controller 或 UI 事件里手工管理事务。至于 UI 层WinForms 直接用事件绑定到服务方法即可WPF 则建议包一层 ViewModel 调用服务。3.2 销售出库先锁定再出库最后确认销售出库是进销存系统里最容易出错的环节尤其是“开单”和“出库”两个动作的先后关系处理不当就会出现超卖或者库存变负。这里给出源码里最常见的标准做法创建销售订单时把需要出库的商品按“锁定数量”预占掉买家取消订单时释放锁定实际出库时再把锁定数量扣减成真实出库数量。public async TaskResult CreateSaleOrderAsync(SaleOrderCreateDto dto) { using var transaction await _db.Database.BeginTransactionAsync(); try { foreach (var item in dto.Items) { // 第一层校验当前可用数量是否足够 var inv await _stockRepo.GetInventoryAsync(item.WarehouseId, item.ProductId); var available inv.Quantity - inv.LockedQuantity; if (available item.Quantity) return Result.Fail($商品 {item.ProductName} 可用库存不足); // 第二层追加锁定数量不直接扣减真实库存 await _stockRepo.LockAsync(item.WarehouseId, item.ProductId, item.Quantity); await _transRepo.AddAsync(InventoryTransaction.CreateLocked(...)); } // 保存订单主表和明细表 await _saleOrderRepo.AddAsync(...); await transaction.CommitAsync(); } catch (Exception ex) { await transaction.RollbackAsync(); return Result.Fail(ex.Message); } }这段代码的关键在两层设计上。第一步的 available 计算保证了不会预占超出可用库存的数量第二步的 Lock 操作把数量加到 LockedQuantity 字段而不是减 Quantity。真实的可用库存在这个时候是没有变化的所以其它销售单仍然能看到正确的剩余量。等到实际出库时再执行一次“减 Quantity、减 LockedQuantity”的更新同时写一条方向为 -1 的正式流水。对比一下常见的错误写法直接在最开始的锁库存阶段就减去 Quantity这个操作会让“订单创建”本身变得不可回退。因为改回了草稿状态你还得小心翼翼地加回去一旦中途有其它并发写入数量就乱了。3.3 采购入库为什么必须校验单价和税率采购入库在进销存系统里属于“增加库存”和“增加应付”的双操作。它的特殊之处在于金额字段会直接影响财务应收应付数据所以入库时对于单价、税率、含税金额的校验比销售出库要严格得多。常见做法是把“含税单价”“税率”“不含税金额”做成根据业务类型自动计算的字段界面禁止用户直接编辑不含税金额。税率变了不含税金额就应该重新算而不是让用户手工改。源码里对应的校验逻辑通常长这样if (dto.UnitPrice 0) throw new BusinessException(采购单价必须大于0); if (dto.TaxRate 0 || dto.TaxRate 1) throw new BusinessException(税率范围应为0-1); var amount dto.Quantity * dto.UnitPrice * (1 dto.TaxRate); if (Math.Abs(amount - dto.Amount) 0.01m) throw new BusinessException(单据金额与明细行金额不一致);这里的税率的边界判断是为了防止用户在界面上填了 20 或者 0.2 这种单位不一致的数据。金额比较用了 0.01m 的误差范围是因为 decimal 乘法可能会产生尾差直接用 比较会出现莫名其妙的校验失败。界面传参的时候把 quantity 和 unitPrice 放到同一个 DTO 里但金额最好不要从前端传进来后端算好了再写入数据库。4. 源码跑起来之后的验证与并发调优不要只看界面能不能点4.1 本地把源码跑通的三步路径拿到一套 C# 进销存源码之后第一件事不是在 Visual Studio 里按 F5 直接启动而是先确认数据库脚本在哪、连接字符串指向哪。多数源码会附带一个 database 目录或 Scripts 目录里面是建库脚本或者初始化数据脚本。我一般会按这个顺序操作# 第一步建库假设源码里的初始化脚本是 init.sql sqlcmd -S .\SQLEXPRESS -d master -i init.sql # 第二步确认连接字符串 # 打开 appsettings.json 或 App.config修改 Server、User ID、Password 三项连接字符串的常见格式是设置成一个串appsettings.json 里面有一个 Default 节点。改的时候注意两点数据库实例名要跟本机一致本地开发不用 “localhost\SQLEXPRESS” 这种硬编码的写法因为不同机器的 SQL Server 实例名可能不一样另外看下有没有开启 EncryptTrue新版 SqlClient 默认连接 SQL Server 时可能因为证书问题报错。跑起来后不用急着点菜单。建议打开 SQL Server Management Studio 直接执行一条聚合查询看流水表和库存表的初始数据是不是对得上SELECT ProductId, SUM(CASE WHEN Direction 1 THEN Quantity ELSE -Quantity END) AS calc_quantity FROM InventoryTransaction GROUP BY ProductId;把这结果和 Inventory 表里的 Quantity 字段对比两条结果完全一致说明这套源码在基础数据模型上是自洽的。对不上也没有关系这正好可以促进调试后面第 5 章会给出自动对账的方法。4.2 并发扣减库存从“感觉没问题”到“真的没问题”单机版进销存很容易给人“系统很稳定”的错觉因为一个用户操作的时候不会有并发问题。真正出问题的是多人同时操作比如仓库里两个销售员同时开单商品就剩 10 件两人各提交 6 件。C# 服务层如果不做并发控制两条请求都会先读到可用库存 10然后都判定成功最后库存变成 -2。预防方案不在 C# 里用 lock 关键字因为多线程部署IIS 多进程、或未来拆服务多实例下进程内锁根本锁不住。正确做法是让数据库在更新的时候做原子判断。对应的 SQL 通常这样写UPDATE Inventory SET Quantity Quantity - Quantity WHERE WarehouseId WarehouseId AND ProductId ProductId AND Quantity - LockedQuantity Quantity;执行完后检查影响行数如果行数为 0说明这行数据已经被别的会话改过了。C# 里的写法是用 ExecuteNonQuery 的返回值判断返回值 1 代表成功0 代表失败。这样写避免了先 SELECT 再 UPDATE 两步操作的竞态窗口把判断和更新合并成一个原子操作。4.2.1 配合事务隔离级别解决死锁并发扣减虽然解决了超卖但也会带来新的问题锁等待和死锁。两个事务各自更新 A 商品和 B 商品然后反向更新对方的商品数据库就会检测到死锁自动回滚其中一个。C# 服务层要做的不是在数据库层面硬扛而是捕获死锁异常并重试。常见做法是把数据库隔离级别设置成 Read Committed然后捕获 SqlException 的错误码 1205死锁牺牲者做最多三次的重试等待for (int retry 0; retry 3; retry) { try { return await DoWorkAsync(dto); } catch (SqlException ex) when (ex.Number 1205) { await Task.Delay(200 * (retry 1)); } }这里等待时间逐步增加是为了错开两个事务的竞争周期。重试之前先 Rollback否则事务内已经执行的语句会残留。还可以在 UPDATE 语句中加 WITH (ROWLOCK, UPDLOCK) 来缩小锁粒度把锁降到行级别而不是页级进销存场景里这通常能明显减少死锁发生的频率。4.3 性能卡点先看 N1 查询进销存单据明细加载时经常出现 N1 问题先查出一条主单据再用循环逐条查询商品信息。只要商品数量到几百条页面就会明显变慢。做源码优化时优先找主表查询后明细逐条的循环体改成一次 IN 查询var ids orderItems.Select(i i.ProductId).ToList(); var products await _productRepo.GetByIdsAsync(ids); // 内部生成 WHERE Id IN (...)这样原来的 N 次数据库往返变成 1 次。注意进销存的单据行数通常不会超过几千所以 IN 的列表长度可控如果已经上万就需要考虑分批查询。另一个常见卡点是查询所有单据时 LEFT JOIN 了全部明细表这个最好在列表页只查主表点开明细再加载。5. 不写代码的账实核对流水重算 差异定位的一体化技巧5.1 每天自动跑一遍“流水重算库存”任务账实核对最实用的技巧就是写一个不进业务事务的后台校验任务每天凌晨对全部商品做一次流水重算并和 Inventory 表的余额做对比把差异列表输出到一个独立的异常表里。这个任务不参与出入库操作它只读数据所以对线上没有性能影响我一般用 Windows 计划任务或一个简单的 Worker Service 来跑。public class StockReconcileService { public async Task ReconcileAsync() { // 单独查出一整天的全部流水按键分组在内存中聚合并比对 var transactions await _transRepo.GetAfterLastRunAsync(); var grouped transactions.GroupBy(t new { t.WarehouseId, t.ProductId }); foreach (var g in grouped) { var calc g.Sum(x x.Direction * x.Quantity); var actual await _invRepo.GetQuantityAsync(g.Key.WarehouseId, g.Key.ProductId); if (Math.Abs(calc - actual) 0.0001m) // 容差10克以下忽略 await _reconcileLogRepo.AddAsync(...); } } }这个技巧有个重要细节交易流水表里的 Direction 字段1 代表增加、-1 代表减少所以在内存里直接乘以数量再 Sum 就行不要再用 SQL 的 SUM(CASE WHEN...) 去数据库里再算一次把计算逻辑保留在一处避免两种实现方式各自出 bug。容差 0.0001 是为了避免小数精度误差干扰判断这个值不要设为 0。5.2 给“单据改单”加上状态机白名单进销存的单据最怕乱改。从草稿变成已审核之后不同状态下能执行的操作是完全不同的。源码里如果没有状态机约束通常表现为“只要改数据库就能把已审核的单子改回草稿”这样数据链条就会断裂。我在设计时会把状态流转做成白名单例如销售单只允许这样流转草稿 → 已审核 → 出库中 → 已完成从“已完成”回退到“草稿”的操作在任何入口都禁止只能在权限里配置专门的反审核入口。具体实现是在 C# 里维护一个字典key 是当前状态value 是允许跳转的状态列表。每次状态更新的入口都校验一遍不在一张白名单里的一律抛异常。这样改单的代码在使用的时候是安全的不会因为某个按钮忘了判断状态导致已经出库的数据被回滚。最后再配合第 4 章的 UPDATE 原子库存扣减方法整个进销存系统的账面数据就能稳定在一个比较高的水平上。本文还有配套的精品资源点击获取