🤖 第六章 应用实战:采购订单智能识别与ERP自动化填报助手
📄 一句话概括

这一步是"送料":RPA 把单据文件送进识别工作流,换回一个任务号。技术含量不高,但有两个地方决定成败:送之前的三查(在不在、对不对、重不重),和 Token 与请求体的卫生(它一旦泄露,别人就能拿你的额度跑你的工作流)。

📝 本节和前面几节的关系

6.3.1 造好了工作流,本节负责"把它调用起来"——这是 RPA 侧三步里的第一步。 与前面几章的同行对照:3.4.1 / 4.4.1 / 5.4.1 都是"提交任务",但它们送的是文字(选题、关键词、任务卡);本节第一次送【文件】——而"送文件"比"送文字"多了三件事:文件要先落在能被访问的地方、要防重复、要防大文件。

一、RPA 在这条链里的位置:中间那根传送带

先把位置摆清楚:

本地收件目录 ──RPA 送──→ 识别工作流(扣子) ──RPA 取──→ 结构化结果
     ↑                                                        ↓
  6.2 第①格                                              6.2 第③格(校验)

RPA 在这里做两件不聪明的事: 1. 把文件送进去(本节); 2. 把结果取回来(下一节)。

它不需要理解单据内容——"看懂"是工作流的活,"搬运"是 RPA 的活。 这条分工从第一章 1.6.1 讲的三种交互模式里就定下来了(RPA 调 Agent),本章只是把它用在了一个新的对象上:文件。

📝 为什么不是"Agent 直接把结果写进 ERP"

因为扣子这类平台访问不了你公司内网的 ERP。它能在云端识别图片,但"把单子填进用友/金蝶/SAP"这件事必须发生在你办公网里的一台机器上。 这就是本课从头到尾那条分工的物理基础:Agent 在云上想,RPA 在本地做。

二、送进去的是什么:文件必须先落到一个"两边都能访问"的地方

这是本章新增的第一件事。 前面几章送的是一段文字(写进请求体就行),本章送的是一个文件——而文件必须有一个【能被工作流读到的地址】。

两条路:

做法 怎么走 适合
先传文件服务,再传地址 RPA 把图传到对象存储/文件服务 → 请求体里带 URL 推荐。地址可留痕、可复用(合规账要的"影像关联"正好能用它)
直接把文件作为参数传 取决于工作流的开始节点怎么定义 小量、临时

无论走哪条,一句话必须记住:文件的参数形式由工作流的开始节点决定(6.3.1 讲的 File-Image 类型就是它)。

⚠️ 以"部署页面的参数说明"为准,不要凭记忆写

扣子的工作流 API 是动态 schema——接口路径和鉴权是固定的,但【入参和出参】由你这个工作流自己生成。 所以不同工作流的参数名可能不同,唯一可靠的依据是部署页面上的参数说明和调用示例。 本课那条老规矩在这里又用一次:先看现场,再动手。

三、怎么发起:把工作流部署成 API

3.1 第一步:部署,并拿到 Token

2026 年在扣子上,这段流程是这样的:

  1. 在工作流开发页把工作流【部署】(没部署的工作流调不了,未发布调用会报错 4200——这个错误码值得记,它说明"你的工作流压根没上线");
  2. 部署完成后,平台自动生成一组 API 接口(同步执行、流式执行、异步执行、查询异步任务状态、查询工作流参数);
  3. 在部署总览页创建 API Token,调用时放在请求头 Authorization 里。

关于 Token,有三条必须记住的:

性质 说明
只显示一次 创建后立刻复制——页面上不会再显示第二次
永久有效 没有自动过期 → 泄露了必须手动删
有数量上限 每个项目最多 10 个 → 别一个人一个,用完就删

域名:国内区 api.coze.cn,国际区 api.coze.com——用哪个取决于你的账号在哪个区。

3.2 第二步:选调用方式(同步 / 流式 / 异步)

