从 40 分钟到 38 秒:SAP Business One 物料库存自动取数实战

文章来源声明: 原文作者:心理之旅; 来源站点:掘金; 原文链接:https://juejin.cn/post/7692881705649094697; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

对仍在用 SAP B1 老版本、被手工导出折磨的 PMC 与 IT 来说,这是一份可直接抄作业的实战笔记;其中口径对齐与静默数据损坏的排查思路,比脚本本身更值钱。

从 40 分钟到 38 秒:SAP Business One 物料库存自动取数实战 -----------------------------------------

一个 PMC(生产物料控制)日常取数流程的自动化改造记录。
涉及 SAP B1 DI API、Python、Excel 二进制改写,以及一堆中文 Windows 环境下特有的坑。


一、起因:每天 40 分钟的手工搬运

我在制造企业做 PMC,每天早上的固定动作是这样的:

  1. 打开 SAP Business One 客户端,输账号密码登录
  2. 找到物料库存报表,设置筛选条件(仓库、非零库存)
  3. 导出成 Excel
  4. 把数据粘贴进另一张维护了很久的工作簿
  5. 跑一个汇总脚本,生成当日库存汇总
  6. 发给生产和采购

整条链路大约 40 分钟,其中 90% 的时间花在"搬运数据"上——复制、粘贴、检查有没有粘错行。

真正的痛点不是慢,是易错。手工操作总会有一次少粘了几行、或者筛选条件没设对,而下游的采购决策依赖这份数据。

也考虑过 SAP 自带的定时导出功能,但它解决不了后半段——数据还要落到一张人工维护多年、带公式和结存记录的工作簿里,再跑自定义汇总。所以只能自己动手。

目标因此很明确:

双击一个图标,不用输密码,自动完成"连 SAP → 导出 → 写表 → 出汇总"。

最后做出来是 38 秒。下面把完整过程和踩过的坑记录下来。


二、环境与通道选型

环境:

  • SAP Business One 9.2 PL08,本地部署(SQL Server 后端)
  • Windows 10 / 11
  • Python 3.14

SAP B1 对外取数大致有三条通道,各有取舍:

通道原理优点限制
**Service Layer**官方 REST API官方推荐、跨平台、稳定需 B1 10.0+ 且单独部署;老版本没有
**DI API**本地 COM 组件老版本可用、功能全**32 位**、依赖客户端安装
**直连数据库**直接连 SQL Server最快、最简单绕过业务逻辑与权限体系,需 DBA 授权

选型过程:

先确认 Service Layer 是否已部署——查询 B1 的 Service Layer 端口,无响应。向 IT 确认,答复是未部署,且短期内不打算部署。

于是走 DI API。相比直连数据库,DI API 的好处是走 B1 自己的业务对象和权限体系,拿到的是 B1 认可的、口径一致的数据,不会因为绕过业务逻辑而产生歧义。

但立刻撞上第一个坑。

坑 1:DI API 是 32 位的,宿主进程也必须是 32 位

DI API 以 COM 组件形式注册,且只有 32 位版本(SAPbobsCOM90.dll,位于 DI API 90 目录)。

用 64 位的 PowerShell 或 64 位 Python 去创建对象,会直接报 Class not registered。

解法是让脚本跑在 32 位宿主里:

rem 指向 32 位 PowerShell(SysWOW64 而非 System32)
set "PSEXE=%SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe"

记住这个规律:Windows 下 System32 是 64 位,SysWOW64 才是 32 位。名字极具误导性。


三、打通连接

DI API 连接需要这几个参数:服务器地址、公司库名、用户名、密码,外加一个数据库类型标识。

$company = New-Object -ComObject SAPbobsCOM.Company

$company.Server     = "sap-server"        # 服务器地址或 SLD 别名
$company.CompanyDB  = "COMPANY_DB"        # 公司库名
$company.UserName   = "b1user"
$company.Password   = $pwd
$company.DbServerType = 8                 # dst_MSSQL2014

$ret = $company.Connect()
if ($ret -ne 0) {
    # 失败原因要从 GetLastErrorDescription 取,只看返回值没用
    $company.GetLastErrorDescription()
}

坑 2:Connect() 返回 0 才是成功

不是 True/False,是整数错误码,0 表示成功。

而且失败时,光看返回值毫无信息量,必须调 GetLastErrorDescription() 才能拿到真正的原因(账号错误?库名不对?License 问题?)。

