forked from ChaiHao/BPOS-MVP
测试台改仪表台:启动输入换成一封信;卡片化触发器与大模型;清掉 ⓘ 说教条
**测试台**
· 启动输入不再是「调用参数」表 —— 这条流真实的起法就是客户来信,
界面上就该是一封信(发件人 / 收件人 / 主题 / 正文 / BOM 附件)。
服务端拿**真通道的抽键正则** + **真触发器的装配映射**推出调用入参:
测试台自己不解析邮件,否则试通的是测试台而不是这条流。
抽不出必填就如实报「哪个字段没命中、通道的正则要什么」,不静默起空单。
· 一屏两大块:左选流,右两节 —— 01 输入配置、02 节点指派。
· 起单钮只剩一枚(原来页头一枚、RUN 卡里一枚)。
· 改暗色仪表台:调色板集中在 .g-tb/.g-rig 的一组变量上,
TRACE 那张树 + 甘特的结构一行没动,只是纸变成了面板。
**触发器 / 大模型** 列表改卡片:一条规则、一个实例都要一眼看全
(何时起 · 起哪条流 · 开没开 / 谁家的 · 什么型号 · 密钥绑没绑)。
触发器卡是 div 不是 button —— 卡里还坐着「改规则」那枚真按钮。
**同类卡等高**:底栏钉在卡底、说明恒占两行、卡头一行截断。
实测同一货架内 13 张工具卡、6 张活动卡、3 张模型卡各自完全等高。
**删掉 19 条 ⓘ 说教横幅**(「只列有生效版的包…」那一类)。
留下的是三种:确认带里的影响说明(点了动作才出现,是决策依据)、
warn/ok 的真反馈、以及「X 不存在」这类空态。
删掉入站邮件演练整屏 —— 它验的那两步已经并进测试台的起单路径了。
新增一条接缝测试:通道抽出来的键叫 keys.customerCode,触发器映射也得照这名字取;
哪头改了名字单还是能起,只是入参悄悄变空、流走到 G1 才莫名其妙地停。
四闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 一封信 → 调用入参 —— 测试台起单前那两步的**接缝**。
|
||||
*
|
||||
* 两步各自有测试(channel 的抽键、trigger 的装配),但**串起来**是新的:
|
||||
* 通道抽出来的键叫 `keys.customerCode`,触发器的映射也得照这个名字取 ——
|
||||
* 中间任何一头改了字段名,单还是能起,只是入参悄悄变空,流走到 G1 才莫名其妙地停。
|
||||
* 所以这条测试盯的就是这个名字对不对得上。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluate as evalSfeel } from '@sfeel';
|
||||
import { extractFields } from '@modules/channel';
|
||||
import { assembleInput, scopeOf } from '@modules/trigger';
|
||||
import type { TriggerConfig } from '@modules/trigger';
|
||||
|
||||
/** 与种子里 `ch-rfq-mail` 同一套抽键规则 */
|
||||
const EXTRACT = [
|
||||
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' as const },
|
||||
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+(?:-[A-Z0-9]+)*', source: 'both' as const },
|
||||
];
|
||||
|
||||
/** 与种子里「客户RFQ询价」同一套装配映射 */
|
||||
const TRIGGER = {
|
||||
id: 'trg-rfq-mail',
|
||||
name: '客户RFQ询价',
|
||||
processPackageId: 'unified-quote',
|
||||
enabled: true,
|
||||
spec: { kind: 'event', eventType: 'data', rule: 'true' },
|
||||
mapping: [
|
||||
{ source: { from: 'expression', sfeel: 'keys.customerCode' }, target: 'customerCode' },
|
||||
{ source: { from: 'expression', sfeel: 'keys.rfqRef' }, target: 'rfqRef' },
|
||||
{ source: { from: 'expression', sfeel: 'subject' }, target: 'subject' },
|
||||
{ source: { from: 'expression', sfeel: 'body' }, target: 'body' },
|
||||
],
|
||||
} as TriggerConfig;
|
||||
|
||||
const evaluator = (expression: string, scope: Readonly<Record<string, unknown>>): unknown => {
|
||||
try {
|
||||
return evalSfeel(expression, scope);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
function assemble(subject: string, body = '正文'): ReturnType<typeof assembleInput> {
|
||||
const keys = extractFields({ subject, body, from: 'buyer@x.com', to: ['rfq@we.com'] }, EXTRACT);
|
||||
const scope = scopeOf({
|
||||
type: 'data',
|
||||
keys,
|
||||
source: 'channel',
|
||||
occurredAt: '2026-07-29T00:00:00.000Z',
|
||||
payload: { subject, body, from: 'buyer@x.com', channelId: 'ch-rfq-mail' },
|
||||
});
|
||||
return assembleInput(TRIGGER, scope, ['customerCode', 'rfqRef'], evaluator);
|
||||
}
|
||||
|
||||
describe('一封信 → 调用入参', () => {
|
||||
it('主题里带客户码与询价单号:两个必填都装得上', () => {
|
||||
const out = assemble('【询价】C014 / RFQ-2607-001 工控主板 BOM 12 项');
|
||||
expect(out.kind).toBe('ready');
|
||||
if (out.kind !== 'ready') return;
|
||||
expect(out.data['customerCode']).toBe('C014');
|
||||
expect(out.data['rfqRef']).toBe('RFQ-2607-001');
|
||||
expect(out.data['subject']).toContain('工控主板');
|
||||
});
|
||||
|
||||
it('正文里带也算 —— 抽键两处都看(source: both)', () => {
|
||||
const out = assemble('询价', '客户 C014,单号 RFQ-2607-002,请报价');
|
||||
expect(out.kind).toBe('ready');
|
||||
if (out.kind !== 'ready') return;
|
||||
expect(out.data['rfqRef']).toBe('RFQ-2607-002');
|
||||
});
|
||||
|
||||
it('抽不出来就如实报缺哪个 —— 不静默起一张空单', () => {
|
||||
const out = assemble('随便问问,有没有便宜的板子');
|
||||
expect(out.kind).toBe('missing');
|
||||
if (out.kind !== 'missing') return;
|
||||
expect(out.fields).toEqual(expect.arrayContaining(['customerCode', 'rfqRef']));
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
upsertTriggerRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { evaluate as evalSfeel } from '@sfeel';
|
||||
import { extractFields } from '@modules/channel';
|
||||
import { assembleInput, scopeOf } from '@modules/trigger';
|
||||
import { InstallRejectedError } from '@modules/activity-registry';
|
||||
import type { InstallVerdict } from '@modules/activity-registry';
|
||||
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
||||
@@ -92,6 +95,72 @@ async function destroySimulations(container: Container, workflowId?: string): Pr
|
||||
return taskCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一封信 → 调用入参 —— **借的全是真件**:
|
||||
* 收件地址认通道(真通道的抽键正则),包上挂的触发器出装配映射(真触发器的 mapping)。
|
||||
*
|
||||
* 测试台自己不解析邮件。它要是自带一套解析,试通的就是测试台而不是这条流 ——
|
||||
* 线上真来一封信走的是通道抽键 + 触发器装配这两步,这里必须是同两步。
|
||||
*/
|
||||
async function assembleFromMail(
|
||||
container: Container,
|
||||
packageId: string,
|
||||
mail: { from: string; to: string; subject: string; body: string; attachments: readonly { name: string }[] },
|
||||
): Promise<Record<string, unknown>> {
|
||||
const channels = await container.channel.list();
|
||||
const channel =
|
||||
channels.find((ch) => ch.address === mail.to) ?? channels.find((ch) => ch.kind === 'mail' && ch.enabled);
|
||||
if (channel === undefined) throw new Error(`没有可用的邮件通道 —— 收件地址 ${mail.to} 对不上任何通道`);
|
||||
|
||||
const triggers = await container.trigger.list();
|
||||
const forPackage = triggers.filter((t) => t.processPackageId === packageId);
|
||||
const trigger = forPackage.find((t) => t.spec.kind === 'event') ?? forPackage[0];
|
||||
if (trigger === undefined) {
|
||||
throw new Error(`流程包 ${packageId} 还没有任何调起规则 —— 装配映射从触发器来,先去触发器区配一条`);
|
||||
}
|
||||
|
||||
const keys = extractFields(
|
||||
{ subject: mail.subject, body: mail.body, from: mail.from, to: [mail.to] },
|
||||
channel.extract,
|
||||
);
|
||||
const scope = scopeOf({
|
||||
type: 'data',
|
||||
keys,
|
||||
source: 'channel',
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: {
|
||||
subject: mail.subject,
|
||||
body: mail.body,
|
||||
from: mail.from,
|
||||
channelId: channel.id,
|
||||
// 附件只登记不解析 —— 解析 Excel 是活动的事
|
||||
attachments: mail.attachments.map((a) => a.name),
|
||||
},
|
||||
});
|
||||
|
||||
// 与起单取版同口径:必填字段读**生效版本**的调用接口
|
||||
const active = (await container.process.listPackages()).find((x) => x.id === packageId && x.state === 'active');
|
||||
const declared = active?.callInterface.input as { required?: string[] } | undefined;
|
||||
const required = declared?.required ?? [];
|
||||
const assembled = assembleInput(trigger, scope, required, (expression, evalScope) => {
|
||||
try {
|
||||
return evalSfeel(expression, evalScope);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
if (assembled.kind === 'missing') {
|
||||
const wanted = channel.extract
|
||||
.filter((r) => assembled.fields.includes(r.key))
|
||||
.map((r) => `${r.key} 要匹配 /${r.regex}/`);
|
||||
throw new Error(
|
||||
`这封信里抽不出 ${assembled.fields.join('、')} —— 通道「${channel.name}」的抽键规则没命中` +
|
||||
(wanted.length > 0 ? `(${wanted.join(';')})` : ''),
|
||||
);
|
||||
}
|
||||
return assembled.data;
|
||||
}
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||
@@ -280,16 +349,18 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
// **一次只跑一条**:再起一单就先把上一单连根拔掉 —— 仿真台上不留过夜的东西
|
||||
await destroySimulations(container);
|
||||
const { packageId, mail, assignments } = parsed.data;
|
||||
try {
|
||||
const data = await assembleFromMail(container, packageId, mail);
|
||||
// **一次只跑一条**:装配成了才拔上一单 —— 装配失败不该把在跑的那条也搭进去
|
||||
await destroySimulations(container);
|
||||
const record = await container.process.start({
|
||||
packageId: parsed.data.packageId,
|
||||
subject: parsed.data.subject,
|
||||
packageId,
|
||||
subject: mail.subject,
|
||||
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
|
||||
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
|
||||
data: parsed.data.data,
|
||||
...(Object.keys(parsed.data.assignments).length === 0 ? {} : { assignments: parsed.data.assignments }),
|
||||
data,
|
||||
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
||||
});
|
||||
return c.json({ workflowId: record.id, state: record.state });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user