从 40 分钟到 38 秒:SAP Business One 物料库存自动取数实战
一个 PMC(生产物料控制)日常取数流程的自动化改造记录。
涉及 SAP B1 DI API、Python、Excel 二进制改写,以及一堆中文 Windows 环境下特有的坑。
一、起因:每天 40 分钟的手工搬运
我在制造企业做 PMC,每天早上的固定动作是这样的:
- 打开 SAP Business One 客户端,输账号密码登录
- 找到物料库存报表,设置筛选条件(仓库、非零库存)
- 导出成 Excel
- 把数据粘贴进另一张维护了很久的工作簿
- 跑一个汇总脚本,生成当日库存汇总
- 发给生产和采购
整条链路大约 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 位宿主里:
bat
rem 指向 32 位 PowerShell(SysWOW64 而非 System32)
set "PSEXE=%SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe"
记住这个规律 :Windows 下
System32是 64 位,SysWOW64才是 32 位。名字极具误导性。
三、打通连接
DI API 连接需要这几个参数:服务器地址、公司库名、用户名、密码,外加一个数据库类型标识。
powershell
$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):
powershell
$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(仓库)三表关联:
sql
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%。
字段名看着完全对得上,但业务上根本没人维护它。真正的规格描述散落在另一份人工维护的表里。
如果没发现这一点,导出的表里这一列会全空,而且不会报任何错------静默地把数据弄丢了。
所以我加了一个机制:字段映射核对。
scss
[3/6] 字段映射核对 ...
物料描述:可比 7,187 种,一致 7,187 (100.0%)
映射核对通过。
逻辑是:拿上一版表和新导出的数据,按物料编号做交集,逐列比对一致率。任何一列的一致率异常低,就中断并报警。
这个机制后来救了我至少两次。
五、写回 Excel:最凶险的一步
数据拿到了,要写进那张维护多年的工作簿。这张工作簿里有两个工作表,另一个表里有 6000 多个公式。
第一版我用 openpyxl 读入、写入、保存------结果是一次静默的数据灾难。
坑 6:openpyxl 保存会清空公式的缓存值
实测数据:
| 写入前 | 写入后 | |
|---|---|---|
| 公式缓存值 | 6206 个 | 0 个 |
| 下游读到的结存种类 | 1459 种 | 0 种 |
原因在于 xlsx 的内部结构:一个公式单元格长这样------
xml
<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 条目换掉,其余条目原样复制。
python
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"
python
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 把文字内联在单元格里,不依赖外部索引,牺牲一点体积换绝对安全。
写入前 + 写入后都要校验:
csharp
[5/6] 写入 ...
写入前:结存表现结存列有缓存值 6206 格
工作表 数据 -> xl/worksheets/sheet1.xml(8904 行,含表头)
[6/6] 写入后校验 ...
校验通过(行数 / 列名 / 结存表缓存值 / 工作表结构)
六、三层验证:怎么确认数据是对的
自动化最大的风险不是"跑不起来",而是**"跑起来了、但数据是错的",而且没人发现**。
所以我建了三道校验,都是被上面那些坑逼出来的。
第一层:字段映射核对(写入前)
拿上一版数据与新数据按主键做交集,逐列算一致率:
scss
物料描述:可比 7,187 种,一致 7,187 (100.0%)
一旦某列一致率异常(比如规格描述只有 0.1%),立刻中断并报警。这一层专门防"字段名对、语义不对"。
第二层:写入前后确定性校验(写入中)
- 写入前记录目标工作表的公式缓存格数
- 写入后重新解压读取,确认缓存格数未变
- 同时校验行数、列名、工作表结构
这一层专门防 openpyxl 那种静默破坏。
第三层:数据新鲜度提示(运行时)
markdown
数据文件 : 表一数据_20261006_0956.csv
导出于 2026-10-06 09:56(0 分钟前导出)
如果 CSV 的修改时间超过 24 小时,直接醒目提醒:
css
[提醒] 这份 CSV 不是今天导出的,写入的仍是旧数据。
这一层防的是**"我以为数据更新了,其实复用了几百年前的缓存文件"**------这种错误最隐蔽,因为所有校验都会通过。
心得:校验不是"锦上添花",而是自动化能不能被信任的前提。没有校验的自动化,只是把手工错误换成了批量错误。
七、免密码:凭据文件 + 优先级链
需求很直接:别让我每次输密码。
做法是给脚本加一条凭据解析链:
diff
-Password 参数 > 环境变量 > 凭据文件 > 交互输入
凭据文件用宽容解析------支持显式键值对,也支持"用户名、密码"按行排列,还能自动跳过 sap b1: 这类标题行:
xml
sap b1:
b1user
<你的密码>
安全上的三条纪律:
- 只读不打印:脚本读文件,但绝不把密码输出到控制台
- 不进日志 :日志里只记录「凭据来源:
C:\path\to\凭据.txt」,不记内容 - 事后核查:把全部运行日志 grep 一遍密码关键字,确认为 0 命中
⚠️ 老实说,明文存密码始终有风险。建议:限制文件权限、不要放进云同步目录、不要截图外发。更正规的做法是用 Windows 凭据管理器或加密配置,但那是另一个话题了。
八、一键化:中文编码是最大的时间黑洞
三个脚本串成一条链:
① 32 位 PowerShell + DI API → 导出 CSV
② Python → 写入工作簿的目标工作表
③ Python → 生成汇总表
用 bat 串起来,双击即跑。
但中文 Windows 下的编码问题,吃掉了整个项目最多的时间。
现象是:脚本逻辑全对,数据全对,但控制台输出中文全是乱码。
排查顺序(建议照抄):
| 顺序 | 检查项 | 说明 |
|---|---|---|
| 1 | bat 文件本身的编码 | cmd 在 chcp 936 下要求 GBK。用 UTF-8 存 bat,中文文件名和 echo 全乱 |
| 2 | bat 内 set PYTHONUTF8= |
清空外部环境变量干扰 |
| 3 | bat 内 set PYTHONIOENCODING=gbk |
明确告诉 Python 输出走 GBK |
| 4 | Python 里不要写 reconfigure(encoding=...) |
⚠️ 见下 |
坑 7:sys.stdout.reconfigure(encoding="utf-8") 才是乱码的根因
我原先在脚本里加了这句"保险",结果它反而覆盖了环境变量,强制用 UTF-8 往 GBK 控制台写 → 必然乱码。
一度以为是 bat 的问题,来回改了三轮都没解决。
正确写法是只加容错、不改编码:
python
try:
sys.stdout.reconfigure(errors="replace") # 只加容错
except Exception:
pass
坑 8:子 bat 改了代码页,call 返回后要改回来
导出脚本内部为了配合 PowerShell 输出用了 chcp 65001。主 bat 用 call 调它,返回后当前代码页仍是 65001,接着跑 Python 就乱码了。
解法:
bat
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 只写 <f> 不写 <v> |
zip 字节级替换 + inlineStr |
| 7 | 控制台中文乱码 | Python 强制 encoding="utf-8" |
只写 errors="replace" |
| 8 | 跨 bat 调用后乱码 | 子 bat 改了代码页 | call 返回后重新 chcp 936 |
| 9 | 程序像卡死 | cmd 快速编辑模式被误触 | 标题出现「选择」按 Esc |
| 10 | 手动停止后二次启动失败 | Edge 残留进程锁住 profile | 启动前清理残留进程 |
第 9 条补充说明:Windows 控制台的快速编辑模式 下,只要在窗口里点一下鼠标,整个进程会被系统挂起,
Esc。这个坑很值得单独说:它没有任何报错,只是"停住了"。当时的排查方向全在脚本逻辑上,最后才发现是窗口标题多了「选择」两个字。
十、效果
| 项目 | 改造前 | 改造后 |
|---|---|---|
| 耗时 | 约 40 分钟 | 38 秒 |
| 密码 | 每次手输 | 自动读取凭据文件 |
| 操作 | 6 个手工步骤 | 双击一次 |
| 口径一致性 | 依赖人 | 代码固定 |
| 出错发现时机 | 下游用错数据时 | 写入前校验拦截 |
一天省 40 分钟,一年按 250 个工作日算,是 160 多小时 。但我觉得更值钱的不是时间,是确定性------数据口径被代码固定下来了,不再取决于那天有没有人手滑。
十一、几点可复用的经验
- 集成老系统时,先把通道摸清。 官方推荐的方案(Service Layer)可能根本没部署,而"过时"的方案(DI API)反而是唯一可用的。别只盯着文档。
- 位元数是隐形的墙。 32 位组件必须有 32 位宿主。遇到
Class not registered,先想这个。 - 字段映射核对是救命机制。 尤其面对"字段名对、语义不对"的老系统。任何自动化都要有"结果验证",否则静默错误比报错更可怕。
- 改别人的 Excel,永远不要整体重存。 公式缓存、样式、数据验证、宏------都可能被悄无声息地抹掉。要么字节级改,要么另存新文件。
- 中文 Windows 脚本的编码问题有固定排查顺序(bat 编码 → 环境变量 → Python reconfigure)。按顺序走,别跳。
- 把"避免再次手工"当成第一原则。 我最初写了三个 bat 让人分步点。后来又花时间合成一个------因为这决定了它会不会被长期用下去。多一步操作,使用率就掉一半。
- 给"卡住"留一个可见的出口。 控制台可能被冻结、窗口可能被误关。关键节点同时写日志文件,是成本极低、回报很高的习惯。
本文基于真实工作案例整理,涉及的企业名称、账号信息、物料数据与内网地址均已做脱敏处理;技术方案与代码片段保留原始实现细节。