三面拆分⑲:审批点卸岗位 —— 负责人一律运营时分配,绑员工组,实例记 assignee

模型(用户定):审批点不绑岗位;节点负责人是运营时分配的,绑定到员工组。

- 契约:approval 节点删 approver(功能属性只剩驳回走向/催办/委托消息);
  CLOSURE_VIOLATION_KINDS 删 missing-owner-position(岗位不入编排,无岗可核);
  治理审批列表删 approverPosition 字段
- 运行时统一口径(活动点=审批点):工作流绑了员工组按组轮值(组员并集最闲者先),
  没绑组全员轮值;staffId 稳定排序保证确定性,组员全不在岗退回全员。
  ADR-011「绑定先于策略」随岗位退场 —— 工作组快照的成员集合成为轮值池
- 实例记 assignee(迁移 0014 node_instance.assignee_id):分派结果是流转事实 ——
  审批箱收件(i.assigneeId === me)、裁决权(同一判据)、员工面链条报实际承接人、
  治理台审批列表报人,全部按它认,不再有岗位比对
- closure 卸 positions 入参;desk/desk-approval/governance-ops 三路由改口;
  web 属性卡删「审批岗位」选择与警示、positions 管线拆除;种子 A5 卸 PM;
  导出依赖文件审批段只留编排语义

