VB6+SQL Server销售管理系统源码拆解与开单二次开发

发布时间:2026/11/4 8:08:06
VB6+SQL Server销售管理系统源码拆解与开单二次开发

简介:面向VB6.0与SQL Server 2000入门及进阶学习者的销售管理系统完整源码包,可用于理解桌面数据库应用的窗体组织、数据访问与报表输出流程。压缩包共51个文件,约1.37MB,以frm窗体、frx窗体资源、bas模块、cls类模块和vbp工程文件构成主体,并附带ocx控件、sql数据库脚本及db示例数据,便于直接打开工程研究界面与业务逻辑的对应关系。已有442人学习,说明该范例具备一定参考价值。系统覆盖产品信息录入、产品管理、查询打印、金额汇总和简单报表等模块,登录界面与多文档窗体结构可帮助新手梳理VB6项目分层;SQL目录中的脚本可用于还原库表,并练习连接配置、记录集操作与报表数据绑定,适合课程设计、自学练手或对照拆解数据库实例,有助于理解VB6与SQL Server的数据交互。

1. 一套跑了十年的开单系统,为什么还值得拆

接手一套跑了十年的开单系统,最怕的不是代码老,而是没人说得清它当初怎么跑起来。前段时间某开发者把一份 VB6 + SQL Server 的销售管理系统源代码丢给我,说客户还想在原有开单流程上加一个字段。我原本以为要大改,结果拆开发现结构意外地清楚:MDI 主窗体、六张业务表、一层薄薄的 ADO 连接封装,一个下午就把登录和开单跑通了。

这套 VB6 销售管理系统源代码的价值不在技术新,而在它把“客户—商品—订单—明细—库存”整条链路用最笨也最直白的方式写完了,读一遍就能明白中小企业的销售单据是怎么流转、怎么落库、怎么扣库存的。它适合两类人:手上有老系统要接手维护的,和想拿一套能跑通的 VB源码-数据库实例来练手增删改查与事务的。下面按工程结构、数据库、核心代码、跑通、避坑、改造六段走,尽量把每一步写到你照着敲就能复现。

2. 工程结构与运行环境:从 .vbp 开始认识这套代码

VB6 项目最容易劝退人的地方,是它没有现代意义上的“解决方案”,一切从.vbp工程文件开始。这个文件是纯文本,双击用记事本就能看,里面列了窗体、模块、类模块,以及最关键的“引用”和“部件”。搞不清依赖关系的时候,先读它比读代码有用。

2.1 .vbp 里的三行关键信息

打开工程文件,前面是文件清单,后面是引用和控件。销售系统这类项目,几乎必然依赖两个东西:ADO 类型库和 Windows 公共控件。

Type=Exe Form=frmLogin.frm Form=frmMain.frm Form=frmSale.frm Form=frmCustomer.frm Form=frmProduct.frm Form=frmReport.frm Module=modDB; modDB.bas Module=modPub; modPub.bas Class=clsUser; clsUser.cls ' 数据访问:ADO 2.5 以上,Win7 之后系统自带 2.8 Reference=*\G{00000205-0000-0010-8000-00AA006D2EA4}#2.5#0#..\Program Files\Common Files\System\ado\msado25.tlb#Microsoft ActiveX Data Objects 2.5 Library ' 控件:MSCOMCTL 提供 ListView / TreeView / ImageList Object={831FDD16-0C5C-11D2-A9FC-0000F8754DA1}#2.0#0; MSCOMCTL.OCX ' 控件:MSCOMCT2 提供 DTPicker 日期选择、MonthView Object={86CF1D34-0C5F-11D2-A9FC-0000F8754DA1}#2.0#0; MSCOMCT2.OCX

Reference=那行是类型库引用,Object=那行是二进制控件。两者的差别在于:类型库缺了,编译时直接报“用户定义类型未定义”;控件缺了,打开窗体时提示“该部件的许可证信息未找到”或者窗体上的控件直接变成空白方块。所以先看这两行,再去装环境,能省掉一半的排查时间。

对应的目录大致是这样组织的,老项目基本跳不出这个套路:

