Files
JX-BPOS/apps/api/src/app/seed-quote.ts
T
JCAI Main AgentandClaude Opus 5 2f01eda3fb M5 收官:六个活动包 + 统一报价全图 + 任务推进器
活动包(按 MVP示例工作流 归一化):
- quote-intake  A1 受理归一(flow):LLM 抽取 → 条件跳步补全 → 查历史价 → DMN 打标 → 验收
- quote-pricing A4 成本定价(flow):DMN 利润模型 P-01 + 底价门禁 P-02;超限条目调人决策
- quote-negotiate A6 报价议价(flow):写类动作登记意向、放行即落地;建 D+3 跟进监听;
  议价结果建模为选择组三口(成交/有异议/拒绝)—— 业务分支走输出口,不用异常终态编码
- quote-archive A7 情报归档(flow):写共享域(历史价格情报/客户资料库/落地率统计)
- quote-sourcing A3 渠道寻源(loop):CMMN 四阶段 + 里程碑 + 裁量项 + 两个时间监听;
  DMN 采用策略判定;案例说明书与 XML 同版本
- quote-alternative A2 替代选型(loop):CMMN 三阶段 + userEventListener(客户改需求);
  DMN 替代等级判定;禁区「差异没核清的不放行」有机检形态

流程包全图:7 节点 + 3 网关 + 1 审批点
- 已裁定①:A5 = 审批点,G3 并入驳回走向(驳回 → 回 A4 重开新实例)
- 已裁定②:G2 = 并行网关,A3/A2 失败走向 = 容忍
- 已裁定④:议价回环 = 流程层回边(reopen)

闭合校验在此拦下一个真实建模错误(A4 有两条并发入边映射同名字段),
按规范改拓扑:TYPE-A 直通也走汇合;同时把「回边不是并发汇合」写进判定 ——
reopen 边不参与隐式合并检查。

app:task-advancer worker —— 过程在后端,运行中的任务一拍一拍推,不依赖浏览器在线;
进入等待或终结即停手,不空转。第一方工具补齐 P-01/P-02/画像/落地率/出站发送。

已验证(真机):
- 六个活动包经安装通道装入,静态审各 0 条提示
- 流程包闭合校验通过并发布
- 台账自生长:11 张表由活动声明物化(含 shared 三张共享域表)
- 邮件入站 → 触发器 → 工作流 → A1 落到冯兵工位 → 推进器推 2 拍:
  装载 5 步声明 → 执行 LLM 步 → 按声明重试 2 次 → 转人兜底 → awaiting-event
  (LLM 端点是占位 key,失败路径因此走通了 —— 正是声明给定的兜底)

358 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 02:37:04 +08:00

368 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一报价的验收种子 —— 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 实例:四元绑定(活动只见自定义模型名) */
await container.llm.upsert({
name: 'quote-extractor',
provider: 'openai-compatible',
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? 'https://api.deepseek.com/v1',
model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-chat',
apiKey: process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder',
systemUse: true,
enabled: true,
});
log('① LLM 实例已绑定:quote-extractor');
/* ② 工具注册两审 + 白名单 */
const tools = [
HISTORY_TOOL,
MAIL_TOOL,
CLASSIFY_TOOL,
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' },
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' },
reject: { kind: 'reopen', nodeId: 'A4', mapping: [] },
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' } },
],
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: 'records' } },
{ 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: 'records' } }],
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' } },
{ target: 'records', source: { from: 'initiation', path: 'records' } },
],
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' } },
{ target: 'records', source: { from: 'initiation', path: 'records' } },
],
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' } },
],
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)');
}