MVP底座④:统一报价 1:1 流程包单实例 —— 四域物化,种子整体切换

14 语义节点/16 边:A5 是内置审批点(驳回走 reject 声明 reopen A4),
G2 的两条互斥分支显式汇合于 G2J —— 隐式合并过不了闭合校验(实测)。
顺手修 graph-to-pkg:卡点带 to 时还原成语义边,否则回边画成卡点一存盘就没了。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 01:49:49 +08:00
co-authored by Claude Fable 5
parent 90ca92aa0e
commit 0fe5222f79
7 changed files with 662 additions and 356 deletions
@@ -0,0 +1,86 @@
/**
* 画布图 → 流程包:**合并**,不是重建。
*
* 这里守两条容易丢东西的路:
* ① 画布装不下的声明(边映射、驳回走向、负责人策略)保存后必须还在;
* ② 回边以**卡点**呈现(MVP 的画法)—— 存盘要还原成语义边,不能连边一起丢。
*/
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts';
import type { FlowGraph } from '../flow-layout.ts';
import { mergeGraphIntoPackage } from '../graph-to-pkg.ts';
const basePkg: ProcessPackage = processPackageSchema.parse({
id: 'p',
name: '包',
version: 1,
state: 'draft',
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{
id: 'A',
kind: 'activity',
label: '甲',
activityId: 'act-a',
owner: { position: 'BD', strategy: 'rotation' },
failure: { kind: 'terminate' },
},
{ id: 'G', kind: 'gateway', label: '岔', gateway: 'exclusive', mode: 'fork' },
{ id: 'E', kind: 'end', label: '终' },
],
edges: [
{ id: 'e-s-a', from: 'S', to: 'A', mapping: [{ target: 'x', source: { from: 'initiation', path: 'x' } }] },
{ id: 'e-a-g', from: 'A', to: 'G', mapping: [] },
{ id: 'e-g-e', from: 'G', to: 'E', condition: 'ok = true', mapping: [] },
{ id: 'e-g-a', from: 'G', to: 'A', condition: 'ok = false', mapping: [], reopen: true },
],
variables: [],
});
/** 画布的样子:回边不画长线,画一枚卡点 */
const graph: FlowGraph = {
manual: true,
rows: [],
anchors: [],
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{ id: 'A', kind: 'activity', label: '甲', owner: 'BD' },
{ id: 'G', kind: 'gateway', label: '岔', mode: 'x' },
{ id: 'E', kind: 'end', label: '终' },
{ id: 'c1', kind: 'callout', label: '返回调整', to: 'A' },
],
edges: [
{ from: 'S', to: 'A' },
{ from: 'A', to: 'G' },
{ from: 'G', to: 'E', label: '通过', tone: 'ok' },
{ from: 'G', to: 'c1', label: '有异议', tone: 'bad' },
],
};
describe('mergeGraphIntoPackage', () => {
it('卡点还原成语义边 —— 回边不因「画成卡点」而消失', () => {
const merged = mergeGraphIntoPackage(basePkg, graph);
const back = merged.edges.find((e) => e.from === 'G' && e.to === 'A');
expect(back).toBeDefined();
// 旧边的 id 与 reopen 标记跟着保留:合并按 from→to 认亲
expect(back?.id).toBe('e-g-a');
expect(back?.reopen).toBe(true);
expect(back?.condition).toBe('ok = false');
});
it('卡点本身不入语义节点,但整张图存进 diagram', () => {
const merged = mergeGraphIntoPackage(basePkg, graph);
expect(merged.nodes.map((n) => n.id)).toEqual(['S', 'A', 'G', 'E']);
const diagram = merged.diagram as { nodes: { id: string }[] };
expect(diagram.nodes.map((n) => n.id)).toContain('c1');
});
it('画布装不下的声明原样保留:边映射与负责人策略', () => {
const merged = mergeGraphIntoPackage(basePkg, graph);
expect(merged.edges.find((e) => e.id === 'e-s-a')?.mapping).toHaveLength(1);
const a = merged.nodes.find((n) => n.id === 'A');
expect(a?.kind === 'activity' && a.owner.strategy).toBe('rotation');
});
});
+17 -1
View File
@@ -109,7 +109,23 @@ export function mergeGraphIntoPackage(
});
const semanticIds = new Set(semantic.map((n) => n.id));
const edges: Edge[] = graph.edges
/*
* 卡点是**回跳的呈现**:图上不画长线,画一枚「返回调整→A4」的小牌子。
* 所以「X → 卡点(to: Y)」就是语义边 X→Y —— 落包时把它还原回去,
* 否则一存盘回边就没了(卡点不入语义,这条边两端有一端不在 semanticIds 里,会被滤掉)。
*/
const calloutTarget = new Map(
graph.nodes
.filter((n) => n.kind === 'callout' && n.to !== undefined && semanticIds.has(n.to))
.map((n) => [n.id, n.to!]),
);
const resolved = graph.edges.map((e) => {
const via = calloutTarget.get(e.to);
return via === undefined ? e : { ...e, to: via };
});
const edges: Edge[] = resolved
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
.map((e) => {
const prior = priorEdge.get(`${e.from}→${e.to}`);