diff --git a/api-report/apps/api/src/modules/task/port.d.ts b/api-report/apps/api/src/modules/task/port.d.ts index c435340..9279541 100644 --- a/api-report/apps/api/src/modules/task/port.d.ts +++ b/api-report/apps/api/src/modules/task/port.d.ts @@ -118,6 +118,21 @@ export type ReassignNotifier = { }): Promise; }; /** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */ +/** + * 受理补全台(2026-07-30 宽进):把缺的必填字段做成补全卡发到负责人的任务对话流, + * 并武装 human 监听等答复 —— app 注入 human-tool + event。 + */ +export type IntakeDesk = { + askCompletion(input: { + taskId: string; + ownerId: string; + title: string; + fields: ReadonlyArray<{ + key: string; + label: string; + }>; + }): Promise; +}; export type InteractionPhaseSetter = { setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise; }; diff --git a/apps/api/.data/attachments/inbound/intake-c9qirc.eml b/apps/api/.data/attachments/inbound/intake-c9qirc.eml new file mode 100644 index 0000000..dae8397 --- /dev/null +++ b/apps/api/.data/attachments/inbound/intake-c9qirc.eml @@ -0,0 +1,12 @@ +{ + "messageId": "intake-c9qirc", + "from": "someone@new-customer.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "询价:想问下工控板价格", + "body": "我们要 12 片,怎么卖?", + "receivedAt": "2026-07-29T05:38:43.605Z", + "attachments": [] +} \ No newline at end of file diff --git a/apps/api/.data/attachments/inbound/intake2-dycbis.eml b/apps/api/.data/attachments/inbound/intake2-dycbis.eml new file mode 100644 index 0000000..43b6bd9 --- /dev/null +++ b/apps/api/.data/attachments/inbound/intake2-dycbis.eml @@ -0,0 +1,12 @@ +{ + "messageId": "intake2-dycbis", + "from": "someone@new-customer.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "询价:想问下工控板价格", + "body": "我们要 12 片,怎么卖?", + "receivedAt": "2026-07-29T05:42:55.434Z", + "attachments": [] +} \ No newline at end of file diff --git a/apps/api/src/app/__tests__/mail-assembly.test.ts b/apps/api/src/app/__tests__/mail-assembly.test.ts index 5e16bfb..cc08c41 100644 --- a/apps/api/src/app/__tests__/mail-assembly.test.ts +++ b/apps/api/src/app/__tests__/mail-assembly.test.ts @@ -70,10 +70,13 @@ describe('一封信 → 调用入参', () => { expect(out.data['rfqRef']).toBe('RFQ-2607-002'); }); - it('抽不出来就如实报缺哪个 —— 不静默起一张空单', () => { + it('抽不全:如实列出缺哪个,但**带着已有的照样能起** —— 宽进,收敛在活动', () => { const out = assemble('随便问问,有没有便宜的板子'); - expect(out.kind).toBe('missing'); - if (out.kind !== 'missing') return; + expect(out.kind).toBe('partial'); + if (out.kind !== 'partial') return; expect(out.fields).toEqual(expect.arrayContaining(['customerCode', 'rfqRef'])); + // 缺的键不以 undefined 占位 —— 下游按「键在不在」判缺不缺 + expect('customerCode' in out.data).toBe(false); + expect(out.data['subject'], '抽得到的照带').toBeDefined(); }); }); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index e02de48..0f9b631 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -152,15 +152,7 @@ async function assembleFromMail( 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; } diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 691cd9f..c722dc0 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -34,7 +34,7 @@ import type { TriggerService } from '@modules/trigger'; import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel'; import type { StaffId, TaskId } from '@contracts'; -import { staffIdSchema } from '@contracts'; +import { cardIdSchema, staffIdSchema } from '@contracts'; import { createAgentRunner } from '../agent-runner.ts'; import { createMastraAgentRunner } from '../mastra-agent-runner.ts'; import type { Clock, IdGenerator } from '../runtime.ts'; @@ -772,6 +772,32 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, interaction: { setPhase: async (taskId, phase) => void (await deps.interaction.setPhase(taskId, phase)) }, + /* + * 受理补全台(宽进):缺必填 → 补全卡进任务对话流 + 武装 human 监听等答复。 + * 不占活动的人卡预算 —— 补全发生在活动开跑之前,是受理的事,不是活动的打扰。 + */ + intake: { + askCompletion: async (input) => { + const card = { + id: cardIdSchema.parse(ids.next()), + at: clock.now().toISOString(), + k: 'elicitation' as const, + ask: `受理这一单还缺几个信息 —— 补上,流程才跑得下去;实在没法填就写明原因。`, + fields: input.fields.map((f) => ({ key: f.key, label: f.label })), + }; + await deps.humanTool.issueChatCard( + { taskId: input.taskId as never, ownerId: input.ownerId as never, humanCallCap: 1_000_000 }, + card, + ); + await listeners.register({ + taskId: input.taskId as never, + eventType: 'human', + keyFilter: { cardId: card.id }, + // 时限是 ISO-8601 **时长**(P2D = 两天),不是时刻 —— 到点抛时间事件,无死等 + deadline: 'P2D', + }); + }, + }, clock, ids, }); diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 53c0f83..0b483d2 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -25,6 +25,9 @@ 被依赖: trigger, app(治理台 · 工作流管理) 不变量(OS 03 / 3.2): + - **宽进**(2026-07-30):`start` 不再按调用接口拒缺项 —— 输入可以不定或为空, + 收敛点在活动(任务侧受理归一 + 补全单)。`CallInterfaceViolationError` 保留导出, + 仅存量引用,起单路径不再抛它 - **节点负责人三层**(2026-07-30):起单时合并 —— 显式指派(start 入参,测试台定向)> 运营配置(`sys.node_owner`,运营中心 · 工作流里配)> 轮值(员工组 → 全员最闲者先)。 运营配置不随包版本 —— 它说的是「这家企业现在这活谁管」,不是流程长什么样。 diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts index b669213..1fa6b07 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from 'vitest'; import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; -import { CallInterfaceViolationError } from '../contract.ts'; import { createProcessService } from '../internal/usecase/process-service.ts'; import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; import type { WorkflowRecord } from '../port.ts'; @@ -93,7 +92,7 @@ function harness(validationErrors: readonly string[]): Harness { return { service: createProcessService(deps), inserted, published }; } -describe('起单:凡单必合契约', () => { +describe('起单:宽进 —— 输入可以不定或为空', () => { const input = { packageId: 'p1', subject: '测试单', @@ -101,17 +100,12 @@ describe('起单:凡单必合契约', () => { data: {}, }; - it('入参不合调用接口 → 抛错,且**不建工作流、不发事件**', async () => { - const { service, inserted, published } = harness(["must have required property 'customerCode'"]); - await expect(service.start(input)).rejects.toBeInstanceOf(CallInterfaceViolationError); - // 校验若放在建单之后,这里就会留下一条永远推不动的在途单,污染在途口径 - expect(inserted).toHaveLength(0); - expect(published).toHaveLength(0); - }); - - it('错误带上缺了哪个字段 —— 登记人据此补齐,而不是猜', async () => { - const { service } = harness(["must have required property 'customerCode'"]); - await expect(service.start(input)).rejects.toThrow(/customerCode/); + it('宽进(2026-07-30):入参缺项**照样建单** —— 收敛点在活动,不在门口', async () => { + const { service, inserted } = harness(["must have required property 'customerCode'"]); + const record = await service.start(input); + expect(record.id).toBeDefined(); + expect(inserted).toHaveLength(1); + // 缺的字段就是缺着 —— 进活动后受理归一,信息不全由任务侧派补全单收齐 }); it('入参合契约 → 正常建单,来源记在单上', async () => { diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 7b68493..98a330d 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -5,7 +5,7 @@ */ import type { ProcessPackage } from '@contracts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; -import { CallInterfaceViolationError, NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts'; +import { NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -180,10 +180,12 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { // 指名道姓要某一版也得是生效版 —— 「一次只能激活一个版本」对新起单是硬约束 if (pkg.state !== 'active') throw new Error(`该版本未生效,不得起单:${pkg.id}@${pkg.version}`); - // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 - // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) - const check = deps.schemas.validate(pkg.callInterface.input, input.data); - if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors); + /* + * 宽进(2026-07-30,用户定):调用接口的输入**可以不定或为空** —— 门口不设缺项闸。 + * 收敛点在活动:进活动后受理归一,信息不全由任务侧派补全单收齐。 + * (从前这里「先校验后建单」,缺一个字段整条流都起不来 —— 现实是客户来信 + * 本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。) + */ // 工作组按**来源人**装载(ADR-011):无来源人、或此人没建组 → 不装载,全走四策略 const binding = diff --git a/apps/api/src/modules/task/CONTRACT.md b/apps/api/src/modules/task/CONTRACT.md index dc2514e..b6abc79 100644 --- a/apps/api/src/modules/task/CONTRACT.md +++ b/apps/api/src/modules/task/CONTRACT.md @@ -24,6 +24,10 @@ 被依赖: process, org(停用前置检查的在途任务), report, app 不变量(OS 04 / 4.3,AM 10 / 12.1): + - **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 —— + 缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给 + 负责人并武装 human 监听;答复只收契约声明过的键,合并进 data,齐了(或答「没法填」) + 才转 running 开跑。等人期间谱一步不走、不扣步数;补全不占活动的人卡预算 - **恰一负责人**:任务必绑定恰一个 `ownerId`;改派**留痕并通知双方** - **输入必过 schema**:`data` 缺项**不得启动**;`message` 计入初始观察但不得扩大授权 - **预算环外强制**:每一拍先 `judge` 再交引擎 —— 活动代码无从自查自守; diff --git a/apps/api/src/modules/task/__tests__/intake.test.ts b/apps/api/src/modules/task/__tests__/intake.test.ts new file mode 100644 index 0000000..425a181 --- /dev/null +++ b/apps/api/src/modules/task/__tests__/intake.test.ts @@ -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; + asked: Array<{ fields: Array<{ key: string; label: string }> }>; + tasks: Map; +}; + +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(); + 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); + }); +}); diff --git a/apps/api/src/modules/task/__tests__/landing-failure.test.ts b/apps/api/src/modules/task/__tests__/landing-failure.test.ts index 92d3c81..225db9b 100644 --- a/apps/api/src/modules/task/__tests__/landing-failure.test.ts +++ b/apps/api/src/modules/task/__tests__/landing-failure.test.ts @@ -92,6 +92,7 @@ function harness(land: () => Promise | 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' }, }; diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts index 747ca14..8769fd6 100644 --- a/apps/api/src/modules/task/internal/usecase/task-service.ts +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -11,6 +11,7 @@ import type { ActivityLoader, Clock, IdGenerator, + IntakeDesk, InteractionPhaseSetter, LandingGateway, ListenerProbe, @@ -39,10 +40,31 @@ export type TaskServiceDeps = { readonly events: TaskEventPublisher; readonly notifier: ReassignNotifier; readonly interaction: InteractionPhaseSetter; + /** + * 受理补全台(2026-07-30 宽进):缺必填时把补全卡发到负责人的任务对话流, + * 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。 + */ + readonly intake: IntakeDesk; readonly clock: Clock; readonly ids: IdGenerator; }; + +/** + * 缺了哪些必填 —— 键从 JSON-Schema 的 required 来,标签取 properties 里的 title / label, + * 没有就用键名。补全卡要给人看的是「客户码」,不是 customerCode。 + */ +function missingRequired( + inputSchema: Record, + data: Record, +): Array<{ key: string; label: string }> { + const required = Array.isArray(inputSchema['required']) ? (inputSchema['required'] as string[]) : []; + const props = (inputSchema['properties'] ?? {}) as Record; + return required + .filter((key) => data[key] === undefined || data[key] === null || data[key] === '') + .map((key) => ({ key, label: props[key]?.title ?? props[key]?.label ?? key })); +} + export function createTaskService(deps: TaskServiceDeps): TaskService { async function require(taskId: TaskId): Promise { const task = await deps.store.get(taskId); @@ -69,9 +91,13 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { return { async start(input: StartTaskInput): Promise { const loaded = await deps.activities.load(input.activityId); - // 输入 data 必过 schema 校验,**缺项不得启动**(AM 12.1) - const check = deps.schemas.validate(loaded.inputSchema, input.data); - if (!check.ok) throw new Error(`输入不合契约,不得启动:${check.errors.join('; ')}`); + /* + * 受理归一(2026-07-30 宽进,替代旧 AM 12.1 的「缺项不得启动」): + * 输入**可以不定或为空** —— 缺必填不再拒单,而是任务先落成「等人」, + * 把缺的字段做成补全卡发给负责人;补齐(或答「没法填」)后才开跑。 + * 收敛点从门口挪进活动:门口拒单,人连差什么都不知道;补全卡上写得明明白白。 + */ + const missing = missingRequired(loaded.inputSchema, input.data); const now = deps.clock.now(); const task: Task = { @@ -82,7 +108,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { ...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }), ownerId: input.ownerId, title: input.title, - runtime: 'running', + runtime: missing.length > 0 ? 'awaiting-human' : 'running', data: input.data, envelope: loaded.envelope, caps: loaded.caps, @@ -99,6 +125,15 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { }); await engineOf(task).load(task.id, loaded.businessModel); await deps.interaction.setPhase(task.id, 'active'); + if (missing.length > 0) { + await deps.memory.append({ + taskId: task.id, + kind: 'observation', + title: `受理归一:缺 ${missing.map((m) => m.key).join('、')} —— 已派补全单`, + detail: { missing: missing.map((m) => m.key) }, + }); + await deps.intake.askCompletion({ taskId: task.id, ownerId: task.ownerId, title: task.title, fields: missing }); + } await deps.store.markProgress(task.id, now); return task; }, @@ -222,6 +257,52 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { title: `事件到达:${event.kind}/${event.ref}`, detail: event.payload, }); + + /* + * 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件 + * 只可能是补全卡(谱没开跑,别的卡还发不出来)。答的值**只收契约声明过的键**, + * 合并进 data;齐了(或人答了「没法填」)就转 running,交给推进器开跑。 + * 「没法填」不硬拦:人已把话说明,带着缺口进活动,收敛是活动的事。 + */ + if (task.runtime === 'awaiting-human' && event.kind === 'human') { + const loaded = await deps.activities.load(task.activityId); + const stillMissing = missingRequired(loaded.inputSchema, task.data); + if (stillMissing.length > 0) { + const values = (event.payload['values'] ?? {}) as Record; + const declared = new Set(stillMissing.map((m) => m.key)); + const accepted = Object.fromEntries( + Object.entries(values).filter(([k, v]) => declared.has(k) && typeof v === 'string' && v.trim() !== ''), + ); + const data = { ...task.data, ...accepted }; + const remain = missingRequired(loaded.inputSchema, data); + const gaveUp = typeof event.payload['reason'] === 'string' && event.payload['reason'] !== ''; + if (remain.length === 0 || gaveUp) { + await deps.store.update(taskId, { data, runtime: 'running' }); + await deps.memory.append({ + taskId, + kind: 'observation', + title: remain.length === 0 ? '受理归一:补全到齐,开跑' : `受理归一:${remain.map((m) => m.key).join('、')} 没法填 —— 带缺口开跑`, + detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) }, + }); + } else { + await deps.store.update(taskId, { data }); + await deps.memory.append({ + taskId, + kind: 'observation', + title: `受理归一:收到 ${Object.keys(accepted).join('、') || '(空)'},还缺 ${remain.map((m) => m.key).join('、')}`, + detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) }, + }); + await deps.intake.askCompletion({ taskId, ownerId: task.ownerId, title: task.title, fields: remain }); + // 还在等人 —— 不 tick:引擎一步都不该走,谱要等补全齐了才开 + await deps.store.markProgress(taskId, deps.clock.now()); + const held = await require(taskId); + return { task: held, verdict: { kind: 'ok' }, progressed: false }; + } + await deps.store.markProgress(taskId, deps.clock.now()); + return this.tick(taskId); + } + } + await engineOf(task).onEvent(taskId, event); await deps.store.markProgress(taskId, deps.clock.now()); return this.tick(taskId); diff --git a/apps/api/src/modules/task/port.ts b/apps/api/src/modules/task/port.ts index 6785195..31e06dc 100644 --- a/apps/api/src/modules/task/port.ts +++ b/apps/api/src/modules/task/port.ts @@ -112,6 +112,19 @@ export type ReassignNotifier = { }; /** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */ +/** + * 受理补全台(2026-07-30 宽进):把缺的必填字段做成补全卡发到负责人的任务对话流, + * 并武装 human 监听等答复 —— app 注入 human-tool + event。 + */ +export type IntakeDesk = { + askCompletion(input: { + taskId: string; + ownerId: string; + title: string; + fields: ReadonlyArray<{ key: string; label: string }>; + }): Promise; +}; + export type InteractionPhaseSetter = { setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise; }; diff --git a/apps/api/src/modules/trigger/CONTRACT.md b/apps/api/src/modules/trigger/CONTRACT.md index 59d525b..1fcc37a 100644 --- a/apps/api/src/modules/trigger/CONTRACT.md +++ b/apps/api/src/modules/trigger/CONTRACT.md @@ -29,6 +29,8 @@ - **只负责起单**:经调用接口调起流程包;流程包与活动内部不含任何触发逻辑 - **同一事件流,不另设通路**:`match` 供 event 的三级消费判定使用 - **同一事件对同一触发器至多起一单**:以事件 ID 为幂等键 - - **缺必填即起单失败入死信**,不静默丢弃 + - **宽进**(2026-07-30,用户定,替代旧「缺必填即入死信」):缺必填**照样起单** —— + 装到多少带多少(`AssemblyResult.partial`);收敛点在活动:受理归一、信息不全派补全单。 + 死信只留给真错误(勾了确认没指定人、包无生效版这类配置错误) - **数据日历型同一行同一日期只触发一次**(键含日期值);字段值变更后可再触发 - 命中规则求值失败按**不命中**处置 —— 规则写坏了不让事件流停摆 diff --git a/apps/api/src/modules/trigger/__tests__/input-assembly.test.ts b/apps/api/src/modules/trigger/__tests__/input-assembly.test.ts index 5af1753..f136b3c 100644 --- a/apps/api/src/modules/trigger/__tests__/input-assembly.test.ts +++ b/apps/api/src/modules/trigger/__tests__/input-assembly.test.ts @@ -48,7 +48,7 @@ describe('assembleInput', () => { it('事件触发:scope 不打底 —— 事件载荷与调用接口是两套字段名', () => { // 不声明 mapping 就装配不出必填字段,应入死信而不是把事件载荷整个塞给流程包 const r = assembleInput(event(), { subject: '询价', customerCode: 'C001' }, ['customerCode'], noExpr); - expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] }); + expect(r).toMatchObject({ kind: 'partial', fields: ['customerCode'] }); }); it('事件触发:mapping 声明了才装配得出来', () => { @@ -61,8 +61,8 @@ describe('assembleInput', () => { expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001' } }); }); - it('缺必填即 missing,列出缺哪几个', () => { + it('缺必填即 partial,列出缺哪几个 —— 但已装上的照带', () => { const r = assembleInput(registration(), { rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr); - expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] }); + expect(r).toMatchObject({ kind: 'partial', fields: ['customerCode'] }); }); }); diff --git a/apps/api/src/modules/trigger/__tests__/matching.test.ts b/apps/api/src/modules/trigger/__tests__/matching.test.ts index aea0f42..67b3ace 100644 --- a/apps/api/src/modules/trigger/__tests__/matching.test.ts +++ b/apps/api/src/modules/trigger/__tests__/matching.test.ts @@ -57,7 +57,7 @@ describe('matchesEvent —— 流程层的系统级事件监听', () => { }); }); -describe('assembleInput —— 缺必填即起单失败入死信', () => { +describe('assembleInput —— 缺必填即 partial —— 宽进,收敛在活动', () => { const evalExpr = (expr: string, s: Readonly>): unknown => evalSfeel(expr, s); it('必填齐备即 ready', () => { @@ -65,9 +65,9 @@ describe('assembleInput —— 缺必填即起单失败入死信', () => { expect(result).toEqual({ kind: 'ready', data: { customerCode: 'C001', channel: 'mail' } }); }); - it('缺必填即 missing,并指出缺谁', () => { + it('缺必填即 partial,并指出缺谁', () => { const result = assembleInput(eventTrigger(), scopeOf({ ...scope, keys: {} }), ['customerCode'], evalExpr); - expect(result).toEqual({ kind: 'missing', fields: ['customerCode'] }); + expect(result).toMatchObject({ kind: 'partial', fields: ['customerCode'] }); }); it('常量映射不依赖事件', () => { diff --git a/apps/api/src/modules/trigger/internal/domain/matching.ts b/apps/api/src/modules/trigger/internal/domain/matching.ts index ef731ff..306728a 100644 --- a/apps/api/src/modules/trigger/internal/domain/matching.ts +++ b/apps/api/src/modules/trigger/internal/domain/matching.ts @@ -41,8 +41,11 @@ export function matchesEvent(config: TriggerConfig, scope: EventScope, evaluate: export type AssemblyResult = | { readonly kind: 'ready'; readonly data: Record } - /** 缺必填字段则**起单失败、入死信** */ - | { readonly kind: 'missing'; readonly fields: readonly string[] }; + /** + * 缺必填 —— **不再是终局**(2026-07-30 宽进):装到多少带多少,缺的字段列出来。 + * 收敛点在活动:受理归一、信息不全派补全单;门口只装配,不裁决。 + */ + | { readonly kind: 'partial'; readonly data: Record; readonly fields: readonly string[] }; export function assembleInput( config: TriggerConfig, @@ -75,7 +78,9 @@ export function assembleInput( } } const missing = required.filter((field) => data[field] === undefined || data[field] === null); - return missing.length > 0 ? { kind: 'missing', fields: missing } : { kind: 'ready', data }; + // 表达式没算出来的键别以 undefined 占位 —— 下游按「键在不在」判缺不缺 + for (const key of Object.keys(data)) if (data[key] === undefined || data[key] === null) delete data[key]; + return missing.length > 0 ? { kind: 'partial', data, fields: missing } : { kind: 'ready', data }; } function pick(scope: Readonly>, path: string): unknown { diff --git a/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts b/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts index fc21710..8ec48c2 100644 --- a/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts +++ b/apps/api/src/modules/trigger/internal/usecase/trigger-service.ts @@ -35,14 +35,11 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService { ): Promise { const required = await deps.process.requiredInputs(config.processPackageId); const assembled = assembleInput(config, scope, required, deps.sfeel.expression); - if (assembled.kind === 'missing') { - await deps.deadLetters.report({ - triggerId: config.id, - reason: `起单失败:缺必填字段 ${assembled.fields.join('、')}`, - detail: { scope }, - }); - return { triggerId: config.id, skipped: 'input-missing', missing: assembled.fields }; - } + /* + * 宽进(2026-07-30,用户定):**缺必填不再入死信** —— 装到多少带多少照样起单。 + * 收敛点在活动:进活动后受理归一,信息不全由任务侧派补全单收齐。 + * 门口只负责把流起起来;把单卡死在门外,人连「差什么」都要翻死信才知道。 + */ /* * 触发确认链路(OS 用户口述五步的第 4/5 步,2026-07-30): * 勾了「需人工确认」,命中**不等于起单** —— 装配好的入参存成待确认单, diff --git a/apps/web/src/features/gov-testbed/internal/simulate.tsx b/apps/web/src/features/gov-testbed/internal/simulate.tsx index a91d11c..24e9676 100644 --- a/apps/web/src/features/gov-testbed/internal/simulate.tsx +++ b/apps/web/src/features/gov-testbed/internal/simulate.tsx @@ -9,8 +9,9 @@ * 视觉语言分两个世界:**机器说 mono**(读数、灯、编号、TRACE), * **那封信说无衬线** —— 它是外界唯一伸进这台仪器的东西。 * 戏眼是左栏的「抽键预检」读数屏:通道的正则**实时**跑在正打的字上 - * (与服务端同一套规则,契约原文回传,不另编一套), - * 该抽的键都点了灯,起单钮才上膛 —— 从前要投出去才知道「抽不出客户码」,现在打字就知道。 + * (与服务端同一套规则,契约原文回传,不另编一套)。 + * 预检是**信息**,不是闸(宽进,2026-07-30):抽不全照样起 —— 缺的键进活动后 + * 由受理归一派补全单收齐;灯只告诉你「哪些会当场带上、哪些要有人补」。 */ import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; @@ -177,12 +178,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { ); const required = requiredOf(pkg); const ruleKeys = new Set((channel?.extract ?? []).map((r) => r.key)); - /** 必填但既不是信自带、又没有任何抽键规则 —— 投出去必死,预检就得说 */ + /** 必填但既不是信自带、又没有任何抽键规则 —— 这键只能靠受理补全单找人收 */ const unruled = required.filter((k) => !MAIL_BUILTIN.has(k) && !ruleKeys.has(k)); const mustCatch = required.filter((k) => ruleKeys.has(k)); - const armed = - pkg !== undefined && channel !== undefined && unruled.length === 0 && mustCatch.every((k) => caught.has(k)); + // 宽进:预检不再决定上不上膛 —— 抽不全照样起,缺的进活动后补全单收齐 + const keysReady = mustCatch.every((k) => caught.has(k)) && unruled.length === 0; const fireable = pkg !== undefined && channel !== undefined && mail.from.trim() !== '' && mail.subject.trim() !== ''; + const armed = fireable; const run = useMutation({ mutationFn: (input: { packageId: string; mail: MailDraft; assignments: Record }) => @@ -302,13 +304,13 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { {k} 必填 - 无抽键规则 + 无规则 · 靠补全 ))} )} -

与服务端同一套正则 —— 灯全亮,这封信才装配得出调用入参。

+

与服务端同一套正则。灯灭不拦起单 —— 抽不出的键由受理补全单找人收齐。

{/* ── 右:① 一封信 ② 派给谁 + 起单条 ── */} @@ -383,12 +385,10 @@ export function SimulateRun({ nav }: { nav: Nav }): ReactNode { {error !== undefined ? ( {error} - ) : armed ? ( - '装配齐了 —— 起单会先销毁上一单,任务真实落到被指派人的工作台' - ) : unruled.length > 0 ? ( - `必填的 ${unruled.join('、')} 没有抽键规则 —— 投出去会被退回` + ) : keysReady ? ( + '键全抽得出 —— 起单会先销毁上一单,任务真实落到被指派人的工作台' ) : ( - '预检还有灯没亮 —— 主题或正文里要写出能被抽中的键' + `有键抽不出(${mustCatch.filter((k) => !caught.has(k)).concat(unruled).join('、')})—— 照样能起,受理人会收到补全单` )}