工作流的 API 一般提供三种:

方式 行为 适合
同步执行 一次请求,等它全跑完再返回 快任务(几秒)
流式执行 用 SSE 持续推事件 要"打字机效果"的对话
异步执行 先返回任务号,之后靠"查状态"取结果 长任务(官方文档说的是 24 小时以内)

本章选【异步】。

为什么? 因为 6.1.1 已经算过:一批单会有几十张,一张图识别几秒,一批就是几分钟。同步调用会一直占着那条连接,而且一旦中途超时,前面白跑。 异步的好处是:提交和取回被拆成两个动作——提交很快(拿到任务号就完事),取回可以慢慢来(甚至分几次),中间断了也能重查。

📝 异步的代价:你要自己管"任务的状态"

同步调用只有两种结果(成功、失败);异步会多出第三种:还不知道。 所以本节结束时要落盘的是一个任务号列表,下一节(6.4.2)要围绕它做轮询、超时与对账。 这就是本课"回收要分档"那条纪律(4.4.2、5.4.1 都出现过)在本章的形态: 异步让链路更稳,代价是把"状态管理"从平台手里接了过来。

四、发起前的三查

这三查加起来不到一秒钟,但它挡掉的是后面整条链路的返工。

第一查:在不在

"文件真的落地了吗"——听起来像废话,但下载中的文件也有文件名、也有大小。 判据:文件的修改时间稳定了没有、能不能完整读出来。

为什么这一查最容易被跳过? 因为它不报错——你送一个"下载了 80% 的图"进去,工作流照样返回,只是识别出一堆乱码。

第二查:对不对

第三查:重不重

按内容哈希去重,不是按文件名。

理由:文件名不可靠——人会加"副本""最终版""(1)",而内容完全一样。而 6.2 说过:重复送会生成两张采购订单,那是账上凭空多一笔。

Demo 会专门演示这一条。

五、发起之后不要干等

异步调用的后半段有三条纪律:

  1. 记下任务号,落盘——它在内存里没有意义(流程一崩就没了);
  2. 别死等,也别轮得太密——隔一段时间查一次,给一个上限(比如 5 分钟);
  3. 超时不是失败,是"待定"——它属于 6.4.2 那个"重试"档(重跑一次多半就好)。
📝 为什么"超时"要单独设计

因为它不是"错了",是"还没好"。 如果把它当失败处理(比如直接标异常交人),人会把一张本来没问题的单子手工录一遍——而那些时间是因为"服务当时有点忙"白花的。 判据仍然只有那一条:重跑一遍能不能修好。

六、动手 Demo:送单预检 + 请求体装配

# -*- coding: utf-8 -*-
"""6.4.1 Demo:送单预检 + 请求体装配(纯标准库)
在调用扣子工作流之前,先把不该送的挡在门外,并把请求体拼出来。
"""
import json
import re

# 待送文件:文件名 / 大小(MB) / 扩展名 / 内容哈希前 8 位
FILES = [
    {"f": "PO-20260913-01.jpg",       "mb": 1.8,  "ext": "jpg",  "h": "a1b2c3d4"},
    {"f": "PO-20260913-02.jpg",       "mb": 2.2,  "ext": "jpg",  "h": "e5f6a7b8"},
    {"f": "PO-20260913-02副本.jpg",    "mb": 2.2,  "ext": "jpg",  "h": "e5f6a7b8"},
    {"f": "quote_20260913.xlsx",      "mb": 0.3,  "ext": "xlsx", "h": "11223344"},
    {"f": "采购订单模板.docx",         "mb": 0.1,  "ext": "docx", "h": "55667788"},
    {"f": "扫描件_2026091301.jpg",     "mb": 12.4, "ext": "jpg",  "h": "99aabbcc"},
    {"f": "wx_20260913_1147.png",     "mb": 0.9,  "ext": "png",  "h": "ddeeff00"},
    {"f": "微信图片_20260913114722.png", "mb": 1.1, "ext": "png", "h": "13579bdf"},
    {"f": "po_20260915.pdf",          "mb": 0.6,  "ext": "pdf",  "h": "2468ace0"},
    {"f": "IMG_0421.jpg",             "mb": 1.5,  "ext": "jpg",  "h": "0f1e2d3c"},
]