这一个函数省掉了我大量瞎猜的时间。

坑 3:DbServerType 不能猜

这个枚举值对应的是 SQL Server 版本族:

  • SQL Server 2005 → dst_MSSQL2005 (6)
  • SQL Server 2008 → dst_MSSQL2008 (7)
  • SQL Server 2014 → dst_MSSQL2014 (8)

填错会连不上,或者连上后行为异常。要去核对实际数据库版本。

怎么把结果集读出来

连上之后,取数用 BoRecordset 对象(BoObjectTypes 中值为 300):

$rs = $company.GetBusinessObject(300)   # 300 = BoRecordset
$rs.DoQuery($sql)

while (-not $rs.EoF) {
    $row = @()
    for ($i = 0; $i -lt $rs.Fields.Count; $i++) {
        $row += $rs.Fields.Item($i).Value
    }
    $rows.Add($row)
    $rs.MoveNext()
}
$rs.Release()

两个容易忽略的点:

  • BoRecordset 是只读前向游标——只能 MoveNext() 顺序遍历,不能回退、不能随机访问,更不能边读边改
  • 用完必须 .Release() 释放,否则 COM 对象不回收,连接不会正常关闭

四、写对 SQL:口径比语法重要

取数的核心是 OITM(物料主数据)、OITW(分仓库存)、OWHS(仓库)三表关联:

SELECT
    T0.ItemCode                     AS 物料编号,
    T0.ItemName                     AS 物料描述,
    T1.WhsCode                      AS 仓库编码,
    T2.WhsName                      AS 仓库名称,
    T1.OnHand                       AS 存货量,
    T1.IsCommited                   AS 已承诺,
    T1.OnOrder                      AS 已订购,
    (T1.OnHand - T1.IsCommited)     AS 可用量
FROM OITW T1
INNER JOIN OITM T0 ON T0.ItemCode = T1.ItemCode
INNER JOIN OWHS T2 ON T2.WhsCode  = T1.WhsCode
WHERE T1.OnHand <> 0
ORDER BY T0.ItemCode, T1.WhsCode

语法很简单,难的是口径对齐。

坑 4:WHERE OnHand <> 0 决定了你的数据对不对

一开始我没加这个条件,导出了近 3 万行;而业务上一直在用的那张表只有不到 1 万行。

反推、对齐、验证之后确认:业务口径就是排除零库存。这一个条件直接决定了行数、决定了汇总结果、决定了采购要不要下单。

教训:和老报表对口径时,先对行数,再对列值。行数不对,说明筛选条件不对,后面全是白干。

坑 5(最凶险的一个):字段名对,数据却是空的

表里有一个"规格描述"字段,我理所当然地映射到了 OITM.FrgnName。

实测:8897 / 8903 行是空的,填充率 0.1%。

字段名看着完全对得上,但业务上根本没人维护它。真正的规格描述散落在另一份人工维护的表里。

如果没发现这一点,导出的表里这一列会全空,而且不会报任何错——静默地把数据弄丢了。

所以我加了一个机制:字段映射核对。

[3/6] 字段映射核对 ...
      物料描述:可比 7,187 种,一致 7,187 (100.0%)
      映射核对通过。

逻辑是:拿上一版表和新导出的数据,按物料编号做交集,逐列比对一致率。任何一列的一致率异常低,就中断并报警。

这个机制后来救了我至少两次。


五、写回 Excel:最凶险的一步

数据拿到了,要写进那张维护多年的工作簿。这张工作簿里有两个工作表,另一个表里有 6000 多个公式。

第一版我用 openpyxl 读入、写入、保存——结果是一次静默的数据灾难。

坑 6:openpyxl 保存会清空公式的缓存值

实测数据:

写入前写入后
公式缓存值6206 个**0 个**
下游读到的结存种类1459 种**0 种**

原因在于 xlsx 的内部结构:一个公式单元格长这样——

<c r="F3"><f>SUM(E3:I3)</f><v>88900</v></c>

<f> 是公式,<v> 是上次计算结果的缓存值。

openpyxl 保存时只写 <f>,不写 <v>。

  • 用 Excel/WPS 打开 → 会自动重算,看起来一切正常
  • 用 pandas / openpyxl(data_only=True) 读 → 公式格全部是 None

这就是为什么它危险:肉眼看不出来。

解法:字节级替换,只动目标工作表

