forked from ChaiHao/BPOS-MVP
三面拆分⑰:编辑即其身 —— 保存写回本版,新版本只出自「新建」
用户问:为什么编辑一个版本、保存就自动多一个副本?那不是我要的。
旧机制「保存恒新增版本号」改为:
- savePackage:pkg.version 已存在 → **就地写回该版**(号不变、状态不变);
`asNew: true`(新建按钮)或新流程首版才铸新版本号(服务端算,不生效)
- 底线三道,一道不少:
① **在途钉版不可变** —— 该版有未完结工作流(按 (包,版本) 钉定义)→ 409,
一字不落库;「就地覆盖会改掉在途正在读的编排」由这道闸守,不再靠只插不改一刀切
② **生效版必须始终可运行** —— 写回生效版跑闭合闸,不闭合 412 带整份报告;
未生效版画图不检查、随手存
③ **陈旧编辑不复活** —— 流程存在而版本号已被清理 → 409(实测:停在已删旧版上的
标签页发来保存,把老内容静默复活成了新版本;StaleVersionError 拦死)
- store:新增 updatePackageDefinition(只许命中现存版本)+ countPinnedInFlight
- 前端:保存按钮「保存 v{n}」;新建走 asNew;文案全线「新建才长版本」
- 测试:save-package.test 六例(写回/钉版/热改闭合/asNew/防复活/首版);
过程中揪出双 API 进程并存(旧进程霸端口跑旧代码)——已重启归一
实测:改名保存 → 版本列表仍唯一 v4 生效中,名字已写回;八例端到端全绿;
五闸退出码全过(api:check 提交后转绿)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+20
-4
@@ -28,10 +28,13 @@ export type StartWorkflowInput = {
|
||||
};
|
||||
export type ProcessService = {
|
||||
/**
|
||||
* 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version),
|
||||
* 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。
|
||||
* 保存 —— **编辑即其身**:pkg.version 已存在则就地写回该版(号不变)。
|
||||
* 两道闸:在途钉版抛 PinnedVersionError;写回生效版须闭合,否则 NotClosedError。
|
||||
* `asNew` 或版本不存在(新流程首版)才产生新版本号(服务端算,不生效)。
|
||||
*/
|
||||
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
||||
savePackage(pkg: ProcessPackage, opts?: {
|
||||
asNew?: boolean;
|
||||
}): Promise<ProcessPackage>;
|
||||
listPackages(): Promise<ProcessPackage[]>;
|
||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||
/**
|
||||
@@ -63,11 +66,24 @@ export declare class CallInterfaceViolationError extends Error {
|
||||
readonly errors: readonly string[];
|
||||
constructor(packageId: string, errors: readonly string[]);
|
||||
}
|
||||
/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */
|
||||
/** 不闭合不得**激活/热改生效版** —— 画图与写回未生效版不拦(中间态天然不完整) */
|
||||
export declare class NotClosedError extends Error {
|
||||
readonly report: ClosureReport;
|
||||
constructor(report: ClosureReport);
|
||||
}
|
||||
/** 陈旧编辑:流程存在但该版本号已不存在(被清理)—— 不静默复活旧内容,刷新后再改 */
|
||||
export declare class StaleVersionError extends Error {
|
||||
readonly packageId: string;
|
||||
readonly version: number;
|
||||
constructor(packageId: string, version: number);
|
||||
}
|
||||
/** 在途钉版:该版本有未完结的工作流按 (包, 版本) 钉着定义 —— 定义不可变,改动请新建一版 */
|
||||
export declare class PinnedVersionError extends Error {
|
||||
readonly packageId: string;
|
||||
readonly version: number;
|
||||
readonly inFlight: number;
|
||||
constructor(packageId: string, version: number, inFlight: number);
|
||||
}
|
||||
export declare class PositionVacantError extends Error {
|
||||
readonly position: string;
|
||||
constructor(position: string);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type { ActivityContractFace, AssembleScope, Candidate, ClosureInput, ClosureReport, ClosureViolation, NodeInstance, OwnerSpec, ProcessPackage, ProcessService, ResolveContext, Resolution, StartWorkflowInput, WorkflowRecord, WorkflowState, } from './contract.ts';
|
||||
export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts';
|
||||
export { CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
|
||||
export type { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts';
|
||||
export type { ProcessServiceDeps } from './internal/usecase/process-service.ts';
|
||||
export { createProcessService } from './internal/usecase/process-service.ts';
|
||||
|
||||
@@ -37,6 +37,10 @@ export type WorkflowRecord = {
|
||||
};
|
||||
export type ProcessStore = {
|
||||
savePackage(pkg: ProcessPackage): Promise<void>;
|
||||
/** 就地写回一版的定义(编辑即其身)—— 撞不存在的版本报错,不静默插入 */
|
||||
updatePackageDefinition(id: string, version: number, pkg: ProcessPackage): Promise<void>;
|
||||
/** 钉在该版上的未完结工作流数 —— 在途钉版不可变的判据 */
|
||||
countPinnedInFlight(id: string, version: number): Promise<number>;
|
||||
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||
|
||||
@@ -630,6 +630,11 @@ export declare const listProcessPackagesRoute: {
|
||||
export declare const saveProcessRoute: {
|
||||
readonly method: "PUT";
|
||||
readonly path: "/api/gov/process";
|
||||
/**
|
||||
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
|
||||
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
|
||||
* 就地写回的两道闸:在途钉版 409;写回生效版不闭合 412(带整份报告)。
|
||||
*/
|
||||
readonly body: z.ZodObject<{
|
||||
pkg: z.ZodObject<{
|
||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||
@@ -791,6 +796,7 @@ export declare const saveProcessRoute: {
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||
}, z.core.$strip>;
|
||||
asNew: z.ZodOptional<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
pkg: z.ZodObject<{
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from '@contracts';
|
||||
import { InstallRejectedError } from '@modules/activity-registry';
|
||||
import type { InstallVerdict } from '@modules/activity-registry';
|
||||
import { NotClosedError } from '@modules/process';
|
||||
import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
@@ -265,7 +265,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/**
|
||||
* 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。
|
||||
* 想改已发布的,另起新版本(恒新增、版本并存,在途按启动时版本跑完)。
|
||||
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
|
||||
*/
|
||||
app.put(saveProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
@@ -276,11 +276,24 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json(body, 400);
|
||||
}
|
||||
/*
|
||||
* 保存**不检查、不生效**:画图的中间态天然不完整,存下来是未生效的新版本。
|
||||
* 返回连同闭合报告 —— 异常看板据它列清单;完整性闸在激活路由。
|
||||
* 编辑即其身:已存在的版本就地写回(号不变);asNew / 新流程首版才长新版本。
|
||||
* 画图不检查(未生效版随手存);返回连同闭合报告 —— 异常看板据它列清单。
|
||||
* 两道闸:在途钉版 409;写回生效版不闭合 412(生效的必须始终可运行)。
|
||||
*/
|
||||
const pkg = await container.process.savePackage(parsed.data.pkg);
|
||||
try {
|
||||
const pkg = await container.process.savePackage(parsed.data.pkg, { asNew: parsed.data.asNew === true });
|
||||
return c.json({ pkg, report: await container.process.validate(pkg) });
|
||||
} catch (error) {
|
||||
if (error instanceof PinnedVersionError || error instanceof StaleVersionError) {
|
||||
const body: ApiError = { code: 'conflict', message: error.message };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
if (error instanceof NotClosedError) {
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
||||
|
||||
@@ -374,7 +374,7 @@ export async function seedMvpFlow(container: Container): Promise<void> {
|
||||
/*
|
||||
* 保存即生效,且**内容没变就不再存新版**。
|
||||
*
|
||||
* 保存恒新增版本(这是「旧任务走完旧版本」的前提),所以种子不能无条件存 ——
|
||||
* 内容变更走新版本(在途钉旧版跑完),所以种子不能无条件存 ——
|
||||
* 那样每跑一次播种就多一个版本,库里堆一串一模一样的定义。
|
||||
* 比对的是语义与呈现,不含 version/state(它们由保存本身决定)。
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
公开出口: index.ts
|
||||
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
|
||||
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
|
||||
- 错误:`NotClosedError` `PositionVacantError`
|
||||
- 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError`
|
||||
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
|
||||
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
|
||||
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
|
||||
@@ -25,9 +25,15 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验,
|
||||
随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时
|
||||
跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
|
||||
- **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本
|
||||
**就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或
|
||||
新流程首版(插入,`retired` 不生效)。两道底线:
|
||||
① **在途钉版不可变** —— 该版有未完结工作流(按 (包, 版本) 钉定义)即抛
|
||||
`PinnedVersionError`(HTTP 409),一字不落库;改动请新建一版。
|
||||
② **生效版必须始终可运行** —— 写回生效版跑 `checkClosure`,不闭合抛
|
||||
`NotClosedError`(HTTP 412 带整份报告);未生效版画图不检查、随手存。
|
||||
- **激活才查完整**:`setPackageState` 激活时跑 `checkClosure`,
|
||||
不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
|
||||
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、
|
||||
活动未装载、审批岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
|
||||
(活动点不核岗位 —— 契约不带负责人,`missing-owner-position` 只出自审批点。)
|
||||
@@ -53,8 +59,9 @@
|
||||
- **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
|
||||
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
|
||||
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
|
||||
- **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义,
|
||||
就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。
|
||||
- **版本号服务端算**(新建时,不采信入参):工作流按 (包id, 版本) 读定义;
|
||||
store 的写回(`updatePackageDefinition`)只许命中已存在的版本,撞不存在报错;
|
||||
「就地覆盖会改掉在途正在读的编排」由在途钉版闸守着,不再靠只插不改一刀切。
|
||||
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
|
||||
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
|
||||
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮):
|
||||
* 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。
|
||||
* 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { processPackageSchema } from '@contracts';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts';
|
||||
import { createProcessService } from '../internal/usecase/process-service.ts';
|
||||
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
|
||||
|
||||
const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage =>
|
||||
processPackageSchema.parse({
|
||||
id: 'p1',
|
||||
name: '包',
|
||||
version,
|
||||
state,
|
||||
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
|
||||
nodes: [
|
||||
{ id: 'S', kind: 'start', label: '起' },
|
||||
{ id: 'E', kind: 'end', label: '终' },
|
||||
],
|
||||
edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }],
|
||||
variables: [],
|
||||
});
|
||||
|
||||
function harness(seed: ProcessPackage[], pinned = 0) {
|
||||
const shelf = [...seed];
|
||||
const updated: Array<{ id: string; version: number }> = [];
|
||||
const inserted: ProcessPackage[] = [];
|
||||
const deps: ProcessServiceDeps = {
|
||||
store: {
|
||||
savePackage: async (pkg) => {
|
||||
inserted.push(pkg);
|
||||
shelf.push(pkg);
|
||||
},
|
||||
updatePackageDefinition: async (id, version, pkg) => {
|
||||
const at = shelf.findIndex((p) => p.id === id && p.version === version);
|
||||
if (at < 0) throw new Error('版本不存在');
|
||||
shelf[at] = pkg;
|
||||
updated.push({ id, version });
|
||||
},
|
||||
countPinnedInFlight: async () => pinned,
|
||||
setPackageState: async () => undefined,
|
||||
getPackage: async (id, version) =>
|
||||
version === undefined
|
||||
? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id)
|
||||
: shelf.find((p) => p.id === id && p.version === version),
|
||||
listPackages: async () => shelf,
|
||||
insertWorkflow: async () => undefined,
|
||||
getWorkflow: async () => undefined,
|
||||
listWorkflows: async () => [],
|
||||
updateWorkflow: async () => {
|
||||
throw new Error('测试:不该走到 updateWorkflow');
|
||||
},
|
||||
listInstances: async () => [],
|
||||
insertInstance: async () => undefined,
|
||||
updateInstance: async () => undefined,
|
||||
findInstanceByTask: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: {
|
||||
positions: async () => new Set<string>(['PM']),
|
||||
candidates: async () => [],
|
||||
workGroupBinding: async () => undefined,
|
||||
isActive: async () => true,
|
||||
},
|
||||
tasks: { start: async () => ({ id: 't' }) },
|
||||
approvals: { raise: async () => ({ id: 'a' }) },
|
||||
|
||||
sfeel: { condition: () => true, expression: () => undefined },
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
events: { publish: async () => undefined },
|
||||
receipts: { notify: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-28T00:00:00.000Z') },
|
||||
ids: { next: () => 'x' },
|
||||
};
|
||||
return { service: createProcessService(deps), shelf, updated, inserted };
|
||||
}
|
||||
|
||||
describe('savePackage —— 编辑即其身', () => {
|
||||
it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => {
|
||||
const h = harness([basePkg(4, 'retired')]);
|
||||
const edited = { ...basePkg(4, 'retired'), name: '改名了' };
|
||||
const saved = await h.service.savePackage(edited);
|
||||
expect(saved.version).toBe(4);
|
||||
expect(saved.state).toBe('retired');
|
||||
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
|
||||
expect(h.inserted).toHaveLength(0);
|
||||
expect(h.shelf).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => {
|
||||
const h = harness([basePkg(4, 'retired')], 2);
|
||||
await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError);
|
||||
expect(h.updated).toHaveLength(0);
|
||||
expect(h.inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('写回生效版须闭合 —— 生效的必须始终可运行', async () => {
|
||||
const h = harness([basePkg(4, 'active')]);
|
||||
// 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合
|
||||
const broken = { ...basePkg(4, 'active'), edges: [] };
|
||||
await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError);
|
||||
expect(h.updated).toHaveLength(0);
|
||||
|
||||
// 闭合的改动照常写回,生效状态保持
|
||||
const ok = { ...basePkg(4, 'active'), name: '热改' };
|
||||
const saved = await h.service.savePackage(ok);
|
||||
expect(saved.state).toBe('active');
|
||||
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
|
||||
});
|
||||
|
||||
it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => {
|
||||
const h = harness([basePkg(4, 'active')]);
|
||||
const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true });
|
||||
expect(saved.version).toBe(5);
|
||||
expect(saved.state).toBe('retired');
|
||||
expect(h.inserted).toHaveLength(1);
|
||||
expect(h.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => {
|
||||
const h = harness([basePkg(4, 'active')]);
|
||||
// 停在已清理旧版(v1)上的标签页发来保存
|
||||
await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError);
|
||||
expect(h.inserted).toHaveLength(0);
|
||||
expect(h.updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('新流程首版:版本不存在即插入 v1,不生效', async () => {
|
||||
const h = harness([]);
|
||||
const saved = await h.service.savePackage(basePkg(1, 'active'));
|
||||
expect(saved.version).toBe(1);
|
||||
expect(saved.state).toBe('retired');
|
||||
expect(h.inserted).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ function harness(validationErrors: readonly string[]): Harness {
|
||||
const deps: ProcessServiceDeps = {
|
||||
store: {
|
||||
savePackage: async () => undefined,
|
||||
updatePackageDefinition: async () => undefined,
|
||||
countPinnedInFlight: async () => 0,
|
||||
setPackageState: async () => undefined,
|
||||
getPackage: async () => PKG,
|
||||
listPackages: async () => [PKG],
|
||||
|
||||
@@ -53,10 +53,11 @@ export type StartWorkflowInput = {
|
||||
|
||||
export type ProcessService = {
|
||||
/**
|
||||
* 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version),
|
||||
* 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。
|
||||
* 保存 —— **编辑即其身**:pkg.version 已存在则就地写回该版(号不变)。
|
||||
* 两道闸:在途钉版抛 PinnedVersionError;写回生效版须闭合,否则 NotClosedError。
|
||||
* `asNew` 或版本不存在(新流程首版)才产生新版本号(服务端算,不生效)。
|
||||
*/
|
||||
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
|
||||
savePackage(pkg: ProcessPackage, opts?: { asNew?: boolean }): Promise<ProcessPackage>;
|
||||
listPackages(): Promise<ProcessPackage[]>;
|
||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||
/**
|
||||
@@ -109,7 +110,7 @@ export class CallInterfaceViolationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */
|
||||
/** 不闭合不得**激活/热改生效版** —— 画图与写回未生效版不拦(中间态天然不完整) */
|
||||
export class NotClosedError extends Error {
|
||||
constructor(readonly report: ClosureReport) {
|
||||
super(`流程包不闭合,不得激活:${report.violations.map((v) => v.message).join('; ')}`);
|
||||
@@ -117,6 +118,29 @@ export class NotClosedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 陈旧编辑:流程存在但该版本号已不存在(被清理)—— 不静默复活旧内容,刷新后再改 */
|
||||
export class StaleVersionError extends Error {
|
||||
constructor(
|
||||
readonly packageId: string,
|
||||
readonly version: number,
|
||||
) {
|
||||
super(`流程包版本不存在:${packageId}@v${version} —— 编辑器里的可能是已清理的旧版,刷新后在现存版本上改`);
|
||||
this.name = 'StaleVersionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** 在途钉版:该版本有未完结的工作流按 (包, 版本) 钉着定义 —— 定义不可变,改动请新建一版 */
|
||||
export class PinnedVersionError extends Error {
|
||||
constructor(
|
||||
readonly packageId: string,
|
||||
readonly version: number,
|
||||
readonly inFlight: number,
|
||||
) {
|
||||
super(`流程包 ${packageId}@v${version} 被 ${inFlight} 条在途工作流钉住 —— 定义不可变,新建一版再改`);
|
||||
this.name = 'PinnedVersionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PositionVacantError extends Error {
|
||||
constructor(readonly position: string) {
|
||||
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
|
||||
|
||||
@@ -18,7 +18,8 @@ export type {
|
||||
WorkflowRecord,
|
||||
WorkflowState,
|
||||
} from './contract.ts';
|
||||
export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts';
|
||||
export { CallInterfaceViolationError, NotClosedError,
|
||||
PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
ActivityDirectory,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* process 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, isNull } from 'drizzle-orm';
|
||||
import type { ProcessPackage, TerminalState, WorkflowState } from '@contracts';
|
||||
import { processPackageSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
@@ -74,6 +74,29 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
return rows.map((r) => processPackageSchema.parse(r.definition));
|
||||
},
|
||||
|
||||
async updatePackageDefinition(id, version, pkg) {
|
||||
const hit = await db
|
||||
.update(processPackageTable)
|
||||
.set({ definition: pkg })
|
||||
.where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version)))
|
||||
.returning({ version: processPackageTable.version });
|
||||
if (hit.length === 0) throw new Error(`就地写回失败:流程包版本不存在 ${id}@${version}`);
|
||||
},
|
||||
|
||||
async countPinnedInFlight(id, version) {
|
||||
const rows = await db
|
||||
.select({ id: workflowTable.id })
|
||||
.from(workflowTable)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowTable.packageId, id),
|
||||
eq(workflowTable.packageVersion, version),
|
||||
isNull(workflowTable.finishedAt),
|
||||
),
|
||||
);
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async insertWorkflow(record) {
|
||||
await db.insert(workflowTable).values({
|
||||
id: record.id,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts';
|
||||
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
|
||||
import { CallInterfaceViolationError, NotClosedError, PositionVacantError } from '../../contract.ts';
|
||||
import { CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityDirectory,
|
||||
ApprovalGateway,
|
||||
@@ -61,7 +61,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** 下一个版本号 —— 保存恒新增,不覆盖任何在途任务正在读的定义 */
|
||||
/** 下一个版本号 —— 只在显式新建 / 新流程首版时用(编辑即其身,写回不长号) */
|
||||
async function nextVersion(id: string): Promise<number> {
|
||||
const all = await deps.store.listPackages();
|
||||
return all.filter((pkg) => pkg.id === id).reduce((max, pkg) => Math.max(max, pkg.version), 0) + 1;
|
||||
@@ -298,10 +298,41 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
* 拿闭合校验拦保存,画到一半就存不了(实测 412 卡死编辑)。
|
||||
* 存下来的是**未生效**的新版本 —— 完整性闸在激活那一步。
|
||||
*/
|
||||
async savePackage(pkg) {
|
||||
async savePackage(pkg, opts) {
|
||||
const existing = opts?.asNew === true ? undefined : await deps.store.getPackage(pkg.id, pkg.version);
|
||||
|
||||
if (existing === undefined) {
|
||||
/*
|
||||
* 流程已存在而这个版本号查无此版 = 陈旧编辑器(实测:停在已清理旧版上的
|
||||
* 标签页发来保存,把老内容静默复活成了新版本)—— 拒掉,让人刷新后再改。
|
||||
* 只有整条流都不存在(新流程首版)或显式 asNew 才走插入。
|
||||
*/
|
||||
if (opts?.asNew !== true) {
|
||||
const newest = await deps.store.getPackage(pkg.id);
|
||||
if (newest !== undefined) throw new StaleVersionError(pkg.id, pkg.version);
|
||||
}
|
||||
// 显式新建 / 新流程首版:插入下一个版本号,不生效 —— 新版本只出自这条路
|
||||
const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' };
|
||||
await deps.store.savePackage(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/*
|
||||
* **编辑即其身**(产品决策 2026-07-28 第四轮):保存写回当前版本,号不变,
|
||||
* 不再每存一次长一版。两道底线:
|
||||
* ① 在途钉版不可变 —— 工作流按 (包, 版本) 钉定义,有未完结的就不许改字
|
||||
* ② 生效版必须始终可运行 —— 写回生效版要过闭合闸(未生效版画图不检查)
|
||||
*/
|
||||
const pinned = await deps.store.countPinnedInFlight(pkg.id, pkg.version);
|
||||
if (pinned > 0) throw new PinnedVersionError(pkg.id, pkg.version, pinned);
|
||||
|
||||
const saved: ProcessPackage = { ...pkg, version: existing.version, state: existing.state };
|
||||
if (existing.state === 'active') {
|
||||
const report = await this.validate(saved);
|
||||
if (!report.closed) throw new NotClosedError(report);
|
||||
}
|
||||
await deps.store.updatePackageDefinition(pkg.id, pkg.version, saved);
|
||||
return saved;
|
||||
},
|
||||
|
||||
async setPackageState(id, version, state) {
|
||||
|
||||
@@ -32,6 +32,10 @@ export type WorkflowRecord = {
|
||||
|
||||
export type ProcessStore = {
|
||||
savePackage(pkg: ProcessPackage): Promise<void>;
|
||||
/** 就地写回一版的定义(编辑即其身)—— 撞不存在的版本报错,不静默插入 */
|
||||
updatePackageDefinition(id: string, version: number, pkg: ProcessPackage): Promise<void>;
|
||||
/** 钉在该版上的未完结工作流数 —— 在途钉版不可变的判据 */
|
||||
countPinnedInFlight(id: string, version: number): Promise<number>;
|
||||
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。
|
||||
*
|
||||
* 版本是**运行时开关**,不是发布流程:
|
||||
* · 画图不检查:保存 = 存为新版,**不生效**;激活才跑闭合校验(异常清零方可)
|
||||
* · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」
|
||||
* · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409)
|
||||
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
|
||||
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
|
||||
*/
|
||||
@@ -218,7 +219,7 @@ function OverviewHome() {
|
||||
<QueryGate query={q}>
|
||||
<div className="g-kpis">
|
||||
<Kpi ico="workflow" tone="ok" label="流程" value={flows.length} sub="一条流恰一个生效版本" />
|
||||
<Kpi ico="history" label="版本" value={packages.length} sub="保存恒新增 · 在途按启动版本跑完" />
|
||||
<Kpi ico="history" label="版本" value={packages.length} sub="新建才长版本 · 在途按启动版本跑完" />
|
||||
<Kpi
|
||||
ico="git-merge"
|
||||
label="边闭合率"
|
||||
@@ -539,7 +540,7 @@ function FlowLab({
|
||||
setSaveErr(undefined);
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process-reports'] });
|
||||
// 存下来的是未生效的新版 —— 钉过去接着画;异常看板会列它还缺什么
|
||||
// 编辑即其身:写回的还是这一版,号不变;异常看板会列它还缺什么
|
||||
onSaved(out.pkg.version);
|
||||
},
|
||||
onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '保存失败'),
|
||||
@@ -564,7 +565,7 @@ function FlowLab({
|
||||
* 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。
|
||||
*/
|
||||
const cloneVersion = useMutation({
|
||||
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg } }),
|
||||
mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg, asNew: true } }),
|
||||
onSuccess: (out) => {
|
||||
setReport(out.report);
|
||||
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
|
||||
@@ -581,7 +582,7 @@ function FlowLab({
|
||||
note: dirty ? (
|
||||
<Note tone="warn">
|
||||
画布上有<b>未保存的改动</b>,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。
|
||||
想连改动一起走,直接点「保存为 v{nextVersion}」。
|
||||
想连改动一起走,先点「保存 v{pkg.version}」再来新建。
|
||||
</Note>
|
||||
) : (
|
||||
<Note>
|
||||
@@ -906,7 +907,7 @@ function FlowLab({
|
||||
if (graph !== null) save.mutate({ graph, idMap: {} });
|
||||
}}
|
||||
>
|
||||
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存为 v${nextVersion}`}
|
||||
<Ico name="send" s={11} /> {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="g-nodecard-b">
|
||||
|
||||
@@ -203,7 +203,12 @@ export const listProcessPackagesRoute = {
|
||||
export const saveProcessRoute = {
|
||||
method: 'PUT',
|
||||
path: '/api/gov/process',
|
||||
body: z.object({ pkg: processPackageSchema }),
|
||||
/**
|
||||
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
|
||||
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
|
||||
* 就地写回的两道闸:在途钉版 409;写回生效版不闭合 412(带整份报告)。
|
||||
*/
|
||||
body: z.object({ pkg: processPackageSchema, asNew: z.boolean().optional() }),
|
||||
output: z.object({ pkg: processPackageSchema, report: closureReportSchema }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user