diff --git a/apps/api/src/app/__tests__/seed-mvp-slots.test.ts b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts new file mode 100644 index 0000000..f1f6699 --- /dev/null +++ b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts @@ -0,0 +1,44 @@ +/** + * 六个活动槽位 —— 契约就绪、实现留白。守:范式/岗位/出口与 MVP 一致; + * 声明只有名(工具按名引用、域对象按名取)—— 活动与工具的解耦就靠这一条。 + * A5 定价审批是**内置审批点**,零开发、不入库 —— 不在槽位之列。 + */ +import { describe, expect, it } from 'vitest'; +import { MVP_SLOTS, SLOT_ARTIFACT } from '../seed-mvp-slots.ts'; + +describe('MVP_SLOTS', () => { + it('六槽位,id 与范式如 MVP(A5 审批点内置,不入库)', () => { + const byId = new Map(MVP_SLOTS.map((m) => [m.identity.activityId as string, m.identity.paradigm])); + expect(MVP_SLOTS).toHaveLength(6); + expect(byId.get('rfq-intake')).toBe('flow'); + expect(byId.get('alt-selection')).toBe('loop'); + expect(byId.get('sourcing-inquiry')).toBe('loop'); + expect(byId.get('cost-pricing')).toBe('flow'); + expect(byId.has('pricing-approval')).toBe(false); + expect(byId.get('quote-negotiation')).toBe('loop'); + expect(byId.get('intel-archive')).toBe('flow'); + }); + + it('A1 出口带两枚去向旗;A2 透传 needSourcing —— 网关条件的数据来源', () => { + const a1 = MVP_SLOTS.find((m) => m.identity.activityId === 'rfq-intake')!; + const p1 = (a1.contract.output.mandatory[0]!.payload as { properties?: Record }).properties ?? {}; + expect(Object.keys(p1)).toEqual(expect.arrayContaining(['needAlternative', 'needSourcing'])); + const a2 = MVP_SLOTS.find((m) => m.identity.activityId === 'alt-selection')!; + const p2 = (a2.contract.output.mandatory[0]!.payload as { properties?: Record }).properties ?? {}; + expect(Object.keys(p2)).toContain('needSourcing'); + }); + + it('A6 出口带 outcome 三值 —— G4 议价结果的判据', () => { + const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!; + const p6 = (a6.contract.output.mandatory[0]!.payload as { properties?: Record> }) + .properties ?? {}; + expect(p6['outcome']?.['enum']).toEqual(['won', 'revise', 'lost']); + }); + + it('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => { + expect(SLOT_ARTIFACT).toContain('未实现'); + for (const m of MVP_SLOTS) { + for (const t of m.declarations.tools) expect(typeof t.name).toBe('string'); + } + }); +}); diff --git a/apps/api/src/app/seed-mvp-slots.ts b/apps/api/src/app/seed-mvp-slots.ts new file mode 100644 index 0000000..6990e35 --- /dev/null +++ b/apps/api/src/app/seed-mvp-slots.ts @@ -0,0 +1,490 @@ +/** + * 六个活动槽位(docs/MVP示例工作流.html §1.1–1.7)—— **契约就绪、实现留白**。 + * + * 槽位 = 活动的契约面:身份 / 七件契约(入参、出口)/ 声明(域对象、工具名)/ 业务模型骨架。 + * 与工具的解耦就在声明:`declarations.tools` 只写**名**,注册、连接值、白名单全在治理侧。 + * A5 定价审批是**内置审批点**(审批不是任务,零开发、不入库),不在槽位之列。 + * + * 实现待底座完备后逐个开发:届时以新包版本经安装通道换入,槽位契约不动。 + */ +import { createHash } from 'node:crypto'; +import { activityManifestSchema } from '@contracts'; +import type { ActivityManifest } from '@contracts'; +import type { Container } from './container.ts'; +import { declarationsFor } from './seed-mvp-data.ts'; + +/** + * 槽位占位入口 —— **不是实现**:装载它只会得到「未实现」的诚实报错。 + * mock 只许在外界:这里绝不发既定载荷装作跑过(README 平移规则)。 + */ +export const SLOT_ARTIFACT = [ + '// 活动槽位:契约已就绪,实现待底座完备后逐个开发。', + "throw new Error('活动尚未实现 —— 这是槽位,契约见治理台活动库');", +].join('\n'); + +const checksum = createHash('sha256').update(SLOT_ARTIFACT).digest('hex'); + +/** flow 槽位的业务模型骨架:MVP 作业指导书的步骤标题 —— 未实现期一步一张执行工单,人工可兜 */ +const flowSkeleton = (steps: readonly string[]) => ({ + paradigm: 'flow' as const, + steps: steps.map((label, i) => ({ + name: `S${i + 1}`, + label, + input: {}, + kind: 'human' as const, + workOrder: 'execution' as const, + card: {}, + deadline: 'P1D', + // 失败策略声明给定,不临场:人步失败即受阻待人 —— 槽位期没有可重试的机器步 + failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' as const } }, + })), + parallelGroups: [], +}); + +/** loop 槽位:CMMN 骨架(MVP 案例图的项)+ 叙事一句话;DMN 待实现期再挂 */ +const loopSkeleton = (items: readonly string[], narrative: string) => ({ + paradigm: 'loop' as const, + cmmnXml: `${items.map((it) => ``).join('')}`, + narrative, + dmn: { drd: [], tables: [] }, +}); + +const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') => ({ + activityId, + name, + packageVersion: '0.1.0', + category: '统一报价', + paradigm, + sdkVersion: '0.1.0-m5', + checksum, +}); + +const harness = { memoryWindow: 20, stallAfter: 'PT45M', nudge: [] }; + +/** 七件契约里逐槽位相同的空段 —— 槽位期没有必交付物/验收细则,实现期再补 */ +const contractRest = { requiredDeliverables: [], acceptanceCriteria: [], prohibitions: [] }; + +const budget = { steps: 40, tokens: 200_000, activeMinutes: 60, humanCalls: 3 }; + +export const MVP_SLOTS: readonly ActivityManifest[] = [ + /* ── A1 客户RFQ受理(SOP · BD) ──────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('rfq-intake', '客户RFQ受理', 'flow'), + contract: { + activityId: 'rfq-intake', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['customerCode', 'rfqRef'], + properties: { + customerCode: { type: 'string' }, + rfqRef: { type: 'string' }, + subject: { type: 'string' }, + body: { type: 'string' }, + quotedAt: { type: 'string' }, + }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'intake', + description: '统一受理记录(打标)+ 两枚去向旗', + payload: { + type: 'object', + required: ['records', 'needAlternative', 'needSourcing'], + properties: { + records: { type: 'array' }, + pn: { type: 'string' }, + type_tag: { type: 'string' }, + needAlternative: { type: 'boolean' }, + needSourcing: { type: 'boolean' }, + }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [{ name: 'crm.history-price-read' }], + ledger: declarationsFor([ + 'project.rfq_file', + 'project.intake_record', + 'project.pn_class', + 'project.alt_request', + 'project.inquiry_request', + 'intel.history_price', + 'customer.profile', + ]), + }, + assets: { + businessModel: flowSkeleton(['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单']), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:受理一单', + input: { customerCode: 'C001', rfqRef: 'RFQ-001' }, + expect: { ports: ['intake'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), + + /* ── A2 替代选型(Case · FAE) ──────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('alt-selection', '替代选型', 'loop'), + contract: { + activityId: 'alt-selection', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['material'], + properties: { + material: { type: 'string' }, + chip_type: { type: 'string' }, + spec_params: { type: 'string' }, + scenario: { type: 'string' }, + needSourcing: { type: 'boolean' }, + }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'benchmark', + description: '替代对标表(已签字)+ needSourcing 原样带出(G2 在本路径读它)', + payload: { + type: 'object', + required: ['rows', 'needSourcing'], + properties: { rows: { type: 'array' }, needSourcing: { type: 'boolean' } }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [{ name: 'crm.history-price-read' }], + ledger: declarationsFor(['intel.chip_spec', 'intel.public_select', 'project.alt_benchmark']), + }, + assets: { + businessModel: loopSkeleton( + ['替代对标表', '初筛清单'], + 'MVP §1.5:上接 G1 是否需要选型;齐了即收 —— 必做项完成且无进行中项即结案,办结经 G2 定去向。', + ), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:一件替代评估', + input: { material: 'LM358DR' }, + expect: { ports: ['benchmark'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), + + /* ── A3 寻源询价(Case · PRM) ──────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('sourcing-inquiry', '寻源询价', 'loop'), + contract: { + activityId: 'sourcing-inquiry', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['pn'], + properties: { + pn: { type: 'string' }, + brand: { type: 'string' }, + era: { type: 'string' }, + project_stage: { type: 'string' }, + accept_alt: { type: 'boolean' }, + }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'compare', + description: '寻源比价表 + 供应商底价表', + payload: { + type: 'object', + required: ['rows', 'floors'], + properties: { rows: { type: 'array' }, floors: { type: 'array' } }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { + tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], + skills: [], + models: [], + roles: ['PRM'], + budget, + }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], + ledger: declarationsFor([ + 'supplier.channel_dir', + 'supplier.dead_stock', + 'project.sourcing_compare', + 'project.supplier_floor', + ]), + }, + assets: { + businessModel: loopSkeleton( + ['寻源比价表', '供应商回价单'], + 'MVP §1.7:上接 G2 是否需要寻源;齐了即收 —— 必做项完成且无进行中项即结案,办结转 A4 成本定价。', + ), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:一单询价', + input: { pn: 'LM358DR' }, + expect: { ports: ['compare'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), + + /* ── A4 成本定价(SOP · BD) ────────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('cost-pricing', '成本定价', 'flow'), + contract: { + activityId: 'cost-pricing', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['records'], + properties: { + records: { type: 'array' }, + floors: { type: 'array' }, + benchmark: { type: 'array' }, + customerCode: { type: 'string' }, + }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'draft', + description: '报价草案:逐条定价,先过底价门禁再提审', + payload: { + type: 'object', + required: ['rows'], + properties: { rows: { type: 'array' }, quote_no: { type: 'string' } }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [{ name: 'crm.history-price-read' }], + ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.quote_draft']), + }, + assets: { + businessModel: flowSkeleton(['取价基准', '逐条定价']), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:一批草案', + input: { records: [] }, + expect: { ports: ['draft'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), + + /* ── A6 报价议价(Case · BD) ───────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('quote-negotiation', '报价议价', 'loop'), + contract: { + activityId: 'quote-negotiation', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['rows'], + properties: { rows: { type: 'array' }, quote_no: { type: 'string' }, customerCode: { type: 'string' } }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'formal', + description: '正式报价单(已送出)+ 跟进提醒 + 议价结果', + payload: { + type: 'object', + required: ['outcome'], + properties: { + outcome: { type: 'string', enum: ['won', 'revise', 'lost'] }, + quote_no: { type: 'string' }, + customer_code: { type: 'string' }, + rows: { type: 'array' }, + }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { + tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], + skills: [], + models: [], + roles: ['BD'], + budget, + }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], + ledger: declarationsFor(['project.formal_quote', 'project.followup']), + }, + assets: { + businessModel: loopSkeleton( + ['正式报价单'], + 'MVP §1.4:上接审批通过进入;发出标准报价并跟谈,办结经 G4 议价结果定去向(成交 / 有异议 / 拒绝或超时)。', + ), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:一单议价', + input: { rows: [] }, + expect: { ports: ['formal'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), + + /* ── A7 情报归档(SOP · BD) ────────────────────────────────────── */ + activityManifestSchema.parse({ + identity: identity('intel-archive', '情报归档', 'flow'), + contract: { + activityId: 'intel-archive', + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['outcome'], + properties: { + outcome: { type: 'string' }, + quote_no: { type: 'string' }, + customer_code: { type: 'string' }, + quotedAt: { type: 'string' }, + }, + }, + messageAllowed: true, + }, + output: { + mandatory: [ + { + name: 'stats', + description: '不成交也留资产:情报、客户画像、落地率', + payload: { + type: 'object', + required: ['landing_rate'], + properties: { landing_rate: { type: 'number' } }, + }, + }, + ], + selective: [], + }, + ...contractRest, + authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 }, + resources: { tools: [], skills: [], models: [], roles: ['BD'], budget }, + }, + declarations: { + models: [], + listeners: [], + harness, + tools: [], + ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.landing_stats']), + }, + assets: { + businessModel: flowSkeleton(['结果登记', '画像更新', '落地率统计']), + skills: [], + viewTemplates: [], + cardSchemas: {}, + distillHooks: [], + }, + samples: [ + { + name: '契约样例:一次归档', + input: { outcome: 'lost' }, + expect: { ports: ['stats'] }, + }, + ], + entry: { file: 'index.js', checksum }, + }), +]; + +export async function installSlots(container: Container): Promise { + for (const manifest of MVP_SLOTS) { + const verdict = await container.registry.install(manifest, SLOT_ARTIFACT); + const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error'); + process.stdout.write( + ` 槽位 ${manifest.identity.name}(${manifest.identity.paradigm})静态审 ${verdict.staticFindings.length} 条` + + (errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') + + '\n', + ); + } +}