SalesSystem/ ├─ SalesSystem.vbp 工程文件 ├─ frm/ 业务窗体:登录、主界面(MDI)、开单、客户、商品、报表 ├─ bas/ 标准模块:modDB(连接) modPub(公共函数) modPrint(打印) ├─ cls/ 类模块:clsUser(当前登录用户) clsBill(单据号) ├─ report/ DataReport 报表定义 ├─ db/ 建库脚本 SalesDB.sql └─ config.ini 服务器地址、库名、账号

把连接参数抽到config.ini而不是写死在代码里,是这个项目做得比较讲究的一点。因为 VB6 编译出来是 EXE,连接串一改就得重新编译、重新分发,运维成本很高;放到外部文件里,现场改一个记事本就能换库。

2.2 运行环境三件套:VB6 IDE、SQL Server、ADO

要把它跑起来,需要三样东西同时到位,缺一个都过不去。

第一是 Visual Basic 6.0 企业版(含 SP6 补丁)。社区版不带一些企业级控件,打开工程可能直接提示缺少引用。装完之后建议把MSCOMCTL.OCX、MSCOMCT2.OCX、COMCT332.OCX这三个常见控件用管理员权限注册一遍:

regsvr32 "C:\Windows\SysWOW64\MSCOMCTL.OCX" regsvr32 "C:\Windows\SysWOW64\MSCOMCT2.OCX"

注意 64 位系统上要用SysWOW64目录,VB6 是 32 位程序,注册错目录等于没注册。

第二是 SQL Server。这套代码通常按 SQL Server 2005/2008 写的语法,在 2012 以上版本跑,只要把数据库的“兼容级别”设成 100(SQL Server 2008)基本不会出问题:

ALTER DATABASE SalesDB SET COMPATIBILITY_LEVEL = 100;

第三是 ADO。Win10/Win11 自带 MDAC,msado28.tlb是有的,但工程文件里引用的是 2.5 版本,实际加载时会自动向上兼容。如果你那边报“找不到可安装的 ISAM”之类,多半是 Provider 名字写错了,跟 ADO 版本没关系。

提示:VB6 的 IDE 在 Win10 以上系统里,右键“以管理员身份运行”能规避一部分注册表和控件加载的权限问题,尤其是打开窗体就崩溃的时候,先试这一招。

2.3 第一次跑起来要改的三处配置

很多人拿到源码包,直接双击.vbp然后 F5,结果卡在登录页连不上库。按顺序改三处就行。

第一处是config.ini。里面一般长这样:

[DB] Server=127.0.0.1 DBName=SalesDB User=sa Pwd=123456

如果 SQL Server 是默认实例,Server写 IP 或机器名都行;如果是命名实例(安装时起了名字),必须写成机器名\实例名,只写机器名连不上——这是新手最容易翻车的一点。

第二处是先把库建出来。db/SalesDB.sql里通常包含建库、建表、建视图、建存储过程和初始化管理员账号,直接在 SSMS 里整段执行即可。执行前确认脚本里没有硬编码的物理文件路径,老脚本经常写着D:\Data\SalesDB.mdf,你机器上没这个目录就报错。

第三处是连接封装。这类项目几乎都会有一个modDB.bas,全工程只从这里拿连接,不会到处New Connection:

' modDB.bas —— 全工程唯一的数据库入口 Public gConn As ADODB.Connection Public gUserID As Long Public gUserName As String Public gUserLevel As Integer ' 1 操作员 2 主管 9 管理员 Public Function OpenDB() As Boolean Dim sErr As String On Error GoTo ErrHandler Set gConn = New ADODB.Connection gConn.ConnectionTimeout = 15 ' 连接超时,秒 gConn.CommandTimeout = 30 ' 命令超时,报表查询要放宽 gConn.CursorLocation = adUseClient ' 客户端游标,配合 DataGrid 断开绑定 gConn.Open GetConnString OpenDB = True Exit Function ErrHandler: MsgBox "数据库连接失败:" & Err.Description, vbCritical, "系统提示" OpenDB = False End Function Private Function GetConnString() As String ' 用 SQLOLEDB,别用 SQLNCLI,老机器上不一定装了 Native Client GetConnString = "Provider=SQLOLEDB.1;Persist Security Info=False;" & _ "User ID=" & ReadIni("DB", "User", "sa") & ";" & _ "Password=" & ReadIni("DB", "Pwd", "") & ";" & _ "Initial Catalog=" & ReadIni("DB", "DBName", "SalesDB") & ";" & _ "Data Source=" & ReadIni("DB", "Server", "127.0.0.1") End Function

