轻易云
注册体验

集成中心架构:PULL × PUSH × 集成转换的三大引擎

· 冯潇· AI 财务对账· 78 次浏览· 约 14 分钟读完
集成中心PULL金蝶 PUSH 推送集成转换ERP金蝶云星空第四类沙箱脚本三大引擎

集成中心 PULL PUSH 集成转换 三大引擎

摘要:把多平台对账结果送进 ERP,是电商财务系统集成最难的「最后一公里」。文章用一个生产级集成中心的真实工程数据,讲清三件事:① 为什么「拉 ERP 数据 + 下推财务单据」必须被设计成 PULL 与 PUSH 两个方向互不耦合的引擎,而不是一个开关;② 为什么中间必须隔一层「集成转换」——第四类沙箱脚本——把对账结果变成目标系统能懂的单据结构;③ 这三大引擎在 integration_handlers 表里如何被统一注册、按 docType 路由、状态机推进。读完之后你会看到,集成中心的复杂度不在于「能不能对接金蝶」,而在于「目标系统换了、字段变了、规则改了,能不能不改代码就接住」。

关键词:集成中心、PULL、PUSH、集成转换、ERP 集成、金蝶云星空、第四类沙箱脚本、三大引擎

一份生产数据:把对账结果推给金蝶的「最后一公里」有多复杂

2026 年 8 月 27 日,一家年 GMV 接近 12 亿的跨平台电商把整套对账链路跑通那天,集成中心的金蝶对接页上同时跑了 9 个 handler、撑起了 164 条转换规则、生成了 232 张转换单据,合计 9,345,659.28 元。从下午 14:00 触发 run-sync 到全部 PUSHED,跨越 5 个平台、6 类单据形态、3 种单据分类(应收单 / 应付单 / 收款单)。其中光是「暂估应收单下推财务应收单」这一个场景就涉及:分录级独立单 vs 整单覆盖的合并单两种形态、暂存态草稿补 Submit/Audit 的恢复路径、Push 后回读产物校验金额的二次保护。

这套链路如果用「拉数据 → 改字段 → 直接调 ERP API」的三段式写法,开发确实快,但维护起来是另一种噩梦:

  • 「平台加一个新店铺」要改 5 处代码
  • 「金蝶加一个自定义字段」要重写 7 个 handler 的 payload
  • 「对账规则变了」要重新跑全量对账 + 全量转换 + 全量推送(每一步都不可逆)
  • 「切换目标系统(金蝶 → 用友 YonBIP)」相当于把整个集成层重写一遍

集成中心的解法是把链路拆成 PULL × PUSH × 集成转换 三个引擎,每个引擎只关心自己边界内的事,跨引擎通过三张表(integration_handlers / integration_tasks / transform_documents)+ 一个统一协议(IntegrationHandler 接口)解耦。下面用真实代码、真实数据、真实踩坑把这三个引擎的边界、责任、协作讲透。

一、集成中心全景:3 条引擎横向并列的统一架构

集成中心的全景是这样一张图(这是 arch-004-integration-center-architecture.jpg 的真实结构):

集成中心架构图,PULL  PUSH 集成转换三大引擎,金蝶云星空与对账系统的统一对接视图

横向看是 3 条引擎:左侧 PULL 引擎负责把 ERP 暂估单等数据拉回对账系统,中间集成转换引擎负责把对账结果按目标系统格式生成转换单据,右侧 PUSH 引擎负责把转换单据推送给 ERP 并完成状态流转。纵向看是 4 层基础设施:最上面是 BullMQ 异步队列与 JobTask 任务层,往下是 NestJS Fastify 的 HTTP + Worker 双进程层,再往下是 PostgreSQL 主数据层(integration_handlers / transform_documents / transform_rules / push_logs),最底下是金蝶云星空 ERP 与对账系统主进程(沙箱)之间的边界。

