☰
ASP.NET进销存源码实战:架构、事务与库存管理
2026/9/29 19:39:16 网站建设 项目流程

简介:面向需要学习或二次开发进销存系统的ASP.NET开发者,这份源码以VS2008+SQL2005为运行环境,借助DXperience控件搭建界面,完整覆盖业务管理、报表管理、基础数据、系统管理、文件管理及设备管理等模块,从采购、生产到销售形成较完整的企业业务闭环。压缩包约22.98MB,共1119个文件,以538个cs核心源码为主,辅以resx/resources界面资源、45个dll依赖库及scc版本控制文件,目录结构清晰,便于按模块定位和查阅。已有462人浏览学习,适合具备一定ASP.NET基础、希望掌握企业级业务流编码实践的开发者参考。源码从客户订单、物料需求计划到采购入库、生产领料、委外加工、销售出库等常见业务单据,再到退料、报废、调拨等多份统计与明细报表,可帮助深入理解进销存全链路的数据流转;BOM、物料编码、操作员授权等基础设置,以及文件管理、设备管理相关单据,也为后续扩展和二次开发提供完整抓手。

1. 先别急着下载:这套 ASP.NET 进销存源码到底能给你什么

ASP.NET 进销存管理系统源码,听起来是个有点“年代感”的词,但恰恰是这类项目,在过去十几年里养活了大量中小企业的信息部门和外包团队。它的核心价值不是界面多好看,而是把“采购→入库→销售→出库→盘点→对账”这条业务闭环完整落到了数据库和页面里,你拿到的是一套能直接看、直接改、直接跑的业务骨架。

这套源码最适合三类人:一是公司里的应用开发或 IT 负责人,想用现成代码快速搭内部管理系统,摆脱手工 Excel 对账;二是刚工作一两年的开发者,想找一份带三层架构、单据流程、权限和库存扣减逻辑的实战项目来练手;三是技术评估者,想算清楚“拿源码二次开发”和“买商业 ERP”到底哪个划算。需要先说明白:进销存不等于 ERP,它只负责进、销、存三条线和往来单位、操作员权限,不管生产排程、财务总账。带着这个预期去选型,后面就不会踩“源码缺这个缺那个”的心理落差。

2. 选型先看骨架:WebForms 还是 MVC,项目结构怎么搭

2.1 为什么市面上大量进销存源码都是 WebForms

如果你去代码托管平台或老外包公司手里翻源码,会发现这类项目里十套有八套是 ASP.NET WebForms,后缀是.aspx加.aspx.cs。这不是巧合,而是时代选择:2005 到 2012 年前后,.NET Framework 2.0 到 4.5 是 Windows 服务器上的主流,企业内部系统标准配置就是“Windows Server + IIS + SQL Server”,而 WebForms 的拖控件式开发、GridView 加 SqlDataSource,能让一个外包开发在两周内把增删改查页面全部堆出来。老板看到的是“今天下单明天就有系统用”,程序员看到的是“不用写一堆 JavaScript 也能出页面”。

放到今天,如果项目是从零开始,我一般不建议再开一个 WebForms 新坑,ASP.NET Core MVC 或 Razor Pages 明显更适合新团队。但如果你手里已经有一套运行稳定、业务验证过的 WebForms 源码,先别急着推翻重写。内部系统用户量通常只有几十人,每天业务单据几百张,WebForms 完全扛得住,重写反而容易把已经跑通的业务流程改出问题。我的原则是:以业务风险为标准选型,不以技术新鲜度为标准。

2.2 三层架构摆在你面前:Model / DAL / BLL / Web 怎么分工

拿到源码第一步不是双击.sln然后按 F5,而是先看项目结构。靠谱的进销存源码一般是一个解决方案下挂四个项目,名字可能略有差异,但职责一致:

  • ZJX.Model:实体类,对应数据库表,比如 Product、StockIn、StockInDetail。
  • ZJX.DAL:数据访问层,封装 SqlConnection 和 SqlCommand,对外提供按主键查、按条件查、插入、更新方法。
  • ZJX.BLL:业务逻辑层,校验库存够不够、单据状态能不能改、事务从哪开始到哪结束。
  • ZJX.Web:WebForms 页面、用户控件、Handler。