测试:closure 25、save-package 6、seed 8、graph-to-pkg 5、端到端八例真库全绿;
五闸退出码全过。库:迁移 + v4 就地同步(唯一生效版)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 21:24:41 +08:00
co-authored by Claude Fable 5
parent ecfaef1463
commit f145bde875
24 changed files with 96 additions and 273 deletions
+1 -4
View File
@@ -50,10 +50,7 @@ export type ProcessStore = {
listWorkflows(limit: number): Promise<WorkflowRecord[]>;
updateWorkflow(id: string, patch: Partial<WorkflowRecord>): Promise<WorkflowRecord>;
listInstances(workflowId: string): Promise<NodeInstance[]>;
insertInstance(workflowId: string, instance: NodeInstance & {
taskId?: string;
approvalId?: string;
}): Promise<void>;
insertInstance(workflowId: string, instance: NodeInstance): Promise<void>;
updateInstance(workflowId: string, nodeId: string, instance: number, patch: Partial<NodeInstance> & {
taskId?: string;
}): Promise<void>;
-63
View File
@@ -521,16 +521,6 @@ export declare const listProcessPackagesRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -700,16 +690,6 @@ export declare const saveProcessRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -863,16 +843,6 @@ export declare const saveProcessRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -970,7 +940,6 @@ export declare const saveProcessRoute: {
"multi-default-edge": "multi-default-edge";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
@@ -1064,16 +1033,6 @@ export declare const setProcessStateRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -1231,16 +1190,6 @@ export declare const validateProcessRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -1339,7 +1288,6 @@ export declare const validateProcessRoute: {
"multi-default-edge": "multi-default-edge";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
@@ -2149,7 +2097,6 @@ export declare const listApprovalsRoute: {
workflowSubject: z.ZodString;
title: z.ZodString;
approverName: z.ZodString;
approverPosition: z.ZodString;
state: z.ZodEnum<{
pending: "pending";
rejected: "rejected";
@@ -2250,16 +2197,6 @@ export declare const govWorkflowRoute: {
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
+1 -24
View File
@@ -180,16 +180,6 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -324,16 +314,6 @@ export declare const processPackageSchema: z.ZodObject<{
}, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"approval">;
approver: z.ZodObject<{
position: z.ZodString;
strategy: z.ZodDefault<z.ZodEnum<{
assigned: "assigned";
sole: "sole";
rotation: "rotation";
"follow-source": "follow-source";
}>>;
staffId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
}, z.core.$strip>;
reject: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"reopen">;
nodeId: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -421,7 +401,7 @@ export declare const processPackageSchema: z.ZodObject<{
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>;
export type ProcessPackage = z.infer<typeof processPackageSchema>;
export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"];
export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity"];
export declare const closureViolationKindSchema: z.ZodEnum<{
"edge-unmapped": "edge-unmapped";
"required-unmapped": "required-unmapped";
@@ -430,7 +410,6 @@ export declare const closureViolationKindSchema: z.ZodEnum<{
"multi-default-edge": "multi-default-edge";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
export type ClosureViolationKind = z.infer<typeof closureViolationKindSchema>;
export declare const closureViolationSchema: z.ZodObject<{
@@ -442,7 +421,6 @@ export declare const closureViolationSchema: z.ZodObject<{
"multi-default-edge": "multi-default-edge";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
@@ -461,7 +439,6 @@ export declare const closureReportSchema: z.ZodObject<{
"multi-default-edge": "multi-default-edge";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
+10 -23
View File
@@ -38,17 +38,11 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
const node = pkg.nodes.find((n) => n.id === i.nodeId);
if (node?.kind !== 'approval') return [];
/*
* 谁的审批,要和**真正派单时的口径一致**(ADR-011:绑定先于策略)。
*
* 从前这里只比岗位码:`node.approver.position !== me.position.code` 就滤掉。
* 可引擎派审批时先看工作组绑定(`workGroup[岗位] → 员工`),装了组的单
* 实际派给的是被绑定的那个人 —— 于是出现「审批派给了我、我的审批箱里没有」:
* 单子谁也看不见,流永远卡在审批点(实测:工作组把 PM 绑到本人,审批箱空)。
* `toChain` 早为同一件事修过一次,这里是漏的那一处。
* 谁的审批,要和**真正派单时的口径一致**:审批人是运营时分配的
* (员工组轮值 / 全员轮值),分派结果记在实例的 assignee 上 ——
* 收件按它认人,不再有岗位可比(编排不带岗位,2026-07-28 第五轮)。
*/
const bound = w.workGroup?.[node.approver.position];
const mine = bound === undefined ? node.approver.position === me.position.code : bound === me.id;
if (!mine) return [];
if (i.assigneeId !== me.id) return [];
return [
{
id: `${w.id}:${i.nodeId}#${i.instance}`,
@@ -94,7 +88,6 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
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);
@@ -103,19 +96,13 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
return c.json(body, 404);
}
/*
* 裁决权也要按**派单口径**核,不是只比岗位码 —— 与上面的收件口径同一条规矩。
* 只比岗位的话:装了工作组的单看得见却裁不了(403),人干瞪眼、流卡死。
* 裁决权与收件同一口径:这一实例分派给谁,谁裁 —— 实例 assignee 是唯一判据
* (审批人运营时分配,编排里没有岗位可比)。
*/
const boundApprover = workflow.workGroup?.[node.approver.position];
const mayDecide = boundApprover === undefined ? node.approver.position === me.position.code : boundApprover === me.id;
if (!mayDecide) {
const body: ApiError = {
code: 'forbidden',
message:
boundApprover === undefined
? `审批点卡在 ${node.approver.position} 工位,岗位 ${me.position.code} 不得裁决`
: `本单的 ${node.approver.position} 由工作组绑定给了他人,你不得裁决`,
};
const instances = await container.process.instancesOf(workflowId);
const current = [...instances].reverse().find((i) => i.nodeId === nodeId && i.activated);
if (current?.assigneeId !== me.id) {
const body: ApiError = { code: 'forbidden', message: '本单分派给了他人,你不得裁决' };
return c.json(body, 403);
}
+10 -27
View File
@@ -94,38 +94,21 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
return await Promise.all(
nodes.map(async (n) => {
/*
* 报谁,要和**真正派单时的口径一致**。
* 审批点:ADR-011 绑定先于策略 —— 先看工作组绑定,再退回岗位解析。
* 活动点:契约不带岗位(分派归运营)—— 起了单的报**实际承接人**(任务上的),
* 还没起的如实报「轮值待派」,不编造具体承接人。
* 报谁,要和**真正派单时的口径一致**:负责人是运营时分配的(员工组轮值 /
* 全员轮值),分派结果记在实例 assignee 上 —— 起了单的报实际承接人,
* 还没起的如实报「轮值待派」,不编造具体承接人。活动点与审批点同一口径。
*/
let who;
let fallbackName = '轮值待派';
let fallbackRole = '全员轮值';
if (n.kind === 'approval') {
const spec = n.approver;
fallbackName = spec.position;
fallbackRole = spec.position;
const bound = workflow.workGroup?.[spec.position];
const boundStaff =
bound === undefined
? undefined
: await container.org.getStaff(staffIdSchema.parse(bound)).catch(() => undefined);
// 岗位唯一解析(sole)即报人名;否则只报岗位
const holders =
boundStaff !== undefined ? [] : await container.org.staffOfPosition(spec.position).catch(() => []);
who = boundStaff ?? (holders.length === 1 ? holders[0] : undefined);
} else {
const inst = instances.find((x) => x.nodeId === n.id && x.taskId !== undefined);
const owned = inst?.taskId === undefined ? undefined : await container.task.get(taskIdSchema.parse(inst.taskId)).catch(() => undefined);
who = owned === undefined ? undefined : await container.org.getStaff(owned.ownerId).catch(() => undefined);
}
const inst = [...instances].reverse().find((x) => x.nodeId === n.id && x.assigneeId !== undefined);
const who =
inst?.assigneeId === undefined
? undefined
: await container.org.getStaff(staffIdSchema.parse(inst.assigneeId)).catch(() => undefined);
return {
nodeId: n.id,
label: n.label,
kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const),
ownerName: who?.name ?? fallbackName,
ownerRole: who?.position.name ?? fallbackRole,
ownerName: who?.name ?? '轮值待派',
ownerRole: who?.position.name ?? '运营分派',
state:
n.id === task.nodeId
? ('current' as const)
+6 -4
View File
@@ -185,15 +185,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
.filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval')
.map(async (i) => {
const node = pkg.nodes.find((n) => n.id === i.nodeId);
const position = node?.kind === 'approval' ? node.approver.position : '';
const holders = position === '' ? [] : await container.org.staffOfPosition(position);
// 审批人是运营时分配的,实例 assignee 即事实 —— 没派到的如实「—」
const assignee =
i.assigneeId === undefined
? undefined
: await container.org.getStaff(staffIdSchema.parse(i.assigneeId)).catch(() => undefined);
return {
id: `${w.id}:${i.nodeId}#${i.instance}`,
workflowId: w.id,
workflowSubject: w.subject,
title: `审批:${node?.label ?? i.nodeId}`,
approverName: holders[0]?.name ?? '—',
approverPosition: position,
approverName: assignee?.name ?? '—',
// 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批
state:
i.terminal === undefined
+1 -3
View File
@@ -63,8 +63,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
id: 'A5',
kind: 'approval',
label: '定价审批',
sub: 'PM',
approver: { position: 'PM', strategy: 'sole' },
/*
* 驳回走向声明式,不由临场决定:退回 A4 重新定价。
* 图上由 A5 自己的 `verdict = "reject"` 条件出边(E10)**显式表达同一件事**,
@@ -236,7 +234,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
{ id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', x: 300, y: 70 },
{ id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 },
{ id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', x: 120, y: 320 },
{ id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', owner: 'PM', flavor: 'approval', x: 300, y: 320 },
{ id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', flavor: 'approval', x: 300, y: 320 },
{ id: 'A6', kind: 'activity', label: '报价议价', code: 'A6', x: 480, y: 320 },
{ id: 'A7', kind: 'activity', label: '情报归档', code: 'A7', x: 640, y: 420 },
{ id: 'end_won', kind: 'end', label: '转订单履约', x: 640, y: 330, lx: 650, ly: 360 },
+3 -3
View File
@@ -35,8 +35,8 @@
- **激活才查完整**:`setPackageState` 激活时跑 `checkClosure`,
不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、
活动未装载、审批岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
(活动点不核岗位 —— 契约不带负责人,`missing-owner-position` 只出自审批点。)
活动未装载、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
(岗位不入编排,闭合校验没有岗位类违规。)
- **纯编排模型**(产品决策 2026-07-28 二、三轮):流程包只决定**活动编排**与
**活动传递的契约**,不决定活动的实现方式(范式/版本住活动库,节点仅以
`activityId` 链接),**也不带负责人岗位**(分派归运营;运营分派机制未建前,
@@ -63,7 +63,7 @@
store 的写回(`updatePackageDefinition`)只许命中已存在的版本,撞不存在报错;
「就地覆盖会改掉在途正在读的编排」由在途钉版闸守着,不再靠只插不改一刀切。
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕(owner-resolved 事件 + 实例 assignee)
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合
- **「完成」凭到过终点节点**,不凭「没有在跑的了」:收敛但未到终点 = 卡住 = `abnormal`。
@@ -56,11 +56,10 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
}) as ProcessPackage;
const contracts = new Map([['a4', face()]]);
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
describe('checkClosure —— 不闭合不得激活', () => {
it('必填全映射、岗位存在即可激活', () => {
const report = checkClosure({ pkg: pkg(), contracts, positions });
const report = checkClosure({ pkg: pkg(), contracts });
expect(report.violations).toEqual([]);
expect(report.closed).toBe(true);
});
@@ -72,7 +71,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const report = checkClosure({ pkg: broken, contracts, positions });
const report = checkClosure({ pkg: broken, contracts });
expect(report.closed).toBe(false);
expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped');
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
@@ -91,41 +90,15 @@ describe('checkClosure —— 不闭合不得激活', () => {
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: partial, contracts, positions }).violations;
const violations = checkClosure({ pkg: partial, contracts }).violations;
expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']);
});
it('引用未安装的活动即拦', () => {
const report = checkClosure({ pkg: pkg(), contracts: new Map(), positions });
const report = checkClosure({ pkg: pkg(), contracts: new Map() });
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
});
/* 活动点不核岗位(契约不带负责人);审批点的岗位是功能语义,不存在即拦 */
it('审批点岗位不存在即拦;活动点没有岗位可核', () => {
const withApproval = pkg({
nodes: [
...pkg().nodes,
{
id: 'AP' as never,
kind: 'approval',
label: '审批',
approver: { position: 'GONE', strategy: 'sole' },
reject: { kind: 'terminate' },
},
],
edges: [
...pkg().edges.filter((e) => e.id !== 'e2'),
{ id: 'e2', from: 'A4' as never, to: 'AP' as never, mapping: [], reopen: false },
{ id: 'e3', from: 'AP' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: withApproval, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'missing-owner-position').map((v) => v.nodeId)).toEqual(['AP']);
// 活动节点引用的岗位集合再小也与它无关 —— 不产生岗位类违规
const noPositions = checkClosure({ pkg: pkg(), contracts, positions: new Set() }).violations;
expect(noPositions.filter((v) => v.kind === 'missing-owner-position')).toEqual([]);
});
it('孤立节点即拦', () => {
const orphan = pkg({
@@ -140,7 +113,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
},
],
});
const violations = checkClosure({ pkg: orphan, contracts, positions }).violations;
const violations = checkClosure({ pkg: orphan, contracts }).violations;
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
});
@@ -154,7 +127,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: twoDefaults, contracts, positions }).violations;
const violations = checkClosure({ pkg: twoDefaults, contracts }).violations;
expect(violations.filter((v) => v.kind === 'multi-default-edge').map((v) => v.nodeId)).toEqual(['A4']);
// 其中一条补上条件即闭合 —— 条件命中优先,无条件边只作默认兜底
@@ -163,7 +136,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
edges: twoDefaults.edges.map((e) => (e.id === 'e9' ? { ...e, condition: 'ok = true' } : e)),
});
expect(
checkClosure({ pkg: oneDefault, contracts, positions }).violations.filter((v) => v.kind === 'multi-default-edge'),
checkClosure({ pkg: oneDefault, contracts }).violations.filter((v) => v.kind === 'multi-default-edge'),
).toEqual([]);
});
@@ -202,7 +175,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations;
const violations = checkClosure({ pkg: tolerant, contracts }).violations;
expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency');
});
});
@@ -53,6 +53,7 @@ export const nodeInstanceTable = sys.table(
failureDigested: boolean('failure_digested'),
taskId: text('task_id'),
approvalId: text('approval_id'),
assigneeId: text('assignee_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
@@ -167,6 +167,7 @@ export function createProcessStore(db: Db): ProcessStore {
failureDigested: instance.failureDigested ?? null,
taskId: instance.taskId ?? null,
approvalId: instance.approvalId ?? null,
assigneeId: instance.assigneeId ?? null,
});
},
@@ -28,12 +28,10 @@ export type ClosureInput = {
readonly pkg: ProcessPackage;
/** 已安装活动的契约面;缺失即 unknown-activity */
readonly contracts: ReadonlyMap<string, ActivityContractFace>;
/** 已存在的岗位编码 */
readonly positions: ReadonlySet<string>;
};
export function checkClosure(input: ClosureInput): ClosureReport {
const { pkg, contracts, positions } = input;
const { pkg, contracts } = input;
const violations: ClosureViolation[] = [];
const byId = new Map(pkg.nodes.map((n) => [n.id, n]));
const incoming = new Map<string, Edge[]>();
@@ -59,14 +57,7 @@ export function checkClosure(input: ClosureInput): ClosureReport {
if (node.kind === 'activity') {
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts));
}
// 活动点不核岗位(契约不带负责人 —— 分派归运营);审批点的岗位是功能语义,照核
if (node.kind === 'approval' && !positions.has(node.approver.position)) {
violations.push({
kind: 'missing-owner-position',
nodeId: node.id,
message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`,
});
}
// 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核
/*
* 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。
* 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序
@@ -123,6 +123,8 @@ export type NodeInstance = {
/** 该实例派出的任务 / 审批(结构件与终点没有)—— 链条视图按它报实际承接人 */
readonly taskId?: string;
readonly approvalId?: string;
/** 派给谁:运营时分配的结果(员工组轮值 / 全员轮值)—— 审批箱与链条按它收单报人 */
readonly assigneeId?: string;
};
export function aggregateWorkflowState(
@@ -123,31 +123,22 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
}
/*
* 谁来干:**活动契约不带岗位**(纯编排,2026-07-28 第三轮)—— 分派是运营的事。
* 运营分派机制未建前,活动任务默认**全员轮值**(在岗候选并集,最闲者先,
* staffId 稳定排序保证同输入必同结果);审批点例外,「卡到人的工位」是它的
* 功能语义,仍按 approver 岗位解析,工作组绑定(ADR-011)也只对它生效。
* 谁来干:**编排不带岗位**(2026-07-28 第五轮,活动点与审批点同一口径)——
* 负责人是**运营时分配**的:工作流绑了员工组就按组轮值(组员并集里最闲者先),
* 没绑组就全员轮值;staffId 稳定排序保证同输入必同结果。
* 分派结果记在节点实例上(assignee)—— 审批箱、链条、治理列表都按它认人。
*/
let spec;
let candidates;
let bound;
if (dispatch.kind === 'start-task') {
spec = { position: '(全员轮值)', strategy: 'sole' as const };
const seen = new Map<string, { staffId: string; name: string; openTasks: number }>();
for (const position of await deps.org.positions()) {
for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c);
}
candidates = [...seen.values()];
bound = undefined;
} else {
spec = dispatch.node.approver;
candidates = await deps.org.candidates(spec.position);
// 绑定先于策略:工作组说了这个岗位是谁,策略就无从发问(ADR-011)。
// 在岗与否在这里核 —— 绑定是**跨岗位**指派,拿该岗位的候选集核不了它。
const boundTo = record.workGroup?.[spec.position];
bound = boundTo !== undefined && (await deps.org.isActive(boundTo)) ? boundTo : undefined;
const seen = new Map<string, { staffId: string; name: string; openTasks: number }>();
for (const position of await deps.org.positions()) {
for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c);
}
const resolution = resolveOwner(spec, { candidates, ...(bound === undefined ? {} : { bound }) });
const everyone = [...seen.values()];
const group = new Set(Object.values(record.workGroup ?? {}));
const groupPool = everyone.filter((c) => group.has(c.staffId));
// 组员全不在岗时退回全员 —— 单不能没人接
const pool = groupPool.length > 0 ? groupPool : everyone;
const spec = { position: groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)', strategy: 'sole' as const };
const resolution = resolveOwner(spec, { candidates: pool });
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
await deps.events.publish({
@@ -173,6 +164,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
waiting: false,
activated: true,
taskId: task.id,
assigneeId: resolution.staffId,
});
await deps.events.publish({ workflowId: record.id, kind: 'task-started', detail: { nodeId: node.id, taskId: task.id } });
return;
@@ -192,6 +184,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
waiting: true,
activated: true,
approvalId: approval.id,
assigneeId: resolution.staffId,
});
await deps.events.publish({ workflowId: record.id, kind: 'approval-raised', detail: { nodeId: node.id, approvalId: approval.id } });
}
@@ -368,7 +361,6 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
return checkClosure({
pkg,
contracts: await deps.activities.faces(),
positions: await deps.org.positions(),
});
},
+1 -1
View File
@@ -47,7 +47,7 @@ export type ProcessStore = {
updateWorkflow(id: string, patch: Partial<WorkflowRecord>): Promise<WorkflowRecord>;
listInstances(workflowId: string): Promise<NodeInstance[]>;
insertInstance(workflowId: string, instance: NodeInstance & { taskId?: string; approvalId?: string }): Promise<void>;
insertInstance(workflowId: string, instance: NodeInstance): Promise<void>;
updateInstance(
workflowId: string,
nodeId: string,
@@ -35,13 +35,12 @@ import {
St,
} from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { useGovActivities, useGovPackages, useGovPkgReports } from '@web-shared/gov/gov-api.ts';
import type { GovActivities } from '@web-shared/gov/gov-api.ts';
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import type { SpPosition } from '@web-shared/gov/flow/support.tsx';
import { TriggerRulesHome } from '@features/gov-trigger';
export type HomeProps = { tab: string; nav: Nav };
@@ -405,7 +404,6 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
const flowId = id.split('@')[0] ?? id;
const q = useGovPackages();
const acts = useGovActivities();
const staffQ = useGovStaff();
const versions = (q.data?.packages ?? [])
.filter((p) => p.id === flowId)
@@ -432,7 +430,6 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
activeVersion={active?.version}
nav={nav}
activities={acts.data?.activities ?? []}
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
nextVersion={Math.max(...versions.map((x) => x.version)) + 1}
closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed}
onPick={(v) => setPinned(v)}
@@ -457,7 +454,6 @@ function FlowLab({
activeVersion,
nav,
activities,
positions,
nextVersion,
closedOf,
onPick,
@@ -468,7 +464,6 @@ function FlowLab({
activeVersion: number | undefined;
nav: Nav;
activities: readonly ActivityRow[];
positions: SpPosition[];
nextVersion: number;
/** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */
closedOf: (version: number) => boolean | undefined;
@@ -671,7 +666,8 @@ function FlowLab({
})),
approvals: pkg.nodes
.filter((n) => n.kind === 'approval')
.map((n) => ({ nodeId: n.id, label: n.label, approver: n.approver, reject: n.reject })),
// 审批人运营时分配,依赖文件只描述编排语义(驳回走向)
.map((n) => ({ nodeId: n.id, label: n.label, reject: n.reject })),
};
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -1013,29 +1009,10 @@ function FlowLab({
/>
</Field>
)}
{node.flavor === 'approval' && (
<Field k="审批岗位" hint="「卡到人的工位」是审批点的功能语义,审批单派给它">
<select
className="g-select"
value={node.owner ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, owner: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">未指定 —— 激活前选一个岗位</option>
{positions.map((q2) => (
<option key={q2.id} value={q2.abbr}>
{q2.abbr} · {q2.name}
</option>
))}
</select>
</Field>
)}
{node.flavor !== 'approval' && (
<Note>
活动点<b>不带岗位</b> —— 谁来干是运营的事(分派机制未建前,运行时全员轮值、最闲者先)。
</Note>
)}
<Note>
编排<b>不带岗位</b> —— 负责人是运营时分配的:工作流绑了员工组按组轮值,
没绑组全员轮值(最闲者先)。审批点与活动点同一口径。
</Note>
{node.kind === 'activity' && node.flavor === 'approval' && (
<Note>
<b>审批点是系统内置要素</b>,不是活动 —— 声明即用、零开发、不进活动库(一个流程包里需要开发的
@@ -1043,9 +1020,6 @@ function FlowLab({
(声明式:回指定节点重开新实例 / 终止)· 催办阶梯。运行时它产出<b>审批单</b>而非任务。
</Note>
)}
{node.flavor === 'approval' && node.owner === undefined && (
<Note tone="warn">这个审批点还没指定岗位 —— 不指定,起单时就不知道审批单该派给谁。</Note>
)}
</Prop>
{node.kind === 'activity' && (
@@ -143,7 +143,6 @@ function RuntimeHome({ tab, nav }: HomeProps) {
title={a.title}
sub={`${a.workflowSubject} · ${a.id}`}
cols={[
{ k: '审批岗位', v: a.approverPosition },
{ k: '审批人', v: a.approverName },
]}
right={
+2 -5
View File
@@ -65,11 +65,8 @@ export function mergeGraphIntoPackage(
return {
...base,
kind: 'approval' as const,
approver: {
position: n.owner ?? priorAp?.approver.position ?? '未声明',
strategy: priorAp?.approver.strategy ?? ('sole' as const),
},
// 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,发布前在属性外补
// 审批人不入编排(运营时分配):功能属性只剩驳回走向 / 催办 / 委托消息
// 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,激活前在属性里补
reject: priorAp?.reject ?? { kind: 'terminate' as const },
...(priorAp?.nudge === undefined ? {} : { nudge: priorAp.nudge }),
...(priorAp?.message === undefined ? {} : { message: priorAp.message }),
+2 -2
View File
@@ -43,7 +43,7 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
id: n.id,
kind: n.kind === 'start' ? ('start' as const) : n.kind === 'end' ? ('end' as const) : ('activity' as const),
label: n.label,
...(n.kind === 'approval' ? { flavor: 'approval' as const, owner: n.approver.position } : {}),
...(n.kind === 'approval' ? { flavor: 'approval' as const } : {}),
...(n.kind === 'activity' ? { code: n.id, act: n.activityId } : {}),
x: 30 + (i % 6) * 160,
y: bottom + Math.floor(i / 6) * 60,
@@ -93,7 +93,7 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark
};
if (n.kind === 'approval') {
// approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何
return { ...base, flavor: 'approval', owner: n.approver.position };
return { ...base, flavor: 'approval' };
}
return {
...base,
+3
View File
@@ -0,0 +1,3 @@
-- 节点实例记「派给谁」:负责人是运营时分配的(有员工组按组轮值,无组全员轮值),
-- 分派结果是流转事实,落在实例上 —— 员工面链条、审批箱、治理台列表都按它报人/收单。
alter table sys.node_instance add column assignee_id text;
+7
View File
@@ -99,6 +99,13 @@
"when": 1785130113168,
"tag": "0013_one_active_version",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1785130113169,
"tag": "0014_instance_assignee",
"breakpoints": true
}
]
}
+7 -6
View File
@@ -18,13 +18,14 @@
| M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 |
| M5-5 | ~~含网关历史版本的运行时兼容三处~~ | 用户令库只留一条流一个版本(外科手术清空),在途与历史归零 —— schema 的 `kind: 'gateway'`、`joinReady`、汇合等待、`gateway-deprecated` 违规项已于次轮全部摘除 | 2026-07-28 | 已清理 |
## 活动分派 · 全员轮值默认(2026-07-28)
## 节点分派 · 员工组轮值(2026-07-28 第五轮更新)
活动契约不带负责人岗位(纯编排第三轮)——「谁来干」归运营,但运营分派机制还没做
(用户明示先不做)。运行时垫了一个诚实默认:**全员轮值**(在岗候选并集、最闲者先、
staffId 稳定排序保证确定性),`owner-resolved` 事件里岗位记作「(全员轮值)」。
运营中心做了任务分派(按人/按组/认领池)后,这个默认让位;审批点不在此列
(approver 是功能语义,照岗位解析,ADR-011 绑定仍生效)。
编排不带岗位(活动点与审批点同一口径)——「谁来干」是运营时分配:
工作流绑了员工组按组轮值(组员并集最闲者先),没绑组**全员轮值**;
分派结果记在实例 assignee 上,审批箱/裁决权/链条/治理列表都按它认人。
ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照的**成员集合**成了轮值池,
按岗位点名的绑定语义不复存在。still 欠:起单时选组、按人指派、认领池 —— 运营中心的
分派界面落地后,这里的默认轮值让位。
## 鉴权(ADR-010 落地后的已知欠账)
-1
View File
@@ -498,7 +498,6 @@ export const listApprovalsRoute = {
workflowSubject: z.string(),
title: z.string(),
approverName: z.string(),
approverPosition: z.string(),
state: z.enum(['pending', 'passed', 'rejected']),
issuedAt: z.string(),
}),
+5 -3
View File
@@ -165,8 +165,11 @@ export const processNodeSchema = z.discriminatedUnion('kind', [
z.object({
...nodeBase,
kind: z.literal('approval'),
/** 审批点功能属性四件:审批岗位 / 输入装配映射 / 驳回走向 / 催办阶梯 */
approver: ownerSpecSchema,
/*
* **不绑岗位**(产品决策 2026-07-28 第五轮):审批人也是运营时分配 ——
* 有员工组按组轮值,无组全员轮值,与活动点同一口径。
* 审批点的功能属性只剩编排语义三件:驳回走向 / 催办阶梯 / 委托消息。
*/
/** 驳回走向:声明式,不由临场决定 */
reject: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('reopen'), nodeId: nodeIdSchema, mapping: z.array(fieldMappingSchema).default([]) }),
@@ -235,7 +238,6 @@ export const CLOSURE_VIOLATION_KINDS = [
'multi-default-edge',
'tolerate-required-dependency',
'unknown-activity',
'missing-owner-position',
] as const;
export const closureViolationKindSchema = z.enum(CLOSURE_VIOLATION_KINDS);
export type ClosureViolationKind = z.infer<typeof closureViolationKindSchema>;