CursorLocation = adUseClient这行是有讲究的。设成客户端游标之后,记录集是“断开式”的,DataGrid、MSFlexGrid绑定完可以直接断开连接,不占用服务器游标;代价是数据量大的时候内存吃得多。销售系统单表几千条记录,这个取舍是划算的。

CommandTimeout = 30也别省。默认 30 秒看起来够,但月度汇总报表在低配机器上跑超时的时候,报的是“超时已过期”,那个错误信息很有迷惑性,会让人以为是网络问题。

3. 数据库设计:六张表撑起整条销售链路

VB6 时代的系统有个特点——业务逻辑大量写在窗体的按钮事件里,数据库这边反而很朴素,主要是表、视图和少量存储过程。所以读懂表结构,基本就读懂了 80% 的业务。

3.1 核心表与字段语义

一套完整的销售管理,最少要这六张表:用户、客户、商品、销售主表、销售明细、库存流水。

表名作用关键字段
t_User登录与权限UserID、UserName、Password、UserLevel、Status
t_Customer客户档案CustID、CustName、Phone、CreditLimit、Balance
t_Product商品档案ProdID、ProdName、Spec、Unit、SalePrice、StockQty
t_SaleMaster销售单头BillNo、BillDate、CustID、SaleMan、TotalAmt、RealAmt、Status
t_SaleDetail销售单明细BillNo、RowNo、ProdID、Qty、Price、Amount
t_Stock出入库流水InOutNo、BillNo、ProdID、Qty、Direction、InOutDate

主表和明细用BillNo关联,明细表的主键是BillNo + RowNo的组合,RowNo从 1 开始自增,保证一张单里同一商品可以分多行录入不同批次。这个设计比单一自增主键更贴合单据的语义,缺点是插入时要自己算行号。

金额字段的选型值得单说。老项目里经常能看到float存金额,那是个定时炸弹——0.1 + 0.2算出来是0.30000000000000004,单据金额合不上,财务一对账就开始扯皮。正确做法是用decimal(18,2),或者在 VB6 侧统一用Currency类型(内部是 64 位定点整数,四位小数),别用Double。

