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
+108
View File
@@ -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 });
});
}
+6
View File
@@ -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);
+50
View File
@@ -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 });
});
}