M6(4/n): 工作台五要素齐活 —— 审批 / 消息 / 纪要 / 汇报

四个 desk-* feature 自 workbench 原型 ModuleWorkspace.tsx 平移(jc-* 类名原样),
共用件落 shared/desk/module-kit;mock 数据换成服务端投影:
  · desk-approval:审批点卡到审批人工位;依据快照、驳回走向(声明式)、驳回必附意见
  · desk-message:通知 + 跨任务征答**就地作答**;卡面表单由 card.ts schema 驱动,
    与后端回传校验同 schema;跨人卡不占任务清单,不开放任务对话流
  · desk-minute:纪要维护 + 决议登记起单;正文可续、决议只增
  · desk-report:四周期草案 + 度量八指标 + 确认即提交
左轨角标常驻(待批 / 未读),责任面要一眼看见什么在等我。

修一个真 bug:process.start 从不校验流程包的 callInterface,
先插工作流再由 task.start 撞活动契约 —— 失败就留下一条永远推不动的在途单。
改为**先校验后建单**(CallInterfaceViolationError),一条工作流都不留;
路由回 400 并带上缺哪个字段。已清掉这次触发出的两条孤儿单。

判据 ⑩ 纪要一环跑通:决议 → 登记触发 → 工作流 → A1 落到 BD 冯兵,
登记人即来源已记在单上;同一决议重复登记被 412 挡下。

gates: tsc / eslint / depcruise(316 模块)/ api-report / 380 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 03:53:36 +08:00
co-authored by Claude Opus 5
parent a60acb2e02
commit 3b9587563a
25 changed files with 1680 additions and 49 deletions
@@ -0,0 +1,121 @@
/**
* 起单守卫:不合调用接口契约的输入,**一条工作流都不留**。
*
* 用例层测试 —— 端口给的是**真实实现**(内存版),不 mock 内部机制;
* 类型一律走 schema 解析而来的真值,不用断言绕过(§12.5#4)。
*/
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';
import type { NodeInstance } from '../internal/domain/owner-resolution.ts';
/** 最小可发布包:一个开始、一个结束、一条边 —— 起单守卫不需要更复杂的图 */
const PKG: ProcessPackage = processPackageSchema.parse({
id: 'p1',
name: '测试包',
version: 1,
state: 'published',
callInterface: {
input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } },
output: { type: 'object' },
},
nodes: [
{ kind: 'start', id: 'S', label: '开始' },
{ kind: 'end', id: 'E', label: '结束' },
],
edges: [{ id: 'e1', from: 'S', to: 'E', mapping: [], reopen: false }],
variables: [],
});
type Harness = {
readonly service: ReturnType<typeof createProcessService>;
readonly inserted: WorkflowRecord[];
readonly published: unknown[];
};
function harness(validationErrors: readonly string[]): Harness {
const inserted: WorkflowRecord[] = [];
const published: unknown[] = [];
const instances: NodeInstance[] = [];
const deps: ProcessServiceDeps = {
store: {
savePackage: async () => undefined,
getPackage: async () => PKG,
listPackages: async () => [PKG],
publishPackage: async () => undefined,
insertWorkflow: async (record) => {
inserted.push(record);
},
getWorkflow: async (id) => inserted.find((w) => w.id === id),
listWorkflows: async () => inserted,
// 内存版要**真的落笔**:用例读回它自己刚写的状态,假实现敷衍就测不出真行为
updateWorkflow: async (id, patch) => {
const at = inserted.findIndex((w) => w.id === id);
if (at < 0) throw new Error(`测试:工作流不存在 ${id}`);
const next = { ...inserted[at]!, ...patch };
inserted[at] = next;
return next;
},
listInstances: async () => instances,
insertInstance: async (_workflowId, instance) => {
instances.push(instance);
},
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
},
activities: { faces: async () => new Map() },
org: { positions: async () => new Set<string>(), candidates: async () => [] },
tasks: { start: async () => ({ id: 'task-1' }) },
approvals: { raise: async () => ({ id: 'apr-1' }) },
sfeel: { condition: () => true, expression: () => undefined },
schemas: {
validate: () => ({ ok: validationErrors.length === 0, errors: [...validationErrors] }),
},
events: {
publish: async (input) => {
published.push(input);
},
},
receipts: { notify: async () => undefined },
clock: { now: () => new Date('2026-07-27T00:00:00.000Z') },
ids: { next: () => 'wf-1' },
};
return { service: createProcessService(deps), inserted, published };
}
describe('起单:凡单必合契约', () => {
const input = {
packageId: 'p1',
subject: '测试单',
source: { kind: 'registration' as const, ref: 'p1', by: 's1' },
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('入参合契约 → 正常建单,来源记在单上', async () => {
const { service, inserted } = harness([]);
const record = await service.start({ ...input, data: { customerCode: 'ACME' } });
expect(record.id).toBe('wf-1');
expect(inserted).toHaveLength(1);
// 凡单必有来源:登记起单的来源就是登记人
expect(inserted[0]?.source).toEqual({ kind: 'registration', ref: 'p1', by: 's1' });
});
});
+11
View File
@@ -90,6 +90,17 @@ export type ProcessService = {
terminate(workflowId: string, reason: string): Promise<WorkflowState>;
};
/** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */
export class CallInterfaceViolationError extends Error {
constructor(
readonly packageId: string,
readonly errors: readonly string[],
) {
super(`输入不合调用接口契约,不得起单:${errors.join('; ')}`);
this.name = 'CallInterfaceViolationError';
}
}
export class NotPublishableError extends Error {
constructor(readonly report: ClosureReport) {
super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`);
+1 -1
View File
@@ -18,7 +18,7 @@ export type {
WorkflowRecord,
WorkflowState,
} from './contract.ts';
export { NotPublishableError, PositionVacantError } from './contract.ts';
export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts';
export type {
ActivityDirectory,
@@ -5,7 +5,7 @@
*/
import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts';
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
import { NotPublishableError, PositionVacantError } from '../../contract.ts';
import { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from '../../contract.ts';
import type {
ActivityDirectory,
ApprovalGateway,
@@ -15,6 +15,7 @@ import type {
OrgDirectory,
ProcessStore,
ReceiptNotifier,
SchemaValidator,
Sfeel,
TaskGateway,
WorkflowRecord,
@@ -31,6 +32,7 @@ export type ProcessServiceDeps = {
readonly tasks: TaskGateway;
readonly approvals: ApprovalGateway;
readonly sfeel: Sfeel;
readonly schemas: SchemaValidator;
readonly events: FlowEventPublisher;
readonly receipts: ReceiptNotifier;
readonly clock: Clock;
@@ -216,6 +218,11 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const pkg = await requirePackage(input.packageId, input.packageVersion);
if (pkg.state !== 'published') 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);
const record: WorkflowRecord = {
id: deps.ids.next(),
packageId: pkg.id,
+9
View File
@@ -82,6 +82,15 @@ export type ApprovalGateway = {
}): Promise<{ id: string }>;
};
/**
* 调用接口的入参校验 —— app 注入 ajv。
* 「凡单必有来源」的另一半是「凡单必合契约」:不合契约**不得起单**,
* 且必须在**建工作流之前**判掉,否则会留下一条永远推不动的在途单。
*/
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};
/** S-FEEL —— app 注入 `@sfeel`:流转零 LLM */
export type Sfeel = {
condition(expression: string, scope: Readonly<Record<string, unknown>>): boolean;