ALLOW_EXT = {"jpg", "jpeg", "png", "pdf", "xlsx"}
MAX_MB = 10.0

def traceable(name):
    """文件名里要能看出这是哪张单:带单号 PO-xxxx,或带 8 位日期"""
    return bool(re.search(r"PO-\d+", name, re.I)) or bool(re.search(r"20\d{6}", name))

def precheck():
    ok, skip, block = [], [], []
    seen = {}
    for r in FILES:
        if r["h"] in seen:
            skip.append((r, "与 %s 内容哈希相同 —— 同一张单被送了两次" % seen[r["h"]]))
            continue
        if r["ext"] not in ALLOW_EXT:
            block.append((r, "扩展名 .%s 不在可识别范围内(%s)" % (
                r["ext"], "/".join(sorted(ALLOW_EXT)))))
            continue
        if r["mb"] > MAX_MB:
            block.append((r, "体积 %.1fMB 超过上限 %.0fMB —— 先压缩或分页" % (r["mb"], MAX_MB)))
            continue
        if not traceable(r["f"]):
            block.append((r, "文件名里看不出是哪张单(既无单号也无日期)—— 出了问题回不到原单"))
            continue
        seen[r["h"]] = r["f"]
        ok.append(r)
    return ok, skip, block

ok, skip, block = precheck()

print("=" * 78)
print("一、送单预检")
print("=" * 78)
for r in ok:
    print("  [通过] %-24s %5.1fMB" % (r["f"], r["mb"]))
for r, why in skip:
    print("  [跳过] %-24s %s" % (r["f"], why))
for r, why in block:
    print("  [阻断] %-24s %s" % (r["f"], why))

print()
print("  合计:送 %d / 跳过 %d / 阻断 %d(共 %d 个文件)" % (
    len(ok), len(skip), len(block), len(FILES)))

print()
print("=" * 78)
print("二、请求体装配(以第一份通过的文件为例)")
print("=" * 78)
body = {
    "workflow_id": "7xxxxxxxxxxxxxxx",
    "parameters": {
        "doc_url": "https://<文件服务>/po-inbox/%s" % ok[0]["f"],
        "task_type": "purchase_order",
        "need_conf": True,
        "schema_version": "po_v1",
    },
}
print(json.dumps(body, ensure_ascii=False, indent=2))
print()
print("  请求头里另需带:Authorization: Bearer <API Token>")
print("  注意:API Token 第一次生成后【只显示一次】,且【永久有效】——")
print("       它必须放在环境变量或凭证保险箱里,绝不能写进脚本。")

print()
print("=" * 78)
print("三、三查之后再说【发起】")
print("=" * 78)
print("  · 一查【在不在】:文件真的落地了吗(不是还在下载中)。")
print("  · 二查【对不对】:格式、体积、文件名标识 —— 就是上面这段。")
print("  · 三查【重不重】:哈希去重。同一张单重复送,会生成两张采购订单。")
print()
print("  这三查加起来不到一秒钟,但它挡掉的是【后面整条链路的返工】。")

实际运行输出:

==============================================================================
一、送单预检
==============================================================================
  [通过] PO-20260913-01.jpg         1.8MB
  [通过] PO-20260913-02.jpg         2.2MB
  [通过] quote_20260913.xlsx        0.3MB
  [通过] wx_20260913_1147.png       0.9MB
  [通过] 微信图片_20260913114722.png    1.1MB
  [通过] po_20260915.pdf            0.6MB
  [跳过] PO-20260913-02副本.jpg     与 PO-20260913-02.jpg 内容哈希相同 —— 同一张单被送了两次
  [阻断] 采购订单模板.docx              扩展名 .docx 不在可识别范围内(jpeg/jpg/pdf/png/xlsx)
  [阻断] 扫描件_2026091301.jpg       体积 12.4MB 超过上限 10MB —— 先压缩或分页
  [阻断] IMG_0421.jpg             文件名里看不出是哪张单(既无单号也无日期)—— 出了问题回不到原单

  合计:送 6 / 跳过 1 / 阻断 3(共 10 个文件)