很多简化版源码会把 DAL 和 BLL 混在一个App_Code文件夹里,那也不是不能用,但你要意识到后期改起来会比较痛苦,尤其是写单元测试的时候没有清晰的项目边界。一个典型的实体类和 SqlHelper 封装长这样:

public class Product { public int ProductId { get; set; } public string ProductCode { get; set; } public string ProductName { get; set; } public string Spec { get; set; } public string Unit { get; set; } public decimal CostPrice { get; set; } public decimal SalePrice { get; set; } public decimal StockQty { get; set; } public decimal MinStock { get; set; } }
public static class SqlHelper { private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["ZjxDb"].ConnectionString; public static DataTable ExecuteQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); var adapter = new SqlDataAdapter(cmd); var dt = new DataTable(); adapter.Fill(dt); return dt; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } }

这里的using不是只用来释放资源,更重要的是数据库连接到底开没开、关没关。进销存系统最怕的就是连接泄漏,表现为跑几天后 IIS 报“连接池已满”。另外注意参数化查询,进销存系统里商品名、供应商名都是用户输入的,直接拼接 SQL 不仅会被注入,还会在商品名带单引号时直接让页面炸掉。参数名要和 SQL 里的@ProductName一一对应,写错一个,运行时抛的是“必须声明标量变量”,那个时刻你会很怀念仔细核对参数名的十分钟。

2.3 第一步配置:连接串、SQL Server 认证与常见失效原因

打开源码后,第一个要改的文件是Web.config。连接字符串通常在<connectionStrings>节点里,常见写法如下:

<connectionStrings> <add name="ZjxDb" connectionString="Data Source=.;Initial Catalog=ZjxDB;User ID=sa;Password=your_password;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings>

Data Source=.表示本机默认实例,服务器上通常是192.168.1.10或带实例名的192.168.1.10\SQLEXPRESS;Initial Catalog是数据库名,要和建库脚本里的库名保持一致;User ID=sa是历史遗留最常见配置,生产环境强烈建议改成 Windows 身份验证,连接串换成Data Source=.;Initial Catalog=ZjxDB;Integrated Security=True,省去管理密码的麻烦。

连接串最常见的翻车点是这三处:第一,键名写错,页面报“在配置中找不到指定连接字符串”,代码里读的是ZjxDb,配置文件里写的是ZjxDB或zjxdb,大小写一不一致无所谓但名字必须严格匹配;第二,发布时Web.config被转换覆盖,开发环境能连,测试服务器连不上,先看服务器上真实生效的配置而不是本地文件;第三,sa密码里带特殊字符比如分号,整个连接串解析会中断。遇到连接问题,不要玄学式地怀疑人生,按“配置文件路径 → 键名 → 数据库实例名 → 账号密码 → 防火墙 1433 端口”逐个查,五分钟定位。

3. 把进销存抽象成数据模型:不超十张表的表结构与索引设计

3.1 核心表:商品、往来单位、单据主表和明细表

进销存系统的数据模型可以浓缩成一句话:围绕“单据”流转。采购员录一张入库单,单据头记录供应商、日期、经手人,单据体记录每一件商品的数量和单价;销售出库同理。所以核心表不会超过十张:

表名角色关键字段
Product商品档案商品编码、名称、规格、单位、成本价、销售价、库存量、最低库存
Supplier供应商名称、联系人、电话
Customer客户名称、联系人、电话
StockIn入库单主表单号、供应商、经办人、入库日期、备注
StockInDetail入库单明细入库单ID、商品ID、数量、单价
StockOut出库单主表单号、客户、经办人、出库日期、备注
StockOutDetail出库单明细出库单ID、商品ID、数量、单价
StockLog库存日志商品ID、变动类型、变动数量、变动前数量、变动后数量、关联单号、操作人、时间
SysUser操作员用户名、密码哈希、角色

