Archived
三面拆分⑭:宽进收敛 —— 起单不卡缺项,进活动受理归一,信息不全派补全单
用户定的逻辑:「调用活动,输入数据可以不定或者为空;进入活动后受理归一,
信息不全派补全单。」把收敛点从门口挪进活动 —— 三道门口闸全撤:
· 触发装配:缺必填**不再入死信**(AssemblyResult 的 missing 改 partial,带上已装配的);
装到多少带多少照样起单。死信只留给真错误(勾了确认没指定人这类配置错误)。
· process.start:撤掉调用接口缺项闸 —— 从前缺一个字段整条流都起不来,
现实是客户来信本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。
· task.start(替代旧 AM 12.1「缺项不得启动」):缺必填的任务落成「等人」,
缺的字段做成**补全卡**(elicitation,标签取 schema 的 title)发到负责人的
任务对话流,并武装 human 监听(IntakeDesk 端口;不占活动的人卡预算)。
答复只收契约声明过的键,合并进 data;齐了或答「没法填」才转 running 开跑;
答一半就就缺的部分再派一张卡。等人期间谱一步不走、不扣步数。
· 测试台预检改成**信息**不是闸:灯灭不拦起单,文案直说
「抽不出的键由受理补全单找人收齐」。
5 条受理归一单测 + 全链实测:投一封只写「想问下工控板价格」的信(零键)→
流照起 → A1 落负责人工位、等人、补全卡在对话流 → 答卡(customerCode/rfqRef)→
任务醒、合并、谱跑 3 步到验收。修一处接线错误:监听 deadline 要 ISO-8601
时长(P2D)不是时刻,传错整个补全监听静默没装上。
trigger/task/process 三份 CONTRACT.md 与产品设计.md 同步(宽进纪律成文)。
五闸绿;契约快照随本提交入库。
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,159 @@
|
||||
/**
|
||||
* 受理归一(2026-07-30 宽进,用户定的逻辑)——
|
||||
* 「调用活动,输入数据可以不定或者为空;进入活动后受理归一,信息不全派补全单。」
|
||||
*
|
||||
* 这里守四条:
|
||||
* ① 缺必填**不拒单**:任务落成「等人」,补全卡发给负责人,谱一步不走;
|
||||
* ② 补全答复只收契约声明过的键,合并进 data,齐了转 running;
|
||||
* ③ 答一半:合并已给的、就缺的部分再派一张卡,还是等人;
|
||||
* ④ 「没法填」不硬拦:人写明原因就带缺口开跑 —— 收敛是活动的事,不是门卫的事。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { LandedFacts, Task } from '../contract.ts';
|
||||
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||
|
||||
type Harness = {
|
||||
service: ReturnType<typeof createTaskService>;
|
||||
asked: Array<{ fields: Array<{ key: string; label: string }> }>;
|
||||
tasks: Map<string, Task>;
|
||||
};
|
||||
|
||||
const INPUT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
subject: { type: 'string' },
|
||||
},
|
||||
required: ['customerCode', 'rfqRef'],
|
||||
};
|
||||
|
||||
function harness(): Harness {
|
||||
const asked: Harness['asked'] = [];
|
||||
const tasks = new Map<string, Task>();
|
||||
let seq = 0;
|
||||
|
||||
const deps: TaskServiceDeps = {
|
||||
store: {
|
||||
get: async (id) => tasks.get(id),
|
||||
insert: async (t) => {
|
||||
tasks.set(t.id, t);
|
||||
},
|
||||
update: async (id, patch) => {
|
||||
const next = { ...tasks.get(id)!, ...patch };
|
||||
tasks.set(id, next);
|
||||
return next;
|
||||
},
|
||||
addConsumption: async (id) => tasks.get(id)!.used,
|
||||
lastProgressAt: async () => new Date('2026-07-30T00:00:01.000Z'),
|
||||
markProgress: async () => undefined,
|
||||
listByOwner: async () => [],
|
||||
},
|
||||
activities: {
|
||||
load: async () => ({
|
||||
paradigm: 'flow' as const,
|
||||
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
|
||||
envelope: {},
|
||||
inputSchema: INPUT_SCHEMA,
|
||||
businessModel: {},
|
||||
stallAfter: 'PT30M',
|
||||
}),
|
||||
},
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
engines: {
|
||||
flow: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||
loop: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||
},
|
||||
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
|
||||
landing: {
|
||||
land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }),
|
||||
},
|
||||
listeners: { hasArmedListener: async () => true },
|
||||
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
|
||||
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: {
|
||||
askCompletion: async (input) => {
|
||||
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
|
||||
},
|
||||
},
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `t-${(seq += 1)}` },
|
||||
};
|
||||
|
||||
return { service: createTaskService(deps), asked, tasks };
|
||||
}
|
||||
|
||||
const START = {
|
||||
activityId: 'a1',
|
||||
ownerId: 's-1' as never,
|
||||
title: '客户RFQ受理',
|
||||
} as const;
|
||||
|
||||
describe('受理归一 —— 宽进,信息不全派补全单', () => {
|
||||
it('① 缺必填不拒单:任务落成等人,补全卡带中文标签发出去', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: { subject: '来信' } });
|
||||
|
||||
expect(task.runtime, '不开跑,先等人').toBe('awaiting-human');
|
||||
expect(h.asked).toHaveLength(1);
|
||||
expect(h.asked[0]!.fields).toEqual([
|
||||
{ key: 'customerCode', label: '客户码' },
|
||||
{ key: 'rfqRef', label: '询价单号' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('② 补全到齐:合并进 data、转 running;不认契约之外的键', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: { customerCode: 'C014', rfqRef: 'RFQ-1', hacker: '塞私货' } },
|
||||
});
|
||||
const after = h.tasks.get(task.id)!;
|
||||
expect(after.runtime).toBe('running');
|
||||
expect(after.data['customerCode']).toBe('C014');
|
||||
expect(after.data['rfqRef']).toBe('RFQ-1');
|
||||
expect('hacker' in after.data, '契约没声明的键不收').toBe(false);
|
||||
});
|
||||
|
||||
it('③ 答一半:合并已给的,就缺的再派一张卡,还是等人', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: { customerCode: 'C014' } },
|
||||
});
|
||||
const after = h.tasks.get(task.id)!;
|
||||
expect(after.runtime).toBe('awaiting-human');
|
||||
expect(after.data['customerCode']).toBe('C014');
|
||||
expect(h.asked, '第二张卡只要还缺的').toHaveLength(2);
|
||||
expect(h.asked[1]!.fields.map((f) => f.key)).toEqual(['rfqRef']);
|
||||
});
|
||||
|
||||
it('④ 没法填:写明原因就带缺口开跑 —— 收敛是活动的事', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: {}, reason: '客户没给单号,先按来信受理' },
|
||||
});
|
||||
expect(h.tasks.get(task.id)!.runtime).toBe('running');
|
||||
});
|
||||
|
||||
it('输入齐全照旧直接开跑 —— 宽进不该让正常路多等一站', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: { customerCode: 'C014', rfqRef: 'RFQ-1' } });
|
||||
expect(task.runtime).toBe('running');
|
||||
expect(h.asked).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
|
||||
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: { askCompletion: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
|
||||
ids: { next: () => 'x' },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user