==============================================================================
二、请求体装配(以第一份通过的文件为例)
==============================================================================
{
  "workflow_id": "7xxxxxxxxxxxxxxx",
  "parameters": {
    "doc_url": "https://<文件服务>/po-inbox/PO-20260913-01.jpg",
    "task_type": "purchase_order",
    "need_conf": true,
    "schema_version": "po_v1"
  }
}

  请求头里另需带:Authorization: Bearer <API Token>
  注意:API Token 第一次生成后【只显示一次】,且【永久有效】——
       它必须放在环境变量或凭证保险箱里,绝不能写进脚本。

==============================================================================
三、三查之后再说【发起】
==============================================================================
  · 一查【在不在】:文件真的落地了吗(不是还在下载中)。
  · 二查【对不对】:格式、体积、文件名标识 —— 就是上面这段。
  · 三查【重不重】:哈希去重。同一张单重复送,会生成两张采购订单。

  这三查加起来不到一秒钟,但它挡掉的是【后面整条链路的返工】。

读结果,五个值得注意的地方:

  1. 那第 3 个文件(PO-20260913-02副本.jpg)是本 Demo 最重要的一条。 它和 PO-20260913-02.jpg 内容一模一样(哈希都是 e5f6a7b8),只是名字后面多了"副本"两个字。 如果按文件名去重,它会被当成一张新单送进去——然后在 ERP 里生成第二张一模一样的采购订单。 这不是"多花点时间",是账上凭空多一笔。 而且它很难被发现——因为两张单确实都"有据可依"(都对应一张真实的图)。

  2. IMG_0421.jpg 被拦的理由,当时看不出任何问题。 它体积正常、格式正常、内容也可能很正常——它只是没有一个能说明"这是哪张单"的名字。 这一查防的不是今天,是三年后。 6.1.2 的合规账说过:原始凭证要存 30 年,影像要能跟订单对上。如果文件名是 IMG_0421.jpg,到时候你只能在几千张图里翻。

  3. 采购订单模板.docx 那条值得一笑,但它是个真问题。 收件目录里总会混进别的东西(模板、说明、聊天导出的文件)。如果不拦,它们会被送进识别、返回一堆没有意义的字段、然后变成"异常"占掉人的时间。 在入口拦住它,成本是 0;让它走到异常表,成本是人的 5 分钟。

  4. 体积那条(12.4MB)值得单独说一句:上限不是"技术上限",是【成本与稳定性的折中】。 图越大,识别越慢、越贵、越容易超时。所以这条线应该按你的实际情况定——但它必须存在,不能"反正都能传就都传"。

  5. 请求体里那个 schema_version 字段看着多余,其实是本章最该抄走的一个设计。 为什么要版本号? 因为字段形状会变(今天要 8 个字段,明天可能要加"原产地")。带上版本号之后,老的脚本和新工作流可以并存——否则一次字段变更会让所有历史脚本同时失效。 这是"数据侧"特有的问题:内容侧换一版提示词就行,数据侧的接口一变,下游全要跟着改。

30 秒实验

把 Demo 里 precheck() 中去重的两行改一下——把 if r["h"] in seen: 改成 if r["f"] in seen:,并把 seen[r["h"]] = r["f"] 改成 seen[r["f"]] = r["f"](也就是改成"按文件名去重"),再跑一次。

会发生什么:

结论:"副本"这两个字,是一笔多付的钱。

