From 15779272140d746f6bb886d5ae0914311ff47dce Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 05:48:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A4=E6=8D=AE=20=E2=91=A8=20=E8=A7=A3?= =?UTF-8?q?=E9=94=81:=E9=80=89=E6=8B=A9=E7=BB=84=20=E2=86=92=20=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=20=E2=86=92=20=E7=BD=91=E5=85=B3=E5=88=A4=E6=8D=AE,?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E6=96=AD=E7=82=B9=E6=8E=A5=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A6 放行选了 lost,却始终走不到 A7。三处各断一截: ① 装配输出口只看**必交付口**。A6 根本没有必交付口(它的输出是选择组 won/revise/lost),于是得到一张空表,下游连判据都读不到。改为必交付口与 选择组的口一并装配。 ② `onTaskTerminal` 只取**第一口**的载荷做路由作用域;没有必交付口时就什么都拿不到。 改为各口载荷合并成一份。 ③ 网关拿到的是**边映射装配后**的载荷,而 A6→G4 这条边没有映射,装配出来是空的 —— 判据依据在这一步整个丢掉,异或网关一条分支都选不中,流悄无声息地停住。 改为:网关按**到达的东西**路由(上游载荷原样穿过);活动与审批点仍只收装配后的载荷, 它们有契约,不该被上游多余字段污染。 另立一条约定并写进注释:**选择组发射的那一口就是这次运行的 outcome** (「恰发一口,随放行一并确认」),下游异或网关的判据读的就是它。 现在:A6 放行选 lost → G4 按 `outcome = "lost"` 判定 → **A7 情报归档起到冯兵工位**。 gates: tsc / eslint / depcruise / 399 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../inbound/rfq-20260728B@x.com.eml | 12 ++++++ .../inbound/rfq-20260728C@x.com.eml | 12 ++++++ .../inbound/rfq-20260728D@x.com.eml | 12 ++++++ .../inbound/rfq-20260728E@x.com.eml | 12 ++++++ apps/api/src/app/wiring/engines.ts | 37 ++++++++++++++++--- .../internal/usecase/process-service.ts | 15 ++++++-- 6 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 .data/attachments/inbound/rfq-20260728B@x.com.eml create mode 100644 .data/attachments/inbound/rfq-20260728C@x.com.eml create mode 100644 .data/attachments/inbound/rfq-20260728D@x.com.eml create mode 100644 .data/attachments/inbound/rfq-20260728E@x.com.eml diff --git a/.data/attachments/inbound/rfq-20260728B@x.com.eml b/.data/attachments/inbound/rfq-20260728B@x.com.eml new file mode 100644 index 0000000..6b25544 --- /dev/null +++ b/.data/attachments/inbound/rfq-20260728B@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-20260728B@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C300 RFQ-20260728B 求报价", + "body": "客户编码 C300,询价单号 RFQ-��\nPN-FULL-003 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T20:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-20260728C@x.com.eml b/.data/attachments/inbound/rfq-20260728C@x.com.eml new file mode 100644 index 0000000..f0e7403 --- /dev/null +++ b/.data/attachments/inbound/rfq-20260728C@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-20260728C@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C400 RFQ-20260728C 求报价", + "body": "客户编码 C400,询价单号 RFQ-��\nPN-FULL-004 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T20:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-20260728D@x.com.eml b/.data/attachments/inbound/rfq-20260728D@x.com.eml new file mode 100644 index 0000000..032e97d --- /dev/null +++ b/.data/attachments/inbound/rfq-20260728D@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-20260728D@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C500 RFQ-20260728D 求报价", + "body": "客户编码 C500,询价单号 RFQ-��\nPN-FULL-005 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T20:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-20260728E@x.com.eml b/.data/attachments/inbound/rfq-20260728E@x.com.eml new file mode 100644 index 0000000..3bfb263 --- /dev/null +++ b/.data/attachments/inbound/rfq-20260728E@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-20260728E@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C600 RFQ-20260728E 求报价", + "body": "客户编码 C600,询价单号 RFQ-��\nPN-FULL-006 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T20:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 50895af..26a6fbd 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -348,13 +348,27 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 输出口声明的属性名与台账列名可能不同形(驼峰 / 下划线),两种写法都认。 */ function outputPortsOf( - manifest: { contract: { output: { mandatory: ReadonlyArray<{ name: string; payload: unknown }> } } }, + manifest: { + contract: { + output: { + mandatory: ReadonlyArray<{ name: string; payload: unknown }>; + selective?: ReadonlyArray<{ group: string; ports: ReadonlyArray<{ name: string; payload: unknown }> }>; + }; + }; + }, landed: LandedFacts, ): Record> { const snake = (name: string): string => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); const ports: Record> = {}; - for (const port of manifest.contract.output.mandatory) { + // 必交付口与选择组的口都要装配:活动可以只有选择组(如 A6 的成交/有异议/拒绝), + // 只看必交付口会得到一张空表,下游连分支判据都读不到。 + const declaredPorts = [ + ...manifest.contract.output.mandatory, + ...(manifest.contract.output.selective ?? []).flatMap((g) => g.ports), + ]; + + for (const port of declaredPorts) { const payload = port.payload as { properties?: Record }; const declared = Object.entries(payload.properties ?? {}); const picked: Record = {}; @@ -442,10 +456,21 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { ledgerRowsMerged: outcome.ledgerRowsMerged, // 输出口载荷 = 已落地的事实:铺平供标量取值,逐行供数组取值 facts: { - flat: outcome.merged.reduce>( - (acc, row) => ({ ...acc, ...row.key, ...row.values }), - {}, - ), + flat: { + ...outcome.merged.reduce>( + (acc, row) => ({ ...acc, ...row.key, ...row.values }), + {}, + ), + /* + * **选择组发射的那一口就是这次运行的 outcome**。 + * 「恰发一口,随放行一并确认」——那一口本身就是这一单的分支结论, + * 下游异或网关的判据读的就是它(如 G4:成交 / 有异议 / 拒绝)。 + * 只有选择组才产生 outcome;必交付口是「都要交」,不构成选择。 + */ + ...(Object.values(input.selectedPorts).length > 0 + ? { outcome: Object.values(input.selectedPorts)[0] } + : {}), + }, rows: outcome.merged.map((r) => ({ key: r.key, values: r.values })), }, }; diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 98bb94c..309ac00 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -177,8 +177,16 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { } const assembled = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression); + /* + * 网关不是责任单元,**按到达的东西路由**:它没有入参契约要满足, + * 上游的输出口载荷要原样穿过去,判据才读得到(如 G4 读 outcome)。 + * 若只给边映射装配出来的那份,无映射的边就把判据依据整个丢掉, + * 异或网关会一条分支都选不中,整条流悄无声息地停住。 + * 活动与审批点仍只收**装配后的**载荷 —— 它们有契约,不该被上游多余字段污染。 + */ + const arriving = target.kind === 'gateway' ? { ...payload, ...assembled } : assembled; try { - await enter(record, pkg, target, assembled); + await enter(record, pkg, target, arriving); } catch (error) { /* * 一条分支起不来,不该把整条终态上报连坐掀翻 —— 那会让**上游任务的终态也报不上去**, @@ -297,8 +305,9 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { const merged = await deps.store.updateWorkflow(record.id, { ports: { ...record.ports, ...ports } }); if (terminal === 'released') { - // 落地收敛后才发射输出口 —— 下游只见落地完成的任务 - const payload = Object.values(ports)[0] ?? {}; + // 落地收敛后才发射输出口 —— 下游只见落地完成的任务。 + // 各口载荷合并成一份路由作用域:只取第一口,活动没有必交付口时就什么都拿不到。 + const payload: Record = Object.assign({}, ...Object.values(ports)); await advanceFrom(merged, pkg, located.nodeId, payload); } else if (node?.kind === 'activity') { // 非完成终态触发**声明的失败走向**;流程层不自动重试