注意一个关键设计:集成转换引擎运行在沙箱里,PULL/PUSH 引擎运行在主进程里。集成转换脚本是「第四类沙箱脚本」,与解析脚本、对账脚本、费用分摊脚本一起共享同一种沙箱契约(sandbox-query.ts 注入的只读 query + Decimal)。但 PULL 与 PUSH handler 没有任何沙箱约束,它们需要直接构造 HTTP 请求、读 transform_documents 真实落库、调金蝶 API。这条边界是工程上的硬约束——「写账」必须由主进程负责,「算账」必须由沙箱隔离。

集成中心 9 个 handler 全景

集成中心方案页,金蝶云星空 9 个 handler 全景,按 PULL/PUSH 方向分组、按场景列出 code 与 name

这张是集成中心方案页的真实截图。9 个 handler 按 direction 字段分两组:左侧 2 个 PULL(health-check 健康检查 + ar-receivable.pull 暂估应收单拉取),右侧 7 个 PUSH(ar-fin-receivable.push 暂估→财务应收下推、ap-other-payable.push 其他应付单 Save、ar-expense-receivable.push 费用应收单 Save、ap-payable.push 费用应付单 Save、ar-receive-bill.push 收款单 Save、cn-bank-transfer.push 银行转账单 Save)。每个 handler 都有独立的 code / direction / isEnabled / configJson,由启动时的 IntegrationsBootstrap upsert 到 integration_handlers 表,运行时按 code 路由到内存的 HANDLER_REGISTRY。

二、PULL 引擎:把 ERP 数据拉回来,但绝不"自己造数据"

PULL 引擎的目标是把 ERP 里和电商业务强相关的数据(暂估应收单、采购入库单、付款单等)按需或定时拉回对账系统,落库到 supply_orders 表。这是整个集成链路的入口——没有正确的 PULL,下游的对账就找不到 ERP 的暂估单,集成转换就生成不出正确的分录匹配。

PULL handler 的契约在 apps/api/src/integrations/common/integration-handler.ts:12 里只有一行:

ts
export type IntegrationDirection = "PULL" | "PUSH";

但 PULL 行为的核心约束写在 handler 实现里——以金蝶云星空应收单 PULL 为例(ar-receivable.pull.ts),三个边界值得拎出来说:

边界一:只拉暂估,不拉业务应收。金蝶的应收单 formId 是统一的 AR_receivable,区分业务应收、暂估应收、财务应收的是 FSetAccountType 字段(1 业务 / 2 暂估 / 3 财务)。PULL handler 在 FilterString 里硬编码 FSetAccountType='2',业务应收和财务应收都不拉——为什么?因为业务应收的金额没有 ERP 的钩稽概念、财务应收是 PUSH 引擎的产物,重复拉只会把 supply_orders 表污染。filter 设计错了,下游对账会把"自己推送出去的单子"当作"ERP 暂估单"再匹配一次,账单就会对平变成对不平。

边界二:FieldKeys 不是越多越好,是"够用就好"。金蝶应收单 QueryBusinessInfo 返回的候选字段有 60+,但实际接入时只验证出 46 个稳定可用(剩 12 个破坏查询或返回全空行)。这条经验是 2026-07-20 真实验证出来的——46/48 字段可用,另有 BASICUNITQTY 和 FSTOCKID.FNumber 是已知"破坏性字段",单独加就导致整个查询返回空行。QueryBusinessInfo 有 ≠ ExecuteBillQuery 可查。新字段接入必须走「BASE + 逐个验证」流程(先用已验证的 BASE 字段集逐个叠加候选字段),不是抄元数据。

边界三:分页 + 翻页 + 幂等三件套。金蝶返回的是 2D 数组,分页靠 nextStartRow = startRow + pageSize + rowsReturned >= pageSize 才翻页。集成中心的 ErpWorker(apps/api/src/integrations/erp/erp.worker.ts)通过 enqueuePage + findExistingPageTask 实现翻页链的幂等:每张翻页子任务有确定性 jobId({code}-{fromDate}-{toDate}-{startRow}-{limit}-{filterHash}),同参数窗口下历史任务已存在则直接跳过,不会重跑。更精妙的是 2026-09-02 的一个坑:原版 buildPageJobId 不含 filterString,同一 fromDate/toDate 窗口换 filterString(如换店铺)会导致翻页链 jobId 撞车断链——修复就是给 jobId 追加 filterString 的 sha256 前 8 位。

