简介:这是一套基于ASP与MSSQL构建的多用户网络进销存与仓库管理源码,面向需要搭建采购、销售、库存及财务一体化管理流程的中小企业、开发者与教学实践者。系统覆盖往来单位、商品、仓库、员工、会计科目等基础资料,并包含期初建账、采购入库与付款退货、销售出库与收款退货、调拨盘点拆装、费用收入单、工资、记帐凭证及财务报表等模块,还提供操作员权限、数据库备份还原、月结存与年结存等系统设置。压缩包约3.43MB,文件总数与主要类型明细上游暂未提供,包内可结合ASP页面、数据库及相关配置内容理解项目组织。目前已有726人学习或下载,适合研究传统Web进销存系统的业务闭环、多用户权限设计、单据流转和ASP+MSSQL项目结构,也可作为课程设计或内部管理原型参考。
1. 一套 ASP 老系统能撑起多仓进销存:先看清它解决什么问题
见过太多小批发仓的账目死在共享 Excel 上:业务员改数量、仓管改单价、财务拷走的是十分钟前的版本,月底对账全靠吼。这套 Web 进销存源码要解决的就是这件事——把商品档案、采购入库、销售出库、库存台账、往来单位和对账查询塞进一个浏览器入口,用 ASP + MSSQL 跑成多用户同时在线的仓库管理系统。技术栈确实不新,但它胜在部署门槛低、数据落在自家服务器、改一个.asp文件刷新就能见效。它适合两类人:手里已有 Windows 服务器、需要一个开箱能用的进销存底座,并且打算自己往上加字段加单据的小团队;以及想弄明白传统三层业务系统到底怎么写库存流水、怎么控权限的开发者。
2. 部署环境:IIS + SQL Server 把站点和库跑通
这套东西是典型的两段式结构:前端是一堆.asp页面,后端是一个 MSSQL 库,中间靠ADODB.Connection连起来。它不需要编译,不需要 Node、不需要 Java 运行时,但也正因如此,环境配置的每一步都会直接决定页面能不能开。我一般按「先让库连上、再让页面开、最后让登录进得去」的顺序推进,出问题时能立刻定位是环境问题还是代码问题。
2.1 环境清单与 IIS 里必须打开的几个开关
运行环境是 Windows Server 或 Win10/11 专业版自带的 IIS,加上 SQL Server。版本上不必追新,SQL Server 2000 到 2008 这一代是这个技术栈最常搭配的区间,高版本(2012 以后的 64 位实例)也能跑,但要留意老代码里SQLOLEDB提供程序和排序规则的差异。IIS 侧要装的是「ASP」这个功能模块,Win 默认是不勾的,很多人第一次打开页面直接下载了一个.asp文件,就是这个原因。
# 管理员权限的命令行,开启 IIS 的 ASP 支持 dism /online /enable-feature /featurename:IIS-ASP /all # 老代码大量使用 <!--#include file="../conn.asp"-->, # 默认站点关闭了父路径,不打开会报 "Active Server Pages 错误 'ASP 0131'" %windir%\system32\inetsrv\appcmd.exe set config "Default Web Site" /section:system.webServer/asp /enableParentPaths:"true" # 应用程序池改成 32 位模式,避免老版本第三方组件加载失败 %windir%\system32\inetsrv\appcmd.exe set apppool "DefaultAppPool" /enable32BitAppOnWin64:"true"dism那行是把 ASP 解释器装上,缺了它 IIS 根本不认识.asp后缀。enableParentPaths是老项目最常见的坑:源码里inc/、conn.asp这类文件互相include时习惯写相对上级路径,父路径一关,页面白屏加 0131 错误。第三行是保险,纯 VBScript 页面其实不依赖 32 位,但只要项目里出现过任何 COM 组件(导出 Excel、打印控件),就必须开。
应用程序池还有两个设置要顺手改:把「启用 32 位应用程序」设为 True 之后,记得把「托管管道模式」保持为「经典」;默认文档里加上index.asp和default.asp。这几项不做,首页会出现「目录浏览被拒绝」或者跳到 IIS 欢迎页。
2.2 数据库初始化与连接字符串的写法
库的初始化走两步:先建一个空库,把源码包里附带的.sql脚本或.mdf数据文件还原进去,再改conn.asp里的连接串。排序规则建议选Chinese_PRC_CI_AS,这样中文字段比较、排序都不会出怪事,后面第 5 章讲乱码时会再提它。
<% ' conn.asp —— 全站公共数据库连接,被所有业务页面 include Dim conn, connStr, dbServer, dbName, dbUser, dbPwd dbServer = "127.0.0.1" ' 数据库地址,独立部署时改成内网 IP dbName = "JXC" ' 进销存库名 dbUser = "sa" ' 生产环境请换成单独建的业务账号,只授 db_owner dbPwd = "YourStrongPwd" ' 部署前必改 connStr = "Provider=SQLOLEDB.1;Data Source=" & dbServer & _ ";Initial Catalog=" & dbName & _ ";User ID=" & dbUser & ";Password=" & dbPwd & _ ";Persist Security Info=False" Set conn = Server.CreateObject("ADODB.Connection") conn.ConnectionTimeout = 15 conn.CommandTimeout = 60 On Error Resume Next conn.Open connStr If Err.Number <> 0 Then Response.Write "数据库连接失败,请检查 conn.asp 配置" Response.End End If On Error GoTo 0 %>这段的关键在Provider=SQLOLEDB.1。它走的是老版 SQL Server Native Client 通道,兼容性最好;如果服务器上装的是新版驱动,可以换MSOLEDBSQL,但要注意它默认要求加密连接,老 IIS 环境可能握手失败。ConnectionTimeout设 15 秒是给网络抖动留余量,CommandTimeout设 60 秒是因为进销存的月末结存、流水汇总查询经常跑得比想象中久。
提示:
conn.asp里直接写明文密码是这套源码的默认做法,上线前至少把文件权限收紧成只有 IIS 匿名账号可读,别让站点目录整站被http://域名/conn.asp直接下载到。
连接对象在全站是全局的,页面底部统一conn.Close : Set conn = Nothing。ASP 没有连接池的显式配置,靠的是 ADO 默认的会话池,所以别在循环里反复Open/Close,性能会掉得很难看。
2.3 站点目录与公共文件的职责划分
解压源码后看到的目录结构,基本就是这套系统的模块地图。我一般先扫一遍根目录,确认哪些是入口、哪些是公共件,再动手改配置。
/ 站点根目录 ├─ conn.asp 数据库连接串 ├─ inc/ 公共函数:分页、权限校验、字符串处理、单据编号生成 ├─ login.asp 登录入口 ├─ index.asp 框架页:左菜单 + 右内容区 ├─ basic/ 基础资料:商品、仓库、往来单位、员工 ├─ buy/ 采购:采购入库单、采购退货单 ├─ sale/ 销售:销售出库单、销售退货单 ├─ stock/ 库存:库存查询、盘点、调拨、流水台账 ├─ report/ 报表:进销存汇总、明细账、应收应付 ├─ sys/ 系统管理:用户、角色、权限分配、参数 └─ images/ css/ js/ 静态资源inc/是必须最先读懂的地方。分页函数、单据号生成、权限判断都堆在这里,业务页面普遍只有几十行调用代码。常见做法是把conn.asp和inc/function.asp在每个业务页头部各 include 一次,页面只负责收参数、校验、调函数、跳转。这种写法的好处是加功能时改动集中;坏处是任何一个公共函数改错,全站一起白屏,所以动inc/之前最好先备份一份。
3. 数据库拆解:商品、单据、库存流水三张底座表怎么串
进销存系统的复杂度八成在数据模型上,代码反而是次要的。这套源码的表结构属于经典的「主数据 + 单据 + 结存」三层设计,理解了这三层,后面看任何一张页面的 SQL 都不会迷路。我按重要性把表分成三组来拆。
3.1 主数据表:商品、仓库、往来单位
主数据是所有业务引用的源头,特点是改动少、被引用多。商品表除了编码、名称、规格、单位,通常还带一个「是否启用批次」的标志位和一个默认进价、默认售价字段;仓库表简单,就是仓库编码、名称、负责人;往来单位表把供应商和客户合并成一张表,用一个PartnerType区分,这样应收应付可以共用一套对账逻辑。
-- 商品档案:GoodsID 是全局主键,Code 是业务编码,两者不要混用 CREATE TABLE Goods ( GoodsID INT IDENTITY(1,1) PRIMARY KEY, Code VARCHAR(30) NOT NULL, -- 商品编码,业务唯一 Name VARCHAR(100) NOT NULL, Spec VARCHAR(80) NULL, -- 规格型号 Unit VARCHAR(20) NULL, -- 计量单位 CategoryID INT NULL, InPrice DECIMAL(18,4) DEFAULT 0, -- 默认进价 OutPrice DECIMAL(18,4) DEFAULT 0, -- 默认售价 IsBatch BIT DEFAULT 0, -- 是否按批次管理 IsActive BIT DEFAULT 1, -- 停用后不允许再开单 CONSTRAINT UQ_Goods_Code UNIQUE (Code) );IsActive这个字段值得单独说。很多二次开发者为了省事直接物理删除商品,结果历史单据的明细行指向了不存在的商品,报表一跑就是空白行。合格做法是停用而不是删除,查询开单下拉框时加WHERE IsActive = 1,查历史单据时不过滤。这个习惯我在好几个项目里都强制推行过。
3.2 单据表与明细表的分层
采购入库、销售出库、退货、调拨、盘点,每一类单据都是「一张主表 + 一张明细表」。主表存单号、日期、往来单位、仓库、制单人、状态;明细表存商品、数量、单价、金额。这种拆法看着啰嗦,但它是唯一能让「一单多品」和「按商品统计」同时成立的结构。
-- 采购入库主表:Status 0=草稿 1=已审核 2=已作废 CREATE TABLE InStock ( BillID INT IDENTITY(1,1) PRIMARY KEY, BillNo VARCHAR(30) NOT NULL, SupplierID INT NOT NULL, WarehouseID INT NOT NULL, BillDate DATETIME NOT NULL, TotalAmount DECIMAL(18,4) DEFAULT 0, Status TINYINT DEFAULT 1, OperatorID INT NOT NULL, CreateTime DATETIME DEFAULT GETDATE(), CONSTRAINT UQ_InStock_No UNIQUE (BillNo) ); -- 采购入库明细:一行一个商品 CREATE TABLE InStockDetail ( DetailID INT IDENTITY(1,1) PRIMARY KEY, BillID INT NOT NULL, GoodsID INT NOT NULL, Qty DECIMAL(18,3) NOT NULL, Price DECIMAL(18,4) NOT NULL, Amount AS (Qty * Price) PERSISTED -- 计算列,避免前后端各算一遍 );Amount用持久化计算列而不是在程序里算,是我很推荐的一个细节。ASP 层里用CDbl转来转去很容易在浮点上丢精度,交给数据库算并且固定到DECIMAL(18,4),前端展示时再FormatNumber保留两位,账就对得上。
3.3 库存表与流水表:为什么不能只存一个数
新手最容易犯的错是只建一张库存表存当前数量,出库时减一下了事。这样做的直接后果是:一旦某个单据被作废或者改过数量,没人知道这个数字是怎么变成现在这样的。合格的进销存一定是「结存表 + 流水表」双写:结存表给查询用,流水表给追溯用。
-- 结存表:每个商品在每个仓库一行,靠唯一约束保证不重复 CREATE TABLE Stock ( StockID INT IDENTITY(1,1) PRIMARY KEY, GoodsID INT NOT NULL, WarehouseID INT NOT NULL, Qty DECIMAL(18,3) NOT NULL DEFAULT 0, AvgCost DECIMAL(18,4) NOT NULL DEFAULT 0, -- 移动加权平均成本 UpdateTime DATETIME DEFAULT GETDATE(), CONSTRAINT UQ_Stock UNIQUE (GoodsID, WarehouseID) ); -- 流水表:只增不改,任何一次库存变动都留痕 CREATE TABLE StockFlow ( FlowID BIGINT IDENTITY(1,1) PRIMARY KEY, BillType VARCHAR(10) NOT NULL, -- IN入库 OUT出库 ADJ盘点调拨 BillNo VARCHAR(30) NOT NULL, BillID INT NOT NULL, GoodsID INT NOT NULL, WarehouseID INT NOT NULL, Qty DECIMAL(18,3) NOT NULL, -- 入库为正,出库为负 Cost DECIMAL(18,4) NULL, OperatorID INT NOT NULL, CreateTime DATETIME DEFAULT GETDATE() ); CREATE INDEX IX_Flow_Goods ON StockFlow(GoodsID, WarehouseID, CreateTime);UQ_Stock这个唯一约束是整套设计的安全绳。有了它,哪怕代码里并发写同一商品,第二次插入会直接报错回滚,而不是悄悄多出一行重复结存。AvgCost存移动加权平均成本,出库时按这个成本结转,月末才能算出毛利。IX_Flow_Goods的索引顺序是GoodsID, WarehouseID, CreateTime,正好对上「查某商品某仓库某段时间流水」这个最高频的查询。
提示:流水表的数据永远不删也不改。单据作废的常见做法是再写一条反向流水,并在
BillType里标记为红冲,而不是DELETE掉原记录。这个约定在二次开发时要写进团队规范,不然三个人能写出三种作废逻辑。
4. 业务链路:从采购入库到销售出库,库存数字是怎么被改写的
表结构定好之后,真正的难点全部落在「一次保存要动几张表」上。采购入库要写主表、写明细、加库存、写流水;销售出库要对库存做校验、减库存、写流水、还可能生成应收。这一整套动作要么全成功,要么全失败,中间断一步就是烂账。
4.1 采购入库:主表、明细、库存一次落库
保存入库单的页面通常叫buy/instock_save.asp。它的骨架很固定:收表单参数、开事务、插主表拿自增 ID、循环插明细并更新库存、提交或回滚。
<% ' buy/instock_save.asp —— 采购入库单保存 Dim billNo, supplierID, warehouseID, billDate, rowCount, i billNo = Trim(Request.Form("billNo")) supplierID = CLng(Request.Form("supplierID")) warehouseID = CLng(Request.Form("warehouseID")) billDate = Trim(Request.Form("billDate")) rowCount = CLng(Request.Form("rowCount")) ' 前端动态表格的行数 On Error Resume Next conn.BeginTrans ' 事务只在这一层开,SP 里不再开 ' 1) 插入主表,取回自增 BillID Dim cmd, rs, billID Set cmd = Server.CreateObject("ADODB.Command") Set cmd.ActiveConnection = conn cmd.CommandText = "INSERT INTO InStock(BillNo,SupplierID,WarehouseID,BillDate,OperatorID,Status) " & _ "VALUES(?,?,?,?,?,1); SELECT SCOPE_IDENTITY() AS NewID;" cmd.Parameters.Append cmd.CreateParameter("billNo", 202, 1, 30, billNo) ' adVarWChar cmd.Parameters.Append cmd.CreateParameter("supplierID", 3, 1, , supplierID) ' adInteger cmd.Parameters.Append cmd.CreateParameter("warehouseID", 3, 1, , warehouseID) cmd.Parameters.Append cmd.CreateParameter("billDate", 135, 1, , CDate(billDate)) ' adDBTimeStamp cmd.Parameters.Append cmd.CreateParameter("operatorID", 3, 1, , Session("UserID")) Set rs = cmd.Execute billID = CLng(rs("NewID")) rs.Close : Set rs = Nothing : Set cmd = Nothing ' 2) 逐行写明细并累加库存 For i = 1 To rowCount Dim goodsID, qty, price goodsID = CLng(Request.Form("goodsID" & i)) qty = CDbl(Request.Form("qty" & i)) price = CDbl(Request.Form("price" & i)) If goodsID > 0 And qty > 0 Then conn.Execute "INSERT INTO InStockDetail(BillID,GoodsID,Qty,Price) " & _ "VALUES(" & billID & "," & goodsID & "," & qty & "," & price & ")" ' 库存累加与流水全部在存储过程里完成,保证原子性 conn.Execute "EXEC sp_StockIn " & goodsID & "," & warehouseID & "," & _ qty & "," & price & ",'" & billNo & "'," & billID & "," & Session("UserID") End If Next If Err.Number <> 0 Then conn.RollbackTrans ErrMsg = "保存失败:" & Err.Description Else conn.CommitTrans Response.Redirect "instock_list.asp?msg=ok" End If On Error GoTo 0 %>主表插入用Command对象加参数,SCOPE_IDENTITY()拿自增主键,比SELECT @@IDENTITY安全,不会因为触发器插了别的表而拿到错 ID。参数类型里的数字是 ADO 常量:202是adVarWChar,3是adInteger,135是adDBTimeStamp。明细部分为了可读性用了字符串拼接,实际项目里我会同样改成参数化,尤其是BillNo、备注这类文本字段,拼接就是给自己埋注入风险。
配套的存储过程负责第二层动作:
CREATE PROCEDURE sp_StockIn @GoodsID INT, @WarehouseID INT, @Qty DECIMAL(18,3), @Price DECIMAL(18,4), @BillNo VARCHAR(30), @BillID INT, @OperatorID INT AS BEGIN SET NOCOUNT ON -- 结存表:有则加,无则插。UPDLOCK 防止并发下两人同时判定"不存在" UPDATE Stock WITH (UPDLOCK, ROWLOCK) SET AvgCost = CASE WHEN Qty + @Qty = 0 THEN AvgCost ELSE (Qty * AvgCost + @Qty * @Price) / (Qty + @Qty) END, Qty = Qty + @Qty, UpdateTime = GETDATE() WHERE GoodsID = @GoodsID AND WarehouseID = @WarehouseID IF @@ROWCOUNT = 0 INSERT INTO Stock(GoodsID, WarehouseID, Qty, AvgCost) VALUES(@GoodsID, @WarehouseID, @Qty, @Price) -- 流水表:入库为正数 INSERT INTO StockFlow(BillType, BillNo, BillID, GoodsID, WarehouseID, Qty, Cost, OperatorID) VALUES('IN', @BillNo, @BillID, @GoodsID, @WarehouseID, @Qty, @Price, @OperatorID) ENDUPDLOCK, ROWLOCK这两个锁提示是老系统里防止并发写坏库存的关键。没有它,两个仓管同时给同一商品入库,可能都判断@@ROWCOUNT = 0,然后一起INSERT,虽然最终会被唯一约束拦下一行报错,但用户看到的是莫名的保存失败。加了更新锁,第二个请求会等第一个提交后再判断,逻辑就顺了。
4.2 销售出库:可用库存校验与负库存的取舍
出库方向比入库多一步:得先确认库存够不够。写法上有两种流派——「先查后改」和「改的时候带上条件」。前者简单但并发下不安全,后者安全但要判断受影响行数。
-- 出库核心:条件更新,Qty 不足时影响 0 行,直接判定失败 UPDATE Stock WITH (UPDLOCK, ROWLOCK) SET Qty = Qty - @Qty, UpdateTime = GETDATE() WHERE GoodsID = @GoodsID AND WarehouseID = @WarehouseID AND Qty >= @Qty -- 不允许负库存时保留;允许负库存就删掉这行 IF @@ROWCOUNT = 0 BEGIN RAISERROR('库存不足或商品不在该仓库,出库失败', 16, 1) RETURN END INSERT INTO StockFlow(BillType, BillNo, BillID, GoodsID, WarehouseID, Qty, Cost, OperatorID) SELECT 'OUT', @BillNo, @BillID, @GoodsID, @WarehouseID, -@Qty, AvgCost, @OperatorID FROM Stock WHERE GoodsID = @GoodsID AND WarehouseID = @WarehouseID负库存允不允许,是个业务问题不是技术问题。批发生意里先开单后到货很常见,硬卡库存会让业务卡壳;但一旦允许负库存,成本核算就会出现「没进货先出库」的AvgCost = 0,毛利虚高。我一般做成系统参数,在sys/里的参数表加一个AllowNegativeStock,出库时读这个开关决定是否拼上AND Qty >= @Qty这一句。这样一套代码能应付两种客户。
4.3 多用户与权限:菜单、页面、数据范围三层防线
「多用户版」这个词经常被理解成「能建多个账号」,实际远远不够。真正的多用户要解决三件事:谁能进哪个页面、谁能看哪些数据、两个人同时改一张单怎么不打架。
第一层是菜单,登录后按角色查权限表,拼出左侧树。第二层是页面,每个.asp文件头部 include 一段校验,页面自己声明需要的权限码:
<% ' inc/auth.asp —— 业务页面第一行包含 Dim needPerm needPerm = "sale_out" ' 页面声明自己的权限码,如 buy_in / stock_query / report_ar Call CheckLogin() Call CheckPerm(needPerm) Sub CheckLogin() If Trim(Session("UserID") & "") = "" Then Response.Redirect "/login.asp?back=" & Server.URLEncode(Request.ServerVariables("URL")) Response.End End If End Sub Sub CheckPerm(code) Dim k If Not IsArray(Session("PermList")) Then Response.Status = "403 Forbidden" Response.Write "无权访问该功能" Response.End End If For k = 0 To UBound(Session("PermList")) If Session("PermList")(k) = code Then Exit Sub Next Response.Status = "403 Forbidden" Response.Write "无权访问该功能" Response.End End Sub %>Session("PermList")是登录时一次性把「角色-权限」关联查出来塞进会话的字符串数组。这样每个页面只做一次内存遍历,不用每次都查库。代价是改权限后必须重新登录才生效,管理员会抱怨,解决办法是在sys/role_perm.asp保存成功后主动清掉相关用户的 Session,或者加一个「刷新权限」按钮。
第三层是数据范围。多仓场景下,A 仓的仓管不该看到 B 仓的库存和单据。常见做法是在用户表上加一个WarehouseIDs字段存逗号分隔的仓库列表,查询时拼AND WarehouseID IN (...)。这一层最容易被漏掉——只做了菜单隐藏,结果用户手动改 URL 参数里的warehouseID就把别仓数据看光了。
注意:ASP 的 Session 存在 IIS 进程内存里,应用程序池一回收、或者改一次
web.config,所有人就被踢回登录页。这不是 bug,但用户会当成 bug 反馈,第 5 章会专门讲怎么缓解。
5. 踩坑排查:ASP + MSSQL 组合下的五类典型翻车
这套技术栈运行稳定,但坑都很「老」,网上搜到的答案经常是十几年前的论坛帖,对不上号。下面五条是我在实际部署和二次开发里反复遇到的,按「现象 → 原因 → 解决」写清楚,照着排能省掉大半天。
5.1 现象:页面中文全变问号或乱码
原因:ASP 页面的编码和数据库排序规则不一致。老源码普遍是 GB2312 存盘、<%@ CodePage=936 %>,如果编辑器另存成了 UTF-8,或者数据库装成了SQL_Latin1_General,两边就对不上。Request.Form收上来的中文也会在这一步变成乱码。
解决:三处要统一。页面文件本身存成 ANSI/GB2312,文件头保留<%@ Language="VBScript" CodePage="936" %>;conn.asp里加Session.CodePage = 936;数据库排序规则用Chinese_PRC_CI_AS。如果一定要用 UTF-8,就把CodePage改成65001,同时在每个页面的Response头里写Response.CharSet = "utf-8",三处有一处漏了就会花屏。改之前先拿一个小页面做验证,别一次性批量转码,转坏了回滚很痛苦。
5.2 现象:库存数量和流水对不上,有时多有时少
原因:九成是事务边界没管住。要么主表插进去了、明细循环中途报错,只回滚了后面几条;要么是保存成功后用户按 F5 刷新,浏览器重发 POST,同一张单被提交了两次;还有一种是把库存更新写在了页面里而不是存储过程里,某次改动漏了一句。
解决:先核对数据——用StockFlow按BillNo分组汇总,看和Stock的差额来自哪张单。确认是重复提交的,加防重逻辑:保存前用BillNo查一次主表,存在就跳转到单据详情页,不再插入;更稳的做法是给BillNo加唯一约束,让数据库兜底。确认是事务问题的,把「明细 + 库存 + 流水」全部收进conn.BeginTrans/CommitTrans区间,中间任何一步Err.Number <> 0立刻RollbackTrans。修完之后一定要写一段对账 SQL 定时跑,把Stock.Qty和StockFlow汇总的差额查出来,有差额就是有漏网之鱼。
5.3 现象:普通账号能打开管理员页面
原因:权限只做在菜单生成那一层,左侧不显示「用户管理」,但页面文件本身没做校验,直接在地址栏敲/sys/user_list.asp就进去了。这是老 ASP 项目最普遍的安全缺陷。
解决:给所有业务页面统一加上inc/auth.asp并在首行声明权限码,包括那些「看起来没人会直接访问」的保存页、删除页、导出页。加完之后做一次全站扫:把目录下所有.asp列出来,逐个确认有没有 include 校验文件。顺手把 SQL 拼接的地方改成Command参数化,两件事一起做,改动成本最低。
5.4 现象:金额合计差几分钱,或者毛利算出来明显不对
原因:金额字段用了FLOAT或REAL。浮点数存 0.1 这种十进制小数本身就是近似值,累加几十行之后误差会放大。另一个原因是出库成本没有用AvgCost结转,而是拿了商品档案里的默认进价。
解决:把所有金额字段改成DECIMAL(18,4),数量字段改成DECIMAL(18,3),ASP 侧统一用CDbl接收后交给参数化传参,展示时再用FormatNumber(x, 2)。历史数据迁移要写脚本ALTER TABLE ... ALTER COLUMN并做一次全表重算。出库成本一律从Stock.AvgCost取,不要回头查商品表。
5.5 现象:用户用着用着被踢回登录页
原因:Session 丢失。触发点通常是应用程序池的空闲回收(默认 20 分钟无请求)、内存回收阈值、或者有人改了站点目录下的任何配置文件导致应用重启。ASP 的 Session 是进程内的,进程一换全清空。
解决:先把应用程序池的「空闲超时」调大或者设为 0,把「固定时间间隔回收」设到凌晨业务低峰;再在web.config或站点属性里把sessionState的超时时间调长。如果还不能忍,就得把登录态从 Session 迁移到 Cookie + 数据库令牌,改动量不小但一劳永逸。我现在的习惯是先做前两步,观察一周,用户不再反馈了就不动代码。
提示:排查这类问题时先看 Windows 事件查看器里的「应用程序」日志,应用程序池回收会留下明确记录,能直接区分「是进程重启」还是「是代码把 Session 清了」。
6. 二次开发:加字段、加单据、加报表的改造顺序
拿到这套源码后,最常见的三个需求是:给商品加几个自定义字段、照着采购入库做一张新的业务单据、加一张按客户维度统计毛利的报表。这三件事的改造路径完全不同,顺序搞反了就是自找麻烦。
加字段最简单,但一定要走完整链路:数据库ALTER TABLE加列 → 基础资料编辑页加输入框 → 列表页加显示列 → 如果你的查询用了SELECT *,改成显式列名。我见过太多人只改了表单没改列表,用户填完看不到,以为没保存。另一个提醒是别在Goods表上无节制加列,超过五六个自定义字段就该考虑建一张GoodsExt扩展表,主表保持精简。
加单据的活儿最重,但套路是可以复制的。以「采购入库」为模板复制一套InStock/InStockDetail/ 页面 / 存储过程,改四处:表名、单据号前缀、StockFlow.BillType的枚举值、菜单权限码。特别要注意的是单据号生成函数,通常在inc/里,格式是「前缀 + yyyyMMdd + 4 位流水」,复制新单据时务必换一个前缀,否则两类单据的号会撞。改完在测试库跑一遍:开单、保存、查库存、作废、再查流水,五个动作全过才算通。
加报表最考验对表结构的熟悉度。我的习惯是先手写 SQL 在 SSMS 里跑出正确结果,再往页面里搬,绝不在页面上边调边试。下面这张对照表是我改造时常用来定位的:
| 需求 | 主要取数表 | 关键过滤条件 |
|---|---|---|
| 商品进出存汇总 | StockFlow + Goods | 按 GoodsID 分组,Qty 正负相加 |
| 客户毛利 | OutStockDetail + StockFlow | 按客户分组,售价减 AvgCost |
| 仓库呆滞品 | Stock + StockFlow | Qty > 0 且近 90 天无出库流水 |
| 应收账龄 | OutStock + 收款表 | 按 BillDate 分区间汇总未收金额 |
写报表时把CommandTimeout调大一点,进销存的数据量涨得比想象中快,一张跨年度的流水汇总在老服务器上跑三十秒很正常,别让用户以为页面卡死了。
最后说一个我自己的习惯。每次动这套系统之前,我会先把conn.asp、inc/整个目录和要改的那几个页面单独打一个压缩包,命名带上日期和改动点;改完之后不看页面,先在 SSMS 里跑三条对账 SQL——结存与流水差额、单据主表与明细金额差额、负库存商品清单。这三条只要有一条不为空,我就知道这次改动碰坏了东西。这个动作从一次深夜把库存表写乱、第二天被仓管堵在门口之后就没断过,慢是慢一点,但睡得着。希望帮到你。
本文还有配套的精品资源,点击获取