CREATE TABLE t_SaleMaster ( BillNo VARCHAR(20) NOT NULL PRIMARY KEY, BillDate DATETIME NOT NULL, CustID VARCHAR(20) NOT NULL, SaleMan VARCHAR(20) NULL, TotalAmt DECIMAL(18,2) NOT NULL DEFAULT 0, -- 折前金额 Discount DECIMAL(5,4) NOT NULL DEFAULT 1, -- 折扣率 0.9500 RealAmt DECIMAL(18,2) NOT NULL DEFAULT 0, -- 折后应收 Status TINYINT NOT NULL DEFAULT 1, -- 1 已保存 2 已审核 9 已作废 CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE t_SaleDetail ( BillNo VARCHAR(20) NOT NULL, RowNo INT NOT NULL, ProdID VARCHAR(20) NOT NULL, Qty DECIMAL(18,3) NOT NULL, Price DECIMAL(18,4) NOT NULL, Amount DECIMAL(18,2) NOT NULL, CONSTRAINT PK_SaleDetail PRIMARY KEY (BillNo, RowNo) );

Qty给到三位小数是为散装商品留的口子,比如按公斤卖的货;Price给四位小数,是给批发折扣留的精度。这两个位数不是随便定的,改动之前先问一遍业务那边是不是真有散装和阶梯价。

3.2 单据号生成:一个存储过程解决并发

单据号形如XS202405150001,前缀 + 日期 + 流水号。这个需求看着简单,实际很容易翻车:两个人同时点“新建”,查出同一个最大号,然后主键冲突。

合格的做法是让数据库来发号,而不是在 VB 里SELECT MAX(BillNo)加一。用一张发号表配合UPDLOCK, HOLDLOCK把行锁住,取号过程串行化:

CREATE PROCEDURE sp_GetNextBillNo @Prefix CHAR(2), @NewNo VARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON DECLARE @Today CHAR(8), @Seq INT SET @Today = CONVERT(CHAR(8), GETDATE(), 112) -- yyyymmdd BEGIN TRAN -- 用 UPDLOCK + HOLDLOCK 把这一行锁住直到事务结束,防止并发取到同号 SELECT @Seq = ISNULL(Seq, 0) FROM t_BillNo WITH (UPDLOCK, HOLDLOCK) WHERE Prefix = @Prefix AND BillDate = @Today AND Id = 1 IF @@ROWCOUNT = 0 BEGIN SET @Seq = 0 INSERT INTO t_BillNo(Prefix, BillDate, Seq, Id) VALUES(@Prefix, @Today, 0, 1) END SET @Seq = @Seq + 1 UPDATE t_BillNo SET Seq = @Seq WHERE Prefix = @Prefix AND BillDate = @Today AND Id = 1 COMMIT TRAN SET @NewNo = @Prefix + @Today + RIGHT('0000' + CAST(@Seq AS VARCHAR(4)), 4) END

WITH (UPDLOCK, HOLDLOCK)是关键。只加UPDLOCK能防住写冲突,但挡不住幻读——第一次查询时行还不存在,两个连接都会走到INSERT分支。加了HOLDLOCK(等价于SERIALIZABLE的范围锁)才能把“不存在”这个状态也锁住。

发号必须在开事务之前单独调用,不要塞进保存单据的大事务里。原因很实际:号取到了但单据没保存成功,会留下一个空号;反过来,如果号和保存放在同一个事务里,回滚之后号也被回滚,并发下反而更容易撞。空号业务上无所谓,撞号是硬伤。

3.3 库存扣减:把判断写进 WHERE 里

库存扣减最常见的写法是“先查库存、再判断够不够、最后更新”,三步分开。这个写法在单机版上没问题,一旦并发就超卖。

比较稳的写法是把判断条件塞进UPDATE的WHERE子句,让数据库在一条语句里完成“判断 + 扣减”,再看影响行数:

UPDATE t_Product SET StockQty = StockQty - @Qty WHERE ProdID = @ProdID AND StockQty >= @Qty; -- 库存不足时这条更新影响 0 行 IF @@ROWCOUNT = 0 BEGIN RAISERROR('商品 %s 库存不足,请检查', 16, 1, @ProdID) RETURN -1 END

UPDATE ... WHERE StockQty >= @Qty在READ COMMITTED隔离级别下会持有行锁,其他会话的同一行更新会等待,天然串行。VB 侧只需要判断存储过程返回值和Err对象,不用自己做锁。

同时要往t_Stock插一条Direction = -1的流水。流水表只增不改,是事后对账的唯一依据。商品表上的StockQty本质上是流水表的物化汇总,速度换一致性,出了问题可以用流水重算。

4. 核心模块代码:登录、开单、报表三段主链路

窗体和控件摆得再花哨,销售系统的骨架就三段:验证身份、写单据、出报表。把这三段读透,剩下的客户档案、商品档案都是增删改查的复制粘贴。

4.1 登录:参数化命令和权限分级

VB6 里拼接 SQL 是通病,"WHERE UserName='" & txtUser.Text & "'"这种写法满大街都是。登录框尤其危险,' or '1'='1直接进系统,后面再挂个; DROP TABLE就热闹了。

用ADODB.Command加参数,顺便把密码散列一次:

Private Sub cmdLogin_Click() Dim cmd As ADODB.Command Dim rs As ADODB.Recordset Dim sPwd As String If Len(Trim$(txtUser.Text)) = 0 Then MsgBox "请输入用户名", vbExclamation, "提示" txtUser.SetFocus Exit Sub End If ' 数据库里存的是 MD5 后的 32 位串,输入侧同样处理再比对 sPwd = MD5(Trim$(txtPwd.Text)) Set cmd = New ADODB.Command Set cmd.ActiveConnection = gConn cmd.CommandType = adCmdText cmd.CommandText = "SELECT UserID, UserName, UserLevel FROM t_User " & _ "WHERE UserName = ? AND Password = ? AND Status = 1" ' 参数顺序必须和 SQL 里的 ? 一一对应,错位不会报错,只是查不出数据 cmd.Parameters.Append cmd.CreateParameter("u", adVarWChar, adParamInput, 30, Trim$(txtUser.Text)) cmd.Parameters.Append cmd.CreateParameter("p", adVarWChar, adParamInput, 32, sPwd) Set rs = cmd.Execute If rs.EOF Then mFailCount = mFailCount + 1 MsgBox "用户名或密码不正确", vbCritical, "登录失败" If mFailCount >= 3 Then Unload Me ' 连续三次退出程序 txtPwd.Text = "" txtPwd.SetFocus Exit Sub End If gUserID = rs("UserID") gUserName = rs("UserName") gUserLevel = rs("UserLevel") Unload Me frmMain.Show End Sub

adVarWChar配nvarchar,长度参数写 30 和 32,不写也行但会退化成变长,无所谓。这段代码有个隐蔽的坑:CreateParameter的顺序必须和 SQL 中?出现的顺序严格一致,顺序错了不会抛异常,只会安静地查不到记录,然后你以为是密码错了。血泪经验,调试登录的时候先在 SSMS 里单独跑一遍带参数的查询。

权限分级落在gUserLevel这个全局变量上,主窗体加载时按级别禁用菜单:

Private Sub Form_Load() ' 操作员只能开单和查询,主管能作废,管理员才有价格修改权 mnuPriceEdit.Enabled = (gUserLevel >= 9) mnuBillCancel.Enabled = (gUserLevel >= 2) mnuUserManage.Enabled = (gUserLevel >= 9) StatusBar1.Panels(1).Text = "操作员:" & gUserName StatusBar1.Panels(2).Text = Format$(Now, "yyyy-mm-dd hh:nn:ss") End Sub

菜单禁用只是“防君子”,真正的校验必须在保存前再做一次。老系统里经常能看到只在前端禁用菜单、后台毫无校验的写法,用另一个账号打开同一张单据就能改价。

4.2 销售开单:主表加明细的批量写入

开单是整个系统的心脏。用户在界面上录一行行明细,MSFlexGrid里可能十几行,保存时要一次性写进主表和明细表,中途失败必须整体回滚。

' 保存销售单:主表 + 明细 + 库存,一个事务到底 Public Function SaveSale(ByVal sCustID As String, ByVal dBillDate As Date, _ ByVal curDiscount As Currency, _ ByRef arrProd As Variant, ByRef arrQty As Variant, _ ByRef arrPrice As Variant) As String Dim cmd As ADODB.Command Dim i As Long, lngRows As Long Dim sBillNo As String Dim curTotal As Currency, curReal As Currency On Error GoTo ErrHandler lngRows = UBound(arrProd) ' 先在事务外取号,避免发号行锁被大事务拖住 sBillNo = GetNextBillNo("XS") If Len(sBillNo) = 0 Then Exit Function gConn.BeginTrans ' 1) 主表 Set cmd = New ADODB.Command Set cmd.ActiveConnection = gConn cmd.CommandText = "INSERT INTO t_SaleMaster(BillNo,BillDate,CustID,SaleMan," & _ "TotalAmt,Discount,RealAmt,Status) VALUES(?,?,?,?,?,?,?,1)" cmd.Parameters.Append cmd.CreateParameter("b", adVarChar, adParamInput, 20, sBillNo) cmd.Parameters.Append cmd.CreateParameter("d", adDBTimeStamp, adParamInput, , dBillDate) cmd.Parameters.Append cmd.CreateParameter("c", adVarChar, adParamInput, 20, sCustID) cmd.Parameters.Append cmd.CreateParameter("s", adVarChar, adParamInput, 20, gUserName) cmd.Parameters.Append cmd.CreateParameter("t", adCurrency, adParamInput, , 0) cmd.Parameters.Append cmd.CreateParameter("dc", adDouble, adParamInput, , CDbl(curDiscount)) cmd.Parameters.Append cmd.CreateParameter("r", adCurrency, adParamInput, , 0) cmd.Execute ' 2) 明细逐行插入,单价和数量来自 MSFlexGrid For i = 1 To lngRows If Val(arrQty(i)) <= 0 Then GoTo NextRow ' 空行跳过 Set cmd = New ADODB.Command Set cmd.ActiveConnection = gConn cmd.CommandText = "INSERT INTO t_SaleDetail(BillNo,RowNo,ProdID,Qty,Price,Amount) " & _ "VALUES(?,?,?,?,?,?)" cmd.Parameters.Append cmd.CreateParameter("b", adVarChar, adParamInput, 20, sBillNo) cmd.Parameters.Append cmd.CreateParameter("r", adInteger, adParamInput, , i) cmd.Parameters.Append cmd.CreateParameter("p", adVarChar, adParamInput, 20, arrProd(i)) cmd.Parameters.Append cmd.CreateParameter("q", adDecimal, adParamInput, , CDec(arrQty(i))) cmd.Parameters.Append cmd.CreateParameter("pr", adDecimal, adParamInput, , CDec(arrPrice(i))) cmd.Parameters.Append cmd.CreateParameter("a", adCurrency, adParamInput, , _ CCur(arrQty(i) * arrPrice(i))) cmd.Execute curTotal = curTotal + CCur(arrQty(i) * arrPrice(i)) ' 3) 扣库存,库存不足直接抛错走回滚 If DeductStock(arrProd(i), CDec(arrQty(i)), sBillNo) < 0 Then Err.Raise vbObjectError + 1001, , "商品 " & arrProd(i) & " 库存不足" End If NextRow: Next i ' 4) 回填主表金额 curReal = curTotal * curDiscount gConn.Execute "UPDATE t_SaleMaster SET TotalAmt=" & curTotal & _ ", RealAmt=" & curReal & " WHERE BillNo='" & sBillNo & "'" gConn.CommitTrans SaveSale = sBillNo Exit Function ErrHandler: gConn.RollbackTrans MsgBox "保存失败:" & Err.Description, vbCritical, "错误" SaveSale = "" End Function

