用 WorkBuddy 打通金蝶云·星空旗舰版 OpenAPI:采购订单从建单到审核全流程实录
一、背景与目标 目标很明确:用 API 创建一张采购订单,并让它到达**已审核(C)**状态。 听起来简单,实际踩了整整一天。核心难点不在于"调接口",而在于: 1. 大量字段是**隐藏必填**,报错一个、补一个、再冒出下一个; 2. 部分字段**key 名与直觉完全不符**,写错了接口还返回成功,只是值不落库(静默失败); 3. 同一个语义的字段(如"货主")在不同实体类型下有**不同的枚举值**,用错类型同样静默失败。 二、环境准备 2.1 认证:OAuth2 取 token 苍穹网关的认证头字段名是 **`accessToken`**(驼峰、无下划线)。写成 `access_token` 或 `Authorization: Bearer` 一律返回 401。 ```python import httpx, uuid, datetime BASE_URL = "https:///kapi" payload = { "client_id": "", "client_secret": "", "username": "", "accountId": "", "language": "zh_CN", "nonce": str(uuid.uuid4()), "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } headers = {"Content-Type": "application/json", "x-acgw-identity": ""} # 私有云需要 r = httpx.post(f"{BASE_URL}/oauth2/getToken", json=payload, headers=headers, timeout=30) token = r.json()["data"]["access_token"] ``` 后续所有请求都带这个头: ```python H = {"Content-Type": "application/json", "accessToken": token, "x-acgw-identity": ""} ``` 2.2 返回码陷阱 **成功与否要看 `errorCode`(字符串 `"0"` 表示成功),不是 HTTP 状态码。** 而且完整错误信息藏在 `data.result[].errors[].rowMsg[]` 里,顶层的 `message` 会被截断成「……等 N 处错误」。排查时一定要打印完整响应体。 2.3 接口路径约定 ``` /v2/{appId}/{formId}/{操作} ``` 采购订单:`/v2/pm/pm_purorderbill/{add|query|getList|batchSubmit|batchAudit}` - 写操作是 **`add`**,不是 `save` / `create` / `submit`(这些全是 404) - 请求体格式:`{"data": [ {...} ]}`,`data` 必须是**数组** - 查询类接口必须带 `pageNo` / `pageSize`,否则返回 0 条 三、五个致命坑(本文最有价值的部分) 坑 1:含税单价的 key 是 `priceandtax`,不是 `taxprice` 最初按直觉写了 `"taxprice": 10`,接口返回 `ec=0`(成功!),但打开单据一看:**含税单价和税率都是空的**。 读回对比手工单才发现问题——所有金额字段全是 0: json { "price": 0.0, "priceandtax": 0.0, "taxrate": 0.0, "taxrateid_number": "" } **正确写法:** ```python "priceandtax": 10, # 含税单价(不是 taxprice!) "taxrate": 6, # 税率数值 "taxrateid_number": "V6", # 税率档编码,必须显式传 ``` > **教训**:API 接受了无效 key 却不报错、也不赋值。这类"静默失败"必须靠**读回校验**才能发现,不能只看返回码。 坑 2:货主的 `ownertype` 是 `bos_org`,不是 `bd_supplier` 这是卡得最久的一个。提交时一直报: ``` 请填写"物料明细"第1行:"货主"。 ``` 直觉认为"采购订单的货主"应该是供应商,于是试了 `bd_supplier`、`bd_customer`、`bd_organization`、`bos_adminorg`——**全部失败**,而且都是 add 成功但 submit 报错。 **破局方法**:去读一张**手工在 GUI 建好并已审核**的单据,看它实际存了什么: ```python # query 接口能返回 ownertype(虽然不返回货主本体) r = call("/v2/pm/pm_purorderbill/query", {"data": {"billno": "CGDD-xxxx"}}) # 明细里:ownertype = 'bos_org' ``` **正确写法:** ```python "owner_number": "hmr", # 货主 = 组织编码(好牧人) "ownertype": "bos_org", # ★ 业务组织,不是 bd_supplier! ``` > **教训**:当一个字段怎么填都不对时,别再猜了——**找一张系统里已经成功的单据读回来对照**。这比任何文档都准。 坑 3:第三方业务编码 `trdbillno` 有唯一性约束 这个字段是租户自定义的 UDF,属于**只写字段**——`query` / `getList` / `getDetail` 都不返回它,所以无法从读回中推断 key(我盲猜了 32 个候选 key 全军覆没,最后是靠指定 key 名解决的)。 更坑的是它有**唯一性约束**,重复值直接报错: ``` 第三方单据业务编码重复:000121cs ``` **所以每次建单都要换一个值**: ```python suffix = str(int(time.time()) % 1000000) rec["trdbillno"] = f"{suffix}cs" rec["billno"] = f"POF{suffix}" ``` ### 坑 4:物料必须用 GUI 手工创建的 API 创建的物料缺少"组织分配",在下游单据里引用会失败。必须使用**在金蝶界面手工保存过**的物料编码(手工保存会自动完成组织分配)。 ```python "material_number": "Item-00000001" # 手工在 GUI 建的物料 ``` 坑 5:提交/审核用复数 `ids`,基础资料审核用单数 `id` ```python 单据(采购订单)提交/审核 —— 复数 ids,值为数组 {"data": {"ids": ["2563846890302997504"]}} 基础资料(供应商/客户/物料)审核 —— 单数 id,值为字符串 {"data": {"id": "2563180392454555648"}} ``` 写反了会报「参数【ids】必填」。 --- 四、最终可用代码 把上面所有坑都规避掉之后,完整脚本: ```python # -*- coding: utf-8 -*- """金蝶云·星空旗舰版 采购订单 建单→提交→审核 全链路""" import time, datetime, httpx def get_token(): ... # 见 2.1 def call_full(path, body): """返回完整响应(含 errorCode),用于判断写操作成败""" H = {"Content-Type": "application/json", "accessToken": get_token(), "x-acgw-identity": ""} return httpx.post(f"{BASE_URL}{path}", json=body, headers=H, timeout=60).json() suffix = str(int(time.time()) % 1000000) rec = { "billno": f"POF{suffix}", "supplier_number": "01234", # 供应商 "org_number": "hmr", # 组织 "biztime": "2026-09-07 00:00:00", "currency_number": "CNY", "settlecurrency_number": "CNY", "creator_number": "administrator", "modifier_number": "administrator", "exchangerate": 1, "paymode": "CASH", "billtype_number": "pm_PurOrderBill_STD_BT_S", # 标准采购订单 "biztype_number": "110", # 物料类采购 "exratetable_number": "ERT-01", # 固定汇率 "trdbillno": f"{suffix}cs", # ★ 唯一值 "billentry": [{ # ★ 明细数组 key 是这个 "material_number": "Item-00000001", # ★ GUI 手工建的物料 "qty": 10, "priceandtax": 10, # ★ 不是 taxprice "taxrate": 6, "taxrateid_number": "V6", # ★ 税率档 "entryrecorg_number": "hmr", "entrysettleorg_number": "hmr", "entryreqorg_number": "hmr", "owner_number": "hmr", # ★ 货主=组织 "ownertype": "bos_org", # ★ 不是 bd_supplier "unit_number": "jin", "deliverdate": "2026-09-10 00:00:00", "discounttype": "A", "linetype_number": "010", # 物资 }], } 1) 建单 r = call_full("/v2/pm/pm_purorderbill/add", {"data": [rec]}) assert str(r.get("errorCode")) == "0", r rid = r["data"]["result"][0]["id"] 2) 提交(复数 ids) r2 = call_full("/v2/pm/pm_purorderbill/batchSubmit", {"data": {"ids": [rid]}}) assert str(r2.get("errorCode")) == "0", r2 3) 审核(复数 ids) r3 = call_full("/v2/pm/pm_purorderbill/batchAudit", {"data": {"ids": [rid]}}) assert str(r3.get("errorCode")) == "0", r3 print(f"✅ 采购订单 {rec['billno']} 已审核(C)") ``` **运行结果:** ``` add ec=0 → submit ec=0 → audit ec=0 ✅ 采购订单 POF834365 已审核(C) 成功 ``` 系统自动完成价税计算:含税单价 10 → 不含税单价 9.434、金额 94.34、税额 5.66、价税合计 100.00。 五、固化成 MCP 工具,让能力可复用 调通之后,我把这套逻辑封装成了 MCP 工具 `flagship_add_po`,以后一句话就能建单。 > **注意**:MCP 服务端原有的 `_call` 封装只返回 `data` 内层,会**丢掉顶层的 `errorCode`**,导致无法判断写操作成败。必须新增一个返回完整响应的底层调用: ```python def _call_full(path: str, body: dict) -> dict: """返回完整响应(含 errorCode),用于判断写操作成败""" token = _get_token() headers = {"Content-Type": "application/json", "accessToken": token} r = httpx.post(f"{BASE_URL}{path}", json=body, headers=headers, timeout=60) r.raise_for_status() return r.json() @mcp.tool() async def flagship_add_po(linetype="010", material_number="Item-00000001", qty=10, priceandtax=10, ...): """创建采购订单并自动提交+审核""" ... ``` 工具已把所有陷阱写进 docstring,参数全带默认值,`billno`/`trdbillno` 自动生成唯一值。 六、复盘:四个可复用的方法论 1. **不要相信"返回成功"**。凡是金额/引用类字段,建完单一定要**读回校验**。这次两个最隐蔽的坑(`taxprice`、错误的 `ownertype`)都是靠读回才发现的。 2. **卡住时去读"已经成功的那张单"**。金蝶里手工建的单据是最好的参照物。用 `query` 读回来逐字段比对,比猜 key 名高效得多(我盲猜 32 个 key 全失败,读回一次就破局)。 3. **区分"枚举"与"基础资料"报错**。 - 枚举值错:`XX输入了未知的枚举值` → 可以暴力枚举试 - 基础资料错:`"XX"下"编码=YY"导入不成功` → 必须拿真实编码,猜是没用的 4. **每解决一个必填字段,可能冒出下一个**。行类型 → 第三方业务编码 → 货主,是串行的。要有心理准备,别指望一次通过。 --- 附:常用查询备忘 ```python # 查询(通配符是 * 不是 %;% 会被当字面量返回 0 条) {"data": {"billno": "*"}} # getList(需起止业务时间) {"data": {"start_biztime": "2020-01-01 00:00:00", "end_biztime": "2030-01-01 00:00:00"}} ``` 单据状态:**A = 暂存**(API 新建后默认)、**B = 提交/待审**、**C = 已审核**。 --- *本文基于真实项目实践整理,涉及的企业域名、账套、密钥均已脱敏。*