PULL 引擎的边界总结成一句话:只读,不写;只拉需要的,不贪多;分页幂等,可重跑。集成中心的 PULL 不做任何"加工"——拉回来的原始数据就是 supply_orders 表里的一行,分录拆分、SKU 聚合、店铺映射都是对账引擎的事,不归 PULL 管。

三、集成转换引擎:第四类沙箱脚本,对账结果 → 目标系统单据

集成转换引擎是整个集成中心最特殊的一环——它不是 handler,而是沙箱脚本。沙箱脚本的 4 个分类(解析 / 对账 / 费用分摊 / 集成转换)在 arch-006 这张图里:

沙箱机制架构图,双层隔离  4 类脚本,集成转换脚本作为第四类沙箱脚本的定位

集成转换脚本(Integration Transform Script)和其他三类沙箱脚本共享同一套沙箱契约:禁止 import / fetch / 直写库,禁止网络访问,只能用沙箱注入的只读 query 和 Decimal。但它有一个独特之处——消费的不是原始账单,而是对账结果(IncomePlanItem + materialItems / ExpenseAggregation),输出不是中间表,而是目标系统能直接调用的单据结构(transform_documents,含 head + entries + payload JSONB)。

集成转换脚本的骨架(arch-020-transform-skeleton.jpg)是这样的:

集成转换通用骨架,transform_documents  payload JSONB 四层结构:单据头 体行 payload sourceRefs 状态机

第一层:入参(TransformInput)——脚本只读 input.scriptKind(INCOME / EXPENSE,Q9 严格分离)+ input.incomeItems 或 input.expenseAggregations(单侧喂数,Q9)+ input.shop + input.accountingItems + input.transformRules。脚本不知道也不关心平台名(platform)——平台口径在 transform_rules 里,规则表的「唯一键」是五元组 (sourceType, accountingItemId, amountKind, direction, targetSystem),不带 platform 维度。这意味着新增「拼多多」转换 = 加规则,零代码。

第二层:返回骨架——targetSystem 自由文本(不是枚举,枚举即硬编码)+ documents[](每张 = { docType, head, entries[] })+ unmatched(无规则命中行汇总,禁止静默丢弃)+ columnSchema + editableFields(金额语义键禁入,Q3e)。脚本输出的 head/payload 内部结构系统不解释——金蝶的 head.orderNo / 用友的 head.voucherType / SAP 的 head.BUKRS 全部由脚本自己定义,系统只负责把这份结构原封不动落进 transform_documents.payload JSONB 列。

第三层:状态机 transform_documents.status——DRAFT → CONFIRMED → PUSHING → PUSHED / FAILED,PUSHING 是 PUSH 引擎接管的入口;FAILED/PUSHED 可经 confirm 重回 CONFIRMED 重新推送(不可逆操作走另一条通道)。这条状态机是三大引擎协作的「接力棒」:集成转换脚本不知道 PUSH 何时执行,PUSH 引擎不知道集成转换何时生成——两者完全靠 transform_documents.status + direction 解耦。

第四层:推送就绪契约——PUSH handler 不回查业务主数据(店铺 / 核算项目 / 转换规则),所有推送所需的业务键必须由转换脚本携带进 head/payload。这条契约 2026-08-24 推送设计整体定稿后变为硬约束,缺哪个键 PUSH 直接 FAILED 并写明缺哪个——不猜、不兜底、不出错误单据。比如暂估→财务应收的 ar-fin-receivable.push 强制要求 head.orderNo(= businessOrderNo),因为这个键是匹配 supply_orders.businessOrderNo → 暂估单 FID(→ Push Ids 入参)的唯一通路。

集成转换脚本的工程纪律浓缩成一张表(来自 integration-transform-script-development skill 的硬约束清单):

