Archived
三面拆分⑲:审批点卸岗位 —— 负责人一律运营时分配,绑员工组,实例记 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:
@@ -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(),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user