三面拆分⑮:对齐 Mastra 数据流 —— 边只表达顺序,数据按输入/输出字段名流动
用户定的机制对齐:Mastra 的边不传数据,所有约束力靠输入/输出字段(schema 咬合)完成。
照此把「边映射」整个退役:
· 契约:edge 去 mapping,审批 reject 去 mapping —— 边只剩 顺序 + 条件(读上游
输出字段)+ 标签/回边。fieldMapping 只留给触发器的起单装配(系统边界)。
存量包无需迁移:zod 解析时把旧 jsonb 里的 mapping 静默剥掉,图照常有效。
· 运行时:assemble(边装配)→ flatScope(铺平作用域)——
起单入参垫底 ← 沿途各输出口字段依次盖上 ← 刚发射的载荷(Mastra 语义:
下一步的 input 首先是上一步的 output)。下游按自己的契约取字段;
「__」前缀是运行时自留地,不入业务作用域。审批驳回重开、失败改道同一条路。
· 闭合校验:边未映射/必填未映射/容忍依赖三条映射类检查退役
(宽进之下必填有没有着落是运行时补全单的事,不拦激活);
激活闸只留一条硬伤 —— **同名不同型**:名字即管道,名同型异必坏,
容忍依赖改按名判。改名映射不存在:名不对改契约,不是加映射。
· 种子:A2 的入参 material 更名 pn(它就是 PN;上游 A1 发的名就得是它收的名),
契约 0.3.0、槽位 0.8.0;流程种子边全部去映射。
· 前端:边属性卡砍掉「映射 · 载荷装配」整节,只剩 E号/方向/条件/标签;
节点属性卡新增「数据接口 · 按字段名流动」——每个输入字段标出运行时从哪来
(起单入参 / 上游 X / 补全单),输出口列出发什么名;沿边回溯推导,设计期看得见。
画布保存与依赖文件导出同步去映射。
E2E 顺带把宽进接进真流:快速归档流的 A3 要 outcome 而流里没人产 ——
测试如实走补全路(等人 → 答卡 outcome=won → 开跑 → completed)。
实测走查:A2 节点卡上 pn←上游A1、rfqRef←起单入参、scenario←补全单,推导全对。
五闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -252,7 +252,25 @@ describe.skipIf(database === undefined)('从零建一条新流并跑通 ——
|
||||
await container.process.onApprovalVerdict(record.id, 'A2', 'pass', { by: chaihao });
|
||||
await pump();
|
||||
|
||||
// A3 归档:再一张收口验收
|
||||
/*
|
||||
* A3 归档要 `outcome`,这条流里没人产它 —— **宽进**(2026-07-30):
|
||||
* 缺必填不拒单,任务落「等人」、补全卡进对话流;答上,活动才开跑。
|
||||
* 这正是「调用活动输入可以不定,进活动受理归一,信息不全派补全单」在真流里的样子。
|
||||
*/
|
||||
const a3Task = (await container.process.instancesOf(record.id)).find((i) => i.nodeId === 'A3');
|
||||
expect(a3Task?.taskId, 'A3 已进入(等补全,不是没起)').toBeDefined();
|
||||
const held = await container.task.get(a3Task!.taskId as never);
|
||||
expect(held.runtime, '缺 outcome —— 等人补全').toBe('awaiting-human');
|
||||
const cards = await container.humanTool.chatCards(a3Task!.taskId as never);
|
||||
const ask = cards.find((c) => c.k === 'elicitation' && c.done === undefined);
|
||||
expect(ask, '补全卡在对话流里').toBeDefined();
|
||||
await container.humanTool.replyChatCard(
|
||||
{ k: 'elicitation', cardId: ask!.id as never, values: { outcome: 'won' } },
|
||||
chaihao as never,
|
||||
);
|
||||
await pump();
|
||||
|
||||
// A3 归档:补全齐了,照旧一张收口验收
|
||||
await acceptCurrent(record.id);
|
||||
|
||||
expect(await seq(record.id)).toEqual([
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('MVP_PACKAGE', () => {
|
||||
const rejectEdge = outs.find((e) => e.to === 'A4')!;
|
||||
expect(rejectEdge.reopen).toBe(true);
|
||||
expect(rejectEdge.to).toBe(a5.reject.nodeId);
|
||||
expect(rejectEdge.mapping).toEqual(a5.reject.mapping);
|
||||
// 边不带映射(对齐 Mastra):重开载荷 = 铺平作用域,A4 按自己的契约取字段
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -24,11 +24,6 @@ const activityNode = (id: string, label: string, activityId: string, col: number
|
||||
|
||||
|
||||
/** 受理口的两枚去向旗与记录 —— A4 重新入场(回边 / 驳回)时照此装配 */
|
||||
const REENTER_A4 = [
|
||||
{ target: 'records', source: { from: 'output-port' as const, port: 'intake', path: 'records' } },
|
||||
{ target: 'customerCode', source: { from: 'initiation' as const, path: 'customerCode' } },
|
||||
];
|
||||
|
||||
export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
id: 'unified-quote',
|
||||
name: '统一报价',
|
||||
@@ -69,7 +64,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
* 引擎先看图(有边走边,没边才退回本声明),两处须字字对齐 —— 有测试守着。
|
||||
* 本声明不能删:schema 必填,且它是通用机制(不是每条流都把驳回画成边)。
|
||||
*/
|
||||
reject: { kind: 'reopen', nodeId: 'A4', mapping: REENTER_A4 },
|
||||
reject: { kind: 'reopen', nodeId: 'A4' },
|
||||
nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' },
|
||||
layout: { row: 1, col: 5, x: 290, y: 310 },
|
||||
},
|
||||
@@ -83,13 +78,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
id: 'E1',
|
||||
from: 'start',
|
||||
to: 'A1',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
{ target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } },
|
||||
{ target: 'subject', source: { from: 'initiation', path: 'subject' } },
|
||||
{ target: 'body', source: { from: 'initiation', path: 'body' } },
|
||||
{ target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
|
||||
@@ -100,11 +88,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
to: 'A2',
|
||||
condition: 'needAlternative = true',
|
||||
label: '需要选型',
|
||||
mapping: [
|
||||
{ target: 'material', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
{ target: 'needSourcing', source: { from: 'output-port', port: 'intake', path: 'needSourcing' } },
|
||||
{ target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{
|
||||
@@ -113,11 +96,10 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
to: 'A3',
|
||||
condition: 'needAlternative = false and needSourcing = true',
|
||||
label: '直接寻源',
|
||||
mapping: [{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }],
|
||||
reopen: false,
|
||||
},
|
||||
// 默认兜底:不选型不寻源 → 直接定价(条件全不中时走它,旗齐时它就是第三种情形)
|
||||
{ id: 'E4', from: 'A1', to: 'A4', label: '直接定价', mapping: REENTER_A4, reopen: false },
|
||||
{ id: 'E4', from: 'A1', to: 'A4', label: '直接定价', reopen: false },
|
||||
|
||||
/* A2 的两条出边 —— 原 G2 在选型路径上的那一问 */
|
||||
{
|
||||
@@ -126,24 +108,18 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
to: 'A3',
|
||||
condition: 'needSourcing = true',
|
||||
label: '仍需寻源',
|
||||
mapping: [{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'E6', from: 'A2', to: 'A4', label: '选型后定价', mapping: REENTER_A4, reopen: false },
|
||||
{ id: 'E6', from: 'A2', to: 'A4', label: '选型后定价', reopen: false },
|
||||
|
||||
{
|
||||
id: 'E7',
|
||||
from: 'A3',
|
||||
to: 'A4',
|
||||
label: '供应商底价表',
|
||||
mapping: [
|
||||
...REENTER_A4,
|
||||
// 底价只在寻源路径上有;A1/A2 直达的两条边不带它,A4 的 floors 非必填
|
||||
{ target: 'floors', source: { from: 'output-port', port: 'compare', path: 'floors' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'E8', from: 'A4', to: 'A5', mapping: [], label: '报价草案提审', reopen: false },
|
||||
{ id: 'E8', from: 'A4', to: 'A5', label: '报价草案提审', reopen: false },
|
||||
|
||||
/* A5 审批点自己的两条条件出边 —— 裁决(verdict)由引擎在裁决时放进载荷 */
|
||||
{
|
||||
@@ -153,11 +129,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
condition: 'verdict = "pass"',
|
||||
label: '通过',
|
||||
tone: 'ok',
|
||||
mapping: [
|
||||
{ target: 'rows', source: { from: 'output-port', port: 'draft', path: 'rows' } },
|
||||
{ target: 'quote_no', source: { from: 'output-port', port: 'draft', path: 'quote_no' } },
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{
|
||||
@@ -167,7 +138,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
condition: 'verdict = "reject"',
|
||||
label: '不通过',
|
||||
tone: 'bad',
|
||||
mapping: REENTER_A4,
|
||||
// 回边:与 A5 的 reject 声明同一去向;reopen 必标,否则成环、拓扑序崩
|
||||
reopen: true,
|
||||
},
|
||||
@@ -180,7 +150,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
condition: 'outcome = "won"',
|
||||
label: '成交',
|
||||
tone: 'ok',
|
||||
mapping: [],
|
||||
reopen: false,
|
||||
},
|
||||
{
|
||||
@@ -190,7 +159,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
condition: 'outcome = "revise"',
|
||||
label: '有异议',
|
||||
tone: 'bad',
|
||||
mapping: REENTER_A4,
|
||||
reopen: true,
|
||||
},
|
||||
{
|
||||
@@ -200,16 +168,9 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
condition: 'outcome = "lost"',
|
||||
label: '拒绝/超时',
|
||||
tone: 'bad',
|
||||
mapping: [
|
||||
// outcome 由放行选口注入落地事实,随每个发射口的载荷同行(不是 formal 声明的字段)
|
||||
{ target: 'outcome', source: { from: 'output-port', port: 'formal', path: 'outcome' } },
|
||||
{ target: 'quote_no', source: { from: 'output-port', port: 'formal', path: 'quote_no' } },
|
||||
{ target: 'customer_code', source: { from: 'output-port', port: 'formal', path: 'customer_code' } },
|
||||
{ target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'E14', from: 'A7', to: 'end_arch', mapping: [], label: '归档闭环', reopen: false },
|
||||
{ id: 'E14', from: 'A7', to: 'end_arch', label: '归档闭环', reopen: false },
|
||||
],
|
||||
variables: [],
|
||||
/*
|
||||
@@ -340,14 +301,6 @@ const TRIGGER_EVENT = triggerConfigSchema.parse({
|
||||
name: '客户RFQ询价',
|
||||
enabled: true,
|
||||
processPackageId: 'unified-quote',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customerCode' } },
|
||||
{ target: 'rfqRef', source: { from: 'expression', sfeel: 'keys.rfqRef' } },
|
||||
{ target: 'subject', source: { from: 'expression', sfeel: 'subject' } },
|
||||
{ target: 'body', source: { from: 'expression', sfeel: 'body' } },
|
||||
// 单据日期取入站时刻:`intel.history_price` 的主键是 (pn, quoted_at),归档拿不到日期就写不进去
|
||||
{ target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } },
|
||||
],
|
||||
// 按**通道**收口:同一封信投到「询价待确认邮箱」不命中,落人工确认那条路
|
||||
spec: {
|
||||
kind: 'event',
|
||||
@@ -362,7 +315,6 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({
|
||||
name: '人工登记起单(统一报价)',
|
||||
enabled: true,
|
||||
processPackageId: 'unified-quote',
|
||||
mapping: [],
|
||||
spec: { kind: 'registration' },
|
||||
});
|
||||
|
||||
|
||||
@@ -262,10 +262,11 @@ const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') =
|
||||
* 六个槽位一律 flow 范式;预算固定 humanCalls: 2。
|
||||
* 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。
|
||||
* 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。
|
||||
* 契约的入参出口一直没动,故 `contract.version` 始终 0.1.0。
|
||||
* 0.8.0:对齐 Mastra 数据流 —— A2 的入参 `material` 更名 `pn`(它就是 PN):
|
||||
* 边不再带映射,数据按字段名流动,上游发的名就得是下游收的名。
|
||||
* 在途任务按启动时版本跑完,不受影响。
|
||||
*/
|
||||
packageVersion: '0.7.0',
|
||||
packageVersion: '0.8.0',
|
||||
category: '统一报价',
|
||||
paradigm,
|
||||
sdkVersion: '0.1.0-m5',
|
||||
@@ -401,14 +402,14 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
identity: identity('alt-selection', '替代选型', 'flow'),
|
||||
contract: {
|
||||
activityId: 'alt-selection',
|
||||
// 0.2.0:入参加 rfqRef —— 办结时要回写「受理结论」那一行,主键就是它
|
||||
version: '0.2.0',
|
||||
// 0.3.0:material 更名 pn(对齐 Mastra 名流动 —— 上游 A1 发的就是 pn)
|
||||
version: '0.3.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['material', 'rfqRef'],
|
||||
required: ['pn', 'rfqRef'],
|
||||
properties: {
|
||||
material: { type: 'string' },
|
||||
pn: { type: 'string' },
|
||||
chip_type: { type: 'string' },
|
||||
spec_params: { type: 'string' },
|
||||
scenario: { type: 'string' },
|
||||
@@ -464,7 +465,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一件替代评估',
|
||||
input: { material: 'LM358DR' },
|
||||
input: { pn: 'LM358DR' },
|
||||
expect: { ports: ['benchmark'] },
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user