#约束出处
1scriptKind 严格分离(INCOME/EXPENSE 一脚本一侧)Q9
2批次级单次执行(一次调用 = 一套输出,不逐行拆批)沙箱契约
3金额一律禁入 editableFields(Q3e「金额一律禁改」)Q3e
4禁网络 / 禁 import / 禁直写库(沙箱禁网络)沙箱契约
5规则表无 platform 维度(新增平台 = 加规则,零代码)规则表设计
6未匹配不丢数据(无规则 / 异常来源行全进 unmatched)沙箱契约
7差额单带符号,不乘规则 sign(差额 = 账单净额 − 下推金额)PDD v1.1.0 实战
8配平不变式:财务应收 + 费用应收 = 账单净额批次验收锚点

这条约束清单对应到的工程现实是:「换目标系统 = 换脚本 + 换规则取值,零代码」。金蝶脚本处理金蝶 formId,用友脚本处理 YonBIP 单据类型,SAP 脚本处理 BUKRS/BELNR 字段——三套脚本共用同一个集成转换沙箱引擎、共用同一份 transform_documents 状态机、共用同一套 PUSH 路由规则。

集成转换完整流程

集成转换脚本只负责「算账」,从转换到推送的完整流程由集成中心统一调度(proc-005-integration-transform-flow.jpg):

集成转换完整流程图,5 个阶段:对账结果 主数据 转换规则 沙箱转换 transform_documents 推送引擎

5 个阶段横向并列:第 1 阶段(对账结果,从 IncomePlanItem + materialItems / ExpenseAggregation 取数)→ 第 2 阶段(主数据,从 shop / accountingItems 预加载)→ 第 3 阶段(转换规则,从 transform_rules 按 targetSystem 过滤 isActive)→ 第 4 阶段(沙箱转换,Integration Transform Script 跑批,落 transform_batches + transform_documents)→ 第 5 阶段(推送引擎,按 docType 路由到 PUSH handler)。沙箱与主进程之间只有 transform_documents 这张表,没有其他耦合通道。

四、PUSH 引擎:把转换单据「真实落地」到 ERP

PUSH 引擎是三大引擎里最复杂的一环——它要解决四个真实的工程难题:

  1. 金蝶写操作返回 HTTP 200 + ResponseStatus.IsSuccess=false——必须用 isKingdeeSuccess(resp) 判定,失败用 extractKingdeeErrors(resp) 取完整报错原文,不能只看 HTTP 状态码
  2. Push 产物是暂存态(A 态),必须继续走 Submit → Audit 才能成为审核态(C 态),中间任意一步失败都要可恢复
  3. docType 三段式路由:AR_receivable|YSD01_SYS|FIN_FROM_HOOK(formId|billTypeId|scenario),按 integration_handlers.configJson.docTypes 声明为唯一事实源,精确优先 / 前缀兜底,0 命中 404、多命中 409
  4. 金蝶会"已完全下推"成功但本地以为失败——Push 报"已完全下推"时按 orderNo 反查已有财务单做终态确认,已审核(C)直接认领回填 externalBillNo,暂存态(A)草稿补 Submit/Audit 后认领

PUSH 引擎的流程以金蝶云星空为例(proc-007-kingdee-push-flow.jpg):

金蝶云星空 PUSH 推送流程图,4 个阶段:从转换单据到审核态,Push 下推  Submit  Audit 状态机写回

4 个阶段:CONFIRMED 转换单据 → 选 docType 路由 → 锁单据防并发(CONFIRMED→PUSHING)→ 真实调 Push API → Submit → Audit → 状态机写回 PUSHED。每张单据的每次金蝶调用都单独写 integration_push_logs 一行(2026-09-16 需求 37 定稿),含 requestDetail(formId / orderNo / hookBillIds / hookEntryIds / fullFids / subsetPlans)+ responseDetail(billNo / 报错原文)。修复了「subset 循环只留首条响应」的异常回放盲区。

