版本号自己定:点编辑,整列版本进编辑态,取消/保存收尾

用户的目的是**自己改版本号**,不是让系统替他递增;顺带把上一轮那些
指示性交互全撤了 —— 这软件给专业业务设计人员用,不需要「点我改」的提示条。

界面:
  · 顶栏 编辑 → 取消 / 保存(与流程图卡「编辑 → 保存」同一套手势);
  · 行内两个字段:号(自己编)与名(各版不同),行尾 删除;
  · 改动攒在草稿里,按保存一趟落库;取消原样退出;
  · **删掉**:改名指示条、「点我改」虚下划线、`删掉 v4`/`激活 v4` 这类复述钮名的浮字。
    只留下禁用态那一句(生效版先停用 / 还不闭合),那不是指示,是钮为什么点不动。

后端 —— 号从「系统递增」放开为「设计人员自己编」,两道闸是数据本身要求的:
  · 撞号拒(VersionTakenError,409);
  · 在途钉版拒改号(那些单正按这个号找定义,搬号等于抽它们脚下的地板);
  · 已完结的单**跟着改指**:号在三处(包的列、definition、workflow.packageVersion)
    一起改 —— 号只是这一版的名牌,搬号连引用一起搬,留痕仍指同一份定义。
    先前写成「跑过单就不许改号」,实测等于任何试跑过的包永远编不了号,是过闸。
  · 名不设闸:标签不参与流转,在途钉版也能改;改名只动这一版(推翻前一日的
    「及于整条流」)—— 各版本正是靠名字互相区分,全同名等于没名字。

路由 POST /api/gov/process/:id/:version/edit 取代 …/:id/rename(单一消费者是本仓前端);
CONTRACT.md 与产品设计.md 同步,api-report 快照随本次提交。