几个参数得单独说明。adCurrency对应DECIMAL(18,2)有点勉强,实际走的是四舍五入到 4 位,所以Amount计算统一用CCur()包裹,避免Double的二进制误差渗进来。adDecimal需要显式设置Precision和NumericScale才能正确传值,偷懒用adDouble也能跑,但三位小数的数量会偶尔出现1.9999999,然后库存就变成负数。

DeductStock返回 -1 时用Err.Raise主动抛错,是为了让整段代码走ErrHandler的RollbackTrans。VB6 没有try...catch,On Error GoTo就是全部的异常机制,所有资源清理都必须写在ErrHandler里。忘了RollbackTrans的后果是连接一直被事务占着,表现为“程序越用越慢,最后提示超时”。

4.3 查询与报表:DataGrid 绑定和 DataReport 打印

单据查询页面一般是DTPicker选日期区间,点查询,DataGrid出结果。绑定的写法很直白:

Private Sub cmdQuery_Click() Dim rs As ADODB.Recordset Dim sSQL As String sSQL = "SELECT m.BillNo, m.BillDate, c.CustName, m.TotalAmt, m.RealAmt, " & _ " CASE m.Status WHEN 1 THEN '已保存' WHEN 2 THEN '已审核' " & _ " WHEN 9 THEN '已作废' END AS StatusName " & _ " FROM t_SaleMaster m LEFT JOIN t_Customer c ON m.CustID = c.CustID " & _ " WHERE m.BillDate >= ? AND m.BillDate < ? " & _ " ORDER BY m.BillDate DESC, m.BillNo DESC" Set rs = New ADODB.Recordset rs.CursorLocation = adUseClient rs.Open sSQL, gConn, adOpenStatic, adLockReadOnly Set grdSale.DataSource = rs ' 绑定后记录集要留在模块级变量里,不能是局部变量 Set grdSale.DataSource = rs End Sub

