ASP.NET进销存ERP源码实战:单据驱动库存与并发处理
简介:这是一套基于ASP.NET的ERP进销存管理系统源码,面向具备一定.NET基础的开发者与中小企业信息化实施人员,可用于学习企业级进销存业务逻辑,或作为二次开发与课程设计的参考项目。资源包共约2000个文件,压缩后27.33MB,其中351个aspx页面承载各业务模块界面,460个cs文件实现后台逻辑,802个js与120个css负责前端交互与样式,另有42个dll、6个config及若干db、mdf、ldf数据库文件,整体结构完整。开发环境为VisualStudio2010搭配SqlServer2008R2,基于.NET4.0,数据库文件位于DB_sq文件夹,附加后修改web.config中的连接字符串即可运行,管理员账号密码为8001。目前已有243人学习下载,适合通过阅读源码理解进销存系统的分层设计、页面组织与数据访问方式,并在此基础上进行功能扩展与调试实践。
1. 一套 ASP.NET 进销存 ERP 源码,到底能省掉哪些重复造轮子的活
接手过几个中小型贸易公司的数字化项目后,我对「从零写一套进销存」这件事已经彻底祛魅。采购、销售、库存、往来账、报表,这五块业务逻辑在十年里几乎没变过,变的只是前端框架和部署方式。所以当有人问我 ASP.NET 的 ERP 管理系统源码、进销存管理系统源码值不值得拿来用,我的回答通常是:看你缺的是业务骨架还是业务细节。这类源码真正值钱的地方,不是它写了多少行代码,而是它把「单据驱动库存」这套模型跑通了——采购单审核后自动生成入库单,入库单过账后库存数量、批次、成本价同步变动,销售出库再反向冲减。这套链路自己从零搭,光是把并发扣减和负库存拦住,就够折腾两三周。
这篇文章面向两类人:一类是接了小商贸公司私活、想快速交付一套能用的进销存系统的独立开发者;另一类是公司内部要做轻量 ERP、又不想买商业软件的实施或全栈工程师。我会按「拿到源码后先看什么 → 数据库和核心单据怎么落地 → 权限与并发怎么处理 → 部署和踩坑」的顺序讲,中间给可抄的建表语句、过账逻辑和参数配置。ASP.NET 这条技术栈,老项目多是 WebForms 或 MVC5,新一点的用 ASP.NET Core MVC,两者在源码结构上差别不小,选型那节会专门说清楚。
2. 拿到源码先别急着跑:从项目结构判断它值不值得改
2.1 先分清 WebForms、MVC5 和 ASP.NET Core MVC 三种骨架
市面上流通的 ASP.NET 进销存源码,按年代大致分三档。第一档是 WebForms,特征是 .aspx 页面配 .aspx.cs 后台代码,控件拖拽、ViewState 满天飞,优点是上手快,缺点是前后端耦合死,改一个列表页要动三四个文件。第二档是 MVC5 + EF6,有 Controller、View、Model 分层,路由清晰,是过去十年中小项目的主力。第三档是 ASP.NET Core MVC,跨平台、依赖注入原生支持、EF Core 做数据访问,是目前还在维护的源码主流。
判断方法很直接:打开解决方案文件看项目引用。如果看到System.Web.dll、System.Web.Mvc这类引用,基本是 .NET Framework 时代的产物,只能跑在 Windows + IIS 上。如果看到Microsoft.AspNetCore.App这种框架引用,或者 csproj 里写着<TargetFramework>net6.0</TargetFramework>、net8.0,那就是 Core 版本,能上 Linux。这一步决定了你后面部署的自由度,别等写完业务才发现只能绑死在 Windows Server 上。
提示:如果源码里同时出现
System.Web和Microsoft.AspNetCore,多半是迁移到一半的混合项目,这种最坑,改造成本可能比重写还高,接手前先评估。
2.2 用一张表快速体检源码的完整度
拿到一套源码,我一般花半小时做一次「功能体检」,看它到底覆盖了多少进销存的核心环节。下面这张表是我常用的检查清单,缺得越多,你要补的活就越多。
| 模块 | 必须有的能力 | 缺失后的补救成本 |
|---|---|---|
| 基础资料 | 商品、客户、供应商、仓库、计量单位 | 低,自己补几张表 |
| 采购管理 | 采购订单、采购入库、采购退货 | 中,涉及库存和应付 |
| 销售管理 | 销售订单、销售出库、销售退货 | 中,涉及库存和应收 |
| 库存管理 | 即时库存、库存流水、盘点、调拨 | 高,是整套系统的核心 |
| 往来账 | 应收应付、收付款单、对账单 | 中,逻辑独立 |
| 报表 | 进销存汇总、毛利、库存预警 | 低到中,看复杂度 |
| 权限 | 角色、菜单、数据权限 | 中,数据权限最难 |
如果库存流水和往来账这两块是空的,那这套源码基本只能当 UI 模板用,业务价值大打折扣。反过来,如果这两块齐全,哪怕前端丑一点,也值得花时间改。
2.3 跑起来的最小步骤:数据库脚本 + 连接串 + 初始账号
体检完就该让它跑起来。以 ASP.NET Core MVC 版本为例,最小启动路径是三步。第一步,找到数据库脚本,通常在Database或Scripts目录下,扩展名是 .sql。第二步,改连接字符串,位置在appsettings.json。第三步,还原 NuGet 包并启动。
# 1. 建库并导入脚本(以 SQL Server 为例) sqlcmd -S localhost -U sa -P YourPassword -Q "CREATE DATABASE ErpDb" sqlcmd -S localhost -U sa -P YourPassword -d ErpDb -i ./Database/init.sql # 2. 还原依赖并编译 dotnet restore dotnet build # 3. 启动,默认监听 5000 端口 dotnet run --urls "http://0.0.0.0:5000"appsettings.json里的连接串是第一个要改的地方,格式如下。TrustServerCertificate=True在本地自签证书环境下必须加,否则 SQL Server 会直接拒绝连接,这是新手最常卡的一步。
{ "ConnectionStrings": { "Default": "Server=localhost;Database=ErpDb;User Id=sa;Password=YourPassword;TrustServerCertificate=True;" } }初始账号一般在脚本末尾的INSERT INTO Sys_User语句里,密码多是 MD5 或 SHA256 哈希。如果登录报错,先确认哈希算法和代码里VerifyPassword的实现是否一致,很多源码在这里埋了坑——脚本里的哈希和代码用的算法对不上,导致默认账号永远登不进去。
3. 单据驱动库存:进销存的核心模型怎么落地
3.1 库存不是一张表,而是「余额表 + 流水表」双写
新手最容易犯的错,是只建一张Stock表存当前数量,出库就减、入库就加。这样做的后果是:一旦数量对不上,你根本查不出是哪一笔单据出的问题。正确做法是双表结构——StockBalance存每个商品在每个仓库的当前结存,StockFlow存每一笔引起库存变动的流水。任何一次库存变动,都要在同一个事务里同时写这两张表。
-- 库存余额表:商品 + 仓库 维度唯一 CREATE TABLE StockBalance ( Id BIGINT IDENTITY PRIMARY KEY, GoodsId INT NOT NULL, WarehouseId INT NOT NULL, Qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- 当前结存数量 CostPrice DECIMAL(18,4) NOT NULL DEFAULT 0, -- 移动加权成本 RowVersion ROWVERSION, -- 并发控制用 CONSTRAINT UQ_Balance UNIQUE (GoodsId, WarehouseId) ); -- 库存流水表:只增不改,作为对账依据 CREATE TABLE StockFlow ( Id BIGINT IDENTITY PRIMARY KEY, GoodsId INT NOT NULL, WarehouseId INT NOT NULL, BizType TINYINT NOT NULL, -- 1采购入库 2销售出库 3盘点 4调拨 BizNo VARCHAR(32) NOT NULL, -- 来源单据号 ChangeQty DECIMAL(18,4) NOT NULL,-- 正数入库 负数出库 AfterQty DECIMAL(18,4) NOT NULL,-- 变动后结存,便于追溯 CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME() );RowVersion这一列是 SQL Server 的行版本戳,EF Core 里配成并发令牌后,两个用户同时改同一条余额记录时,后提交的那个会抛DbUpdateConcurrencyException,逼你重试。这比用悲观锁简单,也比不锁安全。AfterQty冗余存变动后结存,是为了对账时不用从头累加流水,直接看最后一笔就知道当时库存是多少。
3.2 过账逻辑:一个事务里完成余额更新和流水写入
单据「过账」是进销存里最关键的动词,意思是这张单据正式生效、影响库存和账务。下面这段是采购入库过账的核心逻辑,用 EF Core 写,重点看事务边界和并发处理。
public async Task PostInboundAsync(long inboundId) { using var tx = await _db.Database.BeginTransactionAsync(); try { var order = await _db.InboundOrders .Include(o => o.Items) .FirstAsync(o => o.Id == inboundId); if (order.Status == OrderStatus.Posted) throw new BizException("单据已过账,不能重复操作"); foreach (var item in order.Items) { // 1. 取余额行,带并发令牌 var balance = await _db.StockBalances .FirstOrDefaultAsync(b => b.GoodsId == item.GoodsId && b.WarehouseId == order.WarehouseId); if (balance == null) { balance = new StockBalance { GoodsId = item.GoodsId, WarehouseId = order.WarehouseId, Qty = 0, CostPrice = 0 }; _db.StockBalances.Add(balance); } // 2. 移动加权平均,重算成本价 var oldAmount = balance.Qty * balance.CostPrice; var newAmount = item.Qty * item.Price; var newQty = balance.Qty + item.Qty; balance.CostPrice = newQty == 0 ? 0 : (oldAmount + newAmount) / newQty; balance.Qty = newQty; // 3. 写流水,AfterQty 记录变动后结存 _db.StockFlows.Add(new StockFlow { GoodsId = item.GoodsId, WarehouseId = order.WarehouseId, BizType = 1, BizNo = order.OrderNo, ChangeQty = item.Qty, AfterQty = newQty }); } order.Status = OrderStatus.Posted; order.PostedTime = DateTime.Now; await _db.SaveChangesAsync(); await tx.CommitAsync(); } catch { await tx.RollbackAsync(); throw; } }逻辑说明:整个过账在一个事务里完成,任何一步失败全部回滚,保证余额和流水不会出现「加了一半」的脏数据。移动加权平均的公式是(原库存金额 + 本次入库金额) / (原数量 + 本次数量),这是商贸公司最常用的成本核算方式,比先进先出简单,比固定成本准确。
参数说明:item.Price是入库单价,注意区分含税价和不含税价,很多源码在这里没做区分,导致成本虚高。OrderStatus.Posted是枚举值,过账前必须校验状态,防止重复过账把库存加两遍——这是血泪教训,我见过因为没校验状态,一张单子被点了三次,库存直接翻三倍。
3.3 出库时的负库存拦截与成本回写
销售出库和采购入库方向相反,但多了两个判断:一是库存够不够,二是出库成本按当前移动加权价回写。库存不足时是拦截还是允许负库存,取决于业务。零售场景一般允许负库存(先卖后补),但批发和制造必须拦截。
// 出库前校验,AllowNegativeStock 从配置读取 if (!_options.AllowNegativeStock && balance.Qty < item.Qty) throw new BizException($"商品 {item.GoodsName} 库存不足,当前 {balance.Qty}"); // 出库成本 = 当前移动加权价,直接回写到销售单明细 item.CostPrice = balance.CostPrice; balance.Qty -= item.Qty; // 出库不改变 CostPrice,只减数量AllowNegativeStock这个开关建议做成系统参数,放在Sys_Config表里,不同仓库可以配不同策略。出库成本回写很重要,它决定了毛利报表准不准。如果出库时不记录成本价,事后算毛利只能拿当前成本倒推,库存价一变,历史毛利全乱。
4. 权限、并发和那些让系统半夜报警的坑
4.1 数据权限比功能权限难十倍
功能权限好做,角色绑菜单,登录时把菜单树查出来渲染就行。真正难的是数据权限——张三只能看自己负责的客户,李四只能看本部门的单据。常见做法是在业务表上加OwnerId或DeptId字段,查询时统一拼过滤条件。
// 在 DbContext 或仓储层统一注入数据权限过滤 public IQueryable<SaleOrder> ApplyDataPermission(IQueryable<SaleOrder> query) { var user = _currentUser; if (user.IsAdmin) return query; // 按部门过滤,递归包含子部门 var deptIds = _deptService.GetSelfAndChildren(user.DeptId); return query.Where(o => deptIds.Contains(o.DeptId)); }关键是这个过滤要统一入口,不能每个 Controller 各写各的,否则漏一个地方就是数据泄露。我一般把它放在仓储基类里,所有查询默认走一遍。GetSelfAndChildren用递归 CTE 查部门树,SQL Server 里用WITH ... AS写,别在内存里递归,部门一多性能就崩。
4.2 并发扣库存的三种处理方式对比
高并发下扣库存是经典问题。下面这张表是我实际用过的三种方案,按场景选。
| 方案 | 实现方式 | 适用场景 | 代价 |
|---|---|---|---|
| 乐观锁 | RowVersion 并发令牌 | 并发量中等、冲突少 | 冲突时抛异常需重试 |
| 悲观锁 | SELECT ... WITH (UPDLOCK) | 冲突频繁、单据量小 | 锁等待,吞吐低 |
| 队列串行 | 消息队列按商品分区 | 秒杀级高并发 | 架构复杂,一致性延迟 |
进销存系统绝大多数场景用乐观锁就够了,因为同一商品同一仓库被两个人同时过账的概率并不高。真到了电商大促那种量级,也不是靠数据库锁解决的,而是靠预扣库存 + 异步落库。别一上来就上队列,那是过度设计。
4.3 避坑清单:五个让我加过班的真实问题
现象一:单据过账后库存没变。原因多半是事务提交了但SaveChangesAsync没被调用,或者过账方法里改了实体却没加入跟踪。解决:确认实体是通过_db查出来的(被跟踪),而不是new出来的;在事务提交前显式调用一次SaveChangesAsync。
现象二:库存数量对,但成本价是负数。原因是出库时用了错误的成本价,或者移动加权公式里除数为零没处理。解决:在重算成本价前判断newQty == 0直接置零,出库成本严格取当前balance.CostPrice,不要自己算。
现象三:两个用户同时过账,其中一个报并发异常。这是乐观锁在正常工作,不是 bug。解决:在业务层捕获DbUpdateConcurrencyException,重新加载实体、重放业务逻辑,重试两到三次。别直接把这个异常抛给用户看。
现象四:报表数据和库存余额对不上。八成是有人直接改了余额表,或者历史数据迁移时流水没补全。解决:写一个对账脚本,按StockFlow累加每个商品每个仓库的ChangeQty,和StockBalance.Qty比对,不一致的列出来人工核。这个脚本建议做成定时任务,每天跑一次。
现象五:部署到 Linux 后日期格式全乱。ASP.NET Core 在 Linux 上默认用 ICU 的全球化数据,DateTime.ToString()的输出和 Windows 不一致。解决:在Program.cs里设置CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("zh-CN"),或者干脆在 csproj 里加<InvariantGlobalization>true</InvariantGlobalization>关掉全球化,日期统一用 ISO 格式传输。
5. 从能跑到好用:报表优化和二次开发的取舍
5.1 进销存报表慢,先看索引再谈缓存
进销存汇总报表是性能重灾区,尤其是「某时间段某仓库所有商品的期初、入库、出库、期末」这种。很多人第一反应是加 Redis 缓存,但十有八九是索引没建对。StockFlow表上至少要有一个覆盖索引:(WarehouseId, GoodsId, CreateTime)包含ChangeQty。这样按仓库和时间范围聚合时能走索引扫描,不用回表。
CREATE NONCLUSTERED INDEX IX_StockFlow_Report ON StockFlow (WarehouseId, GoodsId, CreateTime) INCLUDE (ChangeQty, BizType);建完索引再测,如果还慢,再看是不是查询写法有问题——比如在 WHERE 里对CreateTime用了函数(CONVERT(varchar, CreateTime, 112) = '20240101'),这会让索引失效。改成范围查询CreateTime >= '2024-01-01' AND CreateTime < '2024-01-02',性能立刻不一样。缓存是最后手段,不是第一手段。
5.2 二次开发前先想清楚:改源码还是加扩展
拿到一套源码,最忌讳的是直接冲进去改核心逻辑。我的习惯是:能在扩展点解决的,绝不改核心。ASP.NET Core 的依赖注入和中间件机制给了很多扩展空间。比如要加一个「过账后发通知」的功能,不要去改PostInboundAsync,而是定义一个领域事件,在过账成功后发布,由独立的处理器去发通知。
// 过账成功后发布事件,不侵入核心逻辑 await _db.SaveChangesAsync(); await tx.CommitAsync(); await _eventBus.PublishAsync(new InboundPostedEvent(order.Id, order.OrderNo));这样核心过账逻辑保持干净,通知功能可以随时加、随时删,出问题也不影响主流程。如果源码本身没有事件机制,那就自己在过账方法末尾加一行发布调用,改动最小。判断标准很简单:你的改动会不会让「过账」这个动作多一个失败点?会,就别改核心。
5.3 一个我坚持了多年的习惯
每次接手一套新源码,我都会先写一个「冒烟测试脚本」——用最少的步骤把采购入库、销售出库、库存查询、报表生成这条主链路跑一遍,记录每一步的预期结果。这套脚本不追求覆盖率,只求在主流程被改坏时第一时间发现。进销存系统的 bug 有个特点:它不会立刻报错,而是悄悄把数据搞乱,等你月底对账才发现。到那时候,你根本不知道是哪天哪次改动引入的。有了冒烟脚本,每次改完跑一遍,心里有底。这个习惯帮我省下的加班时间,远比写脚本花的时间多。希望帮到你。
本文还有配套的精品资源,点击获取