PUSH handler 里最有代表性的金蝶 Push 整单/分录级判定逻辑(ar-fin-receivable.push.ts:208-230)值得拎出来看:

ts
const fullFids: string[] = [];
const subsetPlans: Array<{ fid: string; entryIds: string[] }> = [];
for (const [fid, entryIds] of pairsByFid) {
  const full = resolved.fidFullEntryIds[fid];
  const isFullCoverage = entryIds.length > 0 && full !== undefined && full.every((e) => entryIds.includes(e));
  // 合并单号(2026-09-10 用户定稿):同一暂估单命中分录属于多个线上订单时强制分录级下推——
  // 整单下推无分录钩稽(FSRCROWID=0),产物分录线上订单号无法按各自订单修正
  const forceEntryLevel = (fidOrderNos.get(fid)?.size ?? 0) > 1;
  if (isFullCoverage && !forceEntryLevel) fullFids.push(fid);
  else subsetPlans.push({ fid, entryIds });
}
// 推法交叉断言(2026-09-16 需求37 定稿):分录级独立单只允许分录级下推——检出整单覆盖
// fid 说明脚本分类与 handler 判定不一致(数据异常),直接 FAILED 不静默继续
if (partialMode && fullFids.length > 0) { ...FAILED... }

这段代码是 PUSH 引擎三大智慧的浓缩:

  • 整单 vs 分录级的双因子判定:覆盖率(isFullCoverage)× 合并单号因子(forceEntryLevel)——前者保证分录 ID 完全覆盖,后者防止「同一暂估单被多个线上订单拆分」的合并单号场景漏判
  • 推法交叉断言:脚本分类(partialMode)与 handler 判定(fullFids)不一致 → 数据异常 → 直接 FAILED,不静默继续——这是脚本与 handler 契约错配的「数据完整性护栏」
  • P3 逐次留痕:每次 Push 调用留痕(callIndex + 当次 Ids/EntryIds + 当次响应单号/错误)——修复「subset 循环只留首条响应」的异常回放盲区

PUSH handler 还有一个反直觉的工程经验值得说:configJson.simulate=true 不等于"推送成功"。simulate 模式只是不直连金蝶,只验证「读 transform_documents → 加工组装 → 状态机 → push_logs」链路。externalBillNo 带 SIM- 前缀的金蝶侧根本没有调用。2026-08-28 收款单实踩中曾出现过 simulate 假成功的单据被遗留「已推送」状态,切真实推送后才发现单据未入库。验收必须用真实金蝶账套推完再用 helper 的 purge 子命令清理回滚——这是用户定稿的验收口径。

五、三大引擎协作:从 DRAFT 到 PUSHED 的状态接力

三大引擎的关系不是上下游,是状态接力。每一棒只知道自己的入口条件,不知道上一棒怎么算出来的,也不知道下一棒怎么落地的:

引擎入口条件出口产物不知道的事
PULLintegration_handlers.isEnabled=true 且 run-sync 触发supply_orders 新增行对账规则、转换规则、PUSH 何时执行
集成转换incomePlan.status ∈ {RECONCILED, CONFIRMED} 或 expensePlan.status ∈ {READY, CONFIRMED}transform_documents.status=DRAFTPUSH 何时触发、金蝶 API 协议
PUSHtransform_documents.status=CONFIRMED 且 direction 命中 handlertransform_documents.status ∈ {PUSHED, FAILED} + integration_push_logs转换规则、对账逻辑

这张表的工程价值是**"换目标系统只改一处"**:金蝶 → 用友 YonBIP = 加一套 PULL handler + 写一套集成转换脚本 + 加一套 PUSH handler,三者之间用 targetSystem 字段串联,集成中心的状态机不动、队列不动、JobTask 不动。这就是为什么集成中心能撑住「9 个金蝶 handler × 164 条规则 × 232 张转换单据 × 5 平台」这种生产体量——边界清晰,每一棒只对自己的契约负责。