这里有个坑值得说:rs如果声明成Dim rs As New ADODB.Recordset并且在过程内被释放,DataGrid会立刻变空白,因为它持有的是引用而不是数据副本。必须声明成模块级变量或者Static。

日期区间用>= ? AND < ?而不是BETWEEN,是为了把当天 23:59:59 之后的数据也算进来。BillDate是DATETIME带时分秒的,用BETWEEN '2024-05-01' AND '2024-05-31'会漏掉 31 号下午的单据,这个 bug 在月末对账时才会暴露,排查起来相当费劲。

报表输出走DataReport,绑定一个DataEnvironment的 Command 对象。如果原项目用的是水晶报表,那还需要额外的运行库;这种情况我一般建议直接把报表改成导出 Excel,用Excel.Application自动化,现场部署省事得多,客户也更认 Excel。

5. 排查:连接失败、控件缺失、报错 91 挨个拆

VB6 项目的报错信息普遍写得含糊,同一个“对象变量或 With 块变量未设置”能对应十几种原因。下面这几条是我实际接手时踩过的,按现象、原因、解决的顺序记下来,遇到类似的可以直接对号入座。

5.1 启动阶段:连不上库和控件加载失败

坑一:打开工程就提示“找不到工程或库”。现象是 VB6 打开.vbp后弹窗,引用列表里有一项被标成“缺失”。原因通常是引用的是绝对路径下的类型库,换台机器路径变了,或者引用了某个第三方控件(比如某些报表控件)但没装。解决方式是打开“工程 → 引用”,把标 MISSING 的那一项取消勾选,然后重新勾选一个系统里已有的相近版本;如果缺的是控件而不是类型库,去“工程 → 部件”里重新勾。实在找不到,把用到该控件的窗体先移除出工程,业务代码还能编译通过。

