/** * 负责人四策略解析与工作流状态聚合 —— 纯逻辑,零 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; }