forked from ChaiHao/BPOS-MVP
三面拆分⑮:对齐 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'] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# module: process
|
||||
|
||||
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图不检查 · 不闭合不得激活)、
|
||||
职责: 流程层:流程包定义与版本、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
|
||||
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
|
||||
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
|
||||
画布的绘制(见 apps/web 的 gov-process)
|
||||
@@ -25,6 +25,12 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射,
|
||||
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
|
||||
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
|
||||
约束力全在两端活动的输入/输出 schema 上 —— 名字即管道,闭合校验只拦
|
||||
「同名不同型」这一条硬伤;必填有没有着落是运行时受理补全单的事,不拦激活。
|
||||
改名映射不存在:上游发的名就得是下游收的名,名不对改**契约**,不是加映射。
|
||||
- **宽进**(2026-07-30):`start` 不再按调用接口拒缺项 —— 输入可以不定或为空,
|
||||
收敛点在活动(任务侧受理归一 + 补全单)。`CallInterfaceViolationError` 保留导出,
|
||||
仅存量引用,起单路径不再抛它
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
|
||||
/** 闭合校验(名流动版)、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { checkClosure } from '../internal/domain/closure.ts';
|
||||
@@ -39,17 +39,9 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
|
||||
{ id: 'end' as never, kind: 'end', label: '终点' },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'start' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [
|
||||
{ target: 'pn', source: { from: 'initiation', path: 'pn' } },
|
||||
{ target: 'basePrice', source: { from: 'initiation', path: 'basePrice' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
// 边只表达顺序与条件(2026-07-30 对齐 Mastra)—— 数据按字段名流动,不带映射
|
||||
{ id: 'e1', from: 'start' as never, to: 'A4' as never, reopen: false },
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
||||
],
|
||||
variables: [],
|
||||
...over,
|
||||
@@ -58,40 +50,32 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
|
||||
const contracts = new Map([['a4', face()]]);
|
||||
|
||||
describe('checkClosure —— 不闭合不得激活', () => {
|
||||
it('必填全映射、岗位存在即可激活', () => {
|
||||
it('结构闭合、活动在库即可激活 —— 必填有没有着落是运行时补全单的事,不拦激活', () => {
|
||||
const report = checkClosure({ pkg: pkg(), contracts });
|
||||
expect(report.violations).toEqual([]);
|
||||
expect(report.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('边未完成映射即拦', () => {
|
||||
const broken = pkg({
|
||||
it('同名字段类型相抵即拦 —— 名字即管道,名同型异必坏', () => {
|
||||
// 上游 done 口发 quote: number …下游若声明 quote: string 就是硬伤
|
||||
const clash = new Map([
|
||||
['a4', face({ inputTypes: { pn: 'string', quote: 'string' }, requiredInputs: ['pn'] })],
|
||||
]);
|
||||
const chain = pkg({
|
||||
nodes: [
|
||||
{ id: 'start' as never, kind: 'start', label: '起点' },
|
||||
{ id: 'A3' as never, kind: 'activity', label: '上游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
|
||||
{ id: 'A4' as never, kind: 'activity', label: '下游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
|
||||
{ id: 'end' as never, kind: 'end', label: '终点' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: 'start' as never, to: 'A4' as never, mapping: [], reopen: false },
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
|
||||
{ id: 'e1', from: 'A3' as never, to: 'A4' as never, reopen: false },
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
||||
],
|
||||
});
|
||||
const report = checkClosure({ pkg: broken, contracts });
|
||||
expect(report.closed).toBe(false);
|
||||
expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped');
|
||||
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
|
||||
});
|
||||
|
||||
it('必填未齐即拦', () => {
|
||||
const partial = pkg({
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'start' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: partial, contracts }).violations;
|
||||
expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']);
|
||||
const violations = checkClosure({ pkg: chain, contracts: clash }).violations;
|
||||
expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']);
|
||||
});
|
||||
|
||||
it('引用未安装的活动即拦', () => {
|
||||
@@ -124,7 +108,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
|
||||
edges: [
|
||||
...pkg().edges,
|
||||
// A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规
|
||||
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false },
|
||||
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: twoDefaults, contracts }).violations;
|
||||
@@ -161,18 +145,14 @@ describe('checkClosure —— 不闭合不得激活', () => {
|
||||
{ id: 'end' as never, kind: 'end', label: '终点' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e0', from: 'start' as never, to: 'A3' as never, mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }, { target: 'basePrice', source: { from: 'initiation', path: 'b' } }], reopen: false },
|
||||
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'A3' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'done', path: 'pn' } },
|
||||
{ target: 'basePrice', source: { from: 'output-port', port: 'done', path: 'quote' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: tolerant, contracts }).violations;
|
||||
|
||||
@@ -40,7 +40,7 @@ export { createProcessService } from './internal/usecase/process-service.ts';
|
||||
|
||||
export {
|
||||
advanceOrder,
|
||||
assemble,
|
||||
flatScope,
|
||||
dispatchOf,
|
||||
rejectRouteOf,
|
||||
selectEdges,
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
} from '../../port.ts';
|
||||
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
|
||||
import type { NodeInstance } from '../domain/owner-resolution.ts';
|
||||
import { assemble, rejectRouteOf } from '../domain/routing.ts';
|
||||
import { flatScope, rejectRouteOf } from '../domain/routing.ts';
|
||||
import { compilePackage } from './workflow-compiler.ts';
|
||||
import type { CompiledFlow, RunResult } from './workflow-compiler.ts';
|
||||
|
||||
@@ -170,7 +170,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime {
|
||||
assembleEdge: async ({ workflowId, edge, payload }) => {
|
||||
const live = await requireWorkflow(workflowId);
|
||||
const scope = scopeOf(live);
|
||||
return assemble(edge, { ...scope, ports: { ...scope.ports, [edge.from]: payload } }, deps.sfeel.expression);
|
||||
return flatScope({ ...scope, ports: { ...scope.ports, [edge.from]: payload } }, payload);
|
||||
},
|
||||
|
||||
evaluate: async ({ workflowId, condition, payload }) => {
|
||||
@@ -435,7 +435,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime {
|
||||
if (verdict === 'reject') {
|
||||
const hasRejectEdge = pkg.edges.some((e) => e.from === nodeId && (e.condition ?? '').includes('reject'));
|
||||
if (!hasRejectEdge) {
|
||||
const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression);
|
||||
const route = rejectRouteOf(node, scopeOf(record));
|
||||
if (route.kind === 'terminate') {
|
||||
await deps.store.updateWorkflow(workflowId, {
|
||||
state: 'terminated',
|
||||
|
||||
@@ -55,7 +55,7 @@ export function checkClosure(input: ClosureInput): ClosureReport {
|
||||
}
|
||||
|
||||
if (node.kind === 'activity') {
|
||||
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts));
|
||||
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts, byId));
|
||||
}
|
||||
// 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核
|
||||
/*
|
||||
@@ -86,6 +86,7 @@ function checkActivityNode(
|
||||
node: Extract<ProcessNode, { kind: 'activity' }>,
|
||||
inbound: readonly Edge[],
|
||||
contracts: ReadonlyMap<string, ActivityContractFace>,
|
||||
byId: ReadonlyMap<string, ProcessNode>,
|
||||
): ClosureViolation[] {
|
||||
const out: ClosureViolation[] = [];
|
||||
const face = contracts.get(node.activityId);
|
||||
@@ -99,39 +100,28 @@ function checkActivityNode(
|
||||
return out;
|
||||
}
|
||||
|
||||
// 连一条边即须完成该边映射
|
||||
/*
|
||||
* 数据按字段名流动(2026-07-30 对齐 Mastra):边不带映射,「必填有没有着落」
|
||||
* 不再是激活阻断 —— 宽进纪律下,缺的字段运行时由受理补全单找人收齐。
|
||||
* 激活闸只留 schema 咬合这一条硬伤:**上游产出与下游输入同名却不同型**,
|
||||
* 名字既是管道,名同型异一定坏 —— 这必须在设计期拦下。
|
||||
*/
|
||||
for (const edge of inbound) {
|
||||
if (edge.mapping.length === 0) {
|
||||
out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `边 ${edge.id} 未完成载荷映射` });
|
||||
}
|
||||
}
|
||||
|
||||
// 必填未映射 = 边未完成
|
||||
const mapped = new Set(inbound.flatMap((e) => e.mapping.map((m) => m.target)));
|
||||
for (const required of face.requiredInputs) {
|
||||
if (!mapped.has(required)) {
|
||||
out.push({
|
||||
kind: 'required-unmapped',
|
||||
nodeId: node.id,
|
||||
field: required,
|
||||
message: `节点「${node.label}」的必填字段未映射:${required}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 类型兼容依两端契约 schema 当场校验
|
||||
for (const edge of inbound) {
|
||||
for (const mapping of edge.mapping) {
|
||||
const targetType = face.inputTypes[mapping.target];
|
||||
if (targetType === undefined || mapping.source.from !== 'output-port') continue;
|
||||
const sourceType = contracts.get(node.activityId)?.outputs[mapping.source.port]?.[mapping.source.path];
|
||||
if (sourceType !== undefined && sourceType !== targetType) {
|
||||
out.push({
|
||||
kind: 'type-conflict',
|
||||
edgeId: edge.id,
|
||||
field: mapping.target,
|
||||
message: `边 ${edge.id} 字段 ${mapping.target} 类型冲突:${sourceType} → ${targetType}`,
|
||||
});
|
||||
const upstream = byId.get(edge.from);
|
||||
if (upstream?.kind !== 'activity') continue;
|
||||
const upFace = contracts.get(upstream.activityId);
|
||||
if (upFace === undefined) continue;
|
||||
for (const [field, targetType] of Object.entries(face.inputTypes)) {
|
||||
for (const portFields of Object.values(upFace.outputs)) {
|
||||
const sourceType = portFields[field];
|
||||
if (sourceType !== undefined && sourceType !== targetType) {
|
||||
out.push({
|
||||
kind: 'type-conflict',
|
||||
edgeId: edge.id,
|
||||
field,
|
||||
message: `边 ${edge.id} 同名字段 ${field} 类型相抵:上游 ${sourceType} → 下游 ${targetType}(名字即管道,名同型异必坏)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,14 +156,20 @@ function checkTolerateDependencies(
|
||||
if (target?.kind !== 'activity') continue;
|
||||
const face = contracts.get(target.activityId);
|
||||
if (face === undefined) continue;
|
||||
for (const mapping of edge.mapping) {
|
||||
if (face.requiredInputs.includes(mapping.target)) {
|
||||
const source = byId.get(edge.from);
|
||||
if (source?.kind !== 'activity') continue;
|
||||
const srcFace = contracts.get(source.activityId);
|
||||
if (srcFace === undefined) continue;
|
||||
// 按名判依赖:容忍分支产出的字段名恰是下游的必填 —— 分支可空,依赖它的必填就会空
|
||||
const produced = new Set(Object.values(srcFace.outputs).flatMap((fields) => Object.keys(fields)));
|
||||
for (const required of face.requiredInputs) {
|
||||
if (produced.has(required)) {
|
||||
out.push({
|
||||
kind: 'tolerate-required-dependency',
|
||||
edgeId: edge.id,
|
||||
nodeId: target.id,
|
||||
field: mapping.target,
|
||||
message: `容忍分支 ${edge.from} 的输出被下游必填字段 ${mapping.target} 依赖 —— 汇合处必填映射不得依赖可空分支`,
|
||||
field: required,
|
||||
message: `容忍分支 ${edge.from} 产出的 ${required} 被下游必填依赖 —— 分支可空,依赖它的必填就会空`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,50 +17,28 @@ export type Dispatch =
|
||||
/** 流程包显式终点:按输出声明交付、按来源回执 */
|
||||
| { readonly kind: 'finish'; readonly node: Extract<ProcessNode, { kind: 'end' }>; readonly payload: Record<string, unknown> };
|
||||
|
||||
/** 边映射装配:上游输出口载荷 / 流程变量 / 起单载荷 / 常量 → 下游 data 字段 */
|
||||
/** 工作流作用域 —— 数据按**字段名**流动(对齐 Mastra):边不带映射,约束力在两端契约 schema 上 */
|
||||
export type AssembleScope = {
|
||||
readonly ports: Readonly<Record<string, Record<string, unknown>>>;
|
||||
readonly vars: Readonly<Record<string, unknown>>;
|
||||
readonly initiation: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export function assemble(
|
||||
edge: Edge,
|
||||
scope: AssembleScope,
|
||||
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const flat: Record<string, unknown> = { ...scope.initiation, ...scope.vars, ...scope.ports };
|
||||
for (const mapping of edge.mapping) {
|
||||
switch (mapping.source.from) {
|
||||
case 'output-port':
|
||||
out[mapping.target] = pick(scope.ports[mapping.source.port], mapping.source.path);
|
||||
break;
|
||||
case 'workflow-var':
|
||||
out[mapping.target] = scope.vars[mapping.source.name];
|
||||
break;
|
||||
case 'initiation':
|
||||
out[mapping.target] = pick(scope.initiation, mapping.source.path);
|
||||
break;
|
||||
case 'constant':
|
||||
out[mapping.target] = mapping.source.value;
|
||||
break;
|
||||
case 'expression':
|
||||
out[mapping.target] = evaluateExpression(mapping.source.sfeel, flat);
|
||||
break;
|
||||
}
|
||||
/**
|
||||
* 铺平作用域:起单入参垫底,沿途各输出口的字段依次盖上(后发生的赢),
|
||||
* 最后是**刚发射的载荷**(Mastra 语义:下一步的 input 首先是上一步的 output)。
|
||||
* `__` 前缀是运行时自留地(胶合层的 __runSeq / 终点的 __output),不入业务作用域。
|
||||
*/
|
||||
export function flatScope(scope: AssembleScope, latest: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...scope.initiation };
|
||||
for (const [port, payload] of Object.entries(scope.ports)) {
|
||||
if (port.startsWith('__')) continue;
|
||||
if (payload !== null && typeof payload === 'object') Object.assign(out, payload);
|
||||
}
|
||||
Object.assign(out, latest);
|
||||
for (const key of Object.keys(out)) if (key.startsWith('__')) delete out[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
function pick(source: Readonly<Record<string, unknown>> | undefined, path: string): unknown {
|
||||
if (source === undefined) return undefined;
|
||||
return path.split('.').reduce<unknown>((acc, seg) => {
|
||||
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
|
||||
return (acc as Record<string, unknown>)[seg];
|
||||
}, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从某节点出发,算出接下来该走的边。
|
||||
* XOR:条件求值,**恰取一条**;AND:全部出边同时走(分叉)。
|
||||
@@ -102,18 +80,10 @@ export type RejectRoute =
|
||||
export function rejectRouteOf(
|
||||
node: Extract<ProcessNode, { kind: 'approval' }>,
|
||||
scope: AssembleScope,
|
||||
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
|
||||
): RejectRoute {
|
||||
if (node.reject.kind === 'terminate') return { kind: 'terminate' };
|
||||
return {
|
||||
kind: 'reopen',
|
||||
nodeId: node.reject.nodeId,
|
||||
payload: assemble(
|
||||
{ id: `${node.id}-reject`, from: node.id, to: node.reject.nodeId, mapping: node.reject.mapping, reopen: true },
|
||||
scope,
|
||||
evaluateExpression,
|
||||
),
|
||||
};
|
||||
// 重开载荷 = 铺平作用域 —— 重入的节点按自己的输入契约取字段,不靠这里挑
|
||||
return { kind: 'reopen', nodeId: node.reject.nodeId, payload: flatScope(scope) };
|
||||
}
|
||||
|
||||
/** 起点:找出流程包的入口节点 */
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
} from '../../port.ts';
|
||||
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
|
||||
import type { NodeInstance } from '../domain/owner-resolution.ts';
|
||||
import { assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
|
||||
import { dispatchOf, flatScope, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
|
||||
|
||||
export type SelfHostedRuntimeDeps = {
|
||||
readonly store: ProcessStore;
|
||||
@@ -231,7 +231,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
|
||||
* 节点只收**装配后的**载荷:活动与审批点都有契约,不被上游多余字段污染;
|
||||
* 判据要用的隐性上下文(审批 verdict、放行选口 outcome)由系统注入判据作用域,不走映射。
|
||||
*/
|
||||
const arriving = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
|
||||
// 数据按字段名流动(对齐 Mastra):边不带映射 —— 下游收铺平作用域,按自己的契约取字段
|
||||
const arriving = flatScope({ ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, payload);
|
||||
try {
|
||||
await enter(record, pkg, target, arriving);
|
||||
} catch (error) {
|
||||
@@ -302,8 +303,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
|
||||
const edge = pkg.edges.find((e) => e.id === disposition.edgeId);
|
||||
const target = edge === undefined ? undefined : pkg.nodes.find((n) => n.id === edge.to);
|
||||
if (edge !== undefined && target !== undefined) {
|
||||
// 失败载荷 = 终态、原因码与说明
|
||||
const payload = assemble(edge, scopeOf(record), deps.sfeel.expression);
|
||||
// 失败改道:载荷照样是铺平作用域 + 终态标记 —— 接失败的节点也按自己的契约取字段
|
||||
const payload = flatScope(scopeOf(record));
|
||||
await enter(record, pkg, target, { ...payload, __terminal: terminal });
|
||||
}
|
||||
}
|
||||
@@ -401,7 +402,7 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
|
||||
*/
|
||||
const routedByEdge = await advanceFrom(record, pkg, nodeId, carried, true);
|
||||
if (!routedByEdge) {
|
||||
const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression);
|
||||
const route = rejectRouteOf(node, scopeOf(record));
|
||||
if (route.kind === 'terminate') {
|
||||
await deps.store.updateWorkflow(workflowId, { state: 'terminated', finishedAt: deps.clock.now().toISOString() });
|
||||
} else {
|
||||
|
||||
@@ -665,6 +665,19 @@
|
||||
.g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; }
|
||||
|
||||
/* ── 节点属性 · 数据接口:契约即管道 —— 输入字段(名/型/来源)+ 输出口 ── */
|
||||
.g-dif { display: flex; flex-direction: column; gap: 2px; }
|
||||
.g-dif + .g-dif { margin-top: 8px; }
|
||||
.g-dif > em { font: 600 9px/1.6 var(--mono); font-style: normal; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-4); padding-bottom: 3px; border-bottom: 1px solid var(--line-soft); }
|
||||
.g-dif-r { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; padding: 4px 0; border-bottom: 1px dashed var(--line-soft); font-size: 11.5px; }
|
||||
.g-dif-r:last-child { border-bottom: 0; }
|
||||
.g-dif-r .k { font-family: var(--mono); font-weight: 700; color: var(--ink); }
|
||||
.g-dif-r .k i { font-style: normal; font-family: var(--mono); font-size: 8.5px; font-weight: 600; letter-spacing: .06em; color: #8a5f1c; margin-left: 5px; padding: 1px 4px; border: 1px solid #e3d1a8; border-radius: 3px; }
|
||||
.g-dif-r .t { font-family: var(--mono); font-size: 10px; color: var(--ink-4); }
|
||||
.g-dif-r .s { margin-left: auto; font-size: 11px; color: var(--ink-3); }
|
||||
.g-dif-r .s.ask { color: #8a5f1c; }
|
||||
.g-dif-r .f { min-width: 0; font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
|
||||
/* ── 运营中心 · 工作流 · 运行配置:一包一卡,里面两节(触发器 / 节点负责人)── */
|
||||
.g-runconf { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 12px; }
|
||||
.g-runconf + .g-runconf { border-top: 1px dashed var(--line-soft); }
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Chip,
|
||||
Field,
|
||||
Kpi,
|
||||
ListEdit,
|
||||
Note,
|
||||
PageHead,
|
||||
Prop,
|
||||
@@ -65,24 +64,20 @@ function flowsOf(packages: readonly ProcessPackage[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
|
||||
|
||||
/** 映射行的工作形状 —— 语义边 mapping 的元素;设计者只见三种来源,其余是系统隐性上下文 */
|
||||
type MapRow = { target: string; source: Record<string, unknown> & { from: string } };
|
||||
const SOURCE_KINDS = [
|
||||
{ k: 'output-port', label: '上游输出口' },
|
||||
{ k: 'initiation', label: '起单载荷' },
|
||||
{ k: 'constant', label: '常量' },
|
||||
] as const;
|
||||
/* ── 边属性:只剩顺序与条件(2026-07-30 对齐 Mastra)────────────────────
|
||||
Mastra 的边不传数据 —— 约束力全在两端活动的输入/输出字段(schema 咬合)。
|
||||
数据按字段名流动:上游发的名就是下游收的名;边属性卡因此只剩条件与标签,
|
||||
「映射 · 载荷装配」整节退役 —— 下游拿什么去**节点属性**的数据接口看。 */
|
||||
|
||||
function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdge) => FlowEdge) => void }) {
|
||||
const rows = (edge.mapping ?? []) as MapRow[];
|
||||
const setRows = (next: MapRow[]) => onPatch((e) => ({ ...e, mapping: next }));
|
||||
const patchRow = (i: number, f: (r: MapRow) => MapRow) => setRows(rows.map((r, j) => (j === i ? f(r) : r)));
|
||||
return (
|
||||
<div className="g-props g-eprops">
|
||||
<Prop ico="git-branch" title={`${edge.code ?? '边'} · ${edge.from} → ${edge.to}`} extra="多出边异或:命中即走;条件留空 = 默认兜底">
|
||||
<Field k="S-FEEL 条件" hint="如 needSourcing = true;可用 and / or 连接。留空即无条件(默认边,至多一条)">
|
||||
<Prop
|
||||
ico="git-branch"
|
||||
title={`${edge.code ?? '边'} · ${edge.from} → ${edge.to}`}
|
||||
extra="边只表达顺序与条件 —— 数据按字段名自动流动"
|
||||
>
|
||||
<Field k="S-FEEL 条件" hint="读上游节点的输出字段,如 needSourcing = true;留空即无条件(默认边,至多一条)">
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={edge.condition ?? ''}
|
||||
@@ -104,101 +99,6 @@ function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdg
|
||||
/>
|
||||
</Field>
|
||||
</Prop>
|
||||
<Prop ico="git-merge" title="映射 · 载荷装配" extra="必填映射齐了这条边才闭合">
|
||||
{/* 明细表列头:行与列头同一 6 列栅格;非「上游输出口」的来源在口/路径两列上合并 */}
|
||||
<div className="g-maphead" aria-hidden="true">
|
||||
<span>下游字段</span>
|
||||
<span className="a">←</span>
|
||||
<span>来源</span>
|
||||
<span>口名</span>
|
||||
<span>路径 · 值</span>
|
||||
<span />
|
||||
</div>
|
||||
{rows.map((r, i) => {
|
||||
const known = SOURCE_KINDS.some((k) => k.k === r.source.from);
|
||||
return (
|
||||
<div className="g-maprow" key={i}>
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={r.target}
|
||||
placeholder="下游字段"
|
||||
onChange={(e) => patchRow(i, (x) => ({ ...x, target: e.target.value }))}
|
||||
/>
|
||||
<span className="g-maparr">←</span>
|
||||
{known ? (
|
||||
<select
|
||||
className="g-select"
|
||||
value={r.source.from}
|
||||
onChange={(e) =>
|
||||
patchRow(i, (x) => ({
|
||||
...x,
|
||||
source:
|
||||
e.target.value === 'output-port'
|
||||
? { from: 'output-port', port: '', path: '' }
|
||||
: e.target.value === 'initiation'
|
||||
? { from: 'initiation', path: '' }
|
||||
: { from: 'constant', value: '' },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{SOURCE_KINDS.map((k) => (
|
||||
<option key={k.k} value={k.k}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// workflow-var / expression 等是系统侧来源 —— 保留展示,不给编辑入口
|
||||
<Chip tone="mute">{String(r.source.from)}(系统)</Chip>
|
||||
)}
|
||||
{r.source.from === 'output-port' && (
|
||||
<>
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={String(r.source['port'] ?? '')}
|
||||
placeholder="口名"
|
||||
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, port: e.target.value } }))}
|
||||
/>
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={String(r.source['path'] ?? '')}
|
||||
placeholder="字段路径"
|
||||
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{r.source.from === 'initiation' && (
|
||||
<input
|
||||
className="g-input mono wide"
|
||||
value={String(r.source['path'] ?? '')}
|
||||
placeholder="起单字段路径"
|
||||
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
|
||||
/>
|
||||
)}
|
||||
{r.source.from === 'constant' && (
|
||||
<input
|
||||
className="g-input mono wide"
|
||||
value={String(r.source['value'] ?? '')}
|
||||
placeholder="常量值"
|
||||
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, value: e.target.value } }))}
|
||||
/>
|
||||
)}
|
||||
<button className="g-btn" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
|
||||
<Ico name="x" s={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: '加一条映射',
|
||||
ico: 'plus',
|
||||
onClick: () => setRows([...rows, { target: '', source: { from: 'output-port', port: '', path: '' } }]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Prop>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -506,6 +406,49 @@ function FlowLab({
|
||||
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
|
||||
);
|
||||
|
||||
/*
|
||||
* 数据接口的来源推导(对齐 Mastra 名流动):这个节点的每个输入字段,运行时会从哪来 ——
|
||||
* ① 起单入参里有同名字段 → 起单带进;② 某个上游(沿边回溯)的输出口发同名字段 → 上游发;
|
||||
* ③ 都没有 → 受理归一时派补全单找人收。设计期看得见,不必等跑起来猜。
|
||||
*/
|
||||
const fieldSources = (() => {
|
||||
if (node === null) return new Map<string, string>();
|
||||
const initFields = new Set(
|
||||
Object.keys(((pkg.callInterface.input as { properties?: Record<string, unknown> }).properties ?? {})),
|
||||
);
|
||||
// 祖先集:沿边回溯(含回边,访问集防环)
|
||||
const ancestors = new Set<string>();
|
||||
const queue = [node.id];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.pop()!;
|
||||
for (const e of graph?.edges ?? []) {
|
||||
if (e.to !== cur || ancestors.has(e.from)) continue;
|
||||
ancestors.add(e.from);
|
||||
queue.push(e.from);
|
||||
}
|
||||
}
|
||||
const actOf = (nid: string): string | undefined => {
|
||||
const g = nodes.find((x) => x.id === nid);
|
||||
if (g?.act !== undefined) return g.act;
|
||||
const sem = pkg.nodes.find((x) => x.id === nid);
|
||||
return sem?.kind === 'activity' ? sem.activityId : undefined;
|
||||
};
|
||||
const out = new Map<string, string>();
|
||||
for (const f of refAct?.contract.inputFields ?? []) {
|
||||
if (initFields.has(f.name)) {
|
||||
out.set(f.name, '起单入参');
|
||||
continue;
|
||||
}
|
||||
const from = [...ancestors].find((nid) =>
|
||||
activities
|
||||
.find((a) => a.activityId === actOf(nid))
|
||||
?.contract.outputPorts.some((port) => port.fields.includes(f.name)),
|
||||
);
|
||||
out.set(f.name, from !== undefined ? `上游 ${from}` : '补全单');
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
|
||||
const liveReport = useQuery({
|
||||
queryKey: ['gov', 'process-report', pkg.id, pkg.version],
|
||||
@@ -661,17 +604,16 @@ function FlowLab({
|
||||
label: n.label,
|
||||
activityId: n.activityId,
|
||||
// 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么
|
||||
// 编排上手写的注记(给人读)
|
||||
declaredIn: n.in ?? [],
|
||||
declaredOut: n.out ?? [],
|
||||
/*
|
||||
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
|
||||
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
|
||||
*/
|
||||
contract: actContract.get(n.activityId) ?? null,
|
||||
upstreamAssembly: pkg.edges
|
||||
.filter((e) => e.to === n.id)
|
||||
.map((e) => ({ from: e.from, mapping: e.mapping })),
|
||||
/*
|
||||
* 数据按字段名流动(对齐 Mastra):依赖文件不再列边映射 ——
|
||||
* 上游从哪来,看 contract.input 的字段名对上游 output 的字段名。
|
||||
*/
|
||||
upstream: pkg.edges.filter((e) => e.to === n.id).map((e) => e.from),
|
||||
})),
|
||||
approvals: pkg.nodes
|
||||
.filter((n) => n.kind === 'approval')
|
||||
@@ -1038,22 +980,50 @@ function FlowLab({
|
||||
)}
|
||||
</Prop>
|
||||
|
||||
{node.kind === 'activity' && (
|
||||
<Prop ico="git-merge" title="输入" extra="上游边映射的落点">
|
||||
<ListEdit
|
||||
items={node.in ?? []}
|
||||
placeholder="如:客户邮件"
|
||||
onChange={(v) => patch((n) => ({ ...n, in: v.length > 0 ? [...v] : undefined }))}
|
||||
/>
|
||||
</Prop>
|
||||
)}
|
||||
{node.kind === 'activity' && (
|
||||
<Prop ico="upload" title="输出口" extra="下游边映射的源">
|
||||
<ListEdit
|
||||
items={node.out ?? []}
|
||||
placeholder="如:受理记录"
|
||||
onChange={(v) => patch((n) => ({ ...n, out: v.length > 0 ? [...v] : undefined }))}
|
||||
/>
|
||||
{node.kind === 'activity' && node.flavor !== 'approval' && (
|
||||
/*
|
||||
* 数据接口(对齐 Mastra):边不传数据,约束力全在输入/输出字段 ——
|
||||
* 这里不填写、只**看清**:每个输入字段运行时从哪来(起单 / 上游同名 / 补全单),
|
||||
* 每个输出口发什么名。要改字段,改的是活动契约,不是这张卡。
|
||||
*/
|
||||
<Prop
|
||||
ico="git-merge"
|
||||
title="数据接口 · 按字段名流动"
|
||||
extra={refAct === undefined ? '先链接活动实现' : '契约即管道:名字对上,数据就到'}
|
||||
>
|
||||
{refAct === undefined ? (
|
||||
<Note>链接活动实现后,这里列出它吃什么、吐什么、每个字段从哪来。</Note>
|
||||
) : (
|
||||
<>
|
||||
<div className="g-dif">
|
||||
<em>输入 · {refAct.contract.inputFields.length} 字段</em>
|
||||
{refAct.contract.inputFields.map((f) => {
|
||||
const src = fieldSources.get(f.name) ?? '补全单';
|
||||
return (
|
||||
<div key={f.name} className="g-dif-r">
|
||||
<span className="k">
|
||||
{f.name}
|
||||
{f.required && <i>必填</i>}
|
||||
</span>
|
||||
<span className="t">{f.type}</span>
|
||||
<span className={'s' + (src === '补全单' ? ' ask' : '')}>
|
||||
{src === '补全单' ? '← 补全单(受理归一找人收)' : `← ${src}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="g-dif">
|
||||
<em>输出 · {refAct.contract.outputPorts.length} 口</em>
|
||||
{refAct.contract.outputPorts.map((port) => (
|
||||
<div key={port.name} className="g-dif-r">
|
||||
<span className="k">{port.name}</span>
|
||||
<span className="f">{port.fields.length > 0 ? port.fields.join(' · ') : '(整包事实)'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Prop>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,11 +25,11 @@ const basePkg: ProcessPackage = processPackageSchema.parse({
|
||||
{ id: 'E', kind: 'end', label: '终' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'E1', from: 'S', to: 'A', mapping: [{ target: 'x', source: { from: 'initiation', path: 'x' } }] },
|
||||
{ id: 'E2', from: 'A', to: 'B', mapping: [] },
|
||||
{ id: 'E1', from: 'S', to: 'A' },
|
||||
{ id: 'E2', from: 'A', to: 'B' },
|
||||
// 乙的两条互斥出边:通过 → 终点;有异议 → 回甲重开(无网关:分支就在节点身上)
|
||||
{ id: 'E3', from: 'B', to: 'E', condition: 'ok = true', mapping: [] },
|
||||
{ id: 'E4', from: 'B', to: 'A', condition: 'ok = false', mapping: [], reopen: true },
|
||||
{ id: 'E3', from: 'B', to: 'E', condition: 'ok = true' },
|
||||
{ id: 'E4', from: 'B', to: 'A', condition: 'ok = false', reopen: true },
|
||||
],
|
||||
variables: [],
|
||||
});
|
||||
@@ -73,9 +73,8 @@ describe('mergeGraphIntoPackage', () => {
|
||||
expect(minted.sort()).toEqual(['E5', 'E6']);
|
||||
});
|
||||
|
||||
it('画布装不下的声明原样保留:边映射与失败走向', () => {
|
||||
it('画布装不下的声明原样保留:失败走向(边映射已随「名流动」退役)', () => {
|
||||
const merged = mergeGraphIntoPackage(basePkg, graph);
|
||||
expect(merged.edges.find((e) => e.id === 'E1')?.mapping).toHaveLength(1);
|
||||
const a = merged.nodes.find((n) => n.id === 'A');
|
||||
expect(a?.kind === 'activity' && a.failure.kind).toBe('terminate');
|
||||
});
|
||||
@@ -95,12 +94,12 @@ describe('mergeGraphIntoPackage', () => {
|
||||
expect(b?.kind === 'activity' && b.activityId).toBe('act-b');
|
||||
});
|
||||
|
||||
it('边的条件/映射工作副本优先;清空条件 = 变默认兜底边', () => {
|
||||
it('边的条件工作副本优先;清空条件 = 变默认兜底边', () => {
|
||||
const edited: FlowGraph = {
|
||||
...graph,
|
||||
edges: graph.edges.map((e) =>
|
||||
e.from === 'B' && e.to === 'E'
|
||||
? { ...e, condition: 'ok = true and sum > 3', mapping: [{ target: 'y', source: { from: 'initiation', path: 'y' } }] }
|
||||
? { ...e, condition: 'ok = true and sum > 3' }
|
||||
: e.from === 'B' && e.to === 'A'
|
||||
? { ...e, condition: '' } // 设计者把条件清空:这条边成了默认兜底
|
||||
: e,
|
||||
@@ -109,10 +108,7 @@ describe('mergeGraphIntoPackage', () => {
|
||||
const merged = mergeGraphIntoPackage(basePkg, edited);
|
||||
const pass = merged.edges.find((e) => e.id === 'E3');
|
||||
expect(pass?.condition).toBe('ok = true and sum > 3');
|
||||
expect(pass?.mapping).toHaveLength(1);
|
||||
const back = merged.edges.find((e) => e.id === 'E4');
|
||||
expect(back?.condition).toBeUndefined();
|
||||
// 没动过的边沿用旧声明
|
||||
expect(merged.edges.find((e) => e.id === 'E1')?.mapping).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,13 +103,12 @@ export function mergeGraphIntoPackage(
|
||||
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
|
||||
.map((e) => {
|
||||
const prior = priorEdge.get(`${e.from}→${e.to}`);
|
||||
// 边属性(传递契约)以属性卡的工作副本优先;没动过的沿用旧声明
|
||||
// 边只带顺序与条件(对齐 Mastra):条件以属性卡的工作副本优先;没动过的沿用旧声明
|
||||
const condition = e.condition !== undefined ? (e.condition.trim() === '' ? undefined : e.condition) : prior?.condition;
|
||||
return {
|
||||
id: prior?.id ?? `E${nextEdgeNo++}`,
|
||||
from: nodeIdSchema.parse(e.from),
|
||||
to: nodeIdSchema.parse(e.to),
|
||||
mapping: (e.mapping as Edge['mapping'] | undefined) ?? prior?.mapping ?? [],
|
||||
...(condition === undefined ? {} : { condition }),
|
||||
reopen: prior?.reopen ?? false,
|
||||
...(e.label === undefined ? {} : { label: e.label }),
|
||||
|
||||
@@ -71,7 +71,6 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
|
||||
...e,
|
||||
code: sem.id as string,
|
||||
...(sem.condition === undefined ? {} : { condition: sem.condition }),
|
||||
mapping: sem.mapping as unknown[],
|
||||
};
|
||||
});
|
||||
return { ...stored, edges, nodes: [...withState(stored.nodes), ...withState(extras)] };
|
||||
|
||||
@@ -121,7 +121,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
title: '流程包管理',
|
||||
icon: 'workflow',
|
||||
accent: 'linear-gradient(140deg,#4954e6,#3a44c9)',
|
||||
desc: '战略——企业以哪些流转运转。一条流一个包,版本收在包内部:画图不检查、激活才查完整,恰一个生效版本、在途按启动版本跑完。编排即定契约:连线即映射,分支就是条件出边。触发器(谁调起这条流)也在本区定义;启停在运营中心。',
|
||||
desc: '战略——企业以哪些流转运转。一条流一个包,版本收在包内部:画图不检查、激活才查完整,恰一个生效版本、在途按启动版本跑完。边只表达顺序与条件(对齐 Mastra),数据按输入/输出字段名流动——约束力在两端活动契约上。触发器已独立成区;启停在运营中心。',
|
||||
},
|
||||
library: {
|
||||
title: '活动库',
|
||||
|
||||
Reference in New Issue
Block a user