三大引擎的协作在 integration_handlers 表里被统一成一个事实源:每行 handler 用 direction 字段(PULL/PUSH)声明方向、用 code 字段({targetSystem}.{scenario}.{direction})声明身份、用 configJson 字段声明可调参数。运行时由 IntegrationsBootstrap 在启动时 upsert(下一篇文章会单独讲),由 ErpWorker 在队列里按 code 调度、由 HANDLER_REGISTRY 在内存里按 code 实例化。

集成中心的金蝶对接页之所以能撑住 9 个 handler + 164 条规则 + 232 张转换单据 + 9,345,659.28 元的真实生产体量,根本原因是三大引擎不共享任何业务逻辑、只共享 transform_documents 这一个数据契约。集成转换脚本对 PUSH 一无所知,PUSH 对集成转换一无所知——这种"各自为战"反而是工程上最强的可扩展性。

六、给集成架构师的三条工程纪律

最后从三个真实故障中提炼三条工程纪律——每一条都是集成中心真正上线后踩出来的。

纪律一:推法判定不能只在脚本里,handler 必须有交叉断言。集成转换脚本分单时把单据标为「分录级独立单」(scenario=FIN_FROM_HOOK_PARTIAL),PUSH handler 必须有对应的整单/分录级交叉断言——否则脚本分类与 handler 判定不一致会静默推错(2026-09-16 需求 37 定稿)。这条纪律的工程价值是契约错配早暴露,不要等金蝶报错才回头查是脚本算错了还是 handler 推错了。

纪律二:状态机写回必须带时间戳 + 错误信息回填路径。PUSH handler 在 writeBackDocument(this.prisma, doc.id, "FAILED", { errorMessage: msg }) 时必须把金蝶报错原文回填到 transform_documents.errorMessage,前端错误面板直接显示 已下推完毕 / 可下推数量为 0 等金蝶协议级报错——不要自己「翻译」成业务语言,业务用户要看的就是金蝶说什么。状态机的 confirmedAt = 推送完成时间(与 PUSHED 绑定),不占确认动作的字段。

纪律三:翻页链 jobId 必须含 filterString 哈希。2026-09-02 实踩一个隐蔽 bug:原版 buildPageJobId = code-fromDate-toDate-startRow-limit 不含 filterString,enqueuePage/enqueueNextPage 按 jobId 查历史任务(无时间界)命中即跳过。同一 fromDate/toDate 窗口换 filterString(如换店铺)重拉时,新链的翻页 jobId 与历史链逐个撞车 → 只拉第 1 页就断链。规避是给 jobId 追加 filterString 的 sha256 前 8 位(同时兼容旧版含 : 的 jobId),并把 limit 值加入防止不同 pageSize 串链。

这三条纪律加上三大引擎的硬边界,构成了集成中心的全部工程基线。


集成中心的设计哲学不是「对接更多 ERP」,而是「用 PULL × PUSH × 集成转换三个不可变引擎 + transform_documents 一个不可变数据契约撑住所有 ERP 集成」。轻易云智能对账系统的「集成中心」模块用 9 个金蝶 handler + 164 条规则 + 232 张转换单据的真实生产数据验证了这条路径——目标系统换了,handler 加一套、规则加一份、脚本写一个,集成中心的引擎不动、状态机不动、契约不动。下一篇会单独讲集成中心的 Bootstrap 机制——启动时如何把内存里的 handler 注册表 upsert 到 integration_handlers 表,以及为何「启动即一致」比「运行即一致」更可靠。

集成中心的完整工程实现涉及金蝶云星空的 11 个请求头 HMAC 双签名、FieldKeys 46/48 验证、Push 协议 + Submit + Audit 三段式状态流转、integration_push_logs 留痕机制等大量细节。进一步的开发与排障指引可参见项目内的金蝶云星空集成 skill 与集成转换脚本开发 skill(同名文章内 skill_refs 字段标注)。

本文为原创内容,转载请注明出处:https://www.qeasy.cloud/insights/reconciliation/6-1-1-integration-center-pull-push-transform-three-engines

评论