简介:面向VB6初学者与数据库入门开发者的产品销售管理系统源码范例,围绕VB6.0与SQL Server 2000的协同开发展开,可用于学习窗体界面设计、数据库连接与增删改查等典型操作。压缩包共51个文件,约1.37MB,包含10个frm窗体与配套frx资源、3个ocx控件、bas模块、cls类模块、vbp工程文件以及SQL目录下的数据库脚本,另有报表设计文件与图片、图标等辅助素材,便于直接打开工程对照研究。已有441人学习,说明该示例具备一定参考价值。读者可从中了解产品信息录入、产品查询与打印、金额汇总及简单报表模块的实现思路,梳理MDI主窗体、登录窗体与业务窗体之间的组织关系,并借助现成数据库脚本快速还原运行环境,适合作为课程设计、毕业设计或自学SQL Server与VB交互的入门参考。对需要复现实验环境、理解经典桌面数据库应用分层与窗体协作的读者较友好。
1. 一份能直接编译的 VB6 销售管理系统源码,值不值得翻
接手一套 VB6 + SQL Server 的销售管理系统源码,第一反应往往是「这年头还有人用」。现实是不少门店、批发商的进销存还跑在上面:客户端几个 exe,服务端一个 SQL Server 实例,装完就能用,几年不宕机。这份 VB 源码-数据库实例打包的是一整套业务,不是只剩窗体的残缺 demo——开单、退货、库存、往来账、报表、权限都在,工程文件 .vbp 双击能打开、能编译,附带的数据库备份还原后就能登录。适合两类人:被派去「把那个 VB 程序改一下」的维护者,和想拿真实数据库实例练手、看清桌面端事务与报表到底怎么写的开发者。下面按工程骨架、数据访问、业务链路、跑通、改造的顺序拆。
2. 工程骨架拆解:.vbp 里的窗体、模块和类模块怎么分工
2.1 这套系统为什么会长成 VB6 加 SQL Server 的样子
那个年代的交付节奏决定的。客户要三个月上线,需求方说不出字段,只能边看界面边改;VB6 拖控件就能出画面,ADO 两行代码连上库,DataGrid 一绑就有列表,这是当时最快的路径。部署也简单:局域网里一台机器装 SQL Server,其余机器拷贝一个 exe 目录、注册几个 OCX 就能用,没有浏览器兼容问题,也没有前端构建。
选型留下的后果同样明显。没有 ORM,SQL 语句以字符串形式散落在窗体事件里;没有依赖注入和容器,全局变量靠标准模块的Public变量满天飞;连接对象往往只有一个,全局共享。好处是调试直观——哪里慢就在哪里Debug.Print,哪条 SQL 出错就把拼好的字符串MsgBox出来看,不用翻框架源码。
理解这一点很关键:读这类代码不能用现代分层项目的眼光去挑刺,得先接受它的组织方式,再找可以下手的缝。真正值得关注的是三件事——连接怎么管、事务边界划在哪、主从表怎么保持一。
2.2 解压后先看 .vbp:工程里到底装了什么
.vbp是工程文件,纯文本,用记事本就能打开。里面逐行列着这个工程包含哪些窗体、模块、类模块,引用了哪些 DLL 和 OCX,编译出来是 EXE 还是 ActiveX 组件。读它比读目录快得多,一眼就能看出项目规模和依赖。
| 文件类型 | 扩展名 | 作用 | 改动风险 |
|---|---|---|---|
| 工程文件 | .vbp / .vbw | 成员清单与引用列表;.vbw 是本地窗口状态 | .vbw 不要提交到版本库 |
| 窗体 | .frm / .frx | 界面与事件代码;.frx 存图标等二进制资源 | 高,容易改崩布局 |
| 标准模块 | .bas | 全局函数、全局变量、API 声明 | 中,改一处影响全局 |
| 类模块 | .cls | 业务对象,如订单、库存 | 低,适合新增 |
| 数据环境 / 报表 | .dsr | DataEnvironment 连接与 DataReport 版式 | 中,绑定关系易断 |
| 配置 | .ini | 连接串、单据前缀、打印机 | 低,部署时必改 |
| 数据库备份 | .bak | 表结构与基础数据 | 只读,先备份再还原 |
典型的目录长这样:
SalesSys\ ├─ SalesSys.vbp 工程文件,双击从这里进 ├─ config.ini 数据库连接、单据前缀、默认打印机 ├─ frm\ 界面层 │ ├─ frmLogin.frm │ ├─ frmMain.frm MDI 主窗体,菜单与权限入口 │ ├─ frmSaleOrder.frm 销售开单 │ └─ frmStockQuery.frm 库存查询 ├─ mod\ 标准模块 │ ├─ modDB.bas 连接、执行、事务封装 │ ├─ modPublic.bas 全局变量与工具函数 │ └─ modAPI.bas 读写 ini、注册表 ├─ cls\ 类模块 │ ├─ clsSaleOrder.cls │ └─ clsStock.cls ├─ rpt\ 报表 │ ├─ deSale.dsr DataEnvironment │ └─ rptSaleBill.dsr DataReport └─ db\SalesDB.bak 数据库备份拿到包先做一件事:把.vbw、*.log、编译产物MSSCCPRJ.SCC之类的本地文件挑出来单独放,剩下的才是干净的源码。这一步不做,后面接版本管理会很痛苦。
2.3 三层结构的土办法:界面层、业务层、数据访问层
这类项目嘴上不说分层,实际结构是清楚的:frm*是界面层,负责取值、校验、提示;cls*是业务层,负责一张单据的完整性;modDB.bas是数据访问层,负责连库和执行。问题在于历史代码经常越界——窗体按钮里直接拼一条INSERT,校验和入库混在一起。
我一般不对老代码做大手术,只立一条规矩:新增功能一律走类模块,老代码保持原样。判断标准很简单,看窗体里有没有gConn.Execute。有,就是历史包袱;没有,说明还在轨道上。这条规矩看着软弱,但能让项目在不大规模重构的前提下慢慢收口,回头想给订单加个折扣审批、给库存加个批次,直接扩clsSaleOrder就行,不用去翻三十个窗体。
3. 数据访问层:ADO 连接、参数化查询与事务边界
3.1 库表设计:主从表加流水表,账才对得上
销售类系统的表结构基本收敛,差别只在字段名。核心是四组表:客户、商品、单据主从表、库存流水。单据用「主表存头、明细存行」的经典结构,主表一条记录对应一张单,明细一个商品一行,靠OrderID关联。
| 表名 | 作用 | 关键字段 |
|---|---|---|
| Customer | 客户档案与信用额度 | CustID、CustName、CreditLimit、Balance |
| Product | 商品档案与当前库存 | ProdID、ProdName、Spec、SalePrice、StockQty |
| SaleOrder | 销售单主表 | OrderID、OrderNo、CustID、OrderDate、TotalAmount、Status |
| SaleOrderDetail | 销售单明细 | DetailID、OrderID、ProdID、Qty、Price、Amount |
| StockLog | 库存流水,只增不改 | LogID、ProdID、ChangeQty、BizType、BizNo、LogTime |
| SysUser / SysRole | 用户与角色权限 | UserID、UserName、PwdHash、RoleID |
StockLog这张流水表值得单独说。很多简化版只改Product.StockQty,看着省事,一旦对不上账就没有后悔药——不知道是哪张单扣错了、哪次退货没回。流水表只增不改,每条记录带业务类型和业务单号,任何时候都能把某个商品的库存从零重算一遍。常见做法是每次变动同时写Product.StockQty和StockLog,前者给查询用,后者给对账用。
字段类型上有两个老坑:金额一律用decimal(18,2)或money,别用float,浮点误差在合计上会放大成一条对不上的账;日期用datetime,VB6 侧对应adDBTimeStamp,别把日期当字符串传,区域设置一变就翻车。
3.2 连接从哪来:modDB.bas 的三个关键点
全局连接放在标准模块里,程序启动时建一次,退出时销毁。连接串不写死在代码里,读同目录的config.ini,现场部署改配置就行,不用重新编译。
' mod\modDB.bas —— 全局连接与配置读取 Option Explicit Public gConn As ADODB.Connection Public gUserID As Long Public gUserName As String Public gRoleID As Long Private Declare Function GetPrivateProfileString Lib "kernel32" Alias "GetPrivateProfileStringA" _ (ByVal lpAppName As String, ByVal lpKeyName As String, ByVal lpDefault As String, _ ByVal lpReturnedString As String, ByVal nSize As Long, ByVal lpFileName As String) As Long Public Function OpenDB() As Boolean On Error GoTo ErrHandler Set gConn = New ADODB.Connection gConn.ConnectionTimeout = 10 gConn.CommandTimeout = 30 gConn.CursorLocation = adUseClient ' 断开式记录集,界面绑定后可以先关连接 gConn.Open GetConnString() OpenDB = True Exit Function ErrHandler: MsgBox "数据库连接失败:" & vbCrLf & Err.Description, vbCritical, "提示" OpenDB = False End Function Private Function GetConnString() As String Dim sIni As String sIni = App.Path & "\config.ini" GetConnString = "Provider=SQLOLEDB.1;Persist Security Info=False;" & _ "Data Source=" & ReadIni("DB", "Server", "127.0.0.1", sIni) & ";" & _ "Initial Catalog=" & ReadIni("DB", "Database", "SalesDB", sIni) & ";" & _ "User ID=" & ReadIni("DB", "User", "sa", sIni) & ";" & _ "Password=" & ReadIni("DB", "Pwd", "", sIni) & ";" End FunctionCursorLocation = adUseClient是这类项目里最容易被忽略的一行。设成客户端游标后,Recordset是断开式的,窗体绑定完可以直接把连接还回去,多个界面同时开着不会互相锁住。代价是大数据量时内存占用高,报表一次拉几万行就要改成服务端游标分批取。
App.Path有个经典毛病:程序装在盘符根目录或网络映射盘时,App.Path末尾会带反斜杠,拼接后变成C:\\config.ini。稳妥的写法是先判断最后一个字符再决定是否补斜杠。
3.3 事务边界:扣库存绝不能出负数
一张销售单要写主表、写明细、扣库存、记流水,四步要么全成要么全不成,所以事务必须包住整个保存过程。VB6 侧用BeginTrans / CommitTrans / RollbackTrans,配合On Error保证任何一步异常都回滚。
扣库存这条 SQL 是整套系统里最需要小心的一句:
' cls\clsStock.cls —— 按可用库存扣减,扣不动就返回 0 Public Function Reduce(ByVal lngProdID As Long, ByVal curQty As Currency, _ ByVal sBizType As String, ByVal sBizNo As String) As Long Dim cmd As ADODB.Command Dim lngRows As Long Set cmd = New ADODB.Command Set cmd.ActiveConnection = gConn cmd.CommandType = adCmdText ' 条件里带 StockQty >= ?,库存不足时更新 0 行,程序据此回滚 cmd.CommandText = "UPDATE Product SET StockQty = StockQty - ? " & _ "WHERE ProdID = ? AND StockQty >= ?" cmd.Parameters.Append cmd.CreateParameter("@Qty", adCurrency, adParamInput, , curQty) cmd.Parameters.Append cmd.CreateParameter("@ProdID", adInteger, adParamInput, , lngProdID) cmd.Parameters.Append cmd.CreateParameter("@Qty2", adCurrency, adParamInput, , curQty) cmd.Execute lngRows, , adExecuteNoRecords ' lngRows 是实际影响行数 Reduce = lngRows End FunctionlngRows是这段代码的核心。库存够,更新 1 行返回 1;库存不够,WHERE不成立,更新 0 行返回 0。调用方拿到 0 就立刻RollbackTrans并提示「商品 X 库存不足」,不会有任何一条脏数据落库。
参数说明上有个细节要注意:adCurrency对应 SQL Server 的money,如果库里是decimal(18,2),用adNumeric更稳,精度设置通过CreateParameter的NumericScale传,否则四舍五入的位置可能和预期差一位。
单据号生成同样有并发问题。SELECT MAX(OrderNo)+1在两个人同时开单时会拿到同一个号,然后主键冲突或者出现重号。常见做法是建一张序号表SysSeq(SeqName, BillDate, CurVal),在事务里用WITH (UPDLOCK, ROWLOCK)更新取值,锁到提交为止。这套写法土,但在这个并发量级下比任何方案都可靠。
4. 核心链路实战:从销售开单到库存扣减再到报表出数
4.1 开单窗体:明细行怎么算钱
开单界面通常是「上头选客户和日期,下头一个表格录商品」。表格用 MSHFlexGrid 或 DataGrid,用户录数量单价,行金额和合计实时算。计算必须用Currency,用Double会在合计上出现1234.5600000001这种尾巴。
' frm\frmSaleOrder.frm —— 明细行金额与合计重算 Private Sub RecalcTotal() Dim i As Long Dim curRow As Currency, curSum As Currency Dim curDiscount As Currency curSum = 0 With grdItems For i = 1 To .Rows - 1 ' 数量 × 单价,空行跳过;CCur 保证按货币精度四舍五入 If Len(Trim(.TextMatrix(i, 1))) > 0 Then curRow = CCur(Val(.TextMatrix(i, 3))) * CCur(Val(.TextMatrix(i, 4))) .TextMatrix(i, 5) = Format(curRow, "0.00") ' 行金额 curSum = curSum + curRow End If Next i End With curDiscount = CCur(Val(txtDiscount.Text)) ' 整单折扣 lblTotal.Caption = Format(curSum - curDiscount, "0.00") m_TotalAmount = curSum - curDiscount ' 留给保存时用 End SubTextMatrix(row, col)是 MSHFlexGrid 取单元格的写法,行列都从 0 开始,第 0 行第 0 列是表头。循环从 1 开始跳过表头,这个下标错一位就会把表头当数据算进去,是新手最常翻车的地方。
保存时把表头和明细分别装进自定义类型和Collection,交给业务层统一处理,界面层不出现一条 SQL。这样单元测试虽然不好写,但至少改保存逻辑时不用满工程搜INSERT INTO SaleOrder。
4.2 退货与库存回滚:反向流水,别删原记录
退货不是把原单删掉,也不是把明细数量改成负数,而是新开一张退货单,走一遍反向流程:主表插一条Status = 2的记录,明细照录,库存加回去,流水表记一条BizType = 'RT'的正数记录。
' cls\clsSaleOrder.cls —— 退货审核通过时回补库存 Public Function ReturnBack(ByVal lngOrderID As Long) As Boolean Dim rst As ADODB.Recordset Dim lngRows As Long On Error GoTo ErrHandler gConn.BeginTrans ' 逐条明细回补库存,条件里带订单号,避免重复回补 Set rst = gConn.Execute("SELECT ProdID, Qty FROM SaleOrderDetail WHERE OrderID = " & lngOrderID) Do Until rst.EOF lngRows = ExecNonQuery("UPDATE Product SET StockQty = StockQty + ? WHERE ProdID = ?", _ rst("Qty").Value, rst("ProdID").Value) If lngRows = 0 Then Err.Raise vbObjectError + 1001, , "商品不存在,回补失败" ExecNonQuery "INSERT INTO StockLog(ProdID,ChangeQty,BizType,BizNo,LogTime) " & _ "VALUES(?,?,'RT',?,GETDATE())", rst("ProdID").Value, rst("Qty").Value, CStr(lngOrderID) rst.MoveNext Loop rst.Close ExecNonQuery "UPDATE SaleOrder SET Status = 3 WHERE OrderID = ? AND Status = 1", lngOrderID gConn.CommitTrans ReturnBack = True Exit Function ErrHandler: gConn.RollbackTrans MsgBox "退货失败:" & Err.Description, vbCritical ReturnBack = False End FunctionUPDATE ... WHERE Status = 1这个附加条件是防止重复审核的保险。审核按钮点两下、或者两个人同时点,第二次更新影响 0 行,业务层据此判断「已经退过了」,直接返回。这类幂等控制在老系统里经常缺失,加上一行WHERE就能省掉很多对账的麻烦。
4.3 报表与打印:DataReport 绑定和导出
报表用 DataReport 设计器做,数据源挂 DataEnvironment。绑定关系在.dsr里是可视化配置的,改字段名要同步改三处:DataEnvironment 的命令文本、DataReport 的字段控件、代码里给DataReport传参的DataMember。三处漏一处,运行时就是一片空白,还不报错。
打印之外,导出 Excel 是现场提得最多的需求。用 Excel 的 COM 接口速度慢、版本兼容差,我一般直接写 CSV 或者用ADODB.Recordset的Save方法存成 XML 表格:
' mod\modExport.bas —— 把查询结果直接导出为 CSV Public Sub ExportToCsv(ByVal sSql As String, ByVal sFile As String) Dim rs As ADODB.Recordset Dim i As Long Dim sLine As String Dim f As Integer Set rs = gConn.Execute(sSql) If rs.EOF Then Exit Sub f = FreeFile Open sFile For Output As #f Do Until rs.EOF sLine = "" For i = 0 To rs.Fields.Count - 1 ' 字段里出现逗号或引号要转义,否则列会错位 sLine = sLine & """" & Replace(Nz(rs(i).Value), """", """""") & """," Next i Print #f, Left$(sLine, Len(sLine) - 1) rs.MoveNext Loop Close #f rs.Close End SubNz是自己写的空值兜底函数,Null拼进字符串会直接抛错。导出文件用 ANSI 编码,用 Excel 打开不乱码;如果现场用新版 Excel 打开中文乱码,改成带 BOM 的 UTF-8 输出即可,这个改动只有一行。
5. 跑通与排查:环境搭建五步走,以及五个必踩的坑
5.1 五步把环境搭起来
第一步,装 VB6。安装包里如果带 SP6,装完本体必须再打一次 SP6,否则某些控件和 ADO 版本对不上。第二步,注册控件。64 位系统上注册 32 位 OCX 要用 SysWOW64 目录下的 regsvr32,命令提示符也要用管理员身份打开。
:: 以管理员身份运行,注册销售系统依赖的三个常见控件 C:\Windows\SysWOW64\regsvr32.exe /s "C:\Windows\SysWOW64\MSADODC.OCX" C:\Windows\SysWOW64\regsvr32.exe /s "C:\Windows\SysWOW64\MSHFLXGD.OCX" C:\Windows\SysWOW64\regsvr32.exe /s "C:\Windows\SysWOW64\MSCOMCTL.OCX"第三步,还原数据库,还原后确认兼容级别和排序规则。第四步,改config.ini里的服务器地址、库名、账号。第五步,双击.vbp,按 F5 从工程启动,先登一次再编译成 exe。
5.2 还原数据库与改连接串
备份文件直接用管理工具还原,或者命令行还原。还原后先跑一条查询确认表和数据都在:
-- 确认核心表行数,顺便看一眼排序规则 SELECT 'Customer' AS Tbl, COUNT(*) AS Cnt FROM Customer UNION ALL SELECT 'Product', COUNT(*) FROM Product UNION ALL SELECT 'SaleOrder', COUNT(*) FROM SaleOrder; SELECT DATABASEPROPERTYEX('SalesDB', 'Collation') AS CollationName, compatibility_level FROM sys.databases WHERE name = 'SalesDB';排序规则必须是中文相关的,比如Chinese_PRC_CI_AS,否则中文姓名排序和模糊查询会出现奇怪的结果。兼容级别如果低于当前实例支持的最低值,datetime的部分函数会报错,往上调一档即可。
连接串改好后,建议先用一段独立的小脚本验证通不通,再进 VB6 里跑,能省掉一半的定位时间:
' 单独建一个标准 EXE 验证连接,通过了再动主工程 Dim c As New ADODB.Connection On Error Resume Next c.Open "Provider=SQLOLEDB.1;Data Source=192.168.1.10;Initial Catalog=SalesDB;User ID=sa;Password=***;" If Err.Number <> 0 Then MsgBox "连不上:" & Err.Description Else MsgBox "连接成功,服务器版本:" & c.Execute("SELECT @@VERSION")(0).Value End If5.3 五个必踩的坑:现象、原因、解决
1. VB6 安装程序卡在「正在更新系统文件」不动现象:进度条停在某个位置十几分钟没反应,硬盘灯不闪。 原因:安装程序要装一个早期的脚本运行库组件,新系统上兼容性差,或者在线校验超时。 解决:先用管理员身份运行兼容模式安装,仍卡住就手动装那个组件再重跑安装程序;临时断网也能绕过在线校验那一步。
2. 打开 .vbp 提示「找不到工程或库」现象:一进 IDE 就弹窗,关掉后窗体上的控件全变成红叉或者消失。 原因:工程引用的某个 OCX 或 DLL 没注册,或者版本号对不上。.vbp里Reference=那几行记录了带版本号的 GUID。 解决:打开「工程 - 引用」对话框,把标着 MISSING 的项取消勾选,重新注册对应控件后再勾回来。别直接删.vbp里的行,删了下次还报。
3. 登录成功但所有列表都是空白现象:查询条件都对,Recordset.RecordCount也是 0,数据库里明明有数据。 原因:游标位置设成服务端,Recordset在绑定前就被关闭;或者CursorLocation前后不一致,客户端和服务端混用。 解决:统一在连接打开时设adUseClient,Recordset用adOpenStatic + adLockBatchOptimistic,绑定完成后再关闭。
4. 库存出现负数,且对不上流水现象:某些商品StockQty小于 0,退货或盘点后差额补不回来。 原因:扣减语句没带StockQty >= ?条件,或者扣库存和写明细不在同一个事务里,中间失败只回滚了一半。 解决:扣减语句加库存判断并按影响行数判定成败;把主表、明细、库存在同一个BeginTrans内完成。已经出现负数的,用流水表从零重算一遍覆盖当前值。
5. 报表打印一片空白,或者提示找不到数据源现象:预览窗口能打开,内容区空白;或者 DataReport 报「DataMember 无效」。 原因:DataEnvironment 的连接在设计时指向了开发机,运行时没换成客户现场库;或者报表的DataMember名和命令名对不上。 解决:运行时用代码把 DataEnvironment 的连接对象替换成gConn,再设置DataReport.DataSource,不要依赖设计时绑定。
6. 二次开发:加字段、加模块、加权限的改造路线
接手维护三个月后,需求往往集中到三类:档案上加个字段、加一个业务模块、把权限管细一点。这三类改造我有一套固定套路,按顺序走能少踩坑。
加字段是最高频的。以给客户加「客户等级」为例,改造清单摊开是这样的:数据库ALTER TABLE Customer ADD CustLevel varchar(10) NULL;档案窗体的录入区加一个下拉框并在Load时绑定;列表查询的SELECT里带上新列;开单界面的客户下拉显示等级;报表如果需要,再补一列。五处漏一处,用户就会在某个界面上看不到这个字段,然后来问你是不是没改。所以我的习惯是先写清单再动手,改完对着清单逐条勾。
加模块要看它属于哪一层。如果是纯查询,直接新增一个frm*加mod里的查询函数就够;如果涉及单据和库存,就必须扩类模块,让新单据的保存走和销售单一样的事务模板。判断标准是「这次操作会不会动Product.StockQty」,会,就走事务,不会,就随意。跨模块的一致性比代码漂亮重要得多。
权限这块,老系统通常是「角色 - 菜单」两级,新增模块时在SysMenu插一条记录,再给角色授权,主窗体的Load里根据gRoleID遍历菜单决定 Visible。要注意的是隐藏菜单不等于没权限,按钮的事件里还得再判一次,否则用户用快捷键或者右键菜单照样能进去。
| 改造类型 | 必查清单 | 验证方法 |
|---|---|---|
| 加字段 | 建表脚本、录入窗体、列表查询、报表、导出 | 录入一条新数据,五个界面逐一确认 |
| 加模块 | 类模块、事务边界、菜单注册、角色授权 | 单据保存后查流水表与库存数是否一致 |
| 加权限 | 菜单表、主窗体装载、按钮事件二次判断 | 用低权限账号登录,试着用快捷键触发 |
验证方法上,我最常用的是 SQL 侧的语句追踪和 VB6 侧的Debug.Print双管齐下:把最终执行的 SQL 和参数打出来,同时在数据库端看实际执行到的是什么,两边一对,问题基本藏不住。事务类的问题尤其如此,回滚了但没提示、提示了但其实提交了,这种「黑匣子」现象光看代码是看不出来的。
血泪经验是:任何一次改库结构之前,先把db目录里那份备份另存一份带日期的副本,再动手。我在一个项目上因为漏了这一条,加字段时把一张历史表的默认值改了,三十多条旧单据的金额全部归零,最后靠前一天的备份才捞回来。从那以后我每次动ALTER之前都强制走一遍「备份 - 改脚本 - 验证 - 记录变更」的流程,哪怕只是加个NULL列。
这份源码最大的价值在于它的完整性:.vbp能编译、.bak能还原、业务链路是通的。照着第 5 章跑一遍,再按第 6 章的清单改一个小功能,基本就摸清这套东西的脾气了。希望帮到你。
本文还有配套的精品资源,点击获取