diff --git a/apps/api/src/app/__tests__/seed-mvp-flow.test.ts b/apps/api/src/app/__tests__/seed-mvp-flow.test.ts new file mode 100644 index 0000000..d4eaddc --- /dev/null +++ b/apps/api/src/app/__tests__/seed-mvp-flow.test.ts @@ -0,0 +1,51 @@ +/** + * 流程包 1:1 —— 守三件事:拓扑与 MVP 图一致;审批点驳回走声明不走边;网关条件读得到出参。 + */ +import { describe, expect, it } from 'vitest'; +import { MVP_PACKAGE } from '../seed-mvp-flow.ts'; + +describe('MVP_PACKAGE', () => { + it('14 语义节点 · 16 边 · 四网关一审批点双终点', () => { + expect(MVP_PACKAGE.nodes).toHaveLength(14); + expect(MVP_PACKAGE.edges).toHaveLength(16); + expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'end')).toHaveLength(2); + expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'gateway')).toHaveLength(4); + expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'approval')).toHaveLength(1); + }); + + it('分叉必有汇合:G2 的两条互斥分支并回 G2J —— 不给汇合点,A4 会被判隐式合并', () => { + const g2j = MVP_PACKAGE.nodes.find((n) => n.id === 'G2J'); + expect(g2j?.kind).toBe('gateway'); + if (g2j?.kind !== 'gateway') return; + expect(g2j.gateway).toBe('exclusive'); + expect(g2j.mode).toBe('join'); + expect(MVP_PACKAGE.edges.filter((e) => e.to === 'G2J')).toHaveLength(2); + // A4 的非回边入边只剩一条:汇合之后再进活动 + expect(MVP_PACKAGE.edges.filter((e) => e.to === 'A4' && !e.reopen)).toHaveLength(1); + }); + + it('A5 是审批点:PM 审,驳回 reopen 回 A4 —— 驳回不是边', () => { + const a5 = MVP_PACKAGE.nodes.find((n) => n.id === 'A5'); + expect(a5?.kind).toBe('approval'); + if (a5?.kind !== 'approval') return; + expect(a5.approver.position).toBe('PM'); + expect(a5.reject.kind).toBe('reopen'); + if (a5.reject.kind === 'reopen') expect(a5.reject.nodeId).toBe('A4'); + // 唯一的 reopen 边是议价「有异议」回 A4 + expect(MVP_PACKAGE.edges.filter((e) => e.reopen).map((e) => `${e.from}→${e.to}`)).toEqual(['G4→A4']); + }); + + it('网关条件按出参写:G1/G2 读旗,G4 读 outcome', () => { + const cond = (from: string, to: string) => MVP_PACKAGE.edges.find((e) => e.from === from && e.to === to)?.condition; + expect(cond('G1', 'A2')).toBe('needAlternative = true'); + expect(cond('G2', 'G2J')).toBe('needSourcing = false'); + expect(cond('G4', 'A7')).toBe('outcome = "lost"'); + }); + + it('手绘图存证在包上 —— 呈现 1:1 的真相(含两枚卡点)', () => { + expect(MVP_PACKAGE.diagram).toBeDefined(); + const nodes = (MVP_PACKAGE.diagram as { nodes: { id: string; kind: string }[] }).nodes; + expect(nodes.filter((n) => n.kind === 'callout')).toHaveLength(2); + expect(nodes).toHaveLength(16); + }); +}); diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts new file mode 100644 index 0000000..048cb86 --- /dev/null +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -0,0 +1,387 @@ +/** + * 「统一报价」流程包 —— 与 `docs/MVP示例工作流.html` 的 FIG.1 一比一。 + * + * 语义 14 节点:起点 · A1 · G1 · A2 · G2 · A3 · G2J · A4 · **A5 审批点** · A6 · G4 · A7 · 双终点。 + * 手绘几何(`diagram`)逐点取自 MVP 的 SVG:节点坐标与折线路径原样搬, + * x 统一去掉 4.7 的偏移吸附到 10px 珊格,y 不动 —— 图看起来就是那张图。 + * + * 两处与 MVP 图的**语义同构、呈现有别**(已与用户确认): + * · A5 定价审批是**内置审批点**(审批不是任务,零开发、不入活动库); + * · 因此 MVP 图上的 G3「审批结果」被审批点吸收 —— 通过走出边,驳回走 `reject` 声明, + * 图上仍以「退回调整→A4」卡点标出那条路。全图因此少一颗菱形。 + * · 反过来多一颗:G2 的两条互斥分支必须显式汇合于 G2J —— MVP 图把它画成两条线各扎进 A4, + * 语义上那是隐式合并,闭合校验不收(见下 joinNode 注释)。 + * + * 回边的画法:MVP 用**卡点**表示回跳(不画长线)。卡点带 `to`, + * `mergeGraphIntoPackage` 会把「X → 卡点(to: Y)」还原成语义边 X→Y,所以编辑器保存不丢回边。 + */ +import { processPackageSchema, triggerConfigSchema } from '@contracts'; +import type { ProcessPackage } from '@contracts'; +import type { Container } from './container.ts'; + +/** 活动节点:岗位独任、失败即终止 —— 槽位期不谈容忍 */ +const activityNode = ( + id: string, + label: string, + activityId: string, + position: string, + col: number, + row: number, + x: number, + y: number, +) => ({ + id, + kind: 'activity' as const, + label, + sub: position, + activityId, + owner: { position, strategy: 'sole' as const }, + failure: { kind: 'terminate' as const }, + layout: { row, col, x, y }, +}); + +/** 判断网关:异或分叉,判据人读、条件在出边 */ +const gatewayNode = (id: string, label: string, basis: string, col: number, row: number, x: number, y: number) => ({ + id, + kind: 'gateway' as const, + label, + gateway: 'exclusive' as const, + mode: 'fork' as const, + basis, + layout: { row, col, x, y }, +}); + +/** + * 异或汇合点 —— 两条互斥分支重新并回一条路。 + * + * MVP 图上这里是两条线各自扎进 A4(隐式合并);语义上必须显式: + * 不给汇合点,A4 就有两条同名映射的入边,闭合校验按「不做隐式合并」拦下(实测)。 + * 分叉必有汇合是 BPMN 的正解,本仓 v1 的 G2/G2J 即此先例。 + */ +const joinNode = (id: string, label: string, col: number, row: number, x: number, y: number) => ({ + id, + kind: 'gateway' as const, + label, + gateway: 'exclusive' as const, + mode: 'join' as const, + layout: { row, col, x, y }, +}); + +/** 受理口的两枚去向旗与记录 —— 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: '统一报价', + version: 1, + state: 'draft', + callInterface: { + input: { + type: 'object', + required: ['customerCode', 'rfqRef'], + properties: { + customerCode: { type: 'string' }, + rfqRef: { type: 'string' }, + subject: { type: 'string' }, + body: { type: 'string' }, + quotedAt: { type: 'string' }, + }, + }, + output: { type: 'object', properties: { landingRate: { type: 'number' } } }, + }, + nodes: [ + { id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0, x: 30, y: 70 } }, + activityNode('A1', '客户RFQ受理', 'rfq-intake', 'BD', 1, 0, 150, 60), + gatewayNode('G1', '是否需要选型', '受理判定:该条是否要先选替代料(needAlternative)', 2, 0, 290, 60), + activityNode('A2', '替代选型', 'alt-selection', 'FAE', 3, 0, 390, 60), + gatewayNode('G2', '是否需要寻源', '受理判定:该条是否要向渠道询底价(needSourcing)', 4, 0, 550, 60), + activityNode('A3', '寻源询价', 'sourcing-inquiry', 'PRM', 5, 0, 660, 60), + joinNode('G2J', '寻源汇合', 6, 0, 760, 150), + activityNode('A4', '成本定价', 'cost-pricing', 'BD', 7, 0, 810, 60), + { + id: 'A5', + kind: 'approval', + label: '定价审批', + sub: 'PM', + approver: { position: 'PM', strategy: 'sole' }, + // 驳回走向声明式,不由临场决定:退回 A4 重新定价(图上「退回调整→A4」卡点即此) + reject: { kind: 'reopen', nodeId: 'A4', mapping: REENTER_A4 }, + nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' }, + layout: { row: 1, col: 6, x: 810, y: 260 }, + }, + activityNode('A6', '报价议价', 'quote-negotiation', 'BD', 3, 1, 370, 260), + gatewayNode('G4', '议价结果', 'A6 办结的去向:成交 / 有异议 / 拒绝或超时(outcome)', 2, 1, 180, 260), + activityNode('A7', '情报归档', 'intel-archive', 'BD', 1, 2, 150, 170), + { id: 'end_won', kind: 'end', label: '转订单履约', layout: { row: 1, col: 0, x: 30, y: 270 } }, + { id: 'end_arch', kind: 'end', label: '归档闭环', layout: { row: 2, col: 0, x: 30, y: 180 } }, + ], + edges: [ + { + id: 'e-start-a1', + 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, + }, + { id: 'e-a1-g1', from: 'A1', to: 'G1', mapping: [], label: '统一受理记录', reopen: false }, + { + id: 'e-g1-a2', + from: 'G1', + to: 'A2', + condition: 'needAlternative = true', + label: '需要选型', + mapping: [ + { target: 'material', source: { from: 'output-port', port: 'intake', path: 'pn' } }, + // 选型完了还要不要寻源,这枚旗得跟着走 —— G2 在本路径上读的是 A2 的载荷 + { target: 'needSourcing', source: { from: 'output-port', port: 'intake', path: 'needSourcing' } }, + ], + reopen: false, + }, + { id: 'e-g1-g2', from: 'G1', to: 'G2', condition: 'needAlternative = false', label: '不需要选型', mapping: [], reopen: false }, + { id: 'e-a2-g2', from: 'A2', to: 'G2', mapping: [], label: '替代对标表', reopen: false }, + { + id: 'e-g2-a3', + from: 'G2', + to: 'A3', + condition: 'needSourcing = true', + label: '需要寻源', + mapping: [{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }], + reopen: false, + }, + { + id: 'e-g2-j', + from: 'G2', + to: 'G2J', + condition: 'needSourcing = false', + label: '不需要寻源', + mapping: [], + reopen: false, + }, + { id: 'e-a3-j', from: 'A3', to: 'G2J', mapping: [], label: '供应商底价表', reopen: false }, + { + id: 'e-j-a4', + from: 'G2J', + to: 'A4', + mapping: [ + ...REENTER_A4, + // 底价只在寻源那条路上有;直通路径取不到即为空,A4 的 floors 非必填 + { target: 'floors', source: { from: 'output-port', port: 'compare', path: 'floors' } }, + ], + reopen: false, + }, + { id: 'e-a4-a5', from: 'A4', to: 'A5', mapping: [], label: '报价草案提审', reopen: false }, + { + id: 'e-a5-a6', + from: 'A5', + to: 'A6', + 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, + }, + { id: 'e-a6-g4', from: 'A6', to: 'G4', mapping: [], label: '正式报价单', reopen: false }, + { + id: 'e-g4-won', + from: 'G4', + to: 'end_won', + condition: 'outcome = "won"', + label: '成交', + tone: 'ok', + mapping: [], + reopen: false, + }, + { + id: 'e-g4-a4', + from: 'G4', + to: 'A4', + condition: 'outcome = "revise"', + label: '有异议', + tone: 'bad', + mapping: REENTER_A4, + // 回边:人裁决后重开新任务实例(同节点、新任务 ID、留痕关联) + reopen: true, + }, + { + id: 'e-g4-a7', + from: 'G4', + to: 'A7', + condition: 'outcome = "lost"', + label: '拒绝/超时', + tone: 'bad', + mapping: [ + { 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: 'e-a7-end', from: 'A7', to: 'end_arch', mapping: [], label: '归档闭环', reopen: false }, + ], + variables: [], + /* + * 手绘几何存证 —— 呈现以它为真相(不走自动布局)。 + * 坐标与折线取自 MVP 的 SVG;卡点是回跳的呈现(带 to,不画长线)。 + */ + diagram: { + manual: true, + rows: [], + anchors: [], + nodes: [ + { id: 'start', kind: 'start', label: '客户RFQ询价', x: 30, y: 70, lx: 44, ly: 104 }, + { id: 'A1', kind: 'activity', label: '客户RFQ受理', code: 'A1', owner: 'BD', x: 150, y: 60 }, + { id: 'G1', kind: 'gateway', label: '是否需要选型', mode: 'x', x: 290, y: 60, lx: 314, ly: 130 }, + { id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', owner: 'FAE', flavor: 'case', x: 390, y: 60 }, + { id: 'G2', kind: 'gateway', label: '是否需要寻源', mode: 'x', x: 550, y: 60, lx: 574, ly: 130 }, + { id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', owner: 'PRM', flavor: 'case', x: 660, y: 60 }, + { id: 'G2J', kind: 'gateway', label: '寻源汇合', mode: 'x', x: 760, y: 150, lx: 780, ly: 205 }, + { id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', owner: 'BD', x: 810, y: 60 }, + { id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', owner: 'PM', flavor: 'approval', x: 810, y: 260 }, + { id: 'A6', kind: 'activity', label: '报价议价', code: 'A6', owner: 'BD', flavor: 'case', x: 370, y: 260 }, + { id: 'G4', kind: 'gateway', label: '议价结果', mode: 'x', x: 180, y: 260, lx: 234, ly: 314 }, + { id: 'A7', kind: 'activity', label: '情报归档', code: 'A7', owner: 'BD', x: 150, y: 170 }, + { id: 'end_won', kind: 'end', label: '转订单履约', x: 30, y: 270, lx: 44, ly: 304 }, + { id: 'end_arch', kind: 'end-bad', label: '归档闭环', x: 30, y: 180, lx: 44, ly: 214 }, + { id: 'c_reject', kind: 'callout', label: '退回调整', to: 'A4', x: 590, y: 370 }, + { id: 'c_revise', kind: 'callout', label: '返回调整', to: 'A4', x: 150, y: 370 }, + ], + edges: [ + { from: 'start', to: 'A1', points: [{ x: 50, y: 80 }, { x: 150, y: 80 }] }, + { from: 'A1', to: 'G1', points: [{ x: 250, y: 80 }, { x: 290, y: 80 }] }, + { from: 'G1', to: 'A2', label: '需要选型', points: [{ x: 330, y: 80 }, { x: 390, y: 80 }] }, + { + from: 'G1', + to: 'G2', + label: '不需要选型', + points: [{ x: 310, y: 60 }, { x: 310, y: 20 }, { x: 570, y: 20 }, { x: 570, y: 60 }], + }, + { from: 'A2', to: 'G2', points: [{ x: 490, y: 80 }, { x: 550, y: 80 }] }, + { from: 'G2', to: 'A3', label: '需要寻源', points: [{ x: 590, y: 80 }, { x: 660, y: 80 }] }, + { + from: 'G2', + to: 'G2J', + label: '不需要寻源', + points: [{ x: 570, y: 100 }, { x: 570, y: 170 }, { x: 760, y: 170 }], + }, + { from: 'A3', to: 'G2J', points: [{ x: 710, y: 100 }, { x: 710, y: 170 }, { x: 760, y: 170 }] }, + { from: 'G2J', to: 'A4', points: [{ x: 800, y: 170 }, { x: 860, y: 170 }, { x: 860, y: 100 }] }, + { + from: 'A4', + to: 'A5', + points: [{ x: 910, y: 80 }, { x: 990, y: 80 }, { x: 990, y: 280 }, { x: 910, y: 280 }], + }, + // 审批通过:MVP 图上这条线经过 G3 的位置,G3 被审批点吸收后连成一条 + { from: 'A5', to: 'A6', label: '通过', tone: 'ok', points: [{ x: 810, y: 280 }, { x: 470, y: 280 }] }, + // 审批驳回:走 reject 声明,图上以卡点标出(不画到 A4 的长线) + { + from: 'A5', + to: 'c_reject', + label: '不通过', + tone: 'bad', + points: [{ x: 860, y: 300 }, { x: 860, y: 340 }, { x: 640, y: 340 }, { x: 640, y: 370 }], + }, + { from: 'A6', to: 'G4', points: [{ x: 370, y: 280 }, { x: 220, y: 280 }] }, + { from: 'G4', to: 'end_won', label: '成交', tone: 'ok', points: [{ x: 180, y: 280 }, { x: 50, y: 280 }] }, + { from: 'G4', to: 'c_revise', label: '有异议', tone: 'bad', points: [{ x: 200, y: 300 }, { x: 200, y: 370 }] }, + { from: 'G4', to: 'A7', label: '拒绝/超时', tone: 'bad', points: [{ x: 200, y: 260 }, { x: 200, y: 210 }] }, + { from: 'A7', to: 'end_arch', points: [{ x: 150, y: 190 }, { x: 50, y: 190 }] }, + ], + }, +}); + +/** 事件触发器:投进公司询价邮箱且主题含询价/RFQ 的自动起单 */ +const TRIGGER_EVENT = triggerConfigSchema.parse({ + id: 'trg-rfq', + 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', + rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))', + eventType: 'data', + }, +}); + +/** 登记触发器:人确认后起单,「登记人即来源」—— 表单由调用接口 schema 自动渲染 */ +const TRIGGER_REGISTRATION = triggerConfigSchema.parse({ + id: 'trg-rfq-manual', + name: '人工登记起单(统一报价)', + enabled: true, + processPackageId: 'unified-quote', + mapping: [], + spec: { kind: 'registration' }, +}); + +export async function seedMvpFlow(container: Container): Promise { + const log = (line: string) => process.stdout.write(line + '\n'); + + await container.process.savePackage(MVP_PACKAGE); + const report = await container.process.validate(MVP_PACKAGE); + if (!report.publishable) { + log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; ')); + } else { + await container.process.publish(MVP_PACKAGE.id, MVP_PACKAGE.version); + log(`④ 流程包「统一报价」v${MVP_PACKAGE.version} 已发布 —— ${MVP_PACKAGE.nodes.length} 节点,边映射闭合校验通过`); + } + + await container.trigger.upsert(TRIGGER_EVENT); + await container.trigger.upsert(TRIGGER_REGISTRATION); + log(' 触发器已配置:客户RFQ询价(事件)· 人工登记起单(登记)'); + + /* 邮件通道两条:自动起单的与人工确认的 —— 同一封 RFQ 两条路,起不起单由通道定 */ + const { positions } = await container.org.listStaff(); + const op = positions.find((p) => p.code === 'OP'); + const pm = positions.find((p) => p.code === 'PM'); + const extract = [ + { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const }, + { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' as const }, + ]; + + await container.channel.upsert({ + id: 'ch-rfq-mail', + name: '公司询价邮箱', + kind: 'mail', + address: 'rfq@we.com', + enabled: true, + extract: [...extract, { key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' }], + mapping: [], + ...(op === undefined ? {} : { fallbackPosition: op.id }), + addressWindowMs: 7 * 86_400_000, + }); + + await container.channel.upsert({ + id: 'ch-rfq-review', + name: '询价待确认邮箱', + kind: 'mail', + address: 'rfq-review@we.com', + enabled: true, + extract, + mapping: [], + ...(pm === undefined ? {} : { fallbackPosition: pm.id }), + addressWindowMs: 7 * 86_400_000, + }); + log(' 邮件通道已配置:公司询价邮箱(OP 兜底)· 询价待确认邮箱(PM 兜底)'); +} diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts new file mode 100644 index 0000000..72f691b --- /dev/null +++ b/apps/api/src/app/seed-mvp.ts @@ -0,0 +1,118 @@ +/** + * MVP 底座的治理台配置 —— 四件事一次做完(README《MVP 完成判据》第 1 条的机器形态): + * + * ① 绑定 LLM 实例(四元绑定;活动只见逻辑名) + * ② 注册功能工具并逐活动给白名单(授权在白名单与契约,不在活动代码) + * ③ **经安装通道**装入六个活动槽位 —— 声明顺带把四域十八对象物化出来 + * ④ 编排「统一报价」流程包 v1 并发布 + 触发器与邮件通道 + * + * 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。 + */ +import type { Container } from './container.ts'; +import { installSlots } from './seed-mvp-slots.ts'; +import { seedMvpFlow } from './seed-mvp-flow.ts'; + +/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */ +const HISTORY_TOOL = { + name: 'crm.history-price-read', + version: '1.0.0', + description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态', + input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } }, + output: { + type: 'object', + properties: { + historyDays: { type: 'integer' }, + marketStable: { type: 'boolean' }, + lifecycle: { type: 'string' }, + hit: { type: 'boolean' }, + }, + }, + effect: 'read' as const, + errors: [{ code: 'not-found', retryable: false, message: '无历史价' }], + idempotent: true, + timeoutMs: 10_000, + retries: 2, + connectionKeys: ['endpoint'], +}; + +/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */ +const MAIL_TOOL = { + ...HISTORY_TOOL, + name: 'mail.send', + description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界', + input: { + type: 'object', + required: ['to', 'subject'], + properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } }, + }, + output: { type: 'object', properties: { queued: { type: 'boolean' } } }, + effect: 'write' as const, + idempotent: true, + connectionKeys: ['endpoint'], +}; + +/** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */ +const ALLOWLIST: Readonly> = { + 'rfq-intake': [HISTORY_TOOL.name], + 'alt-selection': [HISTORY_TOOL.name], + 'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name], + 'cost-pricing': [HISTORY_TOOL.name], + 'quote-negotiation': [HISTORY_TOOL.name, MAIL_TOOL.name], + 'intel-archive': [], +}; + +export async function seedMvp(container: Container): Promise { + const log = (line: string) => process.stdout.write(line + '\n'); + + /* + * ① LLM 实例:四元绑定(Provider / URL / Key / Model),活动只见自定义模型名。 + * 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009)。 + */ + const apiKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder'; + for (const [name, model, systemUse] of [ + ['deepseek-v4-flash', 'deepseek-v4-flash', false], + ['deepseek-v4-pro', 'deepseek-v4-pro', false], + ] as const) { + await container.llm.upsert({ name, provider: 'deepseek', baseUrl: '', model, apiKey, systemUse, enabled: true }); + } + // 用途名,不是型号名:换档只改这一处绑定,活动一行不动。系统用途(小结/汇报/蒸馏)也走它 + await container.llm.upsert({ + name: 'quote-extractor', + provider: 'deepseek', + baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '', + model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash', + apiKey, + systemUse: true, + enabled: true, + }); + log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)'); + + /* ② 功能工具注册两审 + 逐槽位白名单 */ + for (const contract of [HISTORY_TOOL, MAIL_TOOL]) { + const verdict = await container.toolbox.register({ + contract, + samples: [ + { + // 样例集一份喂两件工具:并集覆盖各家必填 + input: { pn: 'LM358DR', to: 'x@y.com', subject: '询价' }, + expectShape: { type: 'object' }, + }, + ], + connectionValues: { endpoint: 'replay://crm' }, + }); + if (!verdict.accepted) { + log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; ')); + } + } + for (const [activityId, tools] of Object.entries(ALLOWLIST)) { + await container.toolbox.setAllowlist(activityId, tools); + } + log('② 功能工具已注册并入白名单(读类 1 · 写类 1,逐槽位授权)'); + + /* ③ 六个活动槽位经安装通道装入 —— 声明物化把四域十八对象建出来 */ + await installSlots(container); + log('③ 六个活动槽位已装入(契约就绪,实现留白)'); + + /* ④ 流程包 1:1 + 触发器 + 通道 */ + await seedMvpFlow(container); +} diff --git a/apps/api/src/app/seed-quote.ts b/apps/api/src/app/seed-quote.ts deleted file mode 100644 index eaf9c6a..0000000 --- a/apps/api/src/app/seed-quote.ts +++ /dev/null @@ -1,352 +0,0 @@ -/** - * 统一报价的验收种子 —— README《MVP 完成判据》第 1 条的机器形态。 - * - * 治理台要做的四件事,这里一次做完: - * ① 绑定 LLM 实例(任一 OpenAI 兼容端点) - * ② 注册功能工具并给活动白名单 - * ③ **经安装通道**装入活动包(读 activities 下各包 dist 的清单与产物 —— 数据,不是 import) - * ④ 编排「统一报价」流程包 v1 并发布(边映射闭合校验通过)+ 配置事件触发器与邮件通道实例 - */ -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { activityManifestSchema, processPackageSchema, triggerConfigSchema } from '@contracts'; -import type { Container } from './container.ts'; - -/** 从仓库根找 activities/ —— 只读文件,不 import(ADR-004:安装通道无代码通道) */ -function activitiesRoot(): string { - let dir = dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 8; i += 1) { - const candidate = join(dir, 'activities'); - if (existsSync(candidate)) return candidate; - const parent = resolve(dir, '..'); - if (parent === dir) break; - dir = parent; - } - throw new Error('找不到 activities/ —— 先跑 pnpm activities:build'); -} - -/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */ -const HISTORY_TOOL = { - name: 'crm.history-price-read', - version: '1.0.0', - description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态', - input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } }, - output: { - type: 'object', - properties: { - historyDays: { type: 'integer' }, - marketStable: { type: 'boolean' }, - lifecycle: { type: 'string' }, - hit: { type: 'boolean' }, - }, - }, - effect: 'read' as const, - errors: [{ code: 'not-found', retryable: false, message: '无历史价' }], - idempotent: true, - timeoutMs: 10_000, - retries: 2, - connectionKeys: ['endpoint'], -}; - -/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */ -const MAIL_TOOL = { - ...HISTORY_TOOL, - name: 'mail.send', - description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界', - input: { - type: 'object', - required: ['to', 'subject'], - properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } }, - }, - output: { type: 'object', properties: { queued: { type: 'boolean' } } }, - effect: 'write' as const, - idempotent: true, - connectionKeys: ['endpoint'], -}; - -/** 纯工具:无外效应、无连接配置、活动私有作用域 */ -function pureTool(name: string, description: string, ownerActivityId: string, required: string[]) { - return { - ...HISTORY_TOOL, - name, - description, - input: { type: 'object', required, properties: Object.fromEntries(required.map((k) => [k, {}])) }, - output: { type: 'object', properties: {} }, - effect: 'read' as const, - connectionKeys: [], - pure: true, - ownerActivityId, - }; -} - -/** 打标纯工具:三分类规则 P-03 的确定性求值 —— 无外效应,活动私有作用域 */ -const CLASSIFY_TOOL = { - ...HISTORY_TOOL, - name: 'quote-intake.classify', - description: '按三分类规则 P-03 逐条打标:输入历史天数/行情稳定/生命周期,输出 Type 与打法', - input: { - type: 'object', - required: ['historyDays'], - properties: { historyDays: {}, marketStable: {}, lifecycle: {} }, - }, - output: { type: 'object', properties: { type: { type: 'string' }, play: { type: 'string' } } }, - connectionKeys: [], - pure: true, - ownerActivityId: 'quote-intake', -}; - -export async function seedQuote(container: Container): Promise { - function log(line: string): void { - process.stdout.write(line + '\n'); - } - - /* - * ① LLM 实例:四元绑定(活动只见自定义模型名)。 - * - * 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009); - * 落库即密封,治理台也只回「是否已绑定」。URL 留空 → 取 provider 画像的默认端点。 - */ - const deepseekKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder'; - - // 两档 DeepSeek:flash 快而省,pro 强而慢 —— 活动按自己的场景声明引用哪一档 - for (const [name, model, systemUse] of [ - ['deepseek-v4-flash', 'deepseek-v4-flash', false], - ['deepseek-v4-pro', 'deepseek-v4-pro', false], - ] as const) { - await container.llm.upsert({ name, provider: 'deepseek', baseUrl: '', model, apiKey: deepseekKey, systemUse, enabled: true }); - } - - /* - * 活动声明里引用的逻辑名是 `quote-extractor` —— 它是**用途名**,不是型号名。 - * 把它指到 flash 档:受理归一是抽取活,快档足够;要换档只改这一处绑定,活动一行不动。 - * 系统用途(叙述性小结 / 汇报 / 蒸馏)也走它。 - */ - await container.llm.upsert({ - name: 'quote-extractor', - provider: 'deepseek', - baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '', - model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash', - apiKey: deepseekKey, - systemUse: true, - enabled: true, - }); - log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)'); - - /* ② 工具注册两审 + 白名单 */ - const tools = [ - HISTORY_TOOL, - MAIL_TOOL, - CLASSIFY_TOOL, - pureTool('quote-pricing.basis', '取价基准:报价相对历史成交价的偏离率,供底价门禁 P-02 求值', 'quote-pricing', ['quotedPrice']), - pureTool('quote-pricing.margin', '利润模型 P-01 的确定性求值:客户等级×物料类型×数量档 → 目标毛利区间', 'quote-pricing', ['customerGrade']), - pureTool('quote-pricing.floor-gate', '底价门禁 P-02 的确定性求值:偏离底价 ±20% 即止', 'quote-pricing', ['deviation']), - pureTool('quote-archive.profile', '客户画像更新:把成交行为写成标签,供 P-01 的客户等级因子取用', 'quote-archive', ['customerCode']), - pureTool('quote-archive.landing-rate', '落地率统计:放行通过的报价 ÷ 发出的报价', 'quote-archive', []), - ]; - for (const contract of tools) { - const verdict = await container.toolbox.register({ - contract, - samples: [ - { - // 样例集一份喂所有工具:并集覆盖各家必填,少一个键就有工具过不了两审 - input: { pn: 'LM358DR', historyDays: 30, customerGrade: '普通客户', deviation: 0, to: 'x@y.com', subject: 't', customerCode: 'C001', quotedPrice: 9.2, lastPrice: 8.4 }, - expectShape: { type: 'object' }, - }, - ], - ...(contract.connectionKeys.length > 0 ? { connectionValues: { endpoint: 'replay://crm' } } : {}), - }); - if (!verdict.accepted) { - log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; ')); - } - } - // 白名单按活动逐个给 —— 授权在白名单与契约,不在活动代码 - await container.toolbox.setAllowlist('quote-intake', [HISTORY_TOOL.name, CLASSIFY_TOOL.name]); - await container.toolbox.setAllowlist('quote-pricing', [HISTORY_TOOL.name]); - await container.toolbox.setAllowlist('quote-negotiate', [HISTORY_TOOL.name, MAIL_TOOL.name]); - await container.toolbox.setAllowlist('quote-archive', []); - await container.toolbox.setAllowlist('quote-sourcing', [HISTORY_TOOL.name, MAIL_TOOL.name]); - await container.toolbox.setAllowlist('quote-alternative', [HISTORY_TOOL.name]); - log('② 功能工具已注册并入白名单(逐活动授权)'); - - /* ③ 经安装通道装入活动包 —— 静态审 + 样例集(首装无历史,免回放审) */ - const root = activitiesRoot(); - const packages = [ - 'quote-intake', - 'quote-pricing', - 'quote-negotiate', - 'quote-archive', - 'quote-sourcing', - 'quote-alternative', - ]; - for (const dir of packages) { - const manifestRaw = JSON.parse(await readFile(join(root, dir, 'dist/manifest.json'), 'utf8')); - const manifest = activityManifestSchema.parse(manifestRaw); - const artifact = await readFile(join(root, dir, 'dist/index.js'), 'utf8'); - const verdict = await container.registry.install(manifest, artifact); - log(' 装入 ' + manifest.identity.name + '(' + manifest.identity.paradigm + ',静态审 ' + String(verdict.staticFindings.length) + ' 条提示)'); - } - log('③ 六个活动包已经安装通道装入'); - - /* ④ 编排「统一报价」流程包 v1 并发布 —— 七节点(SOP×5 + Case×2)+ 网关 + 审批点 */ - const activityNode = ( - id: string, - label: string, - activityId: string, - position: string, - failure: 'terminate' | 'tolerate', - col: number, - row = 0, - ) => ({ - id, - kind: 'activity' as const, - label, - activityId, - owner: { position, strategy: 'sole' as const }, - failure: { kind: failure }, - layout: { row, col }, - }); - - const pkg = processPackageSchema.parse({ - id: 'unified-quote', - name: '统一报价', - version: 2, - state: 'draft', - callInterface: { - input: { - type: 'object', - required: ['customerCode', 'rfqRef'], - properties: { customerCode: { type: 'string' }, rfqRef: { type: 'string' }, subject: { type: 'string' } }, - }, - output: { type: 'object', properties: { landingRate: { type: 'number' } } }, - }, - /* - * **当前只展开第一个活动**(v2)。 - * 后续活动一个一个加回来:每加一个,补它的节点、边映射与出口口载荷, - * 版本随之上抬 —— 在途的旧单仍跑各自起单时那一版,不受影响。 - */ - nodes: [ - { id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } }, - activityNode('A1', '客户RFQ受理', 'quote-intake', 'BD', 'terminate', 1), - { id: 'end', kind: 'end', label: '受理闭环', layout: { row: 0, col: 2 } }, - ], - edges: [ - { - id: 'e-start-a1', - from: 'start', - to: 'A1', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - { target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } }, - // 主题与正文要一路带到 A1:归一步拆的就是它们 - { target: 'subject', source: { from: 'initiation', path: 'subject' } }, - { target: 'body', source: { from: 'initiation', path: 'body' } }, - ], - reopen: false, - }, - { id: 'e-a1-end', from: 'A1', to: 'end', mapping: [], label: '统一受理记录', reopen: false }, - ], - variables: [], - }); - - await container.process.savePackage(pkg); - const report = await container.process.validate(pkg); - if (!report.publishable) { - log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; ')); - } else { - await container.process.publish(pkg.id, pkg.version); - log(`④ 流程包「统一报价」v${pkg.version} 已发布 —— ${pkg.nodes.length} 节点,边映射闭合校验通过`); - } - - /* 事件触发器「客户RFQ询价」 */ - const trigger = triggerConfigSchema.parse({ - id: 'trg-rfq', - 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' } }, - /* - * 单据日期:取入站时刻。 - * MVP 简化 —— 真实系统里「报价日期」应取报价单**实际发出**的时刻(出站回执), - * 而不是询价到达的时刻;两者在一单之内通常只差几天,不影响历史价情报的可用性。 - * 之所以要有它:`shared.history_price` 的主键是 (pn, quoted_at), - * 归档若拿不到日期就写不进去 —— 主键只增不破坏,不能为了省事去改它。 - */ - { target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } }, - ], - // 按**通道**收口:只有投进「公司询价邮箱」的才自动起单; - // 同一封信投到「询价待确认邮箱」不命中,落人工确认那条路 - spec: { - kind: 'event', - rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))', - eventType: 'data', - }, - }); - await container.trigger.upsert(trigger); - log(' 事件触发器「客户RFQ询价」已配置'); - - /* 邮件通道实例:归属键提取规则 + 载荷映射 + 默认认领岗位 */ - const { positions } = await container.org.listStaff(); - const op = positions.find((p) => p.code === 'OP'); - await container.channel.upsert({ - id: 'ch-rfq-mail', - name: '公司询价邮箱', - kind: 'mail', - address: 'rfq@we.com', - enabled: true, - extract: [ - { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' }, - { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' }, - { key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' }, - ], - mapping: [], - ...(op === undefined ? {} : { fallbackPosition: op.id }), - addressWindowMs: 7 * 86_400_000, - }); - log(' 邮件通道实例「公司询价邮箱」已配置(默认认领岗位:OP)'); - - /* - * ⑥ 人工确认通道 + 登记触发器 —— 三类触发里的**登记触发**(OS 3.1)。 - * - * 同一封 RFQ,两条起单路径: - * · 走「公司询价邮箱」→ 事件触发器命中 → **自动起单**; - * · 走「询价待确认邮箱」→ 无触发器命中 → 按「宁交人,不错投」落**消息兜底**到 - * 默认认领岗位(PM)→ 由人确认后**登记起单**,「登记人即来源」。 - * 后者是给需要人过一道目的场景用的:同样的信,起不起单由人定。 - */ - const pm = positions.find((p) => p.code === 'PM'); - await container.channel.upsert({ - id: 'ch-rfq-review', - name: '询价待确认邮箱', - kind: 'mail', - address: 'rfq-review@we.com', - enabled: true, - // 归属键规则与正式通道一致:识别照做,只是不自动起单 - extract: [ - { key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' }, - { key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' }, - ], - mapping: [], - ...(pm === undefined ? {} : { fallbackPosition: pm.id }), - addressWindowMs: 7 * 86_400_000, - }); - - await container.trigger.upsert( - triggerConfigSchema.parse({ - id: 'trg-rfq-manual', - name: '人工登记起单(统一报价)', - enabled: true, - processPackageId: 'unified-quote', - // 登记触发以**登记表单**为输入,不做字段映射:表单由调用接口 schema 自动渲染 - mapping: [], - spec: { kind: 'registration' }, - }), - ); - log(' 人工确认通道「询价待确认邮箱」+ 登记触发器已配置(默认认领岗位:PM)'); -} diff --git a/apps/api/src/app/seed.ts b/apps/api/src/app/seed.ts index f85e5dd..4927bc0 100644 --- a/apps/api/src/app/seed.ts +++ b/apps/api/src/app/seed.ts @@ -4,7 +4,7 @@ */ import { loadConfig } from './config.ts'; import { createContainer } from './container.ts'; -import { seedQuote } from './seed-quote.ts'; +import { seedMvp } from './seed-mvp.ts'; export async function seed(): Promise { const container = await createContainer(loadConfig()); @@ -14,9 +14,9 @@ export async function seed(): Promise { process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`); for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`); - // 统一报价的验收种子(README MVP 完成判据第 1 条) + // MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条) process.stdout.write('\n── 治理台配置 ──\n'); - await seedQuote(container); + await seedMvp(container); } finally { await container.close(); } diff --git a/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts b/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts new file mode 100644 index 0000000..aa0cf27 --- /dev/null +++ b/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/shared/gov/flow/graph-to-pkg.ts b/apps/web/src/shared/gov/flow/graph-to-pkg.ts index 1f50eaa..64a497e 100644 --- a/apps/web/src/shared/gov/flow/graph-to-pkg.ts +++ b/apps/web/src/shared/gov/flow/graph-to-pkg.ts @@ -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}`);