/** * 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。 * * 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。 * 两层分开,是因为它们回答的是两个问题: * · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细; * · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。 * 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。 */ import type { CasePhase } from '@contracts'; import type { ItemRuntime } from './lifecycle.ts'; export type PhaseProgress = { /** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */ readonly index: number; /** 总格数 = 声明的阶段数 **+ 1**(结束态) */ readonly total: number; /** 这一格叫什么 */ readonly name: string; }; /** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */ export const CLOSING_PHASE = '放行收口'; /** * 算这一单在第几阶段。 * * 判据是「这一阶段的计划项**全部收完**」—— * · 收完(Completed / Terminated,后者是被跳过的裁量项)算过; * · 没收完就停在这一阶段,哪怕其中一项已经在办。 * 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。 * * `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态, * 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。 */ export function phaseProgress( phases: readonly CasePhase[] | undefined, items: readonly ItemRuntime[], terminal: boolean, ): PhaseProgress | undefined { if (phases === undefined || phases.length === 0) return undefined; const total = phases.length + 1; if (terminal) return { index: total, total, name: CLOSING_PHASE }; const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)); /* * **被跳过的项不许把进度条钉死**(2026-08-27 用户实测:都在库时数据工单两项一直是 Available, * 从前「一段里的项全收完才算过」让条永远停在 1/6,而单子早已办到审核了)。 * 现在先看「案子走到了哪一段」—— 最后一个有项**动过**(Active / Completed / Terminated)的阶段; * 那一段全收完了就算过、进下一段,没收完就停在那一段。一段都没动过才退回老规矩(第一段)。 */ const touched = new Set( items.filter((i) => i.state === 'Active' || i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id), ); let last = -1; for (const [at, phase] of phases.entries()) if (phase.items.some((id) => touched.has(id))) last = at; if (last >= 0) { const passed = phases[last]!.items.every((id) => settled.has(id)); const current = passed ? last + 1 : last; const phase = phases[current]; return phase === undefined ? { index: total, total, name: CLOSING_PHASE } : { index: current + 1, total, name: phase.name }; } for (const [at, phase] of phases.entries()) { if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name }; } return { index: total, total, name: CLOSING_PHASE }; }