From f145bde875faf6a870684261fbfff2d347fbefd3 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 21:24:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=8B=86=E5=88=86=E2=91=B2:?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E7=82=B9=E5=8D=B8=E5=B2=97=E4=BD=8D=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=B4=9F=E8=B4=A3=E4=BA=BA=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E8=BF=90=E8=90=A5=E6=97=B6=E5=88=86=E9=85=8D,?= =?UTF-8?q?=E7=BB=91=E5=91=98=E5=B7=A5=E7=BB=84,=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E8=AE=B0=20assignee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型(用户定):审批点不绑岗位;节点负责人是运营时分配的,绑定到员工组。 - 契约: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 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../apps/api/src/modules/process/port.d.ts | 5 +- .../contracts/src/api/governance.d.ts | 63 ------------------- .../contracts/src/process-package.d.ts | 25 +------- apps/api/src/app/routes/desk-approval.ts | 33 +++------- apps/api/src/app/routes/desk.ts | 37 +++-------- apps/api/src/app/routes/governance-ops.ts | 10 +-- apps/api/src/app/seed-mvp-flow.ts | 4 +- apps/api/src/modules/process/CONTRACT.md | 6 +- .../modules/process/__tests__/closure.test.ts | 43 +++---------- .../internal/adapter/process-schema.ts | 1 + .../process/internal/adapter/process-store.ts | 1 + .../process/internal/domain/closure.ts | 13 +--- .../internal/domain/owner-resolution.ts | 2 + .../internal/usecase/process-service.ts | 40 +++++------- apps/api/src/modules/process/port.ts | 2 +- .../features/gov-process/internal/panel.tsx | 40 +++--------- .../src/features/gov-task/internal/panel.tsx | 1 - apps/web/src/shared/gov/flow/graph-to-pkg.ts | 7 +-- apps/web/src/shared/gov/flow/pkg-to-graph.ts | 4 +- db/migrations/0014_instance_assignee.sql | 3 + db/migrations/meta/_journal.json | 7 +++ docs/debt.md | 13 ++-- packages/contracts/src/api/governance.ts | 1 - packages/contracts/src/process-package.ts | 8 ++- 24 files changed, 96 insertions(+), 273 deletions(-) create mode 100644 db/migrations/0014_instance_assignee.sql diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 2ab3737..b95f944 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -50,10 +50,7 @@ export type ProcessStore = { listWorkflows(limit: number): Promise; updateWorkflow(id: string, patch: Partial): Promise; listInstances(workflowId: string): Promise; - insertInstance(workflowId: string, instance: NodeInstance & { - taskId?: string; - approvalId?: string; - }): Promise; + insertInstance(workflowId: string, instance: NodeInstance): Promise; updateInstance(workflowId: string, nodeId: string, instance: number, patch: Partial & { taskId?: string; }): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 50f1a83..32e229e 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -521,16 +521,6 @@ export declare const listProcessPackagesRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -700,16 +690,6 @@ export declare const saveProcessRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -863,16 +843,6 @@ export declare const saveProcessRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -970,7 +940,6 @@ export declare const saveProcessRoute: { "multi-default-edge": "multi-default-edge"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; - "missing-owner-position": "missing-owner-position"; }>; nodeId: z.ZodOptional>; edgeId: z.ZodOptional; @@ -1064,16 +1033,6 @@ export declare const setProcessStateRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -1231,16 +1190,6 @@ export declare const validateProcessRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -1339,7 +1288,6 @@ export declare const validateProcessRoute: { "multi-default-edge": "multi-default-edge"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; - "missing-owner-position": "missing-owner-position"; }>; nodeId: z.ZodOptional>; edgeId: z.ZodOptional; @@ -2149,7 +2097,6 @@ export declare const listApprovalsRoute: { workflowSubject: z.ZodString; title: z.ZodString; approverName: z.ZodString; - approverPosition: z.ZodString; state: z.ZodEnum<{ pending: "pending"; rejected: "rejected"; @@ -2250,16 +2197,6 @@ export declare const govWorkflowRoute: { }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index cf91a7c..8d0db09 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -180,16 +180,6 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -324,16 +314,6 @@ export declare const processPackageSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"approval">; - approver: z.ZodObject<{ - position: z.ZodString; - strategy: z.ZodDefault>; - staffId: z.ZodOptional>; - }, z.core.$strip>; reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; @@ -421,7 +401,7 @@ export declare const processPackageSchema: z.ZodObject<{ diagram: z.ZodOptional>; }, z.core.$strip>; export type ProcessPackage = z.infer; -export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"]; +export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity"]; export declare const closureViolationKindSchema: z.ZodEnum<{ "edge-unmapped": "edge-unmapped"; "required-unmapped": "required-unmapped"; @@ -430,7 +410,6 @@ export declare const closureViolationKindSchema: z.ZodEnum<{ "multi-default-edge": "multi-default-edge"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; - "missing-owner-position": "missing-owner-position"; }>; export type ClosureViolationKind = z.infer; export declare const closureViolationSchema: z.ZodObject<{ @@ -442,7 +421,6 @@ export declare const closureViolationSchema: z.ZodObject<{ "multi-default-edge": "multi-default-edge"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; - "missing-owner-position": "missing-owner-position"; }>; nodeId: z.ZodOptional>; edgeId: z.ZodOptional; @@ -461,7 +439,6 @@ export declare const closureReportSchema: z.ZodObject<{ "multi-default-edge": "multi-default-edge"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; - "missing-owner-position": "missing-owner-position"; }>; nodeId: z.ZodOptional>; edgeId: z.ZodOptional; diff --git a/apps/api/src/app/routes/desk-approval.ts b/apps/api/src/app/routes/desk-approval.ts index 207e47f..c9cddba 100644 --- a/apps/api/src/app/routes/desk-approval.ts +++ b/apps/api/src/app/routes/desk-approval.ts @@ -38,17 +38,11 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain const node = pkg.nodes.find((n) => n.id === i.nodeId); if (node?.kind !== 'approval') return []; /* - * 谁的审批,要和**真正派单时的口径一致**(ADR-011:绑定先于策略)。 - * - * 从前这里只比岗位码:`node.approver.position !== me.position.code` 就滤掉。 - * 可引擎派审批时先看工作组绑定(`workGroup[岗位] → 员工`),装了组的单 - * 实际派给的是被绑定的那个人 —— 于是出现「审批派给了我、我的审批箱里没有」: - * 单子谁也看不见,流永远卡在审批点(实测:工作组把 PM 绑到本人,审批箱空)。 - * `toChain` 早为同一件事修过一次,这里是漏的那一处。 + * 谁的审批,要和**真正派单时的口径一致**:审批人是运营时分配的 + * (员工组轮值 / 全员轮值),分派结果记在实例的 assignee 上 —— + * 收件按它认人,不再有岗位可比(编排不带岗位,2026-07-28 第五轮)。 */ - const bound = w.workGroup?.[node.approver.position]; - const mine = bound === undefined ? node.approver.position === me.position.code : bound === me.id; - if (!mine) return []; + if (i.assigneeId !== me.id) return []; return [ { id: `${w.id}:${i.nodeId}#${i.instance}`, @@ -94,7 +88,6 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain const workflowId = c.req.param('workflowId'); const nodeId = c.req.param('nodeId'); - // 审批权按岗位校验:节点声明的审批岗位必须是我的岗位 const workflow = await container.process.getWorkflow(workflowId); const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); const node = pkg?.nodes.find((n) => n.id === nodeId); @@ -103,19 +96,13 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain return c.json(body, 404); } /* - * 裁决权也要按**派单口径**核,不是只比岗位码 —— 与上面的收件口径同一条规矩。 - * 只比岗位的话:装了工作组的单看得见却裁不了(403),人干瞪眼、流卡死。 + * 裁决权与收件同一口径:这一实例分派给谁,谁裁 —— 实例 assignee 是唯一判据 + * (审批人运营时分配,编排里没有岗位可比)。 */ - const boundApprover = workflow.workGroup?.[node.approver.position]; - const mayDecide = boundApprover === undefined ? node.approver.position === me.position.code : boundApprover === me.id; - if (!mayDecide) { - const body: ApiError = { - code: 'forbidden', - message: - boundApprover === undefined - ? `审批点卡在 ${node.approver.position} 工位,岗位 ${me.position.code} 不得裁决` - : `本单的 ${node.approver.position} 由工作组绑定给了他人,你不得裁决`, - }; + const instances = await container.process.instancesOf(workflowId); + const current = [...instances].reverse().find((i) => i.nodeId === nodeId && i.activated); + if (current?.assigneeId !== me.id) { + const body: ApiError = { code: 'forbidden', message: '本单分派给了他人,你不得裁决' }; return c.json(body, 403); } diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 7d63b38..648f7d6 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -94,38 +94,21 @@ export function registerDeskRoutes(app: Hono, container: Container): voi return await Promise.all( nodes.map(async (n) => { /* - * 报谁,要和**真正派单时的口径一致**。 - * 审批点:ADR-011 绑定先于策略 —— 先看工作组绑定,再退回岗位解析。 - * 活动点:契约不带岗位(分派归运营)—— 起了单的报**实际承接人**(任务上的), - * 还没起的如实报「轮值待派」,不编造具体承接人。 + * 报谁,要和**真正派单时的口径一致**:负责人是运营时分配的(员工组轮值 / + * 全员轮值),分派结果记在实例 assignee 上 —— 起了单的报实际承接人, + * 还没起的如实报「轮值待派」,不编造具体承接人。活动点与审批点同一口径。 */ - let who; - let fallbackName = '轮值待派'; - let fallbackRole = '全员轮值'; - if (n.kind === 'approval') { - const spec = n.approver; - fallbackName = spec.position; - fallbackRole = spec.position; - const bound = workflow.workGroup?.[spec.position]; - const boundStaff = - bound === undefined - ? undefined - : await container.org.getStaff(staffIdSchema.parse(bound)).catch(() => undefined); - // 岗位唯一解析(sole)即报人名;否则只报岗位 - const holders = - boundStaff !== undefined ? [] : await container.org.staffOfPosition(spec.position).catch(() => []); - who = boundStaff ?? (holders.length === 1 ? holders[0] : undefined); - } else { - const inst = instances.find((x) => x.nodeId === n.id && x.taskId !== undefined); - const owned = inst?.taskId === undefined ? undefined : await container.task.get(taskIdSchema.parse(inst.taskId)).catch(() => undefined); - who = owned === undefined ? undefined : await container.org.getStaff(owned.ownerId).catch(() => undefined); - } + const inst = [...instances].reverse().find((x) => x.nodeId === n.id && x.assigneeId !== undefined); + const who = + inst?.assigneeId === undefined + ? undefined + : await container.org.getStaff(staffIdSchema.parse(inst.assigneeId)).catch(() => undefined); return { nodeId: n.id, label: n.label, kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const), - ownerName: who?.name ?? fallbackName, - ownerRole: who?.position.name ?? fallbackRole, + ownerName: who?.name ?? '轮值待派', + ownerRole: who?.position.name ?? '运营分派', state: n.id === task.nodeId ? ('current' as const) diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 04507a2..5e8cf0a 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -185,15 +185,17 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai .filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval') .map(async (i) => { const node = pkg.nodes.find((n) => n.id === i.nodeId); - const position = node?.kind === 'approval' ? node.approver.position : ''; - const holders = position === '' ? [] : await container.org.staffOfPosition(position); + // 审批人是运营时分配的,实例 assignee 即事实 —— 没派到的如实「—」 + const assignee = + i.assigneeId === undefined + ? undefined + : await container.org.getStaff(staffIdSchema.parse(i.assigneeId)).catch(() => undefined); return { id: `${w.id}:${i.nodeId}#${i.instance}`, workflowId: w.id, workflowSubject: w.subject, title: `审批:${node?.label ?? i.nodeId}`, - approverName: holders[0]?.name ?? '—', - approverPosition: position, + approverName: assignee?.name ?? '—', // 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批 state: i.terminal === undefined diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index a1f85d0..48f6842 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -63,8 +63,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ id: 'A5', kind: 'approval', label: '定价审批', - sub: 'PM', - approver: { position: 'PM', strategy: 'sole' }, /* * 驳回走向声明式,不由临场决定:退回 A4 重新定价。 * 图上由 A5 自己的 `verdict = "reject"` 条件出边(E10)**显式表达同一件事**, @@ -236,7 +234,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ { id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', x: 300, y: 70 }, { id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 }, { id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', x: 120, y: 320 }, - { id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', owner: 'PM', flavor: 'approval', x: 300, y: 320 }, + { id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', flavor: 'approval', x: 300, y: 320 }, { id: 'A6', kind: 'activity', label: '报价议价', code: 'A6', x: 480, y: 320 }, { id: 'A7', kind: 'activity', label: '情报归档', code: 'A7', x: 640, y: 420 }, { id: 'end_won', kind: 'end', label: '转订单履约', x: 640, y: 330, lx: 650, ly: 360 }, diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 7292bff..6e50467 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -35,8 +35,8 @@ - **激活才查完整**:`setPackageState` 激活时跑 `checkClosure`, 不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。 违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、 - 活动未装载、审批岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 - (活动点不核岗位 —— 契约不带负责人,`missing-owner-position` 只出自审批点。) + 活动未装载、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 + (岗位不入编排,闭合校验没有岗位类违规。) - **纯编排模型**(产品决策 2026-07-28 二、三轮):流程包只决定**活动编排**与 **活动传递的契约**,不决定活动的实现方式(范式/版本住活动库,节点仅以 `activityId` 链接),**也不带负责人岗位**(分派归运营;运营分派机制未建前, @@ -63,7 +63,7 @@ store 的写回(`updatePackageDefinition`)只许命中已存在的版本,撞不存在报错; 「就地覆盖会改掉在途正在读的编排」由在途钉版闸守着,不再靠只插不改一刀切。 - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` - - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 + - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕(owner-resolved 事件 + 实例 assignee) - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, 重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合 - **「完成」凭到过终点节点**,不凭「没有在跑的了」:收敛但未到终点 = 卡住 = `abnormal`。 diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index 55c7c3d..af9df45 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -56,11 +56,10 @@ const pkg = (over: Partial = {}): ProcessPackage => }) 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 }); + const report = checkClosure({ pkg: pkg(), contracts }); expect(report.violations).toEqual([]); expect(report.closed).toBe(true); }); @@ -72,7 +71,7 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, ], }); - const report = checkClosure({ pkg: broken, contracts, positions }); + const report = checkClosure({ pkg: broken, contracts }); expect(report.closed).toBe(false); expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped'); expect(report.violations.map((v) => v.kind)).toContain('required-unmapped'); @@ -91,41 +90,15 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, ], }); - const violations = checkClosure({ pkg: partial, contracts, positions }).violations; + const violations = checkClosure({ pkg: partial, contracts }).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 }); + const report = checkClosure({ pkg: pkg(), contracts: new Map() }); expect(report.violations.map((v) => v.kind)).toContain('unknown-activity'); }); - /* 活动点不核岗位(契约不带负责人);审批点的岗位是功能语义,不存在即拦 */ - it('审批点岗位不存在即拦;活动点没有岗位可核', () => { - const withApproval = pkg({ - nodes: [ - ...pkg().nodes, - { - id: 'AP' as never, - kind: 'approval', - label: '审批', - approver: { position: 'GONE', strategy: 'sole' }, - reject: { kind: 'terminate' }, - }, - ], - edges: [ - ...pkg().edges.filter((e) => e.id !== 'e2'), - { id: 'e2', from: 'A4' as never, to: 'AP' as never, mapping: [], reopen: false }, - { id: 'e3', from: 'AP' as never, to: 'end' as never, mapping: [], reopen: false }, - ], - }); - const violations = checkClosure({ pkg: withApproval, contracts, positions }).violations; - expect(violations.filter((v) => v.kind === 'missing-owner-position').map((v) => v.nodeId)).toEqual(['AP']); - - // 活动节点引用的岗位集合再小也与它无关 —— 不产生岗位类违规 - const noPositions = checkClosure({ pkg: pkg(), contracts, positions: new Set() }).violations; - expect(noPositions.filter((v) => v.kind === 'missing-owner-position')).toEqual([]); - }); it('孤立节点即拦', () => { const orphan = pkg({ @@ -140,7 +113,7 @@ describe('checkClosure —— 不闭合不得激活', () => { }, ], }); - const violations = checkClosure({ pkg: orphan, contracts, positions }).violations; + const violations = checkClosure({ pkg: orphan, contracts }).violations; expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0); }); @@ -154,7 +127,7 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false }, ], }); - const violations = checkClosure({ pkg: twoDefaults, contracts, positions }).violations; + const violations = checkClosure({ pkg: twoDefaults, contracts }).violations; expect(violations.filter((v) => v.kind === 'multi-default-edge').map((v) => v.nodeId)).toEqual(['A4']); // 其中一条补上条件即闭合 —— 条件命中优先,无条件边只作默认兜底 @@ -163,7 +136,7 @@ describe('checkClosure —— 不闭合不得激活', () => { edges: twoDefaults.edges.map((e) => (e.id === 'e9' ? { ...e, condition: 'ok = true' } : e)), }); expect( - checkClosure({ pkg: oneDefault, contracts, positions }).violations.filter((v) => v.kind === 'multi-default-edge'), + checkClosure({ pkg: oneDefault, contracts }).violations.filter((v) => v.kind === 'multi-default-edge'), ).toEqual([]); }); @@ -202,7 +175,7 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, ], }); - const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations; + const violations = checkClosure({ pkg: tolerant, contracts }).violations; expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency'); }); }); diff --git a/apps/api/src/modules/process/internal/adapter/process-schema.ts b/apps/api/src/modules/process/internal/adapter/process-schema.ts index 341f597..678c3b5 100644 --- a/apps/api/src/modules/process/internal/adapter/process-schema.ts +++ b/apps/api/src/modules/process/internal/adapter/process-schema.ts @@ -53,6 +53,7 @@ export const nodeInstanceTable = sys.table( failureDigested: boolean('failure_digested'), taskId: text('task_id'), approvalId: text('approval_id'), + assigneeId: text('assignee_id'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [ diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index ab6c5b4..54f0857 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -167,6 +167,7 @@ export function createProcessStore(db: Db): ProcessStore { failureDigested: instance.failureDigested ?? null, taskId: instance.taskId ?? null, approvalId: instance.approvalId ?? null, + assigneeId: instance.assigneeId ?? null, }); }, diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts index 10000ea..e164072 100644 --- a/apps/api/src/modules/process/internal/domain/closure.ts +++ b/apps/api/src/modules/process/internal/domain/closure.ts @@ -28,12 +28,10 @@ export type ClosureInput = { readonly pkg: ProcessPackage; /** 已安装活动的契约面;缺失即 unknown-activity */ readonly contracts: ReadonlyMap; - /** 已存在的岗位编码 */ - readonly positions: ReadonlySet; }; export function checkClosure(input: ClosureInput): ClosureReport { - const { pkg, contracts, positions } = input; + const { pkg, contracts } = input; const violations: ClosureViolation[] = []; const byId = new Map(pkg.nodes.map((n) => [n.id, n])); const incoming = new Map(); @@ -59,14 +57,7 @@ export function checkClosure(input: ClosureInput): ClosureReport { if (node.kind === 'activity') { violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts)); } - // 活动点不核岗位(契约不带负责人 —— 分派归运营);审批点的岗位是功能语义,照核 - if (node.kind === 'approval' && !positions.has(node.approver.position)) { - violations.push({ - kind: 'missing-owner-position', - nodeId: node.id, - message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`, - }); - } + // 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核 /* * 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。 * 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序 diff --git a/apps/api/src/modules/process/internal/domain/owner-resolution.ts b/apps/api/src/modules/process/internal/domain/owner-resolution.ts index dafcd19..357c92b 100644 --- a/apps/api/src/modules/process/internal/domain/owner-resolution.ts +++ b/apps/api/src/modules/process/internal/domain/owner-resolution.ts @@ -123,6 +123,8 @@ export type NodeInstance = { /** 该实例派出的任务 / 审批(结构件与终点没有)—— 链条视图按它报实际承接人 */ readonly taskId?: string; readonly approvalId?: string; + /** 派给谁:运营时分配的结果(员工组轮值 / 全员轮值)—— 审批箱与链条按它收单报人 */ + readonly assigneeId?: string; }; export function aggregateWorkflowState( diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 776025f..62ac8fa 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -123,31 +123,22 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { } /* - * 谁来干:**活动契约不带岗位**(纯编排,2026-07-28 第三轮)—— 分派是运营的事。 - * 运营分派机制未建前,活动任务默认**全员轮值**(在岗候选并集,最闲者先, - * staffId 稳定排序保证同输入必同结果);审批点例外,「卡到人的工位」是它的 - * 功能语义,仍按 approver 岗位解析,工作组绑定(ADR-011)也只对它生效。 + * 谁来干:**编排不带岗位**(2026-07-28 第五轮,活动点与审批点同一口径)—— + * 负责人是**运营时分配**的:工作流绑了员工组就按组轮值(组员并集里最闲者先), + * 没绑组就全员轮值;staffId 稳定排序保证同输入必同结果。 + * 分派结果记在节点实例上(assignee)—— 审批箱、链条、治理列表都按它认人。 */ - let spec; - let candidates; - let bound; - if (dispatch.kind === 'start-task') { - spec = { position: '(全员轮值)', strategy: 'sole' as const }; - const seen = new Map(); - for (const position of await deps.org.positions()) { - for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c); - } - candidates = [...seen.values()]; - bound = undefined; - } else { - spec = dispatch.node.approver; - candidates = await deps.org.candidates(spec.position); - // 绑定先于策略:工作组说了这个岗位是谁,策略就无从发问(ADR-011)。 - // 在岗与否在这里核 —— 绑定是**跨岗位**指派,拿该岗位的候选集核不了它。 - const boundTo = record.workGroup?.[spec.position]; - bound = boundTo !== undefined && (await deps.org.isActive(boundTo)) ? boundTo : undefined; + const seen = new Map(); + for (const position of await deps.org.positions()) { + for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c); } - const resolution = resolveOwner(spec, { candidates, ...(bound === undefined ? {} : { bound }) }); + const everyone = [...seen.values()]; + const group = new Set(Object.values(record.workGroup ?? {})); + const groupPool = everyone.filter((c) => group.has(c.staffId)); + // 组员全不在岗时退回全员 —— 单不能没人接 + const pool = groupPool.length > 0 ? groupPool : everyone; + const spec = { position: groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)', strategy: 'sole' as const }; + const resolution = resolveOwner(spec, { candidates: pool }); if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position); await deps.events.publish({ @@ -173,6 +164,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { waiting: false, activated: true, taskId: task.id, + assigneeId: resolution.staffId, }); await deps.events.publish({ workflowId: record.id, kind: 'task-started', detail: { nodeId: node.id, taskId: task.id } }); return; @@ -192,6 +184,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { waiting: true, activated: true, approvalId: approval.id, + assigneeId: resolution.staffId, }); await deps.events.publish({ workflowId: record.id, kind: 'approval-raised', detail: { nodeId: node.id, approvalId: approval.id } }); } @@ -368,7 +361,6 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { return checkClosure({ pkg, contracts: await deps.activities.faces(), - positions: await deps.org.positions(), }); }, diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index f77e39f..e7941a7 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -47,7 +47,7 @@ export type ProcessStore = { updateWorkflow(id: string, patch: Partial): Promise; listInstances(workflowId: string): Promise; - insertInstance(workflowId: string, instance: NodeInstance & { taskId?: string; approvalId?: string }): Promise; + insertInstance(workflowId: string, instance: NodeInstance): Promise; updateInstance( workflowId: string, nodeId: string, diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index 9e4a931..4ee0879 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -35,13 +35,12 @@ import { St, } from '@web-shared/gov/primitives.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; -import { useGovActivities, useGovPackages, useGovPkgReports, useGovStaff } from '@web-shared/gov/gov-api.ts'; +import { useGovActivities, useGovPackages, useGovPkgReports } from '@web-shared/gov/gov-api.ts'; import type { GovActivities } from '@web-shared/gov/gov-api.ts'; import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx'; import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts'; import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts'; import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts'; -import type { SpPosition } from '@web-shared/gov/flow/support.tsx'; import { TriggerRulesHome } from '@features/gov-trigger'; export type HomeProps = { tab: string; nav: Nav }; @@ -405,7 +404,6 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) { const flowId = id.split('@')[0] ?? id; const q = useGovPackages(); const acts = useGovActivities(); - const staffQ = useGovStaff(); const versions = (q.data?.packages ?? []) .filter((p) => p.id === flowId) @@ -432,7 +430,6 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) { activeVersion={active?.version} nav={nav} activities={acts.data?.activities ?? []} - positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))} nextVersion={Math.max(...versions.map((x) => x.version)) + 1} closedOf={(v) => reports.data?.get(`${flowId}@${v}`)?.closed} onPick={(v) => setPinned(v)} @@ -457,7 +454,6 @@ function FlowLab({ activeVersion, nav, activities, - positions, nextVersion, closedOf, onPick, @@ -468,7 +464,6 @@ function FlowLab({ activeVersion: number | undefined; nav: Nav; activities: readonly ActivityRow[]; - positions: SpPosition[]; nextVersion: number; /** 该版本闭合与否(undefined = 报告还没回来)—— 激活按钮据此灰显 */ closedOf: (version: number) => boolean | undefined; @@ -671,7 +666,8 @@ function FlowLab({ })), approvals: pkg.nodes .filter((n) => n.kind === 'approval') - .map((n) => ({ nodeId: n.id, label: n.label, approver: n.approver, reject: n.reject })), + // 审批人运营时分配,依赖文件只描述编排语义(驳回走向) + .map((n) => ({ nodeId: n.id, label: n.label, reject: n.reject })), }; const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -1013,29 +1009,10 @@ function FlowLab({ /> )} - {node.flavor === 'approval' && ( - - - - )} - {node.flavor !== 'approval' && ( - - 活动点不带岗位 —— 谁来干是运营的事(分派机制未建前,运行时全员轮值、最闲者先)。 - - )} + + 编排不带岗位 —— 负责人是运营时分配的:工作流绑了员工组按组轮值, + 没绑组全员轮值(最闲者先)。审批点与活动点同一口径。 + {node.kind === 'activity' && node.flavor === 'approval' && ( 审批点是系统内置要素,不是活动 —— 声明即用、零开发、不进活动库(一个流程包里需要开发的 @@ -1043,9 +1020,6 @@ function FlowLab({ (声明式:回指定节点重开新实例 / 终止)· 催办阶梯。运行时它产出审批单而非任务。 )} - {node.flavor === 'approval' && node.owner === undefined && ( - 这个审批点还没指定岗位 —— 不指定,起单时就不知道审批单该派给谁。 - )} {node.kind === 'activity' && ( diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index c2e7a4d..5ee0ab1 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -143,7 +143,6 @@ function RuntimeHome({ tab, nav }: HomeProps) { title={a.title} sub={`${a.workflowSubject} · ${a.id}`} cols={[ - { k: '审批岗位', v: a.approverPosition }, { k: '审批人', v: a.approverName }, ]} right={ diff --git a/apps/web/src/shared/gov/flow/graph-to-pkg.ts b/apps/web/src/shared/gov/flow/graph-to-pkg.ts index fd8d9c1..83344e8 100644 --- a/apps/web/src/shared/gov/flow/graph-to-pkg.ts +++ b/apps/web/src/shared/gov/flow/graph-to-pkg.ts @@ -65,11 +65,8 @@ export function mergeGraphIntoPackage( return { ...base, kind: 'approval' as const, - approver: { - position: n.owner ?? priorAp?.approver.position ?? '未声明', - strategy: priorAp?.approver.strategy ?? ('sole' as const), - }, - // 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,发布前在属性外补 + // 审批人不入编排(运营时分配):功能属性只剩驳回走向 / 催办 / 委托消息 + // 驳回走向是声明式功能属性,画布不改它 —— 没有旧声明就先「终止」,激活前在属性里补 reject: priorAp?.reject ?? { kind: 'terminate' as const }, ...(priorAp?.nudge === undefined ? {} : { nudge: priorAp.nudge }), ...(priorAp?.message === undefined ? {} : { message: priorAp.message }), diff --git a/apps/web/src/shared/gov/flow/pkg-to-graph.ts b/apps/web/src/shared/gov/flow/pkg-to-graph.ts index 1c50d29..0399462 100644 --- a/apps/web/src/shared/gov/flow/pkg-to-graph.ts +++ b/apps/web/src/shared/gov/flow/pkg-to-graph.ts @@ -43,7 +43,7 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark id: n.id, kind: n.kind === 'start' ? ('start' as const) : n.kind === 'end' ? ('end' as const) : ('activity' as const), label: n.label, - ...(n.kind === 'approval' ? { flavor: 'approval' as const, owner: n.approver.position } : {}), + ...(n.kind === 'approval' ? { flavor: 'approval' as const } : {}), ...(n.kind === 'activity' ? { code: n.id, act: n.activityId } : {}), x: 30 + (i % 6) * 160, y: bottom + Math.floor(i / 6) * 60, @@ -93,7 +93,7 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark }; if (n.kind === 'approval') { // approval **不是一种活动**:它挂在 kind:"activity" 下仅因共用同一套方框几何 - return { ...base, flavor: 'approval', owner: n.approver.position }; + return { ...base, flavor: 'approval' }; } return { ...base, diff --git a/db/migrations/0014_instance_assignee.sql b/db/migrations/0014_instance_assignee.sql new file mode 100644 index 0000000..8ab74f1 --- /dev/null +++ b/db/migrations/0014_instance_assignee.sql @@ -0,0 +1,3 @@ +-- 节点实例记「派给谁」:负责人是运营时分配的(有员工组按组轮值,无组全员轮值), +-- 分派结果是流转事实,落在实例上 —— 员工面链条、审批箱、治理台列表都按它报人/收单。 +alter table sys.node_instance add column assignee_id text; diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index bd35501..665cec0 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1785130113168, "tag": "0013_one_active_version", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785130113169, + "tag": "0014_instance_assignee", + "breakpoints": true } ] } diff --git a/docs/debt.md b/docs/debt.md index 4a37552..9931dec 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -18,13 +18,14 @@ | M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 | | M5-5 | ~~含网关历史版本的运行时兼容三处~~ | 用户令库只留一条流一个版本(外科手术清空),在途与历史归零 —— schema 的 `kind: 'gateway'`、`joinReady`、汇合等待、`gateway-deprecated` 违规项已于次轮全部摘除 | 2026-07-28 | 已清理 | -## 活动分派 · 全员轮值默认(2026-07-28) +## 节点分派 · 员工组轮值(2026-07-28 第五轮更新) -活动契约不带负责人岗位(纯编排第三轮)——「谁来干」归运营,但运营分派机制还没做 -(用户明示先不做)。运行时垫了一个诚实默认:**全员轮值**(在岗候选并集、最闲者先、 -staffId 稳定排序保证确定性),`owner-resolved` 事件里岗位记作「(全员轮值)」。 -运营中心做了任务分派(按人/按组/认领池)后,这个默认让位;审批点不在此列 -(approver 是功能语义,照岗位解析,ADR-011 绑定仍生效)。 +编排不带岗位(活动点与审批点同一口径)——「谁来干」是运营时分配: +工作流绑了员工组按组轮值(组员并集最闲者先),没绑组**全员轮值**; +分派结果记在实例 assignee 上,审批箱/裁决权/链条/治理列表都按它认人。 +ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照的**成员集合**成了轮值池, +按岗位点名的绑定语义不复存在。still 欠:起单时选组、按人指派、认领池 —— 运营中心的 +分派界面落地后,这里的默认轮值让位。 ## 鉴权(ADR-010 落地后的已知欠账) diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index c2395f1..f576547 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -498,7 +498,6 @@ export const listApprovalsRoute = { workflowSubject: z.string(), title: z.string(), approverName: z.string(), - approverPosition: z.string(), state: z.enum(['pending', 'passed', 'rejected']), issuedAt: z.string(), }), diff --git a/packages/contracts/src/process-package.ts b/packages/contracts/src/process-package.ts index 34ad17f..af5b534 100644 --- a/packages/contracts/src/process-package.ts +++ b/packages/contracts/src/process-package.ts @@ -165,8 +165,11 @@ export const processNodeSchema = z.discriminatedUnion('kind', [ z.object({ ...nodeBase, kind: z.literal('approval'), - /** 审批点功能属性四件:审批岗位 / 输入装配映射 / 驳回走向 / 催办阶梯 */ - approver: ownerSpecSchema, + /* + * **不绑岗位**(产品决策 2026-07-28 第五轮):审批人也是运营时分配 —— + * 有员工组按组轮值,无组全员轮值,与活动点同一口径。 + * 审批点的功能属性只剩编排语义三件:驳回走向 / 催办阶梯 / 委托消息。 + */ /** 驳回走向:声明式,不由临场决定 */ reject: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('reopen'), nodeId: nodeIdSchema, mapping: z.array(fieldMappingSchema).default([]) }), @@ -235,7 +238,6 @@ export const CLOSURE_VIOLATION_KINDS = [ 'multi-default-edge', 'tolerate-required-dependency', 'unknown-activity', - 'missing-owner-position', ] as const; export const closureViolationKindSchema = z.enum(CLOSURE_VIOLATION_KINDS); export type ClosureViolationKind = z.infer;