模型(用户定):审批点不绑岗位;节点负责人是运营时分配的,绑定到员工组。 - 契约: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
191 lines
8.1 KiB
TypeScript
191 lines
8.1 KiB
TypeScript
/**
|
|
* 负责人四策略解析与工作流状态聚合 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03 / 3.2)。
|
|
*/
|
|
import type { OwnerSpec, TerminalState, WorkflowState } from '@contracts';
|
|
|
|
/* ── 负责人四策略(OS 03) ────────────────────────────────────────────── */
|
|
|
|
export type Candidate = {
|
|
readonly staffId: string;
|
|
readonly name: string;
|
|
/** 轮值用:岗内在途任务数 */
|
|
readonly openTasks: number;
|
|
};
|
|
|
|
export type ResolveContext = {
|
|
readonly candidates: readonly Candidate[];
|
|
/** 来源跟随:同一工作流内该岗位已解析出的人 */
|
|
readonly alreadyResolved?: string | undefined;
|
|
/**
|
|
* 工作组绑定(ADR-011):该岗位绑定到的人,**调用方已确认其在岗**。
|
|
*
|
|
* 为什么不在这里核:`candidates` 是**该岗位**的在岗人员,
|
|
* 而工作组的意义恰恰是跨岗位指派 —— 拿岗位候选集去核绑定,绑定永远核不过。
|
|
* 在岗与否是组织事实,归调用方向 org 问;域侧只认已校验的结果。
|
|
*/
|
|
readonly bound?: string | undefined;
|
|
};
|
|
|
|
export type Resolution =
|
|
| {
|
|
readonly kind: 'resolved';
|
|
readonly staffId: string;
|
|
/** 'work-group' 是**绑定**,不是第五种策略 —— 留痕里一眼可辨 */
|
|
readonly by: OwnerSpec['strategy'] | 'work-group';
|
|
readonly rationale: string;
|
|
}
|
|
/** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */
|
|
| { readonly kind: 'vacant'; readonly position: string };
|
|
|
|
/**
|
|
* 「编排声明岗位,起单时**确定性解析**。策略封闭四选;
|
|
* 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」
|
|
*
|
|
* **绑定先于策略**(ADR-011):四策略回答的是「这个岗位该派给谁」,
|
|
* 工作组已经给出了答案,策略于是无从发问。绑定的人若已离岗则不硬用,退回策略 ——
|
|
* 绑定不该把单派给一个不在岗的人。
|
|
*/
|
|
export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution {
|
|
const active = context.candidates;
|
|
|
|
if (context.bound !== undefined) {
|
|
return {
|
|
kind: 'resolved',
|
|
staffId: context.bound,
|
|
by: 'work-group',
|
|
rationale: `工作组绑定:${spec.position}`,
|
|
};
|
|
}
|
|
|
|
switch (spec.strategy) {
|
|
case 'assigned': {
|
|
const staffId = spec.staffId;
|
|
if (staffId === undefined || !active.some((c) => c.staffId === staffId)) {
|
|
return { kind: 'vacant', position: spec.position };
|
|
}
|
|
return { kind: 'resolved', staffId, by: 'assigned', rationale: '编排直接绑定员工' };
|
|
}
|
|
|
|
case 'follow-source': {
|
|
// 来源跟随:复用同一工作流内该岗位已解析出的人,保持一单一人的连续
|
|
const existing = context.alreadyResolved;
|
|
if (existing !== undefined && active.some((c) => c.staffId === existing)) {
|
|
return { kind: 'resolved', staffId: existing, by: 'follow-source', rationale: '同流内该岗位已解析出的人' };
|
|
}
|
|
return fallbackToRotation(spec, active);
|
|
}
|
|
|
|
case 'rotation':
|
|
return fallbackToRotation(spec, active);
|
|
|
|
case 'sole': {
|
|
if (active.length === 0) return { kind: 'vacant', position: spec.position };
|
|
if (active.length === 1) {
|
|
return { kind: 'resolved', staffId: active[0]!.staffId, by: 'sole', rationale: '岗位唯一人' };
|
|
}
|
|
// 岗上多人时按轮值 —— 默认口径
|
|
return fallbackToRotation(spec, active);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 轮值:岗内以**在途任务数最少者**优先;并列时按 staffId 稳定排序,保证确定性 */
|
|
function fallbackToRotation(spec: OwnerSpec, candidates: readonly Candidate[]): Resolution {
|
|
if (candidates.length === 0) return { kind: 'vacant', position: spec.position };
|
|
const picked = [...candidates].sort(
|
|
(a, b) => a.openTasks - b.openTasks || (a.staffId < b.staffId ? -1 : a.staffId > b.staffId ? 1 : 0),
|
|
)[0]!;
|
|
return {
|
|
kind: 'resolved',
|
|
staffId: picked.staffId,
|
|
by: 'rotation',
|
|
rationale: `轮值:在途任务数 ${picked.openTasks} 最少`,
|
|
};
|
|
}
|
|
|
|
/* ── 工作流状态聚合(OS 3.2) ────────────────────────────────────────── */
|
|
|
|
/**
|
|
* 「工作流状态是**聚合投影** —— 由流内各任务的状态按固定规则聚合推导,
|
|
* 流程层**不另设状态机**。计量对象是**节点实例**(重开产生新实例,旧实例已终,不再入算)。」
|
|
*/
|
|
export type NodeInstance = {
|
|
readonly nodeId: string;
|
|
readonly instance: number;
|
|
readonly running: boolean;
|
|
/** 待人 / 监听中 */
|
|
readonly waiting: boolean;
|
|
readonly terminal?: TerminalState;
|
|
/** 非完成终态的失败走向是否已消化(改道 / 容忍 / 升审批一经生效即为消化) */
|
|
readonly failureDigested?: boolean;
|
|
/** XOR 未选分支从未激活 —— 不入集合 */
|
|
readonly activated: boolean;
|
|
/** 该实例派出的任务 / 审批(结构件与终点没有)—— 链条视图按它报实际承接人 */
|
|
readonly taskId?: string;
|
|
readonly approvalId?: string;
|
|
/** 派给谁:运营时分配的结果(员工组轮值 / 全员轮值)—— 审批箱与链条按它收单报人 */
|
|
readonly assigneeId?: string;
|
|
};
|
|
|
|
export function aggregateWorkflowState(
|
|
instances: readonly NodeInstance[],
|
|
controlTerminated: boolean,
|
|
/**
|
|
* 流程包的终点节点 id —— **判「完成」必须凭它**。
|
|
*
|
|
* 不给(旧签名)时退回「收敛即完成」的老口径,只为不动既有调用;新代码一律传。
|
|
*/
|
|
endNodeIds?: readonly string[],
|
|
): WorkflowState {
|
|
if (controlTerminated) return 'terminated';
|
|
|
|
const live = instances.filter((i) => i.activated);
|
|
|
|
// 任一实例以非完成终态结束且其失败走向未消化 → 异常
|
|
const unresolvedFailure = live.some(
|
|
(i) => i.terminal !== undefined && i.terminal !== 'released' && i.failureDigested !== true,
|
|
);
|
|
if (unresolvedFailure) return 'abnormal';
|
|
|
|
if (live.some((i) => i.running)) return 'running';
|
|
if (live.some((i) => i.waiting)) return 'waiting';
|
|
|
|
// 全部已激活的节点实例收敛(完成或已消化)且无在途实例
|
|
const allConverged = live.every(
|
|
(i) => i.terminal === 'released' || (i.terminal !== undefined && i.failureDigested === true),
|
|
);
|
|
if (!allConverged || live.length === 0) return 'waiting';
|
|
|
|
/*
|
|
* 收敛 ≠ 完成。**完成的判据是到过终点节点**,不是「没有在跑的了」。
|
|
*
|
|
* 实测:A1 办结后发出的 `intake` 口少了必填的两枚去向旗,G1 的异或出边条件
|
|
* 全不成立,流就无声停在 A1 —— 只有一个节点实例,状态却报 `completed`,
|
|
* 界面上是一单「已完成」的报价,业务上一步都没往下走。假成功比报错危险得多。
|
|
*
|
|
* 没到终点又无事可做,就是**卡住了**:报 `abnormal`,进驾驶舱的异常工作流卡,
|
|
* 让人看见并处置。`waiting` 也不对 —— 那是在等某个会到的东西,而这里什么都不会到。
|
|
*/
|
|
if (endNodeIds === undefined) return 'completed';
|
|
const ends = new Set(endNodeIds);
|
|
const reachedEnd = live.some((i) => ends.has(i.nodeId) && i.terminal === 'released');
|
|
return reachedEnd ? 'completed' : 'abnormal';
|
|
}
|
|
|
|
/* ── 失败走向的消化判定(OS 03) ────────────────────────────────────── */
|
|
|
|
export const FAILURE_DIGESTIONS = ['reroute', 'tolerate', 'escalate'] as const;
|
|
|
|
/** 改道、容忍与升审批一经生效即为**消化**,不入异常;终止流待人工处置中仍算异常 */
|
|
export function isDigested(disposition: 'terminate' | 'reroute' | 'escalate' | 'tolerate'): boolean {
|
|
return disposition !== 'terminate';
|
|
}
|
|
|
|
/**
|
|
* 「流程层**不自动重试**活动(过程内重试归 Harness);
|
|
* 流级重做一律是人裁决后**重开新任务实例** —— 同一节点、新任务 ID、留痕关联,不复活旧任务。」
|
|
*/
|
|
export function nextInstanceNumber(instances: readonly NodeInstance[], nodeId: string): number {
|
|
return instances.filter((i) => i.nodeId === nodeId).reduce((max, i) => Math.max(max, i.instance), 0) + 1;
|
|
}
|