
简介面向VB6与SQL Server 2000入门学习者的产品销售管理系统源码包适合希望从完整工程中理解数据库连接、窗体交互与基础报表输出的开发者参考。压缩包共51个文件约1.37MB以frm窗体及frx资源、bas模块、cls类和vbp工程文件为主体并含ocx控件、SQL建库脚本、db数据库与界面图片等登录、产品管理、查询打印和报表设计器等模块均由源码组织呈现。已有442人学习作为教学范例具有一定参考面。读者可据此梳理工程加载与调试流程观察ADO访问SQL Server、控件绑定和金额汇总的写法并借助SQL脚本还原数据库环境报表设计器与表格控件相关文件也有助于理解打印输出和界面展示。整体代码量适中适合新手按模块阅读、改造与排错。1. 一套还能跑的 VB6 销售管理系统为什么会出现在你的待办里接手一套 VB6 SQL Server 销售管理系统完整源代码多数时候不是因为你突然想学 VB6而是因为某台装着 Windows XP 或 Win7 的机器上那套开单、对账、扣库存的流程还在天天跑而当初写它的人已经调岗了。这份 VB 源码实例给的是一个能编译、能连库、能开单的完整闭环登录与角色权限、商品与客户档案、销售开单与退货、库存联动、应收对账外加几张固定格式的统计报表界面层和业务层全在 VB6 工程里数据落在 SQL Server。它适合三类人要接盘维护老系统的、拿它当数据库课程设计底稿的、以及想看清「桌面端 关系库」这套经典分层到底长什么样的。技术不新但闭环完整这一点比一堆跑不起来的半截 Demo 值钱得多。2. 工程结构与技术选型从 .vbp 到建库脚本的一份完整地图拿到一个 .rar 解压出来最怕的是满屏文件不知道从哪下手。这套代码的组织方式是很典型的 VB6 时代做法一个主工程 .vbp 挂所有窗体、模块和类模块数据库脚本单独放一个目录报表和打印逻辑散在几个公共模块里。先把地图画出来后面改哪一行心里才有数。2.1 先读 .vbp再看任何一个 frm.vbp 是纯文本文件用记事本直接打开就能看它记录了这个工程引用了哪些组件库、包含哪些文件、启动对象是哪个窗体。很多人一上来就双击 frm 看界面结果 VB6 报一堆「对象库未注册」其实是依赖没装。# 解压后先看工程文件不要急着双击打开 IDE SalesSystem/ ├── Sales.vbp # 主工程引用清单 文件清单 启动对象 ├── Sales.vbw # 工作区记录上次各窗体窗口位置可删 ├── Forms/ # 全部 frm 窗体 │ ├── frmLogin.frm # 登录 │ ├── frmMain.frm # 主框架 菜单 │ ├── frmBill.frm # 销售开单主从表录入 │ ├── frmGoods.frm # 商品档案 │ └── frmReport.frm # 报表查询条件 ├── Modules/ │ ├── modDB.bas # 连接、执行 SQL、事务封装 │ └── modPub.bas # 公共变量、格式化、权限判断 ├── Classes/ │ └── clsBill.cls # 单据实体负责校验和保存 ├── Reports/ # DataReport / Crystal 报表文件 ├── DB/ │ ├── CreateDB.sql # 建库建表 │ └── InitData.sql # 初始化角色、管理员、基础字典 └── Setup/ # 打包脚本与依赖清单这个结构里最值得先看的是 modDB.bas 和 clsBill.cls 两个文件前者决定了整个系统怎么跟库说话后者决定了业务规则写在哪。Forms 目录里的窗体再多本质都是「收集控件值 → 调 modDB → 刷新列表」这个套路看懂一个就等于看懂一半。工程引用一般离不开三样Microsoft ActiveX Data Objects 2.xADO、Microsoft Windows Common Controls 6.0 (SP6)提供 ListView、TreeView、StatusBar、以及报表控件。前两个是必须的第三个看你用不用自带报表。如果打开 .vbp 时提示某个 OCX 找不到去系统目录注册一下# 以管理员身份运行 cmd路径按实际系统位数调整 regsvr32 C:\Windows\SysWOW64\MSCOMCTL.OCX regsvr32 C:\Windows\SysWOW64\MSCOMCT2.OCX这里的 SysWOW64 是 64 位系统上存放 32 位组件的目录VB6 编译出来的是 32 位程序注册到 System32 反而会报「模块加载失败」。注册完重新打开 .vbp在「工程 → 引用」里把带「丢失」字样的勾去掉再重新勾上多数依赖问题到这一步就解决了。2.2 命名约定把责任边界划得很清楚老项目没有分层框架靠的是前缀约定。这套代码里 frm 开头是窗体、mod 开头是无状态公共过程、cls 开头是带状态的实体类看文件名基本能猜到它干什么。下面这张表是我梳理出来的主要模块职责二次开发时按这个找入口最省时间文件类型主要职责改动频率modDB.bas标准模块打开连接、执行 SQL、事务提交回滚低modPub.bas标准模块全局用户变量、权限判断、金额格式化中clsBill.cls类模块单据头 明细的校验与保存高frmBill.frm窗体开单界面负责主从表联动录入高frmGoods.frm窗体商品档案增删改查中frmReport.frm窗体报表条件收集与结果展示中约定带来的好处是改动可控加一个业务字段通常就是「表加列 → 实体类加属性 → 窗体加控件 → 保存语句加参数 → 报表查询加列」这一条线走完不会出现改了三处漏了第四处的情况。坏处是全局变量满天飞gUserID、gRoleID这类变量在 modPub 里声明成 Public任何窗体都能改调试时就得靠断点一点点追。2.3 建库脚本是整套系统的地基DB 目录下的 CreateDB.sql 一般按「先建库、再建字典表、再建业务表、最后建索引和外键」的顺序写。核心表就那么几张用户表、角色表、商品表、客户表、销售单主表、销售单明细表库存有的项目单独建 Stock 表有的图省事直接在商品表上挂一个 Stock 字段。后面这种在单仓库场景够用多仓库就得改。-- CreateDB.sql 节选销售单主表与明细表 CREATE TABLE SalesBill ( BillID INT IDENTITY(1,1) PRIMARY KEY, BillNo VARCHAR(20) NOT NULL UNIQUE, -- 单号按日期流水生成 BillDate DATETIME NOT NULL DEFAULT GETDATE(), CustomerID INT NOT NULL, TotalAmount DECIMAL(18,2) NOT NULL DEFAULT 0, Discount DECIMAL(18,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 0, -- 0草稿 1已审核 2已红冲 Operator VARCHAR(30) NOT NULL, Remark VARCHAR(200) NULL ); CREATE TABLE SalesBillDetail ( DetailID INT IDENTITY(1,1) PRIMARY KEY, BillID INT NOT NULL, GoodsID INT NOT NULL, Qty DECIMAL(18,3) NOT NULL, Price DECIMAL(18,2) NOT NULL, Amount AS (Qty * Price) PERSISTED -- 计算列省得每处都乘一遍 );金额字段一律用 DECIMAL 而不是 FLOAT这是血泪经验FLOAT 存 0.1 0.2 会出现 0.30000000000000004 这种尾巴汇总报表时几万行累加下来能差出几分钱财务对账时就是解释不清的玄学问题。Amount 用持久化计算列好处是数据库层面保证「数量 × 单价」永远一致坏处是改单价时得回头看有没有业务逻辑依赖这个列做二次打折。InitialData.sql 里会插入角色和管理员账号常见的默认账号密码是 admin/admin 或 admin/123第一次跑起来之后第一件事就是改掉顺手把密码字段的存储方式也确认一下——是明文还是哈希后面第 4 章会细说。3. 核心业务链路一张销售单从录入到库存扣减走了哪几步销售管理系统的骨架其实就一条主线谁能进系统、能开什么单、开完单库存怎么变、错了怎么退。把这四段走通剩下的档案维护、报表统计都是外围。这套代码的主线写得比较朴素但每一段都有值得抄的地方。3.1 登录与权限一个返回值决定菜单亮不亮登录窗体做两件事验证账号密码把用户 ID 和角色 ID 塞进全局变量。验证部分用的是参数化查询这在 VB6 项目里算难得的规范做法很多同类代码直接字符串拼接用户名里带个单引号就炸。 modPub.bas —— 登录校验返回 UserID失败返回 0 Public Function CheckLogin(ByVal sUser As String, ByVal sPwd As String) As Long Dim cmd As ADODB.Command Dim rs As ADODB.Recordset Set cmd New ADODB.Command Set cmd.ActiveConnection gCn cmd.CommandType adCmdText cmd.CommandText SELECT UserID, RoleID, UserName FROM SysUser _ WHERE UserName ? AND UserPwd ? AND IsEnabled 1 参数顺序必须和 SQL 里的问号顺序一致 cmd.Parameters.Append cmd.CreateParameter(u, adVarWChar, adParamInput, 30, sUser) cmd.Parameters.Append cmd.CreateParameter(p, adVarWChar, adParamInput, 64, HashPwd(sPwd)) Set rs cmd.Execute If Not rs.EOF Then gUserID rs(UserID) gRoleID rs(RoleID) gUserName rs(UserName) CheckLogin gUserID Else CheckLogin 0 End If rs.Close End FunctionadVarWChar对应 nvarchar中文用户名才不会变问号长度参数必须给足给 10 而实际传 20 个字符会被截断登录就会出现「密码明明对但登不进去」的怪事。HashPwd 是个简单包装把明文转成哈希再比对具体算法看项目里怎么实现的如果发现是明文存储改成「哈希 每人一个盐」是接手后优先级最高的改造。权限控制的做法很直接frmMain 加载时按 gRoleID 去查角色菜单表把没权限的菜单项 Enabled 设成 False。这种做法的漏洞是只要知道窗体名就能在别的入口调起来所以正规一点还会在每个业务窗体的 Form_Load 里再判一次。二次开发加新功能时两处都要加只加菜单不加窗体判断等于没做权限。3.2 开单主从表一个 DataGrid 撑起整个录入体验开单界面是这套系统里交互最复杂的部分上半部分填客户、日期、备注下半部分是一个可编辑的表格录明细每敲一次数量或单价要实时算出本行金额和整单合计。表格通常用 MSHFlexGrid 或 DataGrid 绑定一个断开连接的 Recordset改完一行再回写。 frmBill.frm —— 明细行变动后重算合计 Private Sub CalcTotal() Dim i As Integer Dim curLine As Currency, curTotal As Currency curTotal 0 With grdDetail For i 1 To .Rows - 1 第 0 行是表头从 1 开始 If Trim(.TextMatrix(i, 1)) Then curLine Val(.TextMatrix(i, 3)) * Val(.TextMatrix(i, 4)) 数量 * 单价 .TextMatrix(i, 5) Format(curLine, 0.00) curTotal curTotal curLine End If Next i End With lblTotal.Caption Format(curTotal - Val(txtDiscount.Text), 0.00) End Sub这段代码的逻辑是遍历表格所有有效行按「数量 × 单价」算出每行金额写回第 5 列再累加成合计最后减去整单折扣。用 Currency 而不是 Double是因为 Currency 是定点数累加几千行不会飘。Val 函数遇到空字符串返回 0所以空行不会报错但要注意它遇到「12abc」也会返回 12录入校验还是得单独做一轮。表格录入最容易翻车的地方是行数。默认 DataGrid 只有一个空行用户敲完第一行必须手动点「新增行」才能续录体验很差。常见的补法是监听 RowColChange发现焦点落在最后一行且该行有内容时自动AddItem一行空行出来。这个细节改完录单速度能差一倍。3.3 保存时库存怎么扣事务包住主表、明细和库存保存是整个链路里唯一必须用事务的地方。主表插入、明细批量插入、库存扣减三件事要么都成要么都不成中间断了就是脏数据。这套代码在 clsBill 里做了封装用的是 ADO 的显式事务。 clsBill.cls —— 保存单据全部成功才提交 Public Function Save() As Boolean Dim cn As ADODB.Connection Dim lngAffected As Long Dim i As Integer Set cn New ADODB.Connection cn.Open gConnString 事务要用独立连接别复用全局连接 cn.BeginTrans On Error GoTo Rollback 1) 写主表用 SCOPE_IDENTITY 拿回自增主键 cn.Execute INSERT INTO SalesBill(BillNo,BillDate,CustomerID,TotalAmount,Discount,Status,Operator) _ VALUES( m_BillNo , Format(m_BillDate, yyyy-mm-dd hh:nn:ss) , _ m_CustomerID , m_TotalAmount , m_Discount ,0, gUserName ), _ lngAffected, adExecuteNoRecords Dim rsID As ADODB.Recordset Set rsID cn.Execute(SELECT SCOPE_IDENTITY() AS NewID) m_BillID rsID(NewID) 2) 逐行写明细并扣库存扣减带条件防止扣成负数 For i 0 To m_Items.Count - 1 cn.Execute INSERT INTO SalesBillDetail(BillID,GoodsID,Qty,Price) VALUES( _ m_BillID , m_Items(i).GoodsID , _ m_Items(i).Qty , m_Items(i).Price ), lngAffected, adExecuteNoRecords cn.Execute UPDATE Goods SET Stock Stock - m_Items(i).Qty _ WHERE GoodsID m_Items(i).GoodsID _ AND Stock m_Items(i).Qty, lngAffected, adExecuteNoRecords If lngAffected 0 Then Err.Raise vbObjectError 1001, , 商品【 m_Items(i).GoodsName 】库存不足 End If Next i cn.CommitTrans Save True Exit Function Rollback: cn.RollbackTrans MsgBox 保存失败 Err.Description, vbCritical Save False End Function几个参数值得说清楚。adExecuteNoRecords告诉 ADO 这次执行不要返回记录集省内存也省时间第二个出参lngAffected拿到受影响行数是判断「库存够不够」的关键——UPDATE 的 WHERE 里带了Stock Qty库存不够时匹配不到行受影响行数为 0立刻抛错让事务回滚。这比「先 SELECT 查库存再判断再 UPDATE」靠谱得多后者在多人同时开单时必然出负库存。用独立连接cn而不是全局gCn也是必须的。全局连接可能被某个还没关闭的 Recordset 占着事务边界会莫名其妙地串到别的操作里去排查起来跟黑匣子一样。3.4 退货与红冲宁加一条负数单不改原单退货的处理方式分两派一派直接改原单状态和数量一派生成一张金额为负的红冲单。这套代码用的是后者Status字段里 2 表示已红冲原单不动新插入一张明细数量为负的单据库存反向加回去。这么做的好处是账目可追溯任何时候翻单据列表都能看到「哪天开了哪张红冲单」。坏处是所有统计 SQL 都必须考虑负数单SUM(Amount)会自动抵消但COUNT(*)数单据张数时会把红冲单也算进去报表解释时要注明。改造成直接改原单的话一定要有操作日志表兜底不然出了问题连后悔药都没有。红冲单的单号一般加个前缀或者后缀区分比如原单XS20240115001对应红冲单XS20240115001R避免和正常单号冲突。这个规则最好写死在生成单号的函数里别指望录入员手动改。4. 数据访问层拆解ADO 连接、参数化命令与报表统计VB6 连 SQL Server 有 DAO、RDO、ADO 三条路2000 年以后的项目基本都是 ADO这套代码也是。数据访问层薄得很一个 modDB.bas 加上各处零散的gCn.Execute谈不上什么设计模式但里面几个细节决定了系统在生产环境稳不稳。4.1 连接字符串与连接生命周期 modDB.bas —— 全局连接程序启动时开一次 Public gCn As ADODB.Connection Public gConnString As String Public Function OpenDB() As Boolean On Error GoTo ErrHandler 优先读配置文件别把密码硬编码在代码里 gConnString ProviderSQLOLEDB.1;Persist Security InfoFalse; _ User ID GetIni(DB, User, sa) ; _ Password GetIni(DB, Pwd, ) ; _ Initial CatalogSalesDB; _ Data Source GetIni(DB, Server, 127.0.0.1) Set gCn New ADODB.Connection gCn.CursorLocation adUseClient 客户端游标断开后 Recordset 仍可读 gCn.CommandTimeout 30 报表查询慢30 秒比较稳 gCn.Open OpenDB True Exit Function ErrHandler: MsgBox 数据库连接失败 Err.Description vbCrLf _ 请检查服务器名、实例名与网络, vbCritical OpenDB False End FunctionCursorLocation adUseClient这一行很关键。用客户端游标时Recordset 打开后数据会一次性拉到本地即使连接断了也能继续遍历这在绑定 DataGrid 做界面展示时特别省事代价是数据量大时内存吃得凶几十万行的报表就不适合这么干得改成服务端游标配分页。CommandTimeout默认是 30 秒但有些项目会把它设成 0无限等待一旦遇到锁表客户端就永远卡在那里用户以为死机了直接杀进程反而更糟。给个明确的上限超时报错比假死好。连接字符串里的 Server 字段是最容易翻车的地方。SQL Server 默认实例写机器名或 IP 就行命名实例必须写机器名\实例名中间是反斜杠不是点。如果客户端和服务器不在同一台机器还要确认 TCP 端口开着、SQL Server 配置管理器里 TCP/IP 协议已启用。4.2 参数化命令挡住引号也挡住注入前面登录那段已经用了 ADO Command 加 Parameters这是整套代码里最值得保留的写法。字符串拼接的老毛病是遇到单引号就语法错误商品名里带个OBrien就能让保存功能崩掉更严重的是注入虽然桌面程序的注入面比网站小但只要有输入框就有风险。 modDB.bas —— 通用参数化查询返回断开连接的 Recordset Public Function QueryRS(ByVal sSQL As String, ParamArray aParams() As Variant) As ADODB.Recordset Dim cmd As ADODB.Command Dim rs As ADODB.Recordset Dim i As Long Set cmd New ADODB.Command Set cmd.ActiveConnection gCn cmd.CommandType adCmdText cmd.CommandText sSQL For i 0 To UBound(aParams) 统一按变长字符串传数字由 SQL Server 隐式转换 cmd.Parameters.Append cmd.CreateParameter(p i, adVarWChar, adParamInput, 255, CStr(aParams(i))) Next i Set rs cmd.Execute Set rs.ActiveConnection Nothing 断开连接避免占用 Set QueryRS rs End FunctionParamArray 让调用方可以写QueryRS SELECT * FROM Goods WHERE Name LIKE ?, % kw %不用为每种查询写一个函数。统一用adVarWChar传字符串是一种偷懒但有效的做法数字和日期交给 SQL Server 隐式转换代价是遇到超大文本超过 255 字符会被截断需要单独处理。Set rs.ActiveConnection Nothing这行是断开 Recordset 的标准动作配合客户端游标界面拿到数据后连接就释放了不会出现「开单界面开着别人就登不进来」的情况——老系统里连接数被打满是很常见的故障。4.3 报表统计按日期分组别在 VB 里循环累加报表部分通常是「条件窗体 结果表格 打印」三件套。查询逻辑尽量压到 SQL 里做VB 端只负责把结果塞进表格。典型的月度销售汇总-- 按月份统计销售额与客户数日期区间用参数传避免全表扫描 SELECT CONVERT(VARCHAR(7), b.BillDate, 120) AS 月份, SUM(b.TotalAmount - b.Discount) AS 销售金额, COUNT(DISTINCT b.CustomerID) AS 客户数, COUNT(*) AS 单据数 FROM SalesBill b WHERE b.BillDate ? AND b.BillDate ? AND b.Status 2 -- 排除已红冲 GROUP BY CONVERT(VARCHAR(7), b.BillDate, 120) ORDER BY 月份;日期区间用「大于等于起始日、小于次日」而不是BETWEEN是为了避开时分秒的坑BETWEEN 2024-01-01 AND 2024-01-31实际只到 1 月 31 日 00:00:00当天白天的单据全丢了。这个坑每年都有人踩。状态过滤条件得想清楚业务含义。上面排除了已红冲的单据但如果红冲单是独立一张负数单那原单状态也会被改成 2两边都不统计金额才对得上。如果只在红冲单上做标记而原单不动那就得改成「含负数单」的统计口径SUM会自动抵消但单据数会偏大。报表控件如果用的是 DataReport要注意它绑定的数据源必须是断开连接的 Recordset且字段名要和报表设计器里绑定的 DataField 完全一致改一个字母就显示空白。这类问题没有报错提示纯靠肉眼比对属于典型的老项目玄学。5. 避坑与排查老 VB6 项目最容易翻车的五处这套代码能不能跑起来八成取决于环境和数据库两件事。下面这五条是我在不同机器上反复遇到的每条按「现象 → 原因 → 解决」记下来遇到了直接对号入座。5.1 环境与依赖类问题现象一双击 .vbp 打开弹「对象库未注册」或某个控件变成红叉窗体设计器打不开。原因是这台机器上没装 VB6 运行时组件或者引用的 OCX 版本不对。VB6 项目里引用的 MSCOMCTL.OCX、MSCOMCT2.OCX、MSADODC.OCX 都不随系统自带。解决办法是以管理员身份运行regsvr32注册对应 OCX注意 64 位系统上 VB6 用的是 32 位组件路径要指到 SysWOW64。注册完还报错就去「工程 → 引用」里把标着「丢失」的项取消勾选再重新浏览到正确文件。现象二连接测试报「未找到或无法访问服务器」但用 SSMS 在同一台机器上能连上。这是最经典的一条。原因通常是连接串里写的服务器名和 SSMS 里显示的不一致SSMS 连的是.\SQLEXPRESS这种命名实例代码里写的却是127.0.0.1或者代码用的是旧版 SQLOLEDB 提供程序而服务器只开了 TCP 且端口不是默认的 1433。解决顺序是先在 SSMS 里确认「服务器名称」的完整写法原样抄进连接串再检查 SQL Server 配置管理器里 TCP/IP 是否已启用命名实例要确认 SQL Browser 服务在跑最后确认客户端防火墙没拦出站。现象三程序在自己机器上好好的拷到客户机上就打不开提示缺 msvbvm60.dll。VB6 编译出来的 exe 依赖 VB6 运行时开发机装了 IDE 自然有客户机没有。解决办法是用打包工具生成安装包把 msvbvm60.dll、oleaut32.dll、olepro32.dll 一起带上并在安装时注册嫌麻烦的话手动把 DLL 拷到程序目录也算能跑但注册 OCX 那一步还是得走一遍。这一条属于部署阶段的老大难一次做好模板后面复用就行。5.2 数据与并发类问题现象四开单保存后明细里有两行一样的商品库存却只扣了一次。原因是明细批量插入用的是循环但库存扣减被放到了循环外面只按第一行处理或者循环变量在嵌套循环里被复用导致后半段明细被跳过。解决办法是把「插明细」和「扣库存」绑在同一个循环体内逐行处理处理完立刻检查受影响行数。改完务必造一个三行明细、其中两行同商品的测试单跑一遍这是最容易漏测的组合。现象五两个人同时给同一个商品开单库存扣成了负数。典型的先查后改竞态A 查到库存 10B 也查到 10各自卖 8都判断「够」最后库存变成 -6。解决办法是把判断条件写进 UPDATE 的 WHERE 里即UPDATE Goods SET Stock Stock - ? WHERE GoodsID ? AND Stock ?然后看受影响行数0 就抛错回滚。这个改法不需要锁表也不需要调事务隔离级别是老系统里成本最低的并发修复方案。除了这五条还有一类问题值得留意金额字段用了 FLOAT长期累加后对账差几分钱。这种问题不会报错只会让财务拿着两张表来问你为什么对不上排查起来极耗时间。接手时先跑一句SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE DATA_TYPE float把所有金额字段改成 DECIMAL一劳永逸。6. 二次开发加字段、加报表、加打印的三条路子给这套系统加东西最容易犯的错是只改看得见的地方。加一个「业务员」字段成交单界面加个下拉框保存语句里没加这个参数数据库里永远是空的等到月底统计才发现返工成本翻几倍。正确的链路是四步走一步都不能跳先在表上ALTER TABLE SalesBill ADD SalesmanID INT NULL一定带 NULL 或者默认值老单据才不会因为非空约束插不进去再在 clsBill 里加一个公开属性和对应的赋值然后窗体上加控件、保存语句里补参数最后把报表和列表查询的 SELECT 加上这一列。四步走完用一张新单和一张老单各验证一次老单能正常打开、新单能正常保存才算过关。加报表相对轻。我的习惯是不新写查询先把 modDB 里已有的 QueryRS 复用起来条件统一成「起始日期 结束日期 可选客户」导出的部分用 Excel COM 自动化或者直接拼 CSV。拼 CSV 更稳不依赖客户机装没装 Office中文用 GBK 编码写出Excel 双击打开不乱码。打印则统一收口到一个 PrintBill 模块里打印机、纸张、边距都在模块里定窗体只管传单号避免每张单据各写一套 Printer 设置。验证方法只有一个造数据对数。开三张单一张正常、一张部分退货、一张整单红冲然后同时看三个地方——单据列表的合计、库存表的变化、月度报表的金额。三个数对上改造才算真跑通。我吃过一次亏只验了界面显示没验库存上线第二天仓库就来找我说系统把同一批货扣了两次。从那以后我每次动老库之前都强制走一遍备份 → 加列带默认值 → 全流程开一张单 → 三处对数。希望帮到你。本文还有配套的精品资源点击获取