坑二:登录报“SQL Server 不存在或访问被拒绝”。现象是gConn.Open直接抛错。原因有三个层次:SQL Server 服务没启动、TCP/IP 协议没启用、连接串里的实例名写错。解决顺序是先在服务管理器确认SQL Server (MSSQLSERVER)在跑,再打开配置管理器把 TCP/IP 启用并重启服务,最后用telnet 127.0.0.1 1433验证端口通不通。端口通了还连不上,那就是混合验证模式没开——sa账号在仅 Windows 验证模式下是被禁用的,得在 SSMS 里把服务器属性改成“SQL Server 和 Windows 身份验证模式”。

5.2 运行阶段:空对象和日期格式

坑三:点开单按钮,报错 91“对象变量或 With 块变量未设置”。现象是程序能登录,一进业务窗体就崩。原因八成是窗体加载时引用了gConn,但登录之后某些代码路径把连接关了,或者窗体是直接被New出来而不是走frmMain的菜单打开,跳过了初始化。解决办法是在每个业务窗体的Form_Load里加一句防御:

Private Sub Form_Load() If gConn Is Nothing Then If Not OpenDB() Then Unload Me: Exit Sub ElseIf gConn.State = adStateClosed Then If Not OpenDB() Then Unload Me: Exit Sub End If ' 正常初始化 End Sub

顺带说一个排查技巧:VB6 里按 F8 单步执行,配合“本地窗口”看对象是不是Nothing,比到处插MsgBox干净得多。

坑四:查询结果比实际少一批数据。现象是同样的日期范围,SSMS 里查出 50 条,程序里只有 47 条。原因是日期字符串传进 SQL 时被按dd/mm/yyyy解析了。VB6 的Format$受区域设置影响,Format$(d, "yyyy-mm-dd")在西语环境下可能变成dd-mm-yyyy,SQL Server 又有自己的语言设置,两边一错位就丢数据。稳妥做法是拼接 SQL 时统一用CONVERT:

WHERE BillDate >= CONVERT(DATETIME, '2024-05-01', 120) AND BillDate < CONVERT(DATETIME, '2024-06-01', 120)

120是yyyy-mm-dd hh:mi:ss的样式码,跟服务器语言设置无关。更彻底的办法是全程用ADODB.Command传adDBTimeStamp参数,让驱动去做类型转换。

5.3 数据层:金额精度和事务没释放

坑五:月度汇总金额和明细对不上,差几分钱。现象是单据逐条相加和汇总报表差 0.01 到 0.03。原因基本确定是float或Double参与了累加。VB6 的Double是二进制浮点,0.1 + 0.2 <> 0.3,累加几百行之后误差就显性化了。解决办法是把所有金额相关的变量声明成Currency(后缀@或者显式As Currency),数据库字段是DECIMAL(18,2),中间不要用Val()转字符串再转数值。另外汇总 SQL 用SUM(CONVERT(DECIMAL(18,2), Amount))强制走定点运算,别让 SQL Server 自己推断类型。

顺带提醒一个连带问题:事务开了不提交,SQL Server 那边的连接会一直持有锁,表现是“开单越来越慢、后来直接超时”。查sys.dm_tran_active_transactions能看到挂着的事务,但根子在代码里每个BeginTrans之后必须有配对的CommitTrans或RollbackTrans,ErrHandler里的回滚不能省。

6. 二次开发:给开单流程加一道信用额度卡控

接手之后最常见的改造需求,就是给客户加信用额度——欠款超过额度就不让开单。这个改动不大,但能覆盖“加字段、改校验、动事务”这三个典型的二次开发动作。

先在t_Customer上加一个CreditLimit DECIMAL(18,2) DEFAULT 0字段,0 表示不控制额度。然后写一个校验函数,注意用参数化而不是拼串:

' 返回可欠额度,负数表示已超额 Public Function GetCreditLeft(ByVal sCustID As String) As Currency Dim cmd As ADODB.Command Dim rs As ADODB.Recordset Set cmd = New ADODB.Command Set cmd.ActiveConnection = gConn cmd.CommandText = "SELECT ISNULL(CreditLimit,0) AS Lmt, ISNULL(Balance,0) AS Bal " & _ "FROM t_Customer WHERE CustID = ?" cmd.Parameters.Append cmd.CreateParameter("c", adVarChar, adParamInput, 20, sCustID) Set rs = cmd.Execute If rs.EOF Then GetCreditLeft = 0 Else GetCreditLeft = CCur(rs("Lmt")) - CCur(rs("Bal")) End If End Function

把它插到SaveSale的BeginTrans之后、主表插入之前:

If GetCreditLeft(sCustID) < curReal Then gConn.RollbackTrans MsgBox "客户欠款已超信用额度,本单不能保存", vbExclamation, "额度控制" Exit Function End If

注意curReal在当前代码里是循环结束后才算出来的,实际改造时得先在明细循环里累加出一个预估金额,再拿它去比。这一步很容易漏,只比curTotal不比折后金额,额度算出来会偏大。

验证方法分三步走:先在 SSMS 里把某个客户的CreditLimit改成 1000、Balance改成 900,然后开一张 200 的单,应该被拦住;再把单改成 50,应该放行;最后把CreditLimit改成 0,确认不控制额度时流程完全不受影响。第三步最关键,很多时候新加的校验把“不启用”这个分支给忘了,导致老客户全部开不了单。

要往外扩的话,还有两个方向值得做:一是把额度校验从“保存时拦截”提前到“录入时提示”,在MSFlexGrid的AfterColEdit事件里实时算,体验会好很多;二是加一张额度变更流水表,每次调整都留痕,审计的时候有据可查。

从那以后我每次接手这类老系统,都先把config.ini、建库脚本、连接封装这三样对一遍,再动业务代码——顺序反了,改到最后你会发现跑不起来,而锅不知道算谁的。希望帮到你。

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