注意主表和明细表必须成对出现,这是进销存和普通增删改查的最大区别。一张入库单对应多行商品明细,如果只建一张“入库记录表”把商品名塞在一个字段里用逗号拼接,那后面做库存统计、按商品查流水时,你会被自己的设计折磨到想重构。做数据模型时记住一句话:一条业务单据 = 主表一条 + 明细多行,所有流程都围绕这个结构展开。

3.2 为什么必须单独建一张库存日志表

新手拿到源码时最容易忽略的表是StockLog。只看业务表面,库存就是商品表里的一个StockQty字段,入库加、出库减,完成。但只维护一个数字,月底盘库对不上账时你完全没有追溯手段——账面少了十件,你只知道“少了”,不知道是哪张单子扣的、谁扣的、什么时候扣的。

库存日志表解决的就是这个“后悔药”问题。每次库存变动,不管入库、出库还是盘点调整,都在同一个事务里往StockLog写一条记录,记录变动前数量、变动数量、变动后数量、关联单号和操作人。这样任何时候都能回答“这个商品这一百件是怎么来的”。我见过大量企业从 Excel 迁移到进销存后,第一年盘库差异很大,根因就是旧系统的库存更新逻辑没写日志,差异发生后只能靠翻纸质单据,效率极低。所以拿到源码先检查:有没有StockLog表,有没有在业务代码里真正写入。没有的话,这不是“简化版”,是“会埋雷版”。

3.3 一套可以直接改的建表 SQL 与索引建议

下面这套 SQL Server 建表脚本覆盖了上述核心表,字段命名做了简化,方便你对照源码调整:

CREATE TABLE dbo.Product ( ProductId INT IDENTITY(1,1) PRIMARY KEY, ProductCode NVARCHAR(32) NOT NULL, ProductName NVARCHAR(64) NOT NULL, Spec NVARCHAR(64) NULL, Unit NVARCHAR(16) NULL, CostPrice DECIMAL(18,2) NOT NULL DEFAULT 0, SalePrice DECIMAL(18,2) NOT NULL DEFAULT 0, StockQty DECIMAL(18,2) NOT NULL DEFAULT 0, MinStock DECIMAL(18,2) NOT NULL DEFAULT 0, IsDelete BIT NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX UX_Product_Code ON dbo.Product(ProductCode); CREATE TABLE dbo.StockIn ( StockInId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL, SupplierId INT NOT NULL, OperatorUserId INT NULL, InDate DATETIME NOT NULL DEFAULT GETDATE(), Remark NVARCHAR(255) NULL ); CREATE UNIQUE INDEX UX_StockIn_OrderNo ON dbo.StockIn(OrderNo); CREATE TABLE dbo.StockInDetail ( DetailId INT IDENTITY(1,1) PRIMARY KEY, StockInId INT NOT NULL, ProductId INT NOT NULL, Qty DECIMAL(18,2) NOT NULL, Price DECIMAL(18,2) NOT NULL ); CREATE TABLE dbo.StockLog ( LogId BIGINT IDENTITY(1,1) PRIMARY KEY, ProductId INT NOT NULL, ChangeType TINYINT NOT NULL, -- 1入库 2出库 3盘点调整 RelatedNo NVARCHAR(32) NULL, -- 关联单号 BeforeQty DECIMAL(18,2) NOT NULL, ChangeQty DECIMAL(18,2) NOT NULL, AfterQty DECIMAL(18,2) NOT NULL, OperatorUserId INT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE INDEX IX_StockLog_ProductId ON dbo.StockLog(ProductId, CreateTime DESC);

商品编码要加唯一索引,单据编号同理,这两个字段是业务上人工可读的“身份证”,不能重复。StockQty用了DECIMAL(18,2)而不是INT,因为很多商品的计量单位是公斤、米、升,按整数设计会在录单时被逼着四舍五入。库存日志的索引建在ProductId + CreateTime DESC上,是为了商品明细页里“查这个商品最近三个月的流水”能走索引,否则流水表到几十万行时页面会明显变慢。

4. 核心流程实现:采购入库、销售出库与库存扣减

4.1 采购入库:主表 + 明细 + 库存更新拧进一个事务

采购入库是最容易写错的核心流程。不少简化版源码的做法是:先插入主表,再循环插入明细,然后每行明细再去 update 一次商品库存。如果这几步之间没有事务,一旦第 5 行明细插入失败,前面 4 行已经写进去了,库存也加了一半,月底对账就永远差这一截。正确做法是用SqlTransaction把这四件事包起来:

public void CreateStockIn(StockInEntity main, List<StockInDetailEntity> details) { string connStr = ConfigurationManager.ConnectionStrings["ZjxDb"].ConnectionString; using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { // 第一步:插入入库单主表,拿到自增主键 string sqlMain = "INSERT INTO StockIn(OrderNo, SupplierId, OperatorUserId, Remark) " + "VALUES(@OrderNo, @SupplierId, @OperatorUserId, @Remark); " + "SELECT SCOPE_IDENTITY();"; using (var cmdMain = new SqlCommand(sqlMain, conn, tx)) { cmdMain.Parameters.AddWithValue("@OrderNo", main.OrderNo); cmdMain.Parameters.AddWithValue("@SupplierId", main.SupplierId); cmdMain.Parameters.AddWithValue("@OperatorUserId", main.OperatorUserId); cmdMain.Parameters.AddWithValue("@Remark", (object)main.Remark ?? DBNull.Value); int stockInId = Convert.ToInt32(cmdMain.ExecuteScalar()); // 第二步:循环插入明细,并同步更新库存和写日志 foreach (var d in details) { string sqlDetail = "INSERT INTO StockInDetail(StockInId, ProductId, Qty, Price) " + "VALUES(@StockInId, @ProductId, @Qty, @Price);"; using (var cmdDetail = new SqlCommand(sqlDetail, conn, tx)) { cmdDetail.Parameters.AddWithValue("@StockInId", stockInId); cmdDetail.Parameters.AddWithValue("@ProductId", d.ProductId); cmdDetail.Parameters.AddWithValue("@Qty", d.Qty); cmdDetail.Parameters.AddWithValue("@Price", d.Price); cmdDetail.ExecuteNonQuery(); } UpdateStockAndLog(conn, tx, d.ProductId, 1, d.Qty, main.OrderNo, main.OperatorUserId); } } tx.Commit(); } catch { tx.Rollback(); throw; } } } }

这段代码的关键点有三个。BeginTransaction()之后的new SqlCommand(sql, conn, tx)必须把事务对象传进去,漏传事务的命令默认在另一个隐式事务里执行,外层Rollback根本管不住它。SCOPE_IDENTITY()用于取当前连接和事务范围内插入的自增主键,它比@@IDENTITY安全,不会被其他连接插入的数据影响。最后UpdateStockAndLog负责加库存和写日志,拆成独立方法是为了采购和销售共用一套库存变更逻辑。这段代码不追求花哨,但它是进销存系统不烂账的底线。

4.2 销售出库:条件更新防止并发超卖

出库流程和入库方向相反,但最大的坑不在“减库存”本身,而在“并发超卖”。两个业务员同时看到库存还有 10 件,A 下了 8 件,B 也下了 8 件,如果代码是先查库存、判断够不够、再 update,那么两次 update 后库存会变成负数。解决方法是把“判断库存是否充足”和“扣减库存”合并进一条 SQL:

public void CreateStockOut(StockOutEntity main, List<StockOutDetailEntity> details) { string connStr = ConfigurationManager.ConnectionStrings["ZjxDb"].ConnectionString; using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { string sqlMain = "INSERT INTO StockOut(OrderNo, CustomerId, OperatorUserId, Remark) " + "VALUES(@OrderNo, @CustomerId, @OperatorUserId, @Remark); " + "SELECT SCOPE_IDENTITY();"; using (var cmdMain = new SqlCommand(sqlMain, conn, tx)) { cmdMain.Parameters.AddWithValue("@OrderNo", main.OrderNo); cmdMain.Parameters.AddWithValue("@CustomerId", main.CustomerId); cmdMain.Parameters.AddWithValue("@OperatorUserId", main.OperatorUserId); cmdMain.Parameters.AddWithValue("@Remark", (object)main.Remark ?? DBNull.Value); int stockOutId = Convert.ToInt32(cmdMain.ExecuteScalar()); foreach (var d in details) { string sqlDetail = "INSERT INTO StockOutDetail(StockOutId, ProductId, Qty, Price) " + "VALUES(@StockOutId, @ProductId, @Qty, @Price);"; using (var cmdDetail = new SqlCommand(sqlDetail, conn, tx)) { cmdDetail.Parameters.AddWithValue("@StockOutId", stockOutId); cmdDetail.Parameters.AddWithValue("@ProductId", d.ProductId); cmdDetail.Parameters.AddWithValue("@Qty", d.Qty); cmdDetail.Parameters.AddWithValue("@Price", d.Price); cmdDetail.ExecuteNonQuery(); } UpdateStockAndLog(conn, tx, d.ProductId, 2, d.Qty, main.OrderNo, main.OperatorUserId); } } tx.Commit(); } catch { tx.Rollback(); throw; } } } }

这里的UpdateStockAndLog内部用的是“条件更新 + 行锁”的组合:

private void UpdateStockAndLog(SqlConnection conn, SqlTransaction tx, int productId, int changeType, decimal qty, string orderNo, int operatorUserId) { string sqlUpdate = @" UPDATE dbo.Product WITH (UPDLOCK, ROWLOCK) SET StockQty = StockQty + @DeltaQty WHERE ProductId = @ProductId"; if (changeType == 2) // 出库不能把库存扣成负数 { sqlUpdate += " AND StockQty >= @DeltaQty"; } using (var cmdUpdate = new SqlCommand(sqlUpdate, conn, tx)) { cmdUpdate.Parameters.AddWithValue("@DeltaQty", changeType == 2 ? qty : qty); cmdUpdate.Parameters.AddWithValue("@ProductId", productId); int affected = cmdUpdate.ExecuteNonQuery(); if (changeType == 2 && affected == 0) { throw new Exception("商品库存不足,出库失败"); } } // 写库存日志,BeforeQty 需要先查一次,但此时行锁已持有,不会产生并发脏读 string sqlLog = @" INSERT INTO StockLog(ProductId, ChangeType, RelatedNo, BeforeQty, ChangeQty, AfterQty, OperatorUserId) SELECT ProductId, @ChangeType, @OrderNo, StockQty - @DeltaQty, @DeltaQty, StockQty, @OperatorUserId FROM dbo.Product WHERE ProductId = @ProductId;"; using (var cmdLog = new SqlCommand(sqlLog, conn, tx)) { cmdLog.Parameters.AddWithValue("@ChangeType", changeType); cmdLog.Parameters.AddWithValue("@OrderNo", orderNo ?? ""); cmdLog.Parameters.AddWithValue("@DeltaQty", qty); cmdLog.Parameters.AddWithValue("@OperatorUserId", operatorUserId); cmdLog.Parameters.AddWithValue("@ProductId", productId); cmdLog.ExecuteNonQuery(); } }

WITH (UPDLOCK, ROWLOCK)的意思是更新时对命中行加更新锁和行锁,锁粒度小,并发下两个出库单不会同时对同一行做“先查后改”。出库的 update 加了StockQty >= @DeltaQty条件,SQL Server 在同一个语句里完成“判断 + 扣减”,比先 select 再 update 少一个竞态窗口。如果 affected 为 0,说明库存不够,直接抛异常让事务回滚。注意写日志时我用StockQty - @DeltaQty算出变动前数量,StockQty是 update 之后的新值,这是利用了同一行数据在锁保护下的稳定性,既不用额外查询,也不会在并发下算错。

4.3 把库存变动收敛到一个存储过程里

上面的 C# 代码虽然逻辑清晰,但“查库存、判断、更新、写日志”毕竟跨了两次数据库往返。如果采购单有 50 行明细,循环里每行都要执行两次命令,一个单据提交就是上百次数据库往返。内网环境下还能接受,但并不意味着这是最优解。更稳妥的常见做法是把库存变动封装成一个存储过程,让事务边界彻底落在数据库里:

CREATE PROCEDURE dbo.sp_StockChange @ProductId INT, @ChangeType TINYINT, -- 1入库 2出库 3盘点调整 @Qty DECIMAL(18,2), @RelatedNo NVARCHAR(32), @OperatorUserId INT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; DECLARE @CurrentQty DECIMAL(18,2); SELECT @CurrentQty = StockQty FROM dbo.Product WITH (UPDLOCK, ROWLOCK) WHERE ProductId = @ProductId; IF @ChangeType = 2 AND @CurrentQty < @Qty BEGIN THROW 50001, N'库存不足,出库失败', 1; END DECLARE @Delta DECIMAL(18,2); SET @Delta = CASE WHEN @ChangeType = 2 THEN -@Qty ELSE @Qty END; UPDATE dbo.Product SET StockQty = StockQty + @Delta WHERE ProductId = @ProductId; INSERT INTO dbo.StockLog (ProductId, ChangeType, RelatedNo, BeforeQty, ChangeQty, AfterQty, OperatorUserId) VALUES (@ProductId, @ChangeType, @RelatedNo, @CurrentQty, @Delta, @CurrentQty + @Delta, @OperatorUserId); COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH; END

存储过程方案最大的好处是不依赖 C# 代码里“每一步都记得把事务传进去”的纪律。只要业务方调sp_StockChange,库存更新和日志写入天然在一个数据库事务里。C# 层只需要在插入主表和明细时开启一个SqlTransaction,循环里调用SqlCommand执行存储过程并把事务对象传给它,整体依然保持单事务语义。对于盘点调整、期初建账这种带审计性质的库存变动,这个存储过程也能复用,只要改@ChangeType传入的值即可。

5. 进销存系统上线前的五个高频踩坑与排查

5.1 GridView 日期显示成 0001/1/1,编辑一保存就格式异常

现象:列表页查看入库日期显示正常,一点“编辑”,日期列变成0001/1/1,或者回发后页面报“字符串未被识别为有效的 DateTime”。

原因:GridView 的BoundField设置了DataFormatString="{0:yyyy-MM-dd}",查看模式显示没问题,但进入编辑模式后,DataFormatString被忽略,文本框里拿到的是原始 DateTime 值,回发时系统尝试按当前区域设置解析这个值,一旦格式不匹配就抛异常。更隐蔽的是数据库字段允许为空时,DBNull转成DateTime默认值恰好是0001/1/1。

解决:编辑模板里显式绑定格式化字符串,去掉对BoundField选项的依赖:

<asp:TemplateField HeaderText="入库日期"> <ItemTemplate><%# Eval("InDate", "{0:yyyy-MM-dd}") %></ItemTemplate> <EditItemTemplate> <asp:TextBox ID="txtInDate" runat="server" Text='<%# Bind("InDate", "{0:yyyy-MM-dd}") %>'></asp:TextBox> </EditItemTemplate> </asp:TemplateField>

5.2 部署到 IIS 后样式全丢、回发跳首页

现象:本地 Visual Studio 调试一切正常,发布到服务器 IIS 后,登录页 CSS 和图片全部加载不出来,登录成功后跳转到/Default.aspx而不是预期的首页。

原因:最常见的两种——页面里用了根目录相对路径,比如href="/css/site.css",站点部署在虚拟目录http://ip/zjx/下时,这个路径实际指向的是站点根目录下的css,文件不存在;另一种是 WebForms 的 Forms 认证配置里loginUrl写死了绝对路径,没带虚拟目录名。

解决:统一用ResolveUrl生成带虚拟目录的路径:

<link href="<%=ResolveUrl("~/css/site.css")%>" rel="stylesheet" />

同时检查Web.config中<forms loginUrl="~/Login.aspx" />,这里的~会被运行时正确解析为虚拟目录。部署前先在服务器上用浏览器开发者工具看 CSS 请求的实际 URL,判断路径差在哪一级。

5.3 库存老是差几个数:日志缺失和盘点不走流程

现象:系统跑了一个季度,月底盘点发现好几个商品账面比实物少十件,查“出入库明细”每个单据都对得上,但总数就是不对。

原因:这个现象八成是库存日志表形同虚设。常见情况是页面里“商品档案维护”可以直接改StockQty字段,修库存时没写StockLog;另一种是盘点差异直接在数据库里手工 UPDATE,没有生成盘点调整单。账面数量对不上的根因不是算错,而是存在“没被记录”的变动。

解决:把库存字段的修改入口全部关掉,商品维护页只允许改编码、名称、价格,库存调整统一走“盘点调整”功能,内部调用sp_StockChange并传@ChangeType = 3。每月对账用下面这条 SQL 检查商品表库存和日志累计是否一致:

SELECT p.ProductId, p.ProductCode, p.ProductName, p.StockQty AS CurrentStock, ISNULL(SUM(CASE WHEN sl.ChangeType IN (1,2,3) THEN sl.ChangeQty ELSE 0 END), 0) AS LoggedChange FROM dbo.Product p LEFT JOIN dbo.StockLog sl ON p.ProductId = sl.ProductId GROUP BY p.ProductId, p.ProductCode, p.ProductName, p.StockQty HAVING p.StockQty <> ISNULL(SUM(CASE WHEN sl.ChangeType IN (1,2,3) THEN sl.ChangeQty ELSE 0 END), 0);

注意StockLog.ChangeQty里出库类型存的是负值,所以 SUM 直接相加就是净变动量。如果这个 SQL 跑出来有行,就说明存在绕过日志的库存修改,优先查手工 UPDATE 的操作记录。

5.4 报表打开就卡死:GridView 全量加载与 ViewState 拖垮页面

现象:库存清单页有几万行数据,打开要等十几秒;点下一页有时直接超时。查看页面源码,发现隐藏字段__VIEWSTATE里塞了几百 KB 的 Base64 字符串。

原因:SqlDataSource 的 Select 语句是SELECT * FROM Product,GridView 一下加载全表,再靠分页控件“切成每页 20 行”——这是假分页,数据已经在内存里了,ViewState 又把当前页的完整数据状态序列化到表单里。几万行时页面自然变得又慢又大。

解决:开启数据库层真分页,配合ObjectDataSource的SelectMethod返回PagedResult;如果源码结构简单,至少给 SqlDataSource 加上EnablePaging="true",并配置SelectCountMethod。同时给数据量大的 GridView 设置EnableViewState="false",页索引和排序状态用 Session 保存。真分页的判断标准是:第一行数据只在 SQL 查询时取,页面回发时不用再把全表数据留在隐藏字段里。

5.5 事务写了对账还是错:连接串和事务边界没查清

现象:代码里明明有BeginTransaction和Commit,但异常时空库、明细和库存却出现了“主表有、明细缺几条”的情况。

原因:最常见的翻车点是循环里又创建了新的SqlConnection实例,新连接不在外层事务范围内,内部执行的命令各自隐式提交;另一种是使用了TransactionScope,而环境没有配置 MSDTC,跨连接事务静默退化或直接抛异常。事务确实写了,只是没写对角。

解决:先确认整个流程只使用一个SqlConnection实例、一个匹配的SqlTransaction实例,所有SqlCommand的构造参数都显式传入这个事务对象。用 SQL Server 的扩展事件或 Profiler 追踪会话,观察是否只有一对BEGIN TRAN和COMMIT TRAN。如果业务复杂到需要跨多个数据库,优先调整设计让它落在同一个数据库内,避免引入分布式事务。

6. 给这套源码加分:GridView 的 jQuery 体验改造与迁移判断

6.1 GridView 三件套:分页、导出Excel、操作列

如果页面还在用 GridView 且暂时不换框架,有三件事花半小时就能提升不少使用感受。第一,开启服务端分页,设置AllowPaging="true"和PageSize="20",在PageIndexChanging事件中重置索引并重新绑定数据。第二,加导出 Excel 按钮,常见做法是把 GridView 渲染成 HTML 表格后设置响应头:

Response.Clear(); Response.ContentType = "application/vnd.ms-excel"; Response.AddHeader("Content-Disposition", "attachment;filename=" + HttpUtility.UrlEncode("库存清单.xls", System.Text.Encoding.UTF8)); StringWriter sw = new StringWriter(); HtmlTextWriter htw = new HtmlTextWriter(sw); gvMain.RenderControl(htw); Response.Write(sw.ToString()); Response.End();

这种导出方式生成的其实是 HTML 文件,Excel 打开时提示“格式与扩展名不符”是正常现象,直接忽略即可。第三,操作列固定放“编辑、删除、查看流水”,删除前用confirm弹窗确认,避免误删单据。

6.2 给 GridView 挂上 jQuery 插件:自动补全、日期选择的正确姿势

GridView 生成的 HTML 比较朴实,但配合 jQuery 插件能补齐体验短板。常见的需求是商品名自动补全和日期选择,搜索“asp.net 的 gridview 的 jquery 插件”能找到很多现成方案,落地时有一个关键坑要避开:WebForms 的 UpdatePanel 局部刷新会覆盖 jQuery 绑定的事件,刷新后插件失效。

解决方法是把绑定的 JS 写成一个独立函数,并在Sys.Application.add_load中调用它。Sys.Application.add_load在 UpdatePanel 每次异步刷新完成后都会触发,比document.ready更可靠。比如商品名自动补全:

Sys.Application.add_load(function () { $("#<%= txtProductName.ClientID %>").autocomplete({ source: function (req, resp) { $.get("/Handlers/ProductSearch.ashx", { term: req.term }, function (data) { resp(JSON.parse(data)); }); }, minLength: 1, select: function (event, ui) { $("#<%= hidProductId.ClientID %>").val(ui.item.id); $("#<%= txtProductName.ClientID %>").val(ui.item.name); return false; } }); });

后台用一个一般处理程序ProductSearch.ashx返回 JSON,搜索时带 LIKE 条件,限制返回前 20 条。需要注意ui.item的具体字段和后台返回结构要对应,排查时先打开浏览器开发者工具看接口返回的原始 JSON,再调前端字段名。

6.3 要不要迁移到 ASP.NET Core:三个判断信号

最后聊一个每个人拿到这类源码都会问的问题:要不要用 ASP.NET Core 重写?我的建议是不看技术情怀,看三个信号。第一,有没有移动端需求,老板要求在手机上录单、查库存,WebForms 那套回发模型在手机上体验很难救,这时候迁到 ASP.NET Core 加 REST API,给小程序或 H5 用是正路。第二,部署环境变没变,客户要求 Linux 或 Docker 容器化,WebForms 绑死 Windows 和 IIS,迁移是硬性需求。第三,团队还有没有后续维护能力,如果维护者只会 WebForms,业务逻辑也复杂,先别动,把数据库事务、库存日志、分页性能这些基础打好,这套系统还能稳定跑很多年。

我接过不少这类进销存系统的维护单,栽过最大的跟头是刚入手时急着给 GridView 换主题、加动画,把精力花在了表面上,结果核心业务单据连“在一个事务里提交”都没做到。先保事务和日志,再谈体验,这个顺序反了,后面全是返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询