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

从 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 位宿主里:

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
<你的密码>

安全上的三条纪律:

  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 全乱
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 控制台的快速编辑模式 下,只要在窗口里点一下鼠标,整个进程会被系统挂起,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. 给"卡住"留一个可见的出口。 控制台可能被冻结、窗口可能被误关。关键节点同时写日志文件,是成本极低、回报很高的习惯。

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

相关推荐
HelloWorld0011 小时前
告别轮询与断流!基于 Spring Boot 3 + SSE + Redis 打造生产级 Agent 流式思考与工具调用中枢
ai编程
9i编程2 小时前
3. 教 AI 上班:带出我的数字同事 —— 文档返工与代码检查——明确指令与丢三落四的拉扯
人工智能·openai·ai编程
guoqihan342svg2 小时前
恶意 README 能遥控你的 Agent:提示词注入攻防实录
ai编程
王中阳Go2 小时前
Agent 第一句就 500,我们查了三轮:入口日志少打了一个参数
后端·agent·ai编程
深蓝AI2 小时前
不生成文本的AI日吞一万亿Token:决策模型Jev撕开大模型的替代路线
人工智能·ai编程
程语AI2 小时前
LLM 流程自动化上线验收:真实流量回放、指标埋点和审计日志
ai编程
技术小事2 小时前
FrugalEvo:AI 如何按成本进化
人工智能·ai编程·腾讯
挖掘狂人3 小时前
把 AI Agent 养在自己电脑上:从本地部署到远程接管的一份完整思路
人工智能·开源·ai编程
空心木偶☜3 小时前
LangGraph
ai·ai编程·langgraph