思路是——不要去"重存整个工作簿",只把目标工作表那一个 XML 条目换掉,其余条目原样复制。

import zipfile

def rewrite_zip(src, dst, patch):
    """patch: {zip内路径: 新的字节内容}"""
    with zipfile.ZipFile(str(src), "r") as zin:
        with zipfile.ZipFile(str(dst), "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zout:
            for info in zin.infolist():
                data = patch.get(info.filename)
                if data is None:
                    data = zin.read(info.filename)   # 原样搬运
                zout.writestr(info, data)

配套两个细节:

① 单元格生成:数字用 <v>,字符串用 t="inlineStr"

if name in NUM_COLS:
    return '<c r="%s"><v>%d</v></c>' % (ref, int(value))

return ('<c r="%s" t="inlineStr"><is><t xml:space="preserve">%s</t></is></c>'
        % (ref, xml_escape(text)))

② 为什么不用共享字符串表(sharedStrings)

如果用 t="s" 引用共享字符串表,一旦索引错位,整张表的文字会全部串行(A 的描述跑到 B 上)。而 inlineStr 把文字内联在单元格里,不依赖外部索引,牺牲一点体积换绝对安全。

写入前 + 写入后都要校验:

[5/6] 写入 ...
      写入前:结存表现结存列有缓存值 6206 格
      工作表 数据 -> xl/worksheets/sheet1.xml(8904 行,含表头)
[6/6] 写入后校验 ...
      校验通过(行数 / 列名 / 结存表缓存值 / 工作表结构)


六、三层验证:怎么确认数据是对的

自动化最大的风险不是"跑不起来",而是**"跑起来了、但数据是错的",而且没人发现**。

所以我建了三道校验,都是被上面那些坑逼出来的。

第一层:字段映射核对(写入前)

拿上一版数据与新数据按主键做交集,逐列算一致率:

物料描述:可比 7,187 种,一致 7,187 (100.0%)

一旦某列一致率异常(比如规格描述只有 0.1%),立刻中断并报警。这一层专门防"字段名对、语义不对"。

第二层:写入前后确定性校验(写入中)

  • 写入前记录目标工作表的公式缓存格数
  • 写入后重新解压读取,确认缓存格数未变
  • 同时校验行数、列名、工作表结构

这一层专门防 openpyxl 那种静默破坏。

第三层:数据新鲜度提示(运行时)

数据文件 : 表一数据_20261006_0956.csv
           导出于 2026-10-06 09:56(0 分钟前导出)

如果 CSV 的修改时间超过 24 小时,直接醒目提醒:

[提醒] 这份 CSV 不是今天导出的,写入的仍是旧数据。

这一层防的是**"我以为数据更新了,其实复用了几百年前的缓存文件"**——这种错误最隐蔽,因为所有校验都会通过。

心得:校验不是"锦上添花",而是自动化能不能被信任的前提。没有校验的自动化,只是把手工错误换成了批量错误。


七、免密码:凭据文件 + 优先级链

需求很直接:别让我每次输密码。

做法是给脚本加一条凭据解析链:

-Password 参数  >  环境变量  >  凭据文件  >  交互输入

凭据文件用宽容解析——支持显式键值对,也支持"用户名、密码"按行排列,还能自动跳过 sap b1: 这类标题行:

sap b1:
b1user
<你的密码>

安全上的三条纪律:

  1. 只读不打印:脚本读文件,但绝不把密码输出到控制台
  2. 不进日志:日志里只记录「凭据来源:C:\path\to\凭据.txt」,不记内容
  3. 事后核查:把全部运行日志 grep 一遍密码关键字,确认为 0 命中

⚠️ 老实说,明文存密码始终有风险。建议:限制文件权限、不要放进云同步目录、不要截图外发。更正规的做法是用 Windows 凭据管理器或加密配置,但那是另一个话题了。


八、一键化:中文编码是最大的时间黑洞

三个脚本串成一条链:

① 32 位 PowerShell + DI API → 导出 CSV
② Python → 写入工作簿的目标工作表
③ Python → 生成汇总表

用 bat 串起来,双击即跑。

但中文 Windows 下的编码问题,吃掉了整个项目最多的时间。

现象是:脚本逻辑全对,数据全对,但控制台输出中文全是乱码。

排查顺序(建议照抄):

顺序检查项说明
1**bat 文件本身的编码**cmd 在 `chcp 936` 下要求 **GBK**。用 UTF-8 存 bat,中文文件名和 echo 全乱
2bat 内 `set PYTHONUTF8=`清空外部环境变量干扰
3bat 内 `set PYTHONIOENCODING=gbk`明确告诉 Python 输出走 GBK
4**Python 里不要写 `reconfigure(encoding=...)`**⚠️ 见下

坑 7:sys.stdout.reconfigure(encoding="utf-8") 才是乱码的根因

我原先在脚本里加了这句"保险",结果它反而覆盖了环境变量,强制用 UTF-8 往 GBK 控制台写 → 必然乱码。

一度以为是 bat 的问题,来回改了三轮都没解决。

正确写法是只加容错、不改编码:

try:
    sys.stdout.reconfigure(errors="replace")   # 只加容错
except Exception:
    pass

坑 8:子 bat 改了代码页,call 返回后要改回来

导出脚本内部为了配合 PowerShell 输出用了 chcp 65001。主 bat 用 call 调它,返回后当前代码页仍是 65001,接着跑 Python 就乱码了。

解法:

call "%EXPORT_BAT%" -NoPause
chcp 936 >nul          rem ← 必须重新切回来


九、踩坑清单汇总

\#坑根因解法
1`Class not registered`DI API 是 32 位用 `SysWOW64` 下的 32 位宿主
2连接失败无信息`Connect()` 返回错误码调 `GetLastErrorDescription()`
3连不上数据库`DbServerType` 枚举填错核对实际 SQL Server 版本族
4行数对不上老报表缺少 `OnHand <> 0` 筛选先对行数,再对列值
5规格描述整列为空字段语义与业务不符**字段映射核对机制**
6公式缓存值被清空`openpyxl` 只写 `` 不写 ``zip 字节级替换 + `inlineStr`
7控制台中文乱码Python 强制 `encoding="utf-8"`只写 `errors="replace"`
8跨 bat 调用后乱码子 bat 改了代码页`call` 返回后重新 `chcp 936`
9程序像卡死cmd 快速编辑模式被误触标题出现「选择」按 `Esc`
10手动停止后二次启动失败Edge 残留进程锁住 profile启动前清理残留进程

第 9 条补充说明:Windows 控制台的快速编辑模式下,只要在窗口里点一下鼠标,整个进程会被系统挂起,print 全部阻塞——看起来像程序死了,其实是窗口在等你按 Esc。

这个坑很值得单独说:它没有任何报错,只是"停住了"。当时的排查方向全在脚本逻辑上,最后才发现是窗口标题多了「选择」两个字。


十、效果

项目改造前改造后
耗时约 40 分钟**38 秒**
密码每次手输自动读取凭据文件
操作6 个手工步骤**双击一次**
口径一致性依赖人代码固定
出错发现时机下游用错数据时写入前校验拦截

一天省 40 分钟,一年按 250 个工作日算,是 160 多小时。但我觉得更值钱的不是时间,是确定性——数据口径被代码固定下来了,不再取决于那天有没有人手滑。


十一、几点可复用的经验

  1. 集成老系统时,先把通道摸清。 官方推荐的方案(Service Layer)可能根本没部署,而"过时"的方案(DI API)反而是唯一可用的。别只盯着文档。
  2. 位元数是隐形的墙。 32 位组件必须有 32 位宿主。遇到 Class not registered,先想这个。
  3. 字段映射核对是救命机制。 尤其面对"字段名对、语义不对"的老系统。任何自动化都要有"结果验证",否则静默错误比报错更可怕。
  4. 改别人的 Excel,永远不要整体重存。 公式缓存、样式、数据验证、宏——都可能被悄无声息地抹掉。要么字节级改,要么另存新文件。
  5. 中文 Windows 脚本的编码问题有固定排查顺序(bat 编码 → 环境变量 → Python reconfigure)。按顺序走,别跳。
  6. 把"避免再次手工"当成第一原则。 我最初写了三个 bat 让人分步点。后来又花时间合成一个——因为这决定了它会不会被长期用下去。多一步操作,使用率就掉一半。
  7. 给"卡住"留一个可见的出口。 控制台可能被冻结、窗口可能被误关。关键节点同时写日志文件,是成本极低、回报很高的习惯。

本文基于真实工作案例整理,涉及的企业名称、账号信息、物料数据与内网地址均已做脱敏处理;技术方案与代码片段保留原始实现细节。