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:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 审批要素的路由(编码规范 §3;语义出处 OS 2.1 / 3.2)。
|
||||
*
|
||||
* 「工作台层面的『审批』**专指工作流级的审批点**」——它卡到审批人工位,产出审批单:
|
||||
* 通过 / 驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。
|
||||
* 驳回走向是**声明式**的(流程包里写死),不由临场决定。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
|
||||
function rejectRouteOf(pkg: ProcessPackage, reject: Extract<ProcessNode, { kind: 'approval' }>['reject']): string {
|
||||
if (reject.kind !== 'reopen') return '驳回 → 终止流';
|
||||
const target = pkg.nodes.find((n) => n.id === reject.nodeId);
|
||||
return `驳回 → 回 ${target?.label ?? reject.nodeId} 重开新实例`;
|
||||
}
|
||||
|
||||
export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
|
||||
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
|
||||
app.get(listApprovalsForMeRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
const nested = await Promise.all(
|
||||
workflows.map(async (w) => {
|
||||
const pkg = await container.process.getPackage(w.packageId, w.packageVersion);
|
||||
if (pkg === undefined) return [];
|
||||
const instances = await container.process.instancesOf(w.id);
|
||||
return instances
|
||||
.filter((i) => i.activated)
|
||||
.flatMap<ApprovalItem>((i) => {
|
||||
const node = pkg.nodes.find((n) => n.id === i.nodeId);
|
||||
if (node?.kind !== 'approval') return [];
|
||||
// 审批人卡到岗位 —— 不是我的岗位就不进我的审批要素
|
||||
if (node.approver.position !== me.position.code) return [];
|
||||
return [
|
||||
{
|
||||
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
||||
workflowId: w.id,
|
||||
workflowSubject: w.subject,
|
||||
nodeId: i.nodeId,
|
||||
title: node.label,
|
||||
// 依据快照取自上游节点的输出口载荷
|
||||
basis: Object.entries(w.ports[i.nodeId] ?? w.initiation ?? {})
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ k, v: String(v) })),
|
||||
ask: '请裁决:通过 / 驳回(驳回必附意见)',
|
||||
state:
|
||||
i.terminal === undefined
|
||||
? ('pending' as const)
|
||||
: i.terminal === 'released'
|
||||
? ('passed' as const)
|
||||
: ('rejected' as const),
|
||||
rejectRoute: rejectRouteOf(pkg, node.reject),
|
||||
raisedAt: w.startedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}),
|
||||
);
|
||||
return c.json({ approvals: nested.flat() });
|
||||
});
|
||||
|
||||
app.post(decideApprovalRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '裁决不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 驳回必附意见 —— 与人类工具三规矩同一条:否定必须带理由
|
||||
if (parsed.data.verdict === 'reject' && (parsed.data.comment ?? '').trim() === '') {
|
||||
const body: ApiError = { code: 'validation-failed', message: '驳回必附意见' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
const workflowId = c.req.param('workflowId');
|
||||
const nodeId = c.req.param('nodeId');
|
||||
|
||||
// 审批权按岗位校验:节点声明的审批岗位必须是我的岗位
|
||||
const workflow = await container.process.getWorkflow(workflowId);
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
const node = pkg?.nodes.find((n) => n.id === nodeId);
|
||||
if (node?.kind !== 'approval') {
|
||||
const body: ApiError = { code: 'not-found', message: `节点不是审批点:${nodeId}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
if (node.approver.position !== me.position.code) {
|
||||
const body: ApiError = { code: 'forbidden', message: `审批点卡在 ${node.approver.position} 工位,岗位 ${me.position.code} 不得裁决` };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
|
||||
const state = await container.process.onApprovalVerdict(workflowId, nodeId, parsed.data.verdict, {
|
||||
by: me.id,
|
||||
...(parsed.data.comment === undefined ? {} : { comment: parsed.data.comment }),
|
||||
});
|
||||
// 审批一裁,流就动了 —— 让在线的浏览器立刻重取投影
|
||||
container.broadcaster.publish({ kind: 'workflow', id: workflowId, reason: 'approval-verdict' });
|
||||
return c.json({ ok: true, workflowState: state });
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
staffIdSchema,
|
||||
submitReportRoute,
|
||||
} from '@contracts';
|
||||
import { CallInterfaceViolationError } from '@modules/process';
|
||||
import type { Minute } from '@modules/minute';
|
||||
import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute';
|
||||
import type { Report } from '@modules/report';
|
||||
@@ -150,6 +151,11 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
if (error instanceof CallInterfaceViolationError) {
|
||||
// 不合调用接口契约:一条工作流都没建,直接回 400 让登记人补齐
|
||||
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.errors };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
if (error instanceof MinuteNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { Hono } from 'hono';
|
||||
import type { ApiError, ChatMessage, TaskCard, ViewBlock } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
replyWorkOrderRoute,
|
||||
sayRoute,
|
||||
@@ -185,7 +187,55 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
body: m.body,
|
||||
at: m.at,
|
||||
read: m.readAt !== undefined,
|
||||
// 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务
|
||||
...(m.card === undefined
|
||||
? {}
|
||||
: {
|
||||
card: {
|
||||
cardId: m.card.cardId,
|
||||
sourceTaskId: m.card.sourceTaskId,
|
||||
fields: (m.card.payload['fields'] as unknown[] | undefined) ?? [],
|
||||
ask: String(m.card.payload['ask'] ?? ''),
|
||||
answered: m.card.answeredAt !== undefined,
|
||||
...(m.card.rejectedReason === undefined ? {} : { rejectedReason: m.card.rejectedReason }),
|
||||
...(m.card.supersededBy === undefined ? {} : { supersededBy: m.card.supersededBy }),
|
||||
},
|
||||
}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(markMessageReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
await container.message.markRead(c.req.param('messageId'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* 跨任务征答的**就地作答**(OS 2.1)。
|
||||
* 「作答权限只及卡面与决策包,**不开放任务对话流**」——所以这里只收卡面回传,
|
||||
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
||||
*/
|
||||
app.post(answerMessageRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const parsed = answerMessageRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '回传不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const answered = await container.message.answer(c.req.param('messageId'), staff.id, parsed.data.answer);
|
||||
// 跨人卡一答,来源任务就可能续跑 —— 让那边的浏览器重取投影
|
||||
if (answered.card !== undefined) {
|
||||
container.broadcaster.publish({ kind: 'task', id: answered.card.sourceTaskId, reason: 'elicitation-answer' });
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
@@ -46,6 +47,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerDeskApprovalRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
@@ -479,6 +479,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
},
|
||||
sfeel,
|
||||
schemas: deps.schemas,
|
||||
events: {
|
||||
publish: async (input) => {
|
||||
await deps.event.publish({
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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('; ')}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user