顺着这个思路想一层:为什么人会加"副本"?因为他自己也怕覆盖原文件——这是个善意动作,但它的后果在另一头(账上多一笔)。 这提醒一件事:上游的善意习惯,必须在下游用技术手段兜住——因为你不能要求所有人改掉多年的习惯。

再想一层:那"按哈希去重"有没有代价?有——它会误杀"内容相同但确实要下两次的单"(比如同一个订单分两批交货,供应商把同一张图发了两遍)。但这种情况下,正确的做法是"补一张有区分的单据",而不是"凭同一张图下两次单"——所以这个误杀的代价,是可以接受的。

常见误区

  1. 【API Token 写在脚本里方便】——绝对不行。 它只显示一次、永久有效、泄露后别人就能拿你的额度跑你的工作流。正确做法:放在环境变量或凭证保险箱里。这也正是 6.1.1 现状三里那条"凭证加密管理:严禁在代码中硬编码密码"——同一个道理,只是对象从账密变成了 Token。

  2. 【调用工作流和调用智能体是一回事】——不是,接口族不同。 工作流的 API 一般是同步执行 / 流式执行 / 异步执行 / 查询异步任务状态 / 查询工作流参数这一组;调用智能体是另一组(如对话接口)。本章要的是工作流的那一组,而且入参出参是【动态 schema】——以部署页面的参数说明为准,不要凭记忆写。

  3. 【未发布的工作流也能调】——不能,会报错 4200。 这个错误码值得记住:它说的不是"参数错了",是"你的工作流压根没部署成 API"。遇到它先回去看部署页,别在请求体里找问题。

  4. 【发送前不用检查,失败了会报错】——错,失败发生在服务端,你的损失是时间和额度。 更要紧的是:有些"失败"根本不会报错——比如送一张"下载了 80% 的图"进去,工作流会正常返回,只是识别出一堆乱码。三查的价值就是把这类"不报错的失败"挡在门外。

  5. 【去重按文件名就行】——错,30 秒实验已经演示过了。 人会加"副本""最终版""(1)",而内容完全一样。只有内容哈希是可靠的——因为它不依赖任何人记得起名字的习惯。

自测三问(点开对答案) 1. **为什么"送文件"比"送文字"多了三件事?** 因为文字可以直接写进请求体,而**文件必须先有一个"能被读到的地址"**(本节第二节),所以多了—— ① **文件要先落到两边都能访问的地方**(对象存储/文件服务),并**在请求体里带上它的地址**; ② **要防重复**(同一张单被送两次 → ERP 里生成两张订单); ③ **要防大文件与异常文件**(体积上限、扩展名、以及"文件其实还没下载完")。 **根子在 6.1.1 那条**:本章的产出要落进 ERP,**而"多一张单"和"少一张单"都是账上的事**,不是"重试一下"能解决的。 2. **为什么本章选"异步执行"而不是"同步执行"?代价是什么?** 因为**一批单有几十张、一张图识别要几秒**,同步调用会一直占着连接,而且**中途超时前面白跑**。 异步的好处是**把"提交"和"取回"拆成两个动作**:提交很快(拿到任务号即可),取回可以慢慢来、甚至分几次、断了还能重查。 **代价是要自己管任务状态**:同步只有"成功/失败"两种结果,**异步多出第三种——"还不知道"**。所以本节结束时落盘的是一张**任务号列表**,下一节要围绕它做轮询、超时与对账。 3. **"文件还没下载完"为什么是最危险的失败?** 因为它**不报错**。 一个下载到 80% 的图,**有文件名、有大小、甚至能被"打开"**——工作流会正常接收、正常返回,**只是识别出一堆乱码或者一个空结果**。 而**这类错误会一路往下走**:变成"识别失败"→ 进异常 → **消耗人的 5 分钟**。**而真正的原因(上游还在写文件)根本不在异常表上。** 所以三查里"一查【在不在】"要放在第一条:**判据是文件的修改时间稳定了没有、能不能完整读出来。**

相关笔记