M5(1/3): process 的 domain —— 闭合校验、四策略解析、状态聚合

- checkClosure:七类违规(边未映射 / 必填未齐 / 类型冲突 / 孤立节点 /
  网关不平衡 / 汇合来源不明 / 容忍分支被必填依赖)+ 活动未安装 + 岗位不存在;
  不闭合不得发布 —— 画布即时校验与后端裁决同源
- resolveOwner:四策略封闭(指定人 / 岗位唯一人 / 轮值 / 来源跟随);
  岗上多人退轮值,并列按 staffId 稳定排序 —— 解析必须确定性
- aggregateWorkflowState:聚合投影,流程层不另设状态机;计量对象是节点实例,
  XOR 未选分支不入集合,改道/容忍/升审批已消化即不入异常
- nextInstanceNumber:流级重做重开新实例,不复活旧任务

325 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:55:47 +08:00
co-authored by Claude Opus 5
parent 3c974d2860
commit a919d2e961
8 changed files with 769 additions and 2 deletions
@@ -0,0 +1,293 @@
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
import { describe, expect, it } from 'vitest';
import type { ProcessPackage } from '@contracts';
import { checkClosure } from '../internal/domain/closure.ts';
import type { ActivityContractFace } from '../internal/domain/closure.ts';
import {
aggregateWorkflowState,
isDigested,
nextInstanceNumber,
resolveOwner,
} from '../internal/domain/owner-resolution.ts';
import type { Candidate, NodeInstance } from '../internal/domain/owner-resolution.ts';
const face = (over: Partial<ActivityContractFace> = {}): ActivityContractFace => ({
activityId: 'a4',
requiredInputs: ['pn', 'basePrice'],
inputTypes: { pn: 'string', basePrice: 'number' },
outputs: { done: { pn: 'string', quote: 'number' } },
...over,
});
/** 统一报价的一小段:起点 → A4 成本定价 → 终点 */
const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
({
id: 'quote' as never,
name: '统一报价',
version: 1,
state: 'draft',
callInterface: { input: {}, output: {} },
nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' },
{
id: 'A4' as never,
kind: 'activity',
label: 'A4 成本定价',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{
id: 'e1',
from: 'start' as never,
to: 'A4' as never,
mapping: [
{ target: 'pn', source: { from: 'initiation', path: 'pn' } },
{ target: 'basePrice', source: { from: 'initiation', path: 'basePrice' } },
],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
variables: [],
...over,
}) 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 });
expect(report.violations).toEqual([]);
expect(report.publishable).toBe(true);
});
it('边未完成映射即拦', () => {
const broken = pkg({
edges: [
{ id: 'e1', from: 'start' as never, to: 'A4' as never, mapping: [], reopen: false },
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const report = checkClosure({ pkg: broken, contracts, positions });
expect(report.publishable).toBe(false);
expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped');
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
});
it('必填未齐即拦', () => {
const partial = pkg({
edges: [
{
id: 'e1',
from: 'start' as never,
to: 'A4' as never,
mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: partial, contracts, positions }).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 });
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
});
it('岗位不存在即拦', () => {
const report = checkClosure({ pkg: pkg(), contracts, positions: new Set(['PM']) });
expect(report.violations.map((v) => v.kind)).toContain('missing-owner-position');
});
it('孤立节点即拦', () => {
const orphan = pkg({
nodes: [
...pkg().nodes,
{
id: 'A9' as never,
kind: 'activity',
label: '游离节点',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
],
});
const violations = checkClosure({ pkg: orphan, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
});
it('异或网关的出边必须有条件 —— 声明式、静态可检', () => {
const withGateway = pkg({
nodes: [
...pkg().nodes,
{ id: 'G2' as never, kind: 'gateway', label: 'G2 料号分流', gateway: 'exclusive', mode: 'fork' },
],
edges: [
...pkg().edges,
{ id: 'g1', from: 'A4' as never, to: 'G2' as never, mapping: [], reopen: false },
{ id: 'g2', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
{ id: 'g3', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations;
expect(violations.some((v) => v.message.includes('缺条件'))).toBe(true);
});
it('容忍分支的输出不得被下游必填依赖', () => {
const tolerant = pkg({
nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' },
{
id: 'A3' as never,
kind: 'activity',
label: 'A3 渠道寻源',
activityId: 'a4' as never,
owner: { position: 'PRM', strategy: 'sole' },
failure: { kind: 'tolerate' },
},
{
id: 'A4' as never,
kind: 'activity',
label: 'A4 成本定价',
activityId: 'a4' as never,
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
},
{ id: 'end' as never, kind: 'end', label: '终点' },
],
edges: [
{ id: 'e0', from: 'start' as never, to: 'A3' as never, mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }, { target: 'basePrice', source: { from: 'initiation', path: 'b' } }], reopen: false },
{
id: 'e1',
from: 'A3' as never,
to: 'A4' as never,
mapping: [
{ target: 'pn', source: { from: 'output-port', port: 'done', path: 'pn' } },
{ target: 'basePrice', source: { from: 'output-port', port: 'done', path: 'quote' } },
],
reopen: false,
},
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations;
expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency');
});
});
describe('resolveOwner —— 四策略确定性解析', () => {
const many: Candidate[] = [
{ staffId: 's2', name: '乙', openTasks: 3 },
{ staffId: 's1', name: '甲', openTasks: 1 },
{ staffId: 's3', name: '丙', openTasks: 1 },
];
it('岗位唯一人', () => {
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [many[1]!] });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'sole' });
});
it('岗上多人时按轮值 —— 在途最少者优先', () => {
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: many });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'rotation' });
});
it('轮值并列时按 staffId 稳定排序 —— 解析必须确定性', () => {
const a = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: many });
const b = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: [...many].reverse() });
expect(a).toEqual(b);
});
it('指定人:不在候选内即空缺', () => {
expect(
resolveOwner({ position: 'BD', strategy: 'assigned', staffId: 'sX' as never }, { candidates: many }),
).toEqual({ kind: 'vacant', position: 'BD' });
});
it('来源跟随:复用同流内已解析出的人', () => {
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many, alreadyResolved: 's2' });
expect(r).toMatchObject({ kind: 'resolved', staffId: 's2', by: 'follow-source' });
});
it('来源跟随:此前没解析过即退到轮值', () => {
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many });
expect(r).toMatchObject({ by: 'rotation' });
});
it('岗位空缺', () => {
expect(resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [] })).toEqual({
kind: 'vacant',
position: 'BD',
});
});
});
describe('aggregateWorkflowState —— 聚合投影,流程层不另设状态机', () => {
const inst = (over: Partial<NodeInstance> = {}): NodeInstance => ({
nodeId: 'A1',
instance: 1,
running: false,
waiting: false,
activated: true,
...over,
});
it('任一实例运行中 → 运行中', () => {
expect(aggregateWorkflowState([inst({ running: true }), inst({ terminal: 'released' })], false)).toBe('running');
});
it('无运行实例但有待人 / 监听 → 等待中', () => {
expect(aggregateWorkflowState([inst({ waiting: true })], false)).toBe('waiting');
});
it('全部收敛 → 已完成', () => {
expect(aggregateWorkflowState([inst({ terminal: 'released' }), inst({ terminal: 'released' })], false)).toBe(
'completed',
);
});
it('非完成终态且失败走向未消化 → 异常', () => {
expect(aggregateWorkflowState([inst({ terminal: 'blocked' })], false)).toBe('abnormal');
});
it('改道 / 容忍 / 升审批已消化即不入异常', () => {
expect(aggregateWorkflowState([inst({ terminal: 'blocked', failureDigested: true })], false)).toBe('completed');
});
it('XOR 未选分支从未激活,不入集合', () => {
expect(
aggregateWorkflowState([inst({ terminal: 'released' }), inst({ activated: false, terminal: 'blocked' })], false),
).toBe('completed');
});
it('控制事件终止 → 已终止', () => {
expect(aggregateWorkflowState([inst({ running: true })], true)).toBe('terminated');
});
});
describe('失败走向的消化与重开', () => {
it('终止流待人工处置中仍算异常', () => {
expect(isDigested('terminate')).toBe(false);
expect(isDigested('reroute')).toBe(true);
expect(isDigested('tolerate')).toBe(true);
expect(isDigested('escalate')).toBe(true);
});
it('重开产生新实例号 —— 不复活旧任务', () => {
const instances: NodeInstance[] = [
{ nodeId: 'A4', instance: 1, running: false, waiting: false, activated: true, terminal: 'released' },
{ nodeId: 'A4', instance: 2, running: true, waiting: false, activated: true },
{ nodeId: 'A6', instance: 1, running: true, waiting: false, activated: true },
];
expect(nextInstanceNumber(instances, 'A4')).toBe(3);
expect(nextInstanceNumber(instances, 'A1')).toBe(1);
});
});