Provider 侧:
· 新增 `provider-profile`(纯数据):各家的默认端点 / 结构化出参姿势 / 是否回推理
集中一处,adapter 只按画像行事 —— 新增 provider 只改画像,不写一族 if-else。
DeepSeek:`https://api.deepseek.com`,支持 json_object,v4 系列是推理模型。
· 建实例时 URL 可留空 → 取该 provider 的默认端点(openai-compatible 本就是
「随便哪个兼容端点」,没有默认,必须自填)。
· 推理正文归 `reasoning` 单列:那是**过程**不是结论,不进出参、不参与 schema 校验、
不入录制(录的是语义入参与结论)。
· 两档实例 deepseek-v4-flash / deepseek-v4-pro 已建;`quote-extractor` 是**用途名**,
指向 flash 档 —— 换档只改这一处绑定,活动一行不动。密钥只从环境读、落库即密封,
治理台只回「是否已绑定」(实测:库里是密文,视图无明文字段,三个实例探活全通)。
沿路修掉五处真问题:
① **同一任务被并发推进**:推进器与派发器各推各的,用的不是同一把锁 ——
实测同一步跑两遍、同一张卡发两张、同一行草稿写出两条互相打架的版本。
两者改用同一把 advisory lock(键的推导提取共用)。
② **LLM 步出参从不校验**:声明了 outputSchema,解析不出来就静默当 `{}` 写下去,
毛病要到下游缺字段时才炸、还炸在别处。改为「出参必过 schema」(AM 12.3),
不过即按声明的失败走向,并把模型原文记进留痕,好让人看见它到底回了什么。
③ **出参路径不认点号**:入参能走 `a.b.0.c`,出参只认扁平键 ——
模型按 schema 回嵌套结构(最常见的情形)就写出一条空记录。两边共用一份解析。
④ **归一步拿不到原文**:通道载荷只带指针不带正文、start→A1 边也没把主题正文传下去,
模型只拿到一个单号,于是回空 records。通道载荷带原文,边映射一路带到 A1。
⑤ **出参形状没讲给模型听**:声明了 schema 却只在提示里写「只输出 JSON」,
模型只能猜键名(实测回 `{"items":[]}` 而声明要 `{"records":[...]}`)。
改由网关按 outputSchema 统一附上说明 —— 这是系统的事,不该每个活动作者各写一遍。
另修:连接池没有 error 处理器,空闲连接被服务端掐断会让整个进程退出(一次运维操作
就能把 API 带崩)。
结果:一封 RFQ 入站 → 模型从正文抽出 pn/brand/eau/目标价/交期 →
D1 判定跳过补全 → 查历史价 → DMN 三分类 Type A → 验收放行卡,**全程零人工兜底**。
gates: tsc / eslint / depcruise(319 模块)/ api-report / 409 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
426 lines
19 KiB
TypeScript
426 lines
19 KiB
TypeScript
/**
|
||
* 统一报价的验收种子 —— 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<void> {
|
||
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: 1,
|
||
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' } } },
|
||
},
|
||
nodes: [
|
||
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } },
|
||
activityNode('A1', 'A1 RFQ需求分析', 'quote-intake', 'BD', 'terminate', 1),
|
||
// 已裁定 ②:G2 料号分流 = **并行网关**,三道并行、每边载荷按 Type 过滤
|
||
{ id: 'G2', kind: 'gateway', label: 'G2 料号分流', gateway: 'parallel', mode: 'fork', layout: { row: 0, col: 2 } },
|
||
// 某类条目为空的分支按「容忍」处置 —— A4 汇合处的必填映射不得依赖可空分支
|
||
activityNode('A3', 'A3 渠道寻源', 'quote-sourcing', 'PRM', 'tolerate', 3, -1),
|
||
activityNode('A2', 'A2 替代选型', 'quote-alternative', 'FAE', 'tolerate', 3, 1),
|
||
{ id: 'G2J', kind: 'gateway', label: 'G2 汇合', gateway: 'parallel', mode: 'join', layout: { row: 0, col: 4 } },
|
||
activityNode('A4', 'A4 成本定价', 'quote-pricing', 'BD', 'terminate', 5),
|
||
// 已裁定 ①:A5 定价审批 = **审批点**;G3 审批结果并入驳回走向(驳回 → 回 A4 重开新实例)
|
||
{
|
||
id: 'AP',
|
||
kind: 'approval',
|
||
label: 'AP 定价审批',
|
||
approver: { position: 'PM', strategy: 'sole' },
|
||
// 驳回走向的载荷映射不能空:重开的是**新的 A4 实例**,它照样要过 A4 的入参契约。
|
||
// 空映射 = 重开必失败,等于把「驳回回 A4」写成了一句空话。
|
||
reject: {
|
||
kind: 'reopen',
|
||
nodeId: 'A4',
|
||
mapping: [
|
||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||
],
|
||
},
|
||
nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] },
|
||
layout: { row: 0, col: 6 },
|
||
},
|
||
activityNode('A6', 'A6 报价议价', 'quote-negotiate', 'BD', 'terminate', 7),
|
||
// G4 议价结果 = 异或网关:成交 / 有异议(回边 A4)/ 拒绝超时
|
||
{ id: 'G4', kind: 'gateway', label: 'G4 议价结果', gateway: 'exclusive', mode: 'fork', layout: { row: 0, col: 8 } },
|
||
activityNode('A7', 'A7 情报归档', 'quote-archive', 'BD', 'terminate', 9),
|
||
{ id: 'end', kind: 'end', label: '归档闭环', layout: { row: 0, col: 10 } },
|
||
],
|
||
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-g2', from: 'A1', to: 'G2', mapping: [], label: '统一受理记录', reopen: false },
|
||
|
||
// 三道并行:TYPE-A 直通定价 / TYPE-B 寻源 / TYPE-C 选型
|
||
// TYPE-A 直通:不经活动,直接进汇合 —— A4 因此只有一条并发入边,来源无歧义
|
||
{ id: 'e-g2-j', from: 'G2', to: 'G2J', label: 'TYPE-A 直通定价', mapping: [], reopen: false },
|
||
{
|
||
id: 'e-g2-a3',
|
||
from: 'G2',
|
||
to: 'A3',
|
||
label: 'TYPE-B 转寻源',
|
||
mapping: [
|
||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||
{ target: 'scenario', source: { from: 'constant', value: 'BOM后期' } },
|
||
],
|
||
reopen: false,
|
||
},
|
||
{
|
||
id: 'e-g2-a2',
|
||
from: 'G2',
|
||
to: 'A2',
|
||
label: 'TYPE-C 转选型',
|
||
mapping: [{ target: 'originalPn', source: { from: 'output-port', port: 'intake', path: 'pn' } }],
|
||
reopen: false,
|
||
},
|
||
|
||
// 汇合:容忍分支的输出**不进 A4 的必填映射**(静态校验会拦)
|
||
{ id: 'e-a3-j', from: 'A3', to: 'G2J', mapping: [], reopen: false },
|
||
{ id: 'e-a2-j', from: 'A2', to: 'G2J', mapping: [], reopen: false },
|
||
{
|
||
id: 'e-j-a4',
|
||
from: 'G2J',
|
||
to: 'A4',
|
||
mapping: [
|
||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
|
||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
|
||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
|
||
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
|
||
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
|
||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||
],
|
||
reopen: false,
|
||
},
|
||
|
||
{ id: 'e-a4-ap', from: 'A4', to: 'AP', mapping: [], label: '报价草案提审', reopen: false },
|
||
{
|
||
id: 'e-ap-a6',
|
||
from: 'AP',
|
||
to: 'A6',
|
||
label: '通过',
|
||
mapping: [
|
||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||
{ target: 'items', source: { from: 'output-port', port: 'draft', path: 'items' } },
|
||
],
|
||
reopen: false,
|
||
},
|
||
|
||
{ id: 'e-a6-g4', from: 'A6', to: 'G4', mapping: [], reopen: false },
|
||
{ id: 'e-g4-end', from: 'G4', to: 'end', condition: 'outcome = "won"', label: '成交 → 转订单履约', reopen: false },
|
||
// 已裁定 ④:议价回环 = **流程层回边**,人裁决后重开新任务实例
|
||
{
|
||
id: 'e-g4-a4',
|
||
from: 'G4',
|
||
to: 'A4',
|
||
condition: 'outcome = "revise"',
|
||
label: '有异议 → 返回调整',
|
||
mapping: [
|
||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
|
||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
|
||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
|
||
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
|
||
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
|
||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||
],
|
||
reopen: true,
|
||
},
|
||
{
|
||
id: 'e-g4-a7',
|
||
from: 'G4',
|
||
to: 'A7',
|
||
condition: 'outcome = "lost"',
|
||
label: '拒绝/超时 → 情报归档',
|
||
mapping: [
|
||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||
{ target: 'quoteNo', source: { from: 'initiation', path: 'rfqRef' } },
|
||
],
|
||
reopen: false,
|
||
},
|
||
{ id: 'e-a7-end', from: 'A7', to: 'end', mapping: [], 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('④ 流程包「统一报价」v1 已发布 —— 7 节点 + 3 网关 + 1 审批点,边映射闭合校验通过');
|
||
}
|
||
|
||
/* 事件触发器「客户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' } },
|
||
],
|
||
spec: { kind: 'event', rule: '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)');
|
||
}
|