实测(丁子乔账号,测后已 reset-runs 清运行数据):v4→v2→v4 往返,列表、画布图签、
库里三处同步;在途单存在时改号如实报 409 并原样退回。五闸绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-29 22:15:19 +08:00
co-authored by Claude Opus 5
parent 05c0a57cad
commit f631b5b8bb
18 changed files with 405 additions and 179 deletions
+26 -7
View File
@@ -36,7 +36,7 @@ import {
staffIdSchema,
taskIdSchema,
deleteProcessVersionRoute,
renameProcessRoute,
editProcessVersionRoute,
setNodeOwnerRoute,
setTriggerConfirmRoute,
listNodeOwnersRoute,
@@ -49,7 +49,13 @@ import { extractFields } from '@modules/channel';
import { assembleInput, scopeOf } from '@modules/trigger';
import { InstallRejectedError } from '@modules/activity-registry';
import type { InstallVerdict } from '@modules/activity-registry';
import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
import {
ActiveVersionError,
NotClosedError,
PinnedVersionError,
StaleVersionError,
VersionTakenError,
} from '@modules/process';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
@@ -505,18 +511,31 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
}
});
/** 改名:名字是这条流的身份 —— 一次改及于所有版本;在途单不受影响(名字不参与流转) */
app.post(renameProcessRoute.path, async (c) => {
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
app.post(editProcessVersionRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = renameProcessRoute.body.safeParse(await c.req.json());
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '流程名不合协议(1–40 字)' };
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
return c.json(body, 400);
}
const version = Number(c.req.param('version'));
if (!Number.isInteger(version)) {
const body: ApiError = { code: 'validation-failed', message: '版本号要是整数' };
return c.json(body, 400);
}
const patch = {
...(parsed.data.version === undefined ? {} : { version: parsed.data.version }),
...(parsed.data.name === undefined ? {} : { name: parsed.data.name.trim() }),
};
try {
return c.json({ packages: await container.process.rename(c.req.param('id'), parsed.data.name.trim()) });
return c.json({ packages: await container.process.editVersion(c.req.param('id'), version, patch) });
} catch (error) {
if (error instanceof VersionTakenError || error instanceof PinnedVersionError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 404);
}
+11 -4
View File
@@ -1,6 +1,6 @@
# module: process
职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
职责: 流程层:流程包定义与版本(建/克隆/启停/**删/改号改名**)、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
画布的绘制(见 apps/web 的 gov-process)
@@ -8,7 +8,7 @@
公开出口: index.ts
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError`
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError` `ActiveVersionError` `VersionTakenError`
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
@@ -29,8 +29,15 @@
类型对不对全归活动自己。激活闸只剩两面:**结构闭合**(dangling-node / multi-default-edge)
与**活动装载**(unknown-activity);字段类的三条检查(边未映射 / 必填未映射 / 类型相抵)全退役。
- **版本可删,两道闸**:生效版不可删(先停用,`ActiveVersionError`)、在途钉版不可删
(那些单要按它跑完,`PinnedVersionError`)。**改名及于整条流的所有版本** ——
名字是流的身份,不是某一版的属性。
(那些单要按它跑完,`PinnedVersionError`)。
- **版本号与名归设计人员自己定**(2026-07-29 用户定):
· **号自己编**,不再只能系统递增 —— 两道闸:不许撞同流已有号(`VersionTakenError`)、
在途钉版不许改号(`PinnedVersionError`,那些单正按这个号找定义)。
号在三处(包的列、definition、跑过的单的 `packageVersion`)**一起改** ——
号只是这一版的名牌,搬号连引用一起搬,已完结的单仍指同一份定义。
· **名只动这一版**(推翻前一日的「及于整条流」)—— 各版本正是靠名字互相区分
(「v3 手工议价」/「v4 含寻源」),全同名等于没名字;流的身份是 `id`,
货架上以生效版的名字示人。名不设闸:标签不参与流转,在途钉版也能改。
- **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射,
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
@@ -59,7 +59,8 @@ function harness(seed: ProcessPackage[], pinned = 0) {
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
deletePackage: async () => undefined,
renameFlow: async () => undefined,
renameVersion: async () => undefined,
renumberVersion: async () => undefined,
nodeOwners: async () => ({}),
setNodeOwner: async () => undefined,
},
@@ -69,7 +69,8 @@ function harness(validationErrors: readonly string[]): Harness {
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
deletePackage: async () => undefined,
renameFlow: async () => undefined,
renameVersion: async () => undefined,
renumberVersion: async () => undefined,
nodeOwners: async () => ({}),
setNodeOwner: async () => undefined,
},
@@ -1,16 +1,19 @@
/**
* 版本的删与改名(2026-07-30 用户要的两件)—— 守两道闸、一条口径。
* 版本记录的删与改 —— 号与名归设计人员自己定,三道闸守住数据说得通。
*
* · **生效版不可删**:先停用。挡的是「手滑把正在起单的定义删了」;
* · **在途钉版不可删**:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动
* —— 与「在途钉版不可改」同一条理由、同一个错(PinnedVersionError);
* · **改名及于整条流的所有版本**:名字是流的身份,不是某一版的属性。
* (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。)
* · **号自己定**(2026-07-29 用户定):不再只能系统递增。两条约束是数据本身要的 ——
* 号不许撞同流已有版本(VersionTakenError);**在途钉版不许改号**
* (PinnedVersionError:那些单正按这个号找定义)。已完结的单由 store 跟着改指,
* 号只是这一版的名牌,搬号连引用一起搬,留痕仍指同一份定义。
* · **名只动这一版**、不设闸:各版本正是靠名字互相区分,标签不参与流转。
*/
import { describe, expect, it } from 'vitest';
import type { ProcessPackage } from '@contracts';
import { processPackageSchema } from '@contracts';
import { ActiveVersionError, PinnedVersionError } from '../contract.ts';
import { ActiveVersionError, PinnedVersionError, StaleVersionError, VersionTakenError } from '../contract.ts';
import { createProcessService } from '../internal/usecase/process-service.ts';
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
@@ -46,10 +49,13 @@ function harness(rows: ProcessPackage[], pinned = 0): Harness {
const i = live.findIndex((p) => p.id === id && p.version === version);
if (i >= 0) live.splice(i, 1);
},
renameFlow: async (id: string, name: string) => {
for (let i = 0; i < live.length; i += 1) {
if (live[i]!.id === id) live[i] = { ...live[i]!, name };
}
renameVersion: async (id: string, version: number, name: string) => {
const i = live.findIndex((p) => p.id === id && p.version === version);
if (i >= 0) live[i] = { ...live[i]!, name };
},
renumberVersion: async (id: string, from: number, to: number) => {
const i = live.findIndex((p) => p.id === id && p.version === from);
if (i >= 0) live[i] = { ...live[i]!, version: to };
},
getPackage: async (id: string, version?: number) =>
live.find((p) => p.id === id && (version === undefined || p.version === version)),
@@ -104,14 +110,35 @@ describe('版本的删与改名', () => {
expect(h.rows).toHaveLength(2);
});
it('改名及于所有版本 —— 名字是这条流的身份', async () => {
it('改名只动这一版 —— 各版本正是靠名字互相区分', async () => {
const h = harness([pkg(2, 'active'), pkg(1, 'retired')]);
const after = await h.service.rename('quote', '统一报价(新)');
expect(after.map((p) => p.name)).toEqual(['统一报价(新)', '统一报价(新)']);
const after = await h.service.editVersion('quote', 1, { name: '统一报价 · 手工议价' });
expect(after.map((p) => `v${p.version} ${p.name}`)).toEqual(['v2 统一报价', 'v1 统一报价 · 手工议价']);
});
it('改不存在的流:如实报,不静默建一条', async () => {
it('版本号自己定 —— 号与名一趟改完,别的版不动', async () => {
const h = harness([pkg(2, 'active'), pkg(1, 'retired')]);
const after = await h.service.editVersion('quote', 1, { version: 10, name: '统一报价 · 手工议价' });
expect(after.map((p) => `v${p.version} ${p.name}`)).toEqual(['v10 统一报价 · 手工议价', 'v2 统一报价']);
});
it('号不许撞同流已有版本', async () => {
const h = harness([pkg(2, 'active'), pkg(1, 'retired')]);
await expect(h.service.editVersion('quote', 1, { version: 2 })).rejects.toBeInstanceOf(VersionTakenError);
expect(h.rows.map((p) => p.version), '一行都没动').toEqual([2, 1]);
});
it('在途钉版不许改号 —— 那些单正按这个号找定义', async () => {
const h = harness([pkg(2, 'active'), pkg(1, 'retired')], 3);
await expect(h.service.editVersion('quote', 1, { version: 7 })).rejects.toBeInstanceOf(PinnedVersionError);
// 但**名**照改不误:标签不参与流转,不受这道闸管
const after = await h.service.editVersion('quote', 1, { name: '旧版' });
expect(after.find((p) => p.version === 1)?.name).toBe('旧版');
});
it('改不存在的版本:如实报,不静默建一条', async () => {
const h = harness([pkg(1, 'retired')]);
await expect(h.service.rename('nope', 'X')).rejects.toThrow(/不存在/);
await expect(h.service.editVersion('quote', 9, { name: 'X' })).rejects.toBeInstanceOf(StaleVersionError);
await expect(h.service.editVersion('nope', 1, { name: 'X' })).rejects.toBeInstanceOf(StaleVersionError);
});
});
+17 -2
View File
@@ -81,8 +81,12 @@ export type ProcessService = {
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
/** 改名:名字是这条流的身份,一次改及于所有版本 */
rename(id: string, name: string): Promise<ProcessPackage[]>;
/**
* 改版本记录:**号与名**,只动这一版 —— 号自己定。
* 名不设闸(标签不参与流转);号设两道:不许撞同流已有号(`VersionTakenError`)、
* 在途钉版不许改号(`PinnedVersionError`);已完结的单跟着改指,留痕不失真。
*/
editVersion(id: string, version: number, patch: { version?: number; name?: string }): Promise<ProcessPackage[]>;
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
nodeOwners(packageId: string): Promise<Record<string, string>>;
@@ -163,6 +167,17 @@ export class PinnedVersionError extends Error {
}
}
/** 号被占:同一条流里版本号唯一 —— 换个号,或先把占号那版删了 */
export class VersionTakenError extends Error {
constructor(
readonly packageId: string,
readonly version: number,
) {
super(`v${version} 已被同流的另一版占着 —— 换个号`);
this.name = 'VersionTakenError';
}
}
export class PositionVacantError extends Error {
constructor(readonly position: string) {
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
+1 -1
View File
@@ -18,7 +18,7 @@ export type {
WorkflowRecord,
WorkflowState,
} from './contract.ts';
export { ActiveVersionError, CallInterfaceViolationError, NotClosedError,
export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, VersionTakenError,
PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
export type {
@@ -64,16 +64,38 @@ export function createProcessStore(db: Db): ProcessStore {
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
},
async renameFlow(id, name) {
async renameVersion(id, version, name) {
// 名字在列上与 definition(jsonb)里各有一份 —— 与 setPackageState 同一口径,两处一起改
const rows = await db.select().from(processPackageTable).where(eq(processPackageTable.id, id));
for (const row of rows) {
const definition = { ...(row.definition as Record<string, unknown>), name };
await db
.update(processPackageTable)
.set({ name, definition })
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, row.version)));
}
const rows = await db
.select()
.from(processPackageTable)
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
const row = rows[0];
if (row === undefined) return; // 用例层已挡「版本不存在」,这里不静默造行
const definition = { ...(row.definition as Record<string, unknown>), name };
await db
.update(processPackageTable)
.set({ name, definition })
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)));
},
async renumberVersion(id, from, to) {
const rows = await db
.select()
.from(processPackageTable)
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, from)));
const row = rows[0];
if (row === undefined) return; // 用例层已挡「版本不存在」
const definition = { ...(row.definition as Record<string, unknown>), version: to };
await db
.update(processPackageTable)
.set({ version: to, definition })
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, from)));
// 跑过的单跟着改指 —— 同一份定义,只是名牌换了号;不搬引用才会让留痕指错
await db
.update(workflowTable)
.set({ packageVersion: to })
.where(and(eq(workflowTable.packageId, id), eq(workflowTable.packageVersion, from)));
},
async getPackage(id, version) {
@@ -5,7 +5,13 @@
*/
import type { ProcessPackage } from '@contracts';
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts';
import {
ActiveVersionError,
NotClosedError,
PinnedVersionError,
StaleVersionError,
VersionTakenError,
} from '../../contract.ts';
import type {
ActivityDirectory,
ApprovalGateway,
@@ -171,11 +177,28 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
},
async rename(id, name) {
const all = await deps.store.listPackages();
if (!all.some((p) => p.id === id)) throw new Error(`流程包不存在:${id}`);
// 名字是这条流的身份 —— 一次改及于所有版本(在途单不受影响:名字不参与流转)
await deps.store.renameFlow(id, name);
async editVersion(id, version, patch) {
const one = await deps.store.getPackage(id, version);
if (one === undefined) throw new StaleVersionError(id, version);
const to = patch.version;
if (to !== undefined && to !== version) {
const all = await deps.store.listPackages();
if (all.some((p) => p.id === id && p.version === to)) throw new VersionTakenError(id, to);
/*
* 在途钉版不许改号:那些单正按这个号找定义,搬号等于在它们脚下抽地板。
* 已完结的单不拦 —— 它们的 `packageVersion` 由 store 跟着一起改指,留痕仍指同一份定义。
*/
const pinned = await deps.store.countPinnedInFlight(id, version);
if (pinned > 0) throw new PinnedVersionError(id, version, pinned);
await deps.store.renumberVersion(id, version, to);
}
/*
* 名不设闸:**在途钉版也允许改** —— 名字是标签不参与流转,
* 那几道闸守的是能跑的东西(定义与号),与名字无关。
*/
if (patch.name !== undefined && patch.name !== one.name) {
await deps.store.renameVersion(id, to ?? version, patch.name);
}
const after = await deps.store.listPackages();
return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version);
},
+8 -2
View File
@@ -77,8 +77,14 @@ export type ProcessStore = {
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
deletePackage(id: string, version: number): Promise<void>;
/** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */
renameFlow(id: string, name: string): Promise<void>;
/** 只改一版的名 —— 名字是版本自己的属性(列上与 definition 里各一份,两处一起改) */
renameVersion(id: string, version: number, name: string): Promise<void>;
/**
* 改版本号 —— 号在三处:包的列上、definition 里、**跑过的单的 `packageVersion` 上**。
* 三处一起改:号只是这一版的名牌,搬号连引用一起搬,谁也不会指空或指错。
* 撞号与在途钉版由用例层先挡。
*/
renumberVersion(id: string, from: number, to: number): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
listPackages(): Promise<ProcessPackage[]>;