This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/modules/process/internal/domain/owner-resolution.ts
T
ChaiHaoandClaude Fable 5 f145bde875 三面拆分⑲:审批点卸岗位 —— 负责人一律运营时分配,绑员工组,实例记 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
2026-07-28 21:24:41 +08:00

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;
}