From cbd49ded6f052717845e75add0207dbbfbb1b7ba Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Mon, 28 Sep 2026 17:13:37 +0800 Subject: [PATCH] 20260928171336 --- .../fms_workflow_mock.sql | 273 --- .../http.js | 124 -- .../workflow-domain.spec.js | 1166 ------------- .../workflow/design.vue | 110 -- .../designer/WorkflowActorSelector.vue | 231 --- .../workflow/designer/WorkflowBasicPanel.vue | 376 ----- .../workflow/designer/WorkflowCanvas.vue | 214 --- .../designer/WorkflowConditionBuilder.vue | 361 ---- .../designer/WorkflowCreateDialog.vue | 204 --- .../workflow/designer/WorkflowDesigner.vue | 692 -------- .../workflow/designer/WorkflowModeTabs.vue | 45 - .../workflow/designer/WorkflowNodePanel.vue | 116 -- .../designer/WorkflowPropertyDrawer.vue | 441 ----- .../designer/WorkflowSimulationPanel.vue | 312 ---- .../workflow/designer/WorkflowStepCard.vue | 206 --- .../workflow/designer/WorkflowStepEditor.vue | 213 --- .../workflow/designer/WorkflowStepInsert.vue | 48 - .../workflow/designer/WorkflowToolbar.vue | 137 -- .../designer/WorkflowValidationPanel.vue | 110 -- .../workflow/designer/api.js | 250 --- .../workflow/designer/constants.js | 308 ---- .../workflow/designer/domain.js | 1489 ----------------- .../workflow/designer/index.js | 11 - .../workflow/designer/nodes.js | 134 -- .../workflow/index.vue | 250 --- code/fms/FMS通用保存接口条件更新设计.md | 150 +- .../fmsapi/service/data/DataSaveService.java | 27 + .../workflow/WorkflowConfigService.java | 15 +- .../workflow/WorkflowRuntimeService.java | 110 +- .../service/data/DataSaveServiceTests.java | 16 + code/fms/sql/fms_workflow.sql | 20 + code/fms/workflow-saveobjt-demo.html | 417 +++++ code/test/form-js | 2 +- code/test/vue-vben-admin | 2 +- 34 files changed, 738 insertions(+), 7842 deletions(-) delete mode 100644 code/fms-workflow-refactor-backup-20260923/fms_workflow_mock.sql delete mode 100644 code/fms-workflow-refactor-backup-20260923/http.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow-domain.spec.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/design.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowActorSelector.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowBasicPanel.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCanvas.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowConditionBuilder.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCreateDialog.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowDesigner.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowModeTabs.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowNodePanel.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowPropertyDrawer.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowSimulationPanel.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepCard.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepEditor.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepInsert.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowToolbar.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowValidationPanel.vue delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/api.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/constants.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/domain.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/index.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/designer/nodes.js delete mode 100644 code/fms-workflow-refactor-backup-20260923/workflow/index.vue create mode 100644 code/fms/workflow-saveobjt-demo.html diff --git a/code/fms-workflow-refactor-backup-20260923/fms_workflow_mock.sql b/code/fms-workflow-refactor-backup-20260923/fms_workflow_mock.sql deleted file mode 100644 index 94ca3973..00000000 --- a/code/fms-workflow-refactor-backup-20260923/fms_workflow_mock.sql +++ /dev/null @@ -1,273 +0,0 @@ -/* ============================================================================ - FMS 工作流模拟数据 —— 费用报销审批(expense_approval) - - 用途:给流程设计器 / 流程管理页造一份「够复杂」的样本,用来测试编辑体验。 - 不是业务模板,也不代表任何一家公司的真实审批制度。 - - 刻意覆盖到的能力(对照《FMS工作流与审批设计》): - - 7 种节点类型全用到:start / user_task / cc_task / exclusive_gateway - / parallel_gateway / service_task / end(§4.4) - - 6 种审批方式全用到:single / any / all / serial / n_of_m / percentage(§6.4) - - 条件分支 + 默认出口(§6.5)、并行分支与汇合、抄送节点(§9.9) - - 8 种审批人解析类型中的 5 种:dept_manager / position / org_role / user(§6.6) - - 节点扩展配置:requiredCount(n_of_m)、requiredRatio(percentage)、 - formPolicy(只读/必填字段)、dueHours、allowClaim、commentRequired - - 前置条件:wf_* 表已由 fms_workflow.sql 建好。 - 使用说明: - 1. 执行前先切换到目标数据库(USE FMS)。 - 2. 本脚本可重复执行:先按流程编码清理本流程的版本/节点/连线/规则,再重建。 - 3. 只写 wf_definition / wf_version / wf_node / wf_edge / wf_actor_rule; - 不写 wf_binding(绑哪个模块是实施决定),也不写任何运行表。 - - 流程走向: - 开始 → 部门负责人审批(或签) → 金额判断 - ├─ 金额 > 10000 → 总经理审批(单人) ┐ - └─ 默认出口 ─────────────────────┴→ 财务复核(会签) → 并行分支 - ├→ 法务串行会签(串行) - ├→ 抄送财务总监(抄送,自动完成) - └→ 风控按比例通过(比例 0.6) - ↓ 并行汇合 - 是否超阈值 → 董事长审批(N 人通过,2 人) - ├─ 金额 >= 50000 ┘ - └─ 默认出口 → 归档 → 结束 - ============================================================================ */ - -SET NOCOUNT ON; - -declare @def varchar(50) = 'expense_approval'; -declare @ver varchar(50) = 'expense_approval_v1'; -declare @by varchar(50) = 'g3soft'; -declare @now datetime2 = sysdatetime(); - -/* ---------------------------------------------------------------------------- - 1. 清理(脚本可重复执行):只动本流程自己的行 - ---------------------------------------------------------------------------- */ - -delete from dbo.wf_actor_rule -where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); - -delete from dbo.wf_edge -where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); - -delete from dbo.wf_node -where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); - -delete from dbo.wf_binding -where b_version_id in (select b_id from dbo.wf_version where b_definition_id = @def); - -delete from dbo.wf_version where b_definition_id = @def; -delete from dbo.wf_definition where b_id = @def; - -/* ---------------------------------------------------------------------------- - 2. 流程定义 + 版本 - - b_canvas_json 只放界面布局(节点坐标),不参与执行(§6.2)。 - 格式与设计器 domain 层约定一致:{ schemaVersion, nodes: { : {x,y} } } - ---------------------------------------------------------------------------- */ - -insert into dbo.wf_definition (b_id, b_name, b_i18n, b_canuse, b_bz, - b_created_by, b_created_at, b_updated_by, b_updated_at) -values (@def, N'费用报销审批', 'workflow.expense_approval', 1, - N'模拟数据:覆盖条件分支、并行、会签、串行会签、N 人通过、按比例通过与抄送', - @by, @now, @by, @now); - -insert into dbo.wf_version (b_id, b_definition_id, b_version_no, b_status, b_schema_version, - b_canvas_json, b_checksum, b_published_by, b_published_at, b_bz, - b_created_by, b_created_at, b_updated_by, b_updated_at) -values (@ver, @def, 1, 'published', 1, - N'{ - "schemaVersion": 1, - "nodes": { - "start": { "x": 120, "y": 320 }, - "dept_manager": { "x": 320, "y": 320 }, - "amount_gateway": { "x": 520, "y": 320 }, - "gm_approve": { "x": 720, "y": 180 }, - "finance_review": { "x": 920, "y": 320 }, - "parallel_split": { "x": 1120, "y": 320 }, - "legal_countersign": { "x": 1320, "y": 160 }, - "cc_cfo": { "x": 1320, "y": 320 }, - "risk_review": { "x": 1320, "y": 480 }, - "parallel_join": { "x": 1520, "y": 320 }, - "big_amount_gateway":{ "x": 1720, "y": 320 }, - "chairman_approve": { "x": 1920, "y": 180 }, - "archive": { "x": 2120, "y": 320 }, - "end": { "x": 2320, "y": 320 } - } -}', - 'mock-checksum-expense_approval_v1', @by, @now, N'模拟数据:已发布版本', - @by, @now, @by, @now); - -/* ---------------------------------------------------------------------------- - 3. 节点(14 个) - - b_approval_mode / b_reject_mode / b_due_hours 是常用列; - requiredCount、requiredRatio、formPolicy 等低频配置进 b_config_json(§6.4)。 - ---------------------------------------------------------------------------- */ - -insert into dbo.wf_node (b_id, b_version_id, b_node_key, b_name, b_node_type, - b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) -values --- 开始 -('n_exp_start', @ver, 'start', N'开始', 'start', - null, null, null, null, 10, 1), - --- 部门负责人审批:或签(部门负责人 或 分管副总 任一人通过即可) -('n_exp_dept_manager', @ver, 'dept_manager', N'部门负责人审批', 'user_task', - 'any', 'return_initiator', 24, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":true,"commentRequired":false,"formPolicy":{"readonly":[],"required":["b_reason"]}}', - 20, 1), - --- 条件分支:金额判断 -('n_exp_amount_gateway', @ver, 'amount_gateway', N'金额判断', 'exclusive_gateway', - null, null, null, null, 30, 1), - --- 总经理审批:单人 -('n_exp_gm_approve', @ver, 'gm_approve', N'总经理审批', 'user_task', - 'single', 'terminate', 48, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"formPolicy":{"readonly":["b_amount","b_dept_id"],"required":[]}}', - 40, 1), - --- 财务复核:会签(全部通过) -('n_exp_finance_review', @ver, 'finance_review', N'财务复核', 'user_task', - 'all', 'terminate', 24, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":false,"formPolicy":{"readonly":["b_amount"],"required":[]}}', - 50, 1), - --- 并行分支 -('n_exp_parallel_split', @ver, 'parallel_split', N'并行分支', 'parallel_gateway', - null, null, null, null, 60, 1), - --- 法务串行会签:按 wf_actor_rule.b_priority 逐个激活 -('n_exp_legal_countersign', @ver, 'legal_countersign', N'法务串行会签', 'user_task', - 'serial', 'return_previous', 72, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"formPolicy":{"readonly":[],"required":[]}}', - 70, 1), - --- 抄送财务总监:不产生待办,节点激活即完成 -('n_exp_cc_cfo', @ver, 'cc_cfo', N'抄送财务总监', 'cc_task', - null, null, null, - N'{"schemaVersion":1,"commentRequired":false}', 80, 1), - --- 风控按比例通过:requiredRatio 在节点激活时换算成应签人数 -('n_exp_risk_review', @ver, 'risk_review', N'风控按比例通过', 'user_task', - 'percentage', 'terminate', 48, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":false,"requiredRatio":0.6,"formPolicy":{"readonly":[],"required":[]}}', - 90, 1), - --- 并行汇合 -('n_exp_parallel_join', @ver, 'parallel_join', N'并行汇合', 'parallel_gateway', - null, null, null, null, 100, 1), - --- 条件分支:是否超阈值 -('n_exp_big_amount_gateway', @ver, 'big_amount_gateway', N'是否超阈值', 'exclusive_gateway', - null, null, null, null, 110, 1), - --- 董事长审批:N 人通过(3 个候选人里 2 人通过即完成) -('n_exp_chairman_approve', @ver, 'chairman_approve', N'董事长审批', 'user_task', - 'n_of_m', 'terminate', 72, - N'{"schemaVersion":1,"allowInitiator":false,"excludePreviousActors":true,"allowClaim":false,"commentRequired":true,"requiredCount":2,"formPolicy":{"readonly":[],"required":[]}}', - 120, 1), - --- 服务节点:归档(第一阶段只占位,不执行) -('n_exp_archive', @ver, 'archive', N'归档', 'service_task', - null, null, null, - N'{"schemaVersion":1,"serviceKey":"expense_archive"}', 130, 1), - --- 结束 -('n_exp_end', @ver, 'end', N'结束', 'end', - null, null, null, null, 140, 1); - -/* ---------------------------------------------------------------------------- - 4. 连线(17 条) - - b_condition_json 为 null 表示默认出口;条件分支节点必须至少有一条(§6.5)。 - 条件用结构化 AST(§7.1):{ all: [ {field, operator, value} ] } / { any: [...] }, - 叶子字段必须来自模块 s_field,SQL 由后端编译为参数化查询。 - b_priority 小的先判断。 - ---------------------------------------------------------------------------- */ - -insert into dbo.wf_edge (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, - b_condition_json, b_priority, b_canuse, b_bz) -values -('e_exp_01', @ver, 'start', 'dept_manager', null, null, 10, 1, null), -('e_exp_02', @ver, 'dept_manager', 'amount_gateway', null, null, 10, 1, null), - --- 金额判断:> 10000 走总经理,否则直接进财务复核(默认出口) -('e_exp_03', @ver, 'amount_gateway', 'gm_approve', N'金额大于 1 万', - N'{"all":[{"field":"b_amount","operator":"gt","value":10000}]}', 10, 1, null), -('e_exp_04', @ver, 'amount_gateway', 'finance_review', N'默认出口', null, 20, 1, N'金额不大于 10000'), -('e_exp_05', @ver, 'gm_approve', 'finance_review', null, null, 10, 1, null), -('e_exp_06', @ver, 'finance_review', 'parallel_split', null, null, 10, 1, null), - --- 并行分支:三条出线同时激活 -('e_exp_07', @ver, 'parallel_split', 'legal_countersign', null, null, 10, 1, null), -('e_exp_08', @ver, 'parallel_split', 'cc_cfo', null, null, 20, 1, null), -('e_exp_09', @ver, 'parallel_split', 'risk_review', null, null, 30, 1, null), - --- 并行汇合:三条支线全部到达后才继续 -('e_exp_10', @ver, 'legal_countersign', 'parallel_join', null, null, 10, 1, null), -('e_exp_11', @ver, 'cc_cfo', 'parallel_join', null, null, 10, 1, null), -('e_exp_12', @ver, 'risk_review', 'parallel_join', null, null, 10, 1, null), -('e_exp_13', @ver, 'parallel_join', 'big_amount_gateway',null, null, 10, 1, null), - --- 是否超阈值:>= 50000 要董事长,否则直接归档(默认出口) -('e_exp_14', @ver, 'big_amount_gateway','chairman_approve', N'金额大于等于 5 万', - N'{"all":[{"field":"b_amount","operator":"ge","value":50000}]}', 10, 1, null), -('e_exp_15', @ver, 'big_amount_gateway','archive', N'默认出口', null, 20, 1, N'金额小于 50000'), -('e_exp_16', @ver, 'chairman_approve', 'archive', null, null, 10, 1, null), -('e_exp_17', @ver, 'archive', 'end', null, null, 10, 1, null); - -/* ---------------------------------------------------------------------------- - 5. 审批人 / 抄送人规则 - - 解析规则执行后必须把规则快照写进运行表(§6.6);这里只配规则本身。 - b_priority 既是串行会签的顺序来源,也是同类型规则的判断顺序。 - ---------------------------------------------------------------------------- */ - -insert into dbo.wf_actor_rule (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, - b_resolve_json, b_condition_json, b_fallback_type, b_fallback_ref, - b_priority, b_canuse, b_bz) -values --- 部门负责人审批(或签):发起人所属部门负责人 或 分管副总 -('r_exp_dm_1', @ver, 'dept_manager', 'dept_manager', null, null, null, 'admin', null, 10, 1, N'发起人或业务归属部门负责人'), -('r_exp_dm_2', @ver, 'dept_manager', 'position', 'vice_president', null, null, 'admin', null, 20, 1, N'分管副总'), - --- 总经理审批(单人) -('r_exp_gm_1', @ver, 'gm_approve', 'position', 'gm', null, null, 'admin', null, 10, 1, N'总经理岗'), - --- 财务复核(会签):两条规则 → 两名审批人都必须通过 -('r_exp_fr_1', @ver, 'finance_review', 'org_role', 'finance', null, null, 'admin', null, 10, 1, N'财务组织角色'), -('r_exp_fr_2', @ver, 'finance_review', 'position', 'finance_manager', null, null, 'admin', null, 20, 1, N'财务经理岗'), - --- 法务串行会签(串行):按 priority 10 → 20 → 30 逐个激活 -('r_exp_lc_1', @ver, 'legal_countersign', 'org_role', 'legal_lead', null, null, 'admin', null, 10, 1, N'法务负责人(第一个签)'), -('r_exp_lc_2', @ver, 'legal_countersign', 'user', 'U001', null, null, 'admin', null, 20, 1, N'指定用户(第二个签)'), -('r_exp_lc_3', @ver, 'legal_countersign', 'position', 'legal_staff', null, null, 'admin', null, 30, 1, N'法务专员(第三个签)'), - --- 抄送财务总监(抄送):不产生待办 -('r_exp_cc_1', @ver, 'cc_cfo', 'org_role', 'cfo', null, null, 'none', null, 10, 1, N'财务总监'), - --- 风控按比例通过(3 名候选人里过 60% → 2 人) -('r_exp_rr_1', @ver, 'risk_review', 'org_role', 'risk', null, null, 'admin', null, 10, 1, N'风控组织角色'), -('r_exp_rr_2', @ver, 'risk_review', 'position', 'risk_manager', null, null, 'admin', null, 20, 1, N'风控经理岗'), -('r_exp_rr_3', @ver, 'risk_review', 'user', 'U002', null, null, 'admin', null, 30, 1, N'指定用户'), - --- 董事长审批(N 人通过,requiredCount = 2) -('r_exp_ca_1', @ver, 'chairman_approve', 'user', 'U001', null, null, 'admin', null, 10, 1, N'候选人一'), -('r_exp_ca_2', @ver, 'chairman_approve', 'user', 'U002', null, null, 'admin', null, 20, 1, N'候选人二'), -('r_exp_ca_3', @ver, 'chairman_approve', 'user', 'U003', null, null, 'admin', null, 30, 1, N'候选人三'); - -/* ---------------------------------------------------------------------------- - 6. 自检 - ---------------------------------------------------------------------------- */ - -select N'流程定义' as 项, count(*) as 行数 from dbo.wf_definition where b_id = @def -union all -select N'流程版本', count(*) from dbo.wf_version where b_definition_id = @def -union all -select N'节点', count(*) from dbo.wf_node where b_version_id = @ver -union all -select N'连线', count(*) from dbo.wf_edge where b_version_id = @ver -union all -select N'审批人规则', count(*) from dbo.wf_actor_rule where b_version_id = @ver; diff --git a/code/fms-workflow-refactor-backup-20260923/http.js b/code/fms-workflow-refactor-backup-20260923/http.js deleted file mode 100644 index 2b7d93a0..00000000 --- a/code/fms-workflow-refactor-backup-20260923/http.js +++ /dev/null @@ -1,124 +0,0 @@ -import axios from 'axios' -import { useAuthStore } from '@/stores/auth' -import { Message } from '@/components/ui' - -const AUTH_ERROR_CODE = 401 -const SESSION_REPLACED_CODE = 40101 - -const http = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || '/api', - timeout: 15000, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, -}) - -let redirectingToLogin = false - -function redirectToLogin(reason) { - const currentPath = window.location.pathname.replace(/\/+$/, '') || '/' - if (currentPath === '/login' || redirectingToLogin) return - - redirectingToLogin = true - const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}` - const query = new URLSearchParams({ redirect }) - if (reason) query.set('reason', reason) - window.location.assign(`/login?${query.toString()}`) -} - -function isAuthenticationError(code) { - return code === AUTH_ERROR_CODE || code === SESSION_REPLACED_CODE -} - -function handleUnauthorized(code) { - const authStore = useAuthStore() - authStore.clearSession() - redirectToLogin(code === SESSION_REPLACED_CODE ? 'session-replaced' : 'expired') -} - -// 请求拦截器:注入 Bearer token(调用时读取 store,保证 pinia 已激活) -http.interceptors.request.use((config) => { - const authStore = useAuthStore() - if (authStore.loginInfo.token) { - config.headers.Authorization = `Bearer ${authStore.loginInfo.token}` - } - return config -}) - -// 响应拦截器:统一按 ApiResult 结构处理 -http.interceptors.response.use( - (response) => { - const payload = response.data - - // 文件下载(导出等):blob 响应没有 code 字段,原样透传响应本身, - // 调用方才能读到 headers(Content-Disposition 里的文件名、content-type 用于区分错误 JSON) - if (typeof Blob !== 'undefined' && payload instanceof Blob) { - return response - } - - // 无 code 字段时按成功透传 - if (payload?.code === undefined) { - return { - success: true, - code: 0, - message: '', - data: payload, - } - } - - payload.success = payload.code === 0 - - if (payload.success) return response - - if (isAuthenticationError(payload.code)) handleUnauthorized(payload.code) - - const err = new Error(payload.message || '请求失败') - err.code = payload.code - err.data = payload.data - err.success = payload.success - throw err - }, - (error) => { - const status = error.response?.status - const payload = error.response?.data - - // 仅鉴权类错误(401 / 40101)才清会话并跳登录页; - // 网络异常(无 status)、服务器 5xx 等普通错误不应把用户登出 - const errorCode = payload?.code ?? status ?? 0 - if (isAuthenticationError(errorCode)) handleUnauthorized(errorCode) - - const message = - payload?.message || - (error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络异常,请检查服务是否正常') - - const requestError = new Error(message) - requestError.code = payload?.code ?? status ?? -1 - requestError.data = payload?.data - requestError.success = false - - // 服务端不可达(无 status)/ 5xx 属全局性问题,统一弹 Message 提示; - // 4xx 等业务错误由调用方自行决定提示方式。 - // 打 toasted 标记:登录、上传/删除等已有自身 Message 的调用方据此去重,避免重复弹窗 - if (!status || status >= 500) { - Message.error(message) - requestError.toasted = true - } - - throw requestError - }, -) - -export function post(path, data, config) { - return http.post(path, data, config).then((r) => r.data) -} - -export function put(path, data, config) { - return http.put(path, data, config).then((r) => r.data) -} - -export function get(path, params, config) { - return http.get(path, { params, ...config }).then((r) => r.data) -} - -export default http diff --git a/code/fms-workflow-refactor-backup-20260923/workflow-domain.spec.js b/code/fms-workflow-refactor-backup-20260923/workflow-domain.spec.js deleted file mode 100644 index 8d32959d..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow-domain.spec.js +++ /dev/null @@ -1,1166 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createBinding, - createDefinitionDraft, - createEmptyDraft, - defaultNodeConfig, - describeActorRules, - describeApprovalMode, - describeCondition, - draftToCanvasJson, - draftToSavePayload, - duplicateLinearStep, - insertLinearStep, - inspectLinear, - listLinearSteps, - logicFlowToWorkflowDraft, - moveLinearStep, - normalizeCondition, - normalizeEdgePriorities, - outgoingEdgesOf, - removeLinearStep, - rowsToWorkflowDraft, - treeSignature, - validateWorkflowDomain, - workflowDraftToTree, - workflowDomainToLogicFlow, - workflowTreeToDraft, -} from '@/views/system/workflow/designer/domain' - -/** 造一份「开始 → 人工审批 → 结束」的线性草稿 */ -function linearDraft() { - return createDefinitionDraft({ - id: 'expense_approval', - name: '费用报销审批', - moduleId: 'cw_expense', - event: 'submit', - }) -} - -/** 取校验结果里的错误文案,便于断言 */ -function errorsOf(draft, options) { - return validateWorkflowDomain(draft, options) - .filter((issue) => issue.level === 'error') - .map((issue) => issue.message) -} - -describe('领域草稿(schemaVersion 2)', () => { - it('空草稿自带开始与结束节点,并用一条默认连线串起来', () => { - const draft = createEmptyDraft() - expect(draft.schemaVersion).toBe(2) - expect(draft.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) - expect(draft.edges).toHaveLength(1) - expect(draft.edges[0]).toMatchObject({ from: 'start', to: 'end', condition: null }) - expect(draft.definition).toEqual({ id: '', name: '', i18n: null, enabled: true, remark: '' }) - expect(draft.bindings).toEqual([]) - }) - - it('新建向导的默认模板是「开始 → 人工审批 → 结束」,审批人留空', () => { - const draft = linearDraft() - expect(draft.definition.id).toBe('expense_approval') - expect(draft.nodes.map((node) => node.nodeType)).toEqual(['start', 'user_task', 'end']) - expect(draft.nodes[1].actorRules).toEqual([]) - expect(draft.edges.map((edge) => [edge.from, edge.to])).toEqual([ - ['start', 'user_task_1'], - ['user_task_1', 'end'], - ]) - expect(draft.bindings[0]).toMatchObject({ moduleId: 'cw_expense', event: 'submit', enabled: true }) - }) - - it('不用模板时只给空白高级流程', () => { - const draft = createDefinitionDraft({ id: 'x', name: 'X', moduleId: 'm', useTemplate: false }) - expect(draft.nodes.map((node) => node.nodeType)).toEqual(['start', 'end']) - }) - - it('抄送节点不带审批设置,只配抄送人规则(§5.3)', () => { - const result = insertLinearStep(linearDraft(), 'start', 'cc_task') - const cc = result.draft.nodes.find((node) => node.nodeKey === result.nodeKey) - expect(cc.config).toEqual({}) - }) -}) - -describe('条件 AST 的空值归一', () => { - it('空逻辑组会被归一成 null(默认出口),但带一条条件行的组会保留', () => { - expect(normalizeCondition({ all: [] })).toBeNull() - expect(normalizeCondition(null)).toBeNull() - const group = { all: [{ field: '', operator: 'like', value: undefined }] } - expect(normalizeCondition(group)).toEqual(group) - }) - - it('抄送发起人自己不算自审风险,只有人工审批节点才算(§9.2)', () => { - const draft = linearDraft() - const withCc = insertLinearStep(draft, 'start', 'cc_task') - const ccKey = withCc.nodeKey - const ccDraft = { - ...withCc.draft, - nodes: withCc.draft.nodes.map((node) => - node.nodeKey === ccKey - ? { ...node, actorRules: [{ actorType: 'initiator', actorRef: '' }] } - : node, - ), - } - const warnings = validateWorkflowDomain(ccDraft) - .filter((issue) => issue.level === 'warn') - .map((issue) => issue.message) - expect(warnings.some((message) => message.includes('允许申请人自审'))).toBe(false) - }) -}) - -describe('数据库行 → 领域草稿', () => { - const rows = { - definition: { b_id: 'expense_approval', b_name: '费用报销审批', b_canuse: 1, b_bz: '备注' }, - version: { - b_id: 'expense_approval_v3', - b_definition_id: 'expense_approval', - b_version_no: 3, - b_status: 'draft', - b_canvas_json: '{"zoom":1,"nodes":{"start":{"x":100,"y":200}}}', - }, - bindings: [ - { - b_id: 'bind_1', - b_module_id: 'cw_expense', - b_event: 'submit', - b_priority: 5, - b_canuse: 1, - b_match_json: '{"all":[]}', - }, - ], - nodes: [ - { - b_node_key: 'start', - b_node_type: 'start', - b_name: '开始', - b_config_json: null, - }, - { - b_node_key: 'dept_manager', - b_node_type: 'user_task', - b_name: '部门负责人审批', - b_approval_mode: 'all', - b_reject_mode: 'return_initiator', - b_due_hours: 24, - b_config_json: '{"requiredCount":2,"allowClaim":true,"commentRequired":true}', - }, - ], - edges: [ - { - b_id: 'e1', - b_from_node_key: 'start', - b_to_node_key: 'dept_manager', - b_name: null, - b_condition_json: '{"all":[{"field":"b_amount","operator":"gt","value":10000}]}', - }, - ], - actorRules: [ - { b_node_key: 'dept_manager', b_actor_type: 'user', b_actor_ref: 'u1', b_priority: 10 }, - { b_node_key: 'dept_manager', b_actor_type: 'dept_manager', b_actor_ref: '', b_priority: 20 }, - ], - } - - it('拆出定义、版本与绑定', () => { - const draft = rowsToWorkflowDraft(rows) - expect(draft.schemaVersion).toBe(2) - expect(draft.definition).toMatchObject({ id: 'expense_approval', name: '费用报销审批', enabled: true }) - expect(draft.version).toMatchObject({ id: 'expense_approval_v3', versionNo: 3, status: 'draft' }) - expect(draft.bindings[0]).toMatchObject({ id: 'bind_1', moduleId: 'cw_expense', event: 'submit', priority: 5 }) - }) - - it('坐标从 b_canvas_json 读回,缺失的节点用默认落点', () => { - const draft = rowsToWorkflowDraft(rows) - const start = draft.nodes.find((node) => node.nodeKey === 'start') - const task = draft.nodes.find((node) => node.nodeKey === 'dept_manager') - expect([start.x, start.y]).toEqual([100, 200]) - expect(Number.isFinite(task.x)).toBe(true) - expect(task.y).toBe(240) - }) - - it('列上的审批设置优先于 b_config_json,其余低频配置保留在 config 里', () => { - const task = rowsToWorkflowDraft(rows).nodes.find((node) => node.nodeKey === 'dept_manager') - expect(task.config).toMatchObject({ - approvalMode: 'all', - rejectMode: 'return_initiator', - dueHours: 24, - requiredCount: 2, - allowClaim: true, - commentRequired: true, - }) - }) - - it('审批人规则按节点分组并保留顺序(串行会签顺序来源)', () => { - const task = rowsToWorkflowDraft(rows).nodes.find((node) => node.nodeKey === 'dept_manager') - expect(task.actorRules.map((rule) => rule.actorType)).toEqual(['user', 'dept_manager']) - expect(task.actorRules[0].actorRef).toBe('u1') - expect(task.actorRules[0].fallbackType).toBe('none') - }) - - it('连线条件解析为结构化 AST', () => { - const edge = rowsToWorkflowDraft(rows).edges[0] - expect(edge.edgeKey).toBe('e1') - expect(edge.condition).toEqual({ all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] }) - }) - - it('脏 JSON 不让设计器打不开', () => { - const draft = rowsToWorkflowDraft({ - ...rows, - version: { ...rows.version, b_canvas_json: '{坏数据' }, - nodes: [{ ...rows.nodes[1], b_config_json: 'not-json' }], - }) - expect(draft.nodes[0].config.approvalMode).toBe('all') - }) -}) - -describe('领域草稿 → 落库载荷', () => { - it('坐标只进 canvas,不写进节点行', () => { - const draft = linearDraft() - draft.nodes[1].x = 333 - draft.nodes[1].y = 444 - const payload = draftToSavePayload(draft) - const taskRow = payload.nodes.find((row) => row.b_node_key === 'user_task_1') - expect(taskRow).not.toHaveProperty('x') - expect(taskRow).not.toHaveProperty('b_canvas_json') - expect(payload.canvas.nodes.user_task_1).toEqual({ x: 333, y: 444 }) - }) - - it('审批方式、驳回策略、时限走列,低频配置走 b_config_json 且不重复', () => { - const draft = linearDraft() - const task = draft.nodes[1] - task.config.approvalMode = 'n_of_m' - task.config.requiredCount = 2 - task.config.allowClaim = true - - const row = draftToSavePayload(draft).nodes.find((item) => item.b_node_key === 'user_task_1') - expect(row.b_approval_mode).toBe('n_of_m') - expect(row.b_reject_mode).toBe('terminate') - expect(row.b_due_hours).toBeNull() - - const config = JSON.parse(row.b_config_json) - expect(config.requiredCount).toBe(2) - expect(config.allowClaim).toBe(true) - expect(config).not.toHaveProperty('approvalMode') - expect(config).not.toHaveProperty('rejectMode') - expect(config).not.toHaveProperty('dueHours') - }) - - it('审批人规则拆平为 wf_actor_rule 行并用 b_priority 保存顺序', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [ - { actorType: 'dept_manager', actorRef: 'initiator_dept', fallbackType: 'none' }, - { actorType: 'user', actorRef: 'u9', fallbackType: 'admin' }, - ] - const rules = draftToSavePayload(draft).actor_rules - expect(rules).toHaveLength(2) - expect(rules[0]).toMatchObject({ b_node_key: 'user_task_1', b_actor_type: 'dept_manager', b_priority: 10 }) - expect(rules[1]).toMatchObject({ b_actor_type: 'user', b_actor_ref: 'u9', b_priority: 20 }) - }) - - it('画布布局只保留坐标', () => { - const draft = linearDraft() - const canvas = draftToCanvasJson(draft) - expect(canvas.zoom).toBe(1) - expect(Object.keys(canvas.nodes)).toEqual(['start', 'user_task_1', 'end']) - }) -}) - -describe('领域草稿 ↔ 画布模型', () => { - it('渲染到画布时带上领域类型与条件摘要标签', () => { - const draft = linearDraft() - draft.edges[0].condition = { all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] } - const graph = workflowDomainToLogicFlow(draft) - expect(graph.nodes.map((node) => node.type)).toEqual(['fms-start', 'fms-user-task', 'fms-end']) - expect(graph.nodes[1].id).toBe('user_task_1') - expect(graph.edges[0].text).toBe('b_amount 大于 10000') - }) - - it('从画布合并回草稿时保留业务配置(画布不承载审批设置)', () => { - const draft = linearDraft() - draft.nodes[1].config.approvalMode = 'serial' - draft.nodes[1].actorRules = [{ actorType: 'user', actorRef: 'u1' }] - - // 画布只回传结构:多了一个节点、节点被移动、文本被改 - const graph = { - nodes: [ - { id: 'start', type: 'fms-start', x: 10, y: 20, text: '开始' }, - { id: 'user_task_1', type: 'fms-user-task', x: 300, y: 120, text: '部门负责人' }, - { id: 'user_task_2', type: 'fms-user-task', x: 500, y: 120, text: '人工审批' }, - { id: 'end', type: 'fms-end', x: 700, y: 120, text: '结束' }, - ], - edges: [ - { id: 'edge_1', sourceNodeId: 'start', targetNodeId: 'user_task_1' }, - { id: 'edge_2', sourceNodeId: 'user_task_1', targetNodeId: 'user_task_2' }, - { id: 'edge_3', sourceNodeId: 'user_task_2', targetNodeId: 'end' }, - ], - } - - const merged = logicFlowToWorkflowDraft(graph, draft) - const kept = merged.nodes.find((node) => node.nodeKey === 'user_task_1') - expect(kept.config.approvalMode).toBe('serial') - expect(kept.actorRules).toEqual([{ actorType: 'user', actorRef: 'u1' }]) - expect([kept.x, kept.y]).toEqual([300, 120]) - expect(kept.name).toBe('部门负责人') - // 新节点拿到默认配置,不会继承别人的审批人 - const added = merged.nodes.find((node) => node.nodeKey === 'user_task_2') - expect(added.actorRules).toEqual([]) - expect(merged.edges).toHaveLength(3) - expect(merged.definition.id).toBe('expense_approval') - }) -}) - -/** 字段编码 → 显示名,模拟从 s_field 取 b_name */ -const fieldNameOf = (code) => (code === 'b_amount' ? '金额' : code) - -describe('条件 AST 摘要', () => { - it('无条件时不给标签(默认出口)', () => { - expect(describeCondition(null)).toBe('') - }) - - it('叶子条件用操作符文案拼一句话', () => { - expect(describeCondition({ field: 'b_amount', operator: 'gt', value: 10000 })).toBe( - 'b_amount 大于 10000', - ) - }) - - it('无值操作符不带比较值', () => { - expect(describeCondition({ all: [{ field: 'b_reason', operator: 'is_null' }] })).toBe( - 'b_reason 为空', - ) - }) - - it('区间与多值操作符按各自形态展示', () => { - expect( - describeCondition({ all: [{ field: 'b_amount', operator: 'between', value: [1, 9] }] }), - ).toBe('b_amount 介于 1 ~ 9') - expect( - describeCondition({ all: [{ field: 'b_status', operator: 'in', value: ['a', 'b'] }] }), - ).toBe('b_status 属于 a、b') - }) - - it('all / any 用且或连接,嵌套组加括号', () => { - expect( - describeCondition({ - all: [ - { field: 'b_amount', operator: 'gt', value: 10000 }, - { field: 'b_company', operator: 'eq', value: '华东' }, - ], - }), - ).toBe('b_amount 大于 10000 且 b_company 等于 华东') - - expect( - describeCondition({ - any: [ - { field: 'a', operator: 'eq', value: 1 }, - { any: [{ field: 'b', operator: 'eq', value: 2 }, { field: 'c', operator: 'eq', value: 3 }] }, - ], - }), - ).toBe('a 等于 1 或 (b 等于 2 或 c 等于 3)') - }) - - it('可以按 s_field 把字段编码换成显示名', () => { - expect( - describeCondition({ all: [{ field: 'b_amount', operator: 'gt', value: 1 }] }, { fieldName: fieldNameOf }), - ).toBe('金额 大于 1') - }) -}) - -describe('单链路判定与卡片模式图操作', () => { - it('线性草稿可以进卡片模式,并给出主链路顺序', () => { - const info = inspectLinear(linearDraft()) - expect(info.linear).toBe(true) - expect(info.order).toEqual(['start', 'user_task_1', 'end']) - }) - - it('含条件分支的流程不能进卡片模式,并说明原因', () => { - const draft = linearDraft() - draft.nodes.push({ - nodeKey: 'gw_1', - nodeType: 'exclusive_gateway', - name: '条件分支', - x: 0, - y: 0, - config: {}, - actorRules: [], - }) - const info = inspectLinear(draft) - expect(info.linear).toBe(false) - expect(info.reason).toContain('条件分支') - expect(listLinearSteps(draft)).toEqual([]) - }) - - it('插入节点:A → B 变成 A → 新节点 → B', () => { - const result = insertLinearStep(linearDraft(), 'start', 'cc_task') - expect(result.ok).toBe(true) - const info = inspectLinear(result.draft) - expect(info.order).toEqual(['start', result.nodeKey, 'user_task_1', 'end']) - expect(result.nodeKey).toBe('cc_task_1') - }) - - it('插入节点的落点取前后节点的中点,不会叠在画布同一处', () => { - const draft = linearDraft() - draft.nodes[0].x = 100 - draft.nodes[0].y = 100 - draft.nodes[1].x = 300 - draft.nodes[1].y = 200 - const result = insertLinearStep(draft, 'start', 'user_task') - const node = result.draft.nodes.find((item) => item.nodeKey === result.nodeKey) - expect(node.x).toBe(200) - expect(node.y).toBe(190) - }) - - it('删除节点:A → B → C 变成 A → C,开始与结束不可删', () => { - const result = removeLinearStep(linearDraft(), 'user_task_1') - expect(result.ok).toBe(true) - expect(result.draft.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) - expect(result.draft.edges).toHaveLength(1) - expect(result.draft.edges[0]).toMatchObject({ from: 'start', to: 'end' }) - - expect(removeLinearStep(linearDraft(), 'start').ok).toBe(false) - expect(removeLinearStep(linearDraft(), 'end').ok).toBe(false) - }) - - it('上移下移只改主链路顺序,连线数量不变', () => { - const draft = linearDraft() - const added = insertLinearStep(draft, 'start', 'cc_task').draft - const info = inspectLinear(added) - const moved = moveLinearStep(added, info.order[1], 1) - expect(moved.ok).toBe(true) - expect(inspectLinear(moved.draft).order).toEqual(['start', 'user_task_1', 'cc_task_1', 'end']) - expect(moved.draft.edges).toHaveLength(3) - }) - - it('开始与结束节点不能换位', () => { - expect(moveLinearStep(linearDraft(), 'start', 1).ok).toBe(false) - expect(moveLinearStep(linearDraft(), 'end', -1).ok).toBe(false) - }) - - it('复制节点带上配置与审批人规则,编码重新生成', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'user', actorRef: 'u1' }] - draft.nodes[1].config.commentRequired = true - - const result = duplicateLinearStep(draft, 'user_task_1') - expect(result.ok).toBe(true) - const clone = result.draft.nodes.find((node) => node.nodeKey === result.nodeKey) - expect(clone.actorRules).toEqual([{ actorType: 'user', actorRef: 'u1' }]) - expect(clone.config.commentRequired).toBe(true) - expect(clone.name).toContain('副本') - // 两份规则不是同一个引用,改副本不影响原节点 - expect(clone.actorRules).not.toBe(draft.nodes[1].actorRules) - expect(inspectLinear(result.draft).order).toEqual(['start', 'user_task_1', result.nodeKey, 'end']) - }) - - it('非单链路时所有图操作都拒绝并给出原因', () => { - const draft = linearDraft() - draft.nodes.push({ - nodeKey: 'gw_1', - nodeType: 'exclusive_gateway', - name: '条件分支', - x: 0, - y: 0, - config: {}, - actorRules: [], - }) - for (const result of [ - insertLinearStep(draft, 'start', 'cc_task'), - removeLinearStep(draft, 'user_task_1'), - moveLinearStep(draft, 'user_task_1', 1), - duplicateLinearStep(draft, 'user_task_1'), - ]) { - expect(result.ok).toBe(false) - expect(result.reason).toBeTruthy() - } - }) -}) - -describe('节点摘要(卡片模式)', () => { - it('审批方式带 N 人通过 / 按比例的参数', () => { - expect(describeApprovalMode({ config: { approvalMode: 'single' } })).toBe('单人审批') - expect(describeApprovalMode({ config: { approvalMode: 'n_of_m', requiredCount: 2 } })).toBe( - 'N 人通过 · 2 人', - ) - expect(describeApprovalMode({ config: { approvalMode: 'percentage', requiredRatio: 0.6 } })).toBe( - '按比例通过 · 60%', - ) - }) - - it('审批人摘要:无参类型只显示类型,有参类型显示对象', () => { - const node = { - actorRules: [ - { actorType: 'dept_manager', actorRef: 'initiator_dept' }, - { actorType: 'user', actorRef: 'u1' }, - { actorType: 'initiator', actorRef: '' }, - ], - } - expect(describeActorRules(node, { nameOf: () => '张三' })).toBe( - '部门负责人(发起人所属部门)、指定用户:张三、发起人', - ) - }) - - it('未选择对象时明确提示,不显示空值', () => { - expect(describeActorRules({ actorRules: [{ actorType: 'user', actorRef: '' }] })).toBe( - '指定用户:未选择', - ) - }) -}) - -describe('发布前校验', () => { - it('默认模板缺审批人、缺绑定信息时逐条报错', () => { - const draft = linearDraft() - const errors = errorsOf(draft) - expect(errors).toContain('「审批」未配置审批人规则') - expect(errors.some((message) => message.includes('绑定 1'))).toBe(false) - }) - - it('没有绑定就不能发布', () => { - const draft = linearDraft() - draft.bindings = [] - expect(errorsOf(draft)).toContain('未配置流程绑定:流程无法被任何业务单据触发') - }) - - it('流程编码与名称必填', () => { - const draft = linearDraft() - draft.definition.id = '' - draft.definition.name = ' ' - const errors = errorsOf(draft) - expect(errors).toContain('流程编码为空') - expect(errors).toContain('流程名称为空') - }) - - it('N 人通过与按比例通过必须给合法参数', () => { - const draft = linearDraft() - const task = draft.nodes[1] - task.actorRules = [{ actorType: 'initiator', actorRef: '' }] - task.config.allowInitiator = true - task.config.approvalMode = 'n_of_m' - expect(errorsOf(draft)).toContain('「审批」选择 N 人通过但未填写通过人数') - - task.config.approvalMode = 'percentage' - task.config.requiredRatio = 1.5 - expect(errorsOf(draft)).toContain('「审批」的通过比例必须在 0 到 1 之间') - }) - - it('把发起人当审批人但未开自审时给警告(§9.2)', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - const warnings = validateWorkflowDomain(draft) - .filter((issue) => issue.level === 'warn') - .map((issue) => issue.message) - expect(warnings.some((message) => message.includes('允许申请人自审'))).toBe(true) - }) - - it('条件分支必须有默认出口', () => { - const draft = linearDraft() - draft.nodes = [ - draft.nodes[0], - { nodeKey: 'gw_1', nodeType: 'exclusive_gateway', name: '金额判断', x: 0, y: 0, config: {}, actorRules: [] }, - draft.nodes[1], - draft.nodes[2], - ] - draft.edges = [ - { edgeKey: 'e1', from: 'start', to: 'gw_1', name: '', condition: null }, - { - edgeKey: 'e2', - from: 'gw_1', - to: 'user_task_1', - name: '', - condition: { all: [{ field: 'b_amount', operator: 'gt', value: 1 }] }, - }, - { edgeKey: 'e3', from: 'user_task_1', to: 'end', name: '', condition: null }, - ] - expect(errorsOf(draft)).toContain('条件分支「金额判断」缺少默认出口(需且仅需一条无条件连线)') - }) - - it('条件字段必须来自绑定模块的 s_field', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - draft.nodes[1].config.allowInitiator = true - draft.edges[1].condition = { all: [{ field: 'b_unknown', operator: 'eq', value: 1 }] } - expect(errorsOf(draft, { fieldCodes: ['b_amount'] })).toContain( - '连线条件引用的字段 b_unknown 不在绑定模块的字段里', - ) - // 不传字段清单时跳过字段存在性校验(前端拿不到 s_field 的场景) - expect(errorsOf(draft)).not.toContain('连线条件引用的字段 b_unknown 不在绑定模块的字段里') - }) - - it('条件缺少比较值时报错', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - draft.nodes[1].config.allowInitiator = true - draft.edges[1].condition = { all: [{ field: 'b_amount', operator: 'gt', value: '' }] } - expect(errorsOf(draft)).toContain('条件字段 b_amount 未填写比较值') - }) - - it('环形路径与无出口节点都报错', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - draft.nodes[1].config.allowInitiator = true - // 把结束节点摘掉,人工审批指向自己形成环 - draft.nodes = draft.nodes.slice(0, 2) - draft.edges = [{ edgeKey: 'e1', from: 'user_task_1', to: 'user_task_1', name: '', condition: null }] - const errors = errorsOf(draft) - expect(errors).toContain('缺少结束节点') - expect(errors.some((message) => message.includes('环形路径'))).toBe(true) - }) - - it('不可达节点给警告', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - draft.nodes[1].config.allowInitiator = true - draft.nodes.push({ - nodeKey: 'user_task_9', - nodeType: 'user_task', - name: '孤立节点', - x: 0, - y: 0, - config: {}, - actorRules: [], - }) - const warnings = validateWorkflowDomain(draft) - .filter((issue) => issue.level === 'warn') - .map((issue) => issue.message) - expect(warnings).toContain('节点「孤立节点」从开始节点不可达') - }) - - it('同一模块同一事件多条启用绑定给出复核提示', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'initiator', actorRef: '' }] - draft.nodes[1].config.allowInitiator = true - draft.bindings.push({ ...draft.bindings[0], id: 'bind_2', priority: 1 }) - const warnings = validateWorkflowDomain(draft) - .filter((issue) => issue.level === 'warn') - .map((issue) => issue.message) - expect(warnings.some((message) => message.includes('条启用绑定'))).toBe(true) - }) - - it('完全配置好的线性流程没有错误', () => { - const draft = linearDraft() - draft.nodes[1].actorRules = [{ actorType: 'dept_manager', actorRef: 'initiator_dept' }] - expect(errorsOf(draft)).toEqual([]) - }) -}) - -/* ============================================================================ - 流程树投影与编译(《FMS审批流程设置器交互重构设计》§5.1 / §5.2) - - 阶段 1 的 fixtures:线性、两个条件、三条件排序、分支后公共审批、嵌套条件, - 以及并行 / 缺默认出口 / 重复默认出口 / 环 / 交叉边这些必须被保护掉的拓扑。 - ============================================================================ */ - -/** 节点构造器:树投影只关心拓扑与条件,坐标与配置给默认值 */ -const N = (nodeKey, nodeType, name) => ({ - nodeKey, - nodeType, - name: name || nodeKey, - x: 0, - y: 0, - config: defaultNodeConfig(nodeType), - actorRules: [], -}) - -/** 连线构造器 */ -const E = (edgeKey, from, to, condition = null, priority = 1) => ({ - edgeKey, - from, - to, - name: '', - condition, - priority, -}) - -const AMOUNT_GT_1W = { all: [{ field: 'b_amount', operator: 'gt', value: 10000 }] } -const AMOUNT_GT_5W = { all: [{ field: 'b_amount', operator: 'gt', value: 50000 }] } -const REGION_EAST = { all: [{ field: 'b_region', operator: 'eq', value: 'east' }] } - -/** 组装一份可直接投影的草稿 */ -function draftOf(nodes, edges) { - const draft = createEmptyDraft() - draft.definition = { id: 'expense_approval', name: '费用报销审批', i18n: null, enabled: true, remark: '' } - draft.version = { id: 'expense_approval_v3', versionNo: 3, status: 'draft' } - draft.bindings = [createBinding('cw_expense', 'submit')] - draft.nodes = nodes - draft.edges = edges - return draft -} - -/** 开始 → 审批 → 结束 */ -function linearFixture() { - return draftOf( - [N('start', 'start'), N('user_task_1', 'user_task', '部门负责人审批'), N('end', 'end')], - [E('e1', 'start', 'user_task_1'), E('e2', 'user_task_1', 'end')], - ) -} - -/** 两个条件:金额大于 1 万走财务,其他情况抄送归档,两者在结束节点汇合 */ -function branchFixture() { - return draftOf( - [ - N('start', 'start'), - N('user_task_1', 'user_task', '部门负责人审批'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('user_task_2', 'user_task', '财务审批'), - N('cc_1', 'cc_task', '抄送归档'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'user_task_1'), - E('e2', 'user_task_1', 'gw_1'), - E('e3', 'gw_1', 'user_task_2', AMOUNT_GT_1W, 1), - E('e4', 'gw_1', 'cc_1', null, 2), - E('e5', 'user_task_2', 'end'), - E('e6', 'cc_1', 'end'), - ], - ) -} - -/** 三条分支汇入公共审批节点 join,再走结束 */ -function mergeFixture() { - return draftOf( - [ - N('start', 'start'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('user_task_a', 'user_task', '财务审批'), - N('user_task_b', 'user_task', '法务审批'), - N('cc_c', 'cc_task', '抄送财务'), - N('user_task_join', 'user_task', '总经理审批'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'gw_1'), - E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), - E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), - E('e4', 'gw_1', 'cc_c', null, 3), - E('e5', 'user_task_a', 'user_task_join'), - E('e6', 'user_task_b', 'user_task_join'), - E('e7', 'cc_c', 'user_task_join'), - E('e8', 'user_task_join', 'end'), - ], - ) -} - -/** 嵌套条件:外层分支里再开一层条件 */ -function nestedFixture() { - return draftOf( - [ - N('start', 'start'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('gw_2', 'exclusive_gateway', '区域判断'), - N('user_task_x', 'user_task', '华东区审批'), - N('user_task_y', 'user_task', '其他区审批'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'gw_1'), - E('e2', 'gw_1', 'gw_2', AMOUNT_GT_1W, 1), - E('e3', 'gw_1', 'user_task_y', null, 2), - E('e4', 'gw_2', 'user_task_x', REGION_EAST, 1), - E('e5', 'gw_2', 'end', null, 2), - E('e6', 'user_task_x', 'end'), - E('e7', 'user_task_y', 'end'), - ], - ) -} - -/** 并行网关:树形编辑器表达不了(§3.2) */ -function parallelFixture() { - return draftOf( - [ - N('start', 'start'), - N('pg_1', 'parallel_gateway', '并行分支'), - N('user_task_a', 'user_task', '财务审批'), - N('user_task_b', 'user_task', '法务审批'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'pg_1'), - E('e2', 'pg_1', 'user_task_a'), - E('e3', 'pg_1', 'user_task_b', null, 2), - E('e4', 'user_task_a', 'end'), - E('e5', 'user_task_b', 'end'), - ], - ) -} - -/** 缺少默认出口 */ -function missingDefaultFixture() { - return draftOf( - [ - N('start', 'start'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('user_task_a', 'user_task', '财务审批'), - N('user_task_b', 'user_task', '法务审批'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'gw_1'), - E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), - E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), - E('e4', 'user_task_a', 'end'), - E('e5', 'user_task_b', 'end'), - ], - ) -} - -/** 两条默认出口:显式条件全被清成 null */ -function duplicateDefaultFixture() { - const draft = missingDefaultFixture() - draft.edges = draft.edges.map((edge) => - edge.edgeKey === 'e2' || edge.edgeKey === 'e3' ? { ...edge, condition: null } : edge, - ) - return draft -} - -/** 环:审批节点回连网关,网关因此有两条入线 */ -function cycleFixture() { - return draftOf( - [ - N('start', 'start'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('user_task_a', 'user_task', '财务审批'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'gw_1'), - E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), - E('e3', 'gw_1', 'end', null, 2), - E('e4', 'user_task_a', 'gw_1'), - ], - ) -} - -/** 交叉边:B 落在 A 的专属路径里,又不是汇合点 */ -function crossFixture() { - return draftOf( - [ - N('start', 'start'), - N('gw_1', 'exclusive_gateway', '金额判断'), - N('user_task_a', 'user_task', '财务审批'), - N('user_task_b', 'user_task', '法务审批'), - N('cc_c', 'cc_task', '抄送财务'), - N('end', 'end'), - ], - [ - E('e1', 'start', 'gw_1'), - E('e2', 'gw_1', 'user_task_a', AMOUNT_GT_1W, 1), - E('e3', 'gw_1', 'user_task_b', AMOUNT_GT_5W, 2), - E('e4', 'gw_1', 'cc_c', null, 3), - E('e5', 'user_task_a', 'user_task_b'), - E('e6', 'user_task_b', 'end'), - E('e7', 'cc_c', 'end'), - ], - ) -} - -/** 校验结果里的全部文案 */ -const messagesOf = (draft) => validateWorkflowDomain(draft).map((item) => item.message) - -/** 取树的路径,便于断言 */ -function pathOf(tree) { - const keys = [] - let item = tree - while (item) { - keys.push(item.kind === 'branch' ? item.gatewayKey : item.nodeKey) - item = item.next - } - return keys -} - -/** 连线按编码建索引:编译产出的数组是后序,顺序与草稿不同但语义无差别 */ -function edgeMapOf(draft) { - return Object.fromEntries( - normalizeEdgePriorities(draft.edges).map((edge) => [ - edge.edgeKey, - { from: edge.from, to: edge.to, priority: edge.priority, condition: edge.condition }, - ]), - ) -} - -describe('流程树投影:可表达的拓扑', () => { - it('线性流程投影成一条 step 链', () => { - const result = workflowDraftToTree(linearFixture()) - expect(result.ok).toBe(true) - expect(pathOf(result.tree)).toEqual(['start', 'user_task_1', 'end']) - expect(result.tree.next.next.next).toBeNull() - }) - - it('两个条件:显式条件在前,「其他情况」固定最后,child 与 next 各归各位', () => { - const result = workflowDraftToTree(branchFixture()) - expect(result.ok).toBe(true) - - const branch = result.tree.next.next - expect(branch.kind).toBe('branch') - expect(branch.gatewayKey).toBe('gw_1') - expect(branch.branches.map((item) => item.edgeKey)).toEqual(['e3', 'e4']) - expect(branch.branches.map((item) => item.priority)).toEqual([1, 2]) - expect(branch.branches[0].condition).toEqual(AMOUNT_GT_1W) - expect(branch.branches[1].condition).toBeNull() - expect(branch.branches[0].child).toEqual({ kind: 'step', nodeKey: 'user_task_2', next: null }) - expect(branch.branches[1].child).toEqual({ kind: 'step', nodeKey: 'cc_1', next: null }) - // 两条分支在结束节点汇合,结束只渲染一次 - expect(pathOf(branch.next)).toEqual(['end']) - }) - - it('三条件按 b_priority 排序,默认出口即使优先级最大也固定最后', () => { - const draft = mergeFixture() - // 打乱优先级:法务 1、抄送 2、财务 3 - draft.edges = draft.edges.map((edge) => { - if (edge.edgeKey === 'e2') return { ...edge, priority: 3 } - if (edge.edgeKey === 'e3') return { ...edge, priority: 1 } - if (edge.edgeKey === 'e4') return { ...edge, priority: 2 } - return edge - }) - - const result = workflowDraftToTree(draft) - expect(result.ok).toBe(true) - const branch = result.tree.next - expect(branch.branches.map((item) => item.edgeKey)).toEqual(['e3', 'e2', 'e4']) - // 投影里的优先级归一化成显示顺序 - expect(branch.branches.map((item) => item.priority)).toEqual([1, 2, 3]) - expect(branch.branches[2].condition).toBeNull() - }) - - it('分支后公共审批:三条分支各自独立,next 指向汇合点', () => { - const result = workflowDraftToTree(mergeFixture()) - expect(result.ok).toBe(true) - - const branch = result.tree.next - expect(branch.kind).toBe('branch') - expect(branch.branches.map((item) => item.child?.nodeKey)).toEqual([ - 'user_task_a', - 'user_task_b', - 'cc_c', - ]) - expect(pathOf(branch.next)).toEqual(['user_task_join', 'end']) - }) - - it('嵌套条件:内层条件块成为外层分支的 child', () => { - const result = workflowDraftToTree(nestedFixture()) - expect(result.ok).toBe(true) - - const outer = result.tree.next - expect(outer.kind).toBe('branch') - expect(outer.branches.map((item) => item.edgeKey)).toEqual(['e2', 'e3']) - - const inner = outer.branches[0].child - expect(inner.kind).toBe('branch') - expect(inner.gatewayKey).toBe('gw_2') - expect(inner.branches.map((item) => item.edgeKey)).toEqual(['e4', 'e5']) - expect(inner.branches[0].child).toEqual({ kind: 'step', nodeKey: 'user_task_x', next: null }) - // 内层的汇合点就是外层分支的出口(结束节点),内层不再重复渲染 next - expect(inner.next).toBeNull() - expect(outer.branches[1].child).toEqual({ kind: 'step', nodeKey: 'user_task_y', next: null }) - expect(pathOf(outer.next)).toEqual(['end']) - }) -}) - -describe('流程树编译:写回领域草稿', () => { - const fixtures = [ - ['线性流程', linearFixture], - ['两个条件', branchFixture], - ['分支后公共审批', mergeFixture], - ['嵌套条件', nestedFixture], - ] - - it.each(fixtures)('%s:加载 → 构树 → 编译,拓扑与编码完全一致', (_name, make) => { - const draft = make() - const result = workflowDraftToTree(draft) - expect(result.ok).toBe(true) - - const compiled = workflowTreeToDraft(result.tree, draft) - expect(treeSignature(workflowDraftToTree(compiled).tree)).toBe(result.signature) - expect(compiled.nodes.map((node) => node.nodeKey).toSorted()).toEqual( - draft.nodes.map((node) => node.nodeKey).toSorted(), - ) - // 连线与条件按编码比对:顺序无关,但拓扑、优先级、AST 必须逐项一致 - expect(edgeMapOf(compiled)).toEqual(edgeMapOf(draft)) - }) - - it('编译保留节点配置与审批人规则,不会因为过一趟树就丢配置', () => { - const draft = linearFixture() - draft.nodes[1].config.approvalMode = 'all' - draft.nodes[1].config.commentRequired = true - draft.nodes[1].actorRules = [{ actorType: 'dept_manager', actorRef: 'initiator_dept' }] - - const compiled = workflowTreeToDraft(workflowDraftToTree(draft).tree, draft) - const kept = compiled.nodes.find((node) => node.nodeKey === 'user_task_1') - expect(kept.config.approvalMode).toBe('all') - expect(kept.config.commentRequired).toBe(true) - expect(kept.actorRules).toEqual([{ actorType: 'dept_manager', actorRef: 'initiator_dept' }]) - }) - - it('从树里删掉节点后编译:节点与它的两条连线一起消失,前后自动接上', () => { - const draft = linearFixture() - const result = workflowDraftToTree(draft) - // start → end:把中间那个审批节点摘掉 - result.tree.next = result.tree.next.next - - const compiled = workflowTreeToDraft(result.tree, draft) - expect(compiled.nodes.map((node) => node.nodeKey)).toEqual(['start', 'end']) - expect(compiled.edges).toHaveLength(1) - expect(compiled.edges[0]).toMatchObject({ from: 'start', to: 'end', condition: null }) - }) - - it('新插入的节点通过 extraNodes 提供定义后能编译进草稿', () => { - const draft = linearFixture() - const result = workflowDraftToTree(draft) - const inserted = { - ...N('user_task_9', 'user_task', '新增审批'), - config: { ...defaultNodeConfig('user_task'), approvalMode: 'any' }, - } - // 插到 start 与 user_task_1 之间 - result.tree.next = { kind: 'step', nodeKey: 'user_task_9', next: result.tree.next } - - const compiled = workflowTreeToDraft(result.tree, draft, [inserted]) - expect(compiled.nodes.map((node) => node.nodeKey)).toEqual(['start', 'user_task_9', 'user_task_1', 'end']) - expect(compiled.nodes.find((node) => node.nodeKey === 'user_task_9').config.approvalMode).toBe('any') - expect( - compiled.edges - .map((edge) => [edge.from, edge.to]) - .toSorted((a, b) => a[0].localeCompare(b[0])), - ).toEqual([ - ['start', 'user_task_9'], - ['user_task_1', 'end'], - ['user_task_9', 'user_task_1'], - ]) - }) - - it('分支顺序变化会改写成 b_priority,默认出口始终最后', () => { - const draft = mergeFixture() - const result = workflowDraftToTree(draft) - // 把第 2 条显式条件换到第 1 位 - const branches = result.tree.next.branches - result.tree.next.branches = [branches[1], branches[0], branches[2]] - - const compiled = workflowTreeToDraft(result.tree, draft) - const gatewayEdges = outgoingEdgesOf(compiled, 'gw_1') - expect(gatewayEdges.map((edge) => edge.edgeKey)).toEqual(['e3', 'e2', 'e4']) - expect(gatewayEdges.map((edge) => edge.priority)).toEqual([1, 2, 3]) - expect(gatewayEdges[2].condition).toBeNull() - }) -}) - -describe('流程树投影:必须保护的拓扑', () => { - const unsupportedCases = [ - ['并行网关', parallelFixture, '并行分支'], - ['缺少默认出口', missingDefaultFixture, '其他情况'], - ['两条默认出口', duplicateDefaultFixture, '其他情况'], - ['环(审批节点回连网关)', cycleFixture, '环形路径'], - ['交叉连线', crossFixture, '交叉'], - ] - - it.each(unsupportedCases)('%s:返回只读保护而不是压平后的树', (_name, make, keyword) => { - const result = workflowDraftToTree(make()) - expect(result.ok).toBe(false) - expect(result.reason).toContain(keyword) - expect(result.tree).toBeUndefined() - }) - - it('服务节点同样受保护', () => { - const draft = linearFixture() - draft.nodes.splice(1, 0, N('service_1', 'service_task', '回调服务')) - draft.edges = [ - E('e1', 'start', 'service_1'), - E('e2', 'service_1', 'user_task_1'), - E('e3', 'user_task_1', 'end'), - ] - const result = workflowDraftToTree(draft) - expect(result.ok).toBe(false) - expect(result.reason).toContain('服务节点') - }) - - it('存在不可达节点时不构树', () => { - const draft = linearFixture() - draft.nodes.push(N('user_task_9', 'user_task', '孤立节点')) - const result = workflowDraftToTree(draft) - expect(result.ok).toBe(false) - expect(result.reason).toContain('孤立节点') - }) -}) - -describe('校验扩展(§5.2.6)', () => { - it('结束节点有出线是错误,不再是警告', () => { - const draft = linearFixture() - draft.edges.push(E('e3', 'end', 'start')) - const issues = validateWorkflowDomain(draft) - expect(issues.some((item) => item.level === 'error' && item.message.includes('结束节点'))).toBe(true) - }) - - it('普通节点有多条出线是错误', () => { - const draft = linearFixture() - draft.edges.push(E('e3', 'user_task_1', 'start')) - expect(messagesOf(draft)).toContain('节点「部门负责人审批」有多条出线,只有条件分支可以分支') - }) - - it('普通连线带条件是错误,不再是警告', () => { - const draft = linearFixture() - draft.edges[1] = { ...draft.edges[1], condition: AMOUNT_GT_1W } - expect(messagesOf(draft)).toContain('「部门负责人审批」不是条件分支,其出线不应带条件') - }) - - it('条件分支缺少默认出口报错', () => { - expect(messagesOf(missingDefaultFixture())).toContain( - '条件分支「金额判断」缺少默认出口(需且仅需一条无条件连线)', - ) - }) - - it('条件分支有两条默认出口报错', () => { - expect(messagesOf(duplicateDefaultFixture())).toContain('条件分支「金额判断」有 2 条默认出口,只允许一条') - }) - - it('条件分支只有一条出线报错', () => { - const draft = branchFixture() - draft.edges = draft.edges.filter((edge) => edge.edgeKey !== 'e4') - expect(messagesOf(draft)).toContain('条件分支「金额判断」至少要有两条分支') - }) - - it('分支优先级重复时提示复核(判断顺序会退化成连线顺序)', () => { - const draft = branchFixture() - draft.edges = draft.edges.map((edge) => (edge.edgeKey === 'e4' ? { ...edge, priority: 1 } : edge)) - const warnings = validateWorkflowDomain(draft) - .filter((item) => item.level === 'warn') - .map((item) => item.message) - expect(warnings).toContain('条件分支「金额判断」的分支优先级有重复,将按连线顺序确定判断顺序') - }) - - it('「其他情况」不在最后时给出可自愈的警告(保存时会重排)', () => { - const draft = branchFixture() - draft.edges = draft.edges.map((edge) => { - if (edge.edgeKey === 'e3') return { ...edge, priority: 2 } - if (edge.edgeKey === 'e4') return { ...edge, priority: 1 } - return edge - }) - const warnings = validateWorkflowDomain(draft) - .filter((item) => item.level === 'warn') - .map((item) => item.message) - expect(warnings).toContain('条件分支「金额判断」的「其他情况」不在最后,保存时会按规范重排') - }) -}) - -describe('边优先级归一化', () => { - it('按来源节点归一化成 1..n,跳号与全局序号都会被收敛', () => { - const edges = [ - E('a1', 'gw', 'x', AMOUNT_GT_1W, 30), - E('a2', 'gw', 'y', null, 10), - E('b1', 'start', 'gw', null, 999), - ] - const normalized = normalizeEdgePriorities(edges) - expect(normalized.map((edge) => [edge.edgeKey, edge.priority])).toEqual([ - ['a1', 2], - ['a2', 1], - ['b1', 1], - ]) - }) - - it('落库载荷写的是归一化后的优先级,不再是全局序号', () => { - const draft = branchFixture() - const payload = draftToSavePayload(draft) - const gatewayEdges = payload.edges.filter((edge) => edge.b_from_node_key === 'gw_1') - expect(gatewayEdges.map((edge) => [edge.b_id, edge.b_priority])).toEqual([ - ['e3', 10], - ['e4', 20], - ]) - }) - - it('outgoingEdgesOf 按判断顺序返回出线', () => { - const draft = mergeFixture() - expect(outgoingEdgesOf(draft, 'gw_1').map((edge) => edge.edgeKey)).toEqual(['e2', 'e3', 'e4']) - }) -}) diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/design.vue b/code/fms-workflow-refactor-backup-20260923/workflow/design.vue deleted file mode 100644 index f32bfa06..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/design.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowActorSelector.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowActorSelector.vue deleted file mode 100644 index 358d1576..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowActorSelector.vue +++ /dev/null @@ -1,231 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowBasicPanel.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowBasicPanel.vue deleted file mode 100644 index d8787dbe..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowBasicPanel.vue +++ /dev/null @@ -1,376 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCanvas.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCanvas.vue deleted file mode 100644 index db1853e0..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCanvas.vue +++ /dev/null @@ -1,214 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowConditionBuilder.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowConditionBuilder.vue deleted file mode 100644 index 963341c3..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowConditionBuilder.vue +++ /dev/null @@ -1,361 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCreateDialog.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCreateDialog.vue deleted file mode 100644 index 7dfa19e8..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowCreateDialog.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowDesigner.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowDesigner.vue deleted file mode 100644 index 6c50236b..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowDesigner.vue +++ /dev/null @@ -1,692 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowModeTabs.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowModeTabs.vue deleted file mode 100644 index 902247ce..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowModeTabs.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowNodePanel.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowNodePanel.vue deleted file mode 100644 index 6aceca09..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowNodePanel.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowPropertyDrawer.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowPropertyDrawer.vue deleted file mode 100644 index 4d142d0d..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowPropertyDrawer.vue +++ /dev/null @@ -1,441 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowSimulationPanel.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowSimulationPanel.vue deleted file mode 100644 index 1add6742..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowSimulationPanel.vue +++ /dev/null @@ -1,312 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepCard.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepCard.vue deleted file mode 100644 index db0b1e18..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepCard.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepEditor.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepEditor.vue deleted file mode 100644 index d2a7ee86..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepEditor.vue +++ /dev/null @@ -1,213 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepInsert.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepInsert.vue deleted file mode 100644 index ded0eb2e..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowStepInsert.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowToolbar.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowToolbar.vue deleted file mode 100644 index 11edc71b..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowToolbar.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowValidationPanel.vue b/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowValidationPanel.vue deleted file mode 100644 index e2fff476..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/WorkflowValidationPanel.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - - - diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/api.js b/code/fms-workflow-refactor-backup-20260923/workflow/designer/api.js deleted file mode 100644 index f5f1ec99..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/api.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * 流程设计器 —— 数据读取与流程服务接口。 - * - * 分两类: - * 1. 只读查询走通用接口(/data/loaddata),不新增后端接口。读表没有「必须走流程服务」 - * 的约束,直接查表最省事。 - * 2. 草稿保存、校验、模拟、发布、停用走流程服务(《FMS工作流与审批设计》§13 / - * 《FMS审批流程交互重构设计》§6.3)。wf_* 的写入**不允许**走通用保存接口, - * 否则发布校验、版本快照、发布后不可改、幂等全部落空。 - * - * 流程服务尚未实现,接口会以 404 失败。调用方用 isWorkflowServiceMissing 识别后 - * 明确提示「流程服务未接入」,绝不能把失败伪装成保存成功(§12.7)。 - */ -import { get, post, put } from '@/services/http' -import { loadDataApi } from '@/services/api' -import { rowsToWorkflowDraft } from './domain' - -/** - * 流程编码会拼进查询条件里,先净化再做字符串拼接。 - * 合法编码形如 expense_approval,只保留字母数字下划线连字符。 - */ -function safeCode(value) { - return String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '') -} - -function quote(value) { - return `N'${String(value).replaceAll("'", "''")}'` -} - -async function query(viewName, condition, orderBy, searchColumns) { - const response = await loadDataApi(viewName, condition, orderBy, searchColumns) - return response?.data || [] -} - -/* ---------------------------------------------------------------------------- - 流程服务可用性 - ---------------------------------------------------------------------------- */ - -/** - * 判断错误是否属于「流程服务未接入」。 - * 后端没有对应映射时返回 404;服务完全不可达时 axios 给不出 status。 - */ -export function isWorkflowServiceMissing(error) { - const code = error?.code - return code === 404 || code === -1 || code === undefined || code === null -} - -/** 流程服务未接入时统一的提示文案 */ -export const WORKFLOW_SERVICE_MISSING_MESSAGE = - '流程服务未接入:保存 / 校验 / 模拟 / 发布需要后端工作流服务(见《FMS审批流程交互重构设计》§6.3),当前只在本页内存中编辑' - -/* ---------------------------------------------------------------------------- - 读取流程草稿 - ---------------------------------------------------------------------------- */ - -/** - * 按流程编码读回领域草稿。 - * 版本选择:优先取已发布版本,没有则取版本号最大的那条(草稿也能打开来编辑)。 - * @param {string} definitionId wf_definition.b_id - * @returns {Promise} 领域草稿(形状见 domain.js) - * @throws {Error} 流程或版本不存在时抛出,调用方自行兜底 - */ -export async function loadWorkflowDraft(definitionId) { - const code = safeCode(definitionId) - if (!code) throw new Error('流程编码为空') - - const definitions = await query('wf_definition', `b_id = ${quote(code)}`, null) - if (!definitions.length) throw new Error(`流程「${definitionId}」不存在`) - const definition = definitions[0] - - const versions = await query('wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC') - if (!versions.length) throw new Error(`流程「${definitionId}」还没有版本`) - const version = versions.find((row) => row.b_status === 'published') || versions[0] - - const versionId = version.b_id - const [bindings, nodes, edges, actorRules] = await Promise.all([ - query('wf_binding', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'), - query('wf_node', `b_version_id = ${quote(versionId)}`, 'b_xh ASC, b_node_key ASC'), - query('wf_edge', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'), - query('wf_actor_rule', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'), - ]) - - return rowsToWorkflowDraft({ definition, version, bindings, nodes, edges, actorRules }) -} - -/** - * 流程定义下的版本列表(版本记录页签、列表页的版本记录弹窗)。 - * 走 v_wf_version 视图:节点数、定义名、状态中文都是视图派生列,不用前端再算一遍。 - */ -export async function loadVersionList(definitionId) { - const code = safeCode(definitionId) - if (!code) return [] - return query('v_wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC') -} - -/* ---------------------------------------------------------------------------- - 选择器数据源(§8.4) - ---------------------------------------------------------------------------- */ - -/** - * 可绑定流程的业务模块。 - * 只取有保存表的 data / virtual 模块:没有保存表的模块(纯视图)不会产生单据, - * 也就不需要送审;工作流自己的模块(/workflow/ 路径下)不能绑定自己。 - */ -export async function loadBindableModules() { - const condition = - "b_canuse = 1 and b_module_type in ('data','virtual') and b_save_table is not null and b_path not like '/workflow/%'" - return query('s_module', condition, 'b_path ASC, b_xh ASC, b_id ASC', [ - 'b_id', - 'b_name', - 'b_path', - 'b_module_type', - 'b_save_table', - ]) -} - -/** - * 模块字段清单(条件字段、业务字段用户、业务状态字段都用它)。 - * 字段编码与显示名来自 s_field,是条件 AST 里 field 的合法取值来源。 - */ -export async function loadModuleFields(moduleId) { - const code = safeCode(moduleId) - if (!code) return [] - return query('s_field', `b_module_id = ${quote(code)} and b_canuse = 1`, 'b_xh ASC, b_field ASC', [ - 'b_field', - 'b_name', - 'b_type', - 'b_i18n', - ]) -} - -/** - * 用户选择器的候选用户。 - * 直接读 b_user 而不是 v_b_user —— 后者带明文密码列,选择器不需要也不该拿到。 - */ -export async function loadUserOptions(keyword = '') { - const text = String(keyword ?? '').trim().replace(/[%_[\]^]/g, '') - const condition = text - ? `b_canuse = 1 and (b_id like N'%${text}%' or b_name like N'%${text}%')` - : 'b_canuse = 1' - return query('b_user', condition, 'b_id ASC', ['b_id', 'b_name', 'b_dept_id']) -} - -/** 部门清单:给用户选择器补部门名、给「部门负责人」来源做展示 */ -export async function loadDeptOptions() { - return query('b_dept', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC', ['b_id', 'b_name']) -} - -/** - * 用户选择器候选项(编码 + 姓名 + 部门名)。 - * 整份清单只拉一次并在整个设计器内共享:Select 自带本地筛选,够用且少一次往返。 - * 失败不缓存,下次调用重新尝试。 - */ -let userOptionCache = null -let userOptionPromise = null - -export function loadUserOptionCache() { - if (userOptionCache) return Promise.resolve(userOptionCache) - if (!userOptionPromise) { - userOptionPromise = (async () => { - const [users, depts] = await Promise.all([loadUserOptions(), loadDeptOptions()]) - const deptName = new Map(depts.map((row) => [row.b_id, row.b_name])) - return users.map((row) => { - const name = row.b_name || row.b_id - const dept = row.b_dept_id ? deptName.get(row.b_dept_id) || row.b_dept_id : '' - return { value: row.b_id, label: dept ? `${name}(${dept})` : name } - }) - })() - userOptionPromise = userOptionPromise.catch((error) => { - userOptionPromise = null - throw error - }) - } - return userOptionPromise.then((options) => { - userOptionCache = options - return options - }) -} - -/** 测试与主体切换时清缓存(避免换机构后还显示上一个机构的人) */ -export function clearWorkflowCaches() { - userOptionCache = null - userOptionPromise = null -} - -/** 组织角色 / 岗位主数据尚未建表(见 §7.4 缺口清单),只能返回空列表并提示手动填编码 */ -export const ORG_MASTER_DATA_MISSING = { - position: '岗位主数据(s_position)尚未建表', - org_role: '组织角色主数据尚未建表', -} - -/* ---------------------------------------------------------------------------- - 流程服务(§6.3) - ---------------------------------------------------------------------------- */ - -/** 读取草稿(带绑定),流程服务版本 */ -export function fetchDraftApi(definitionId) { - return get(`/workflow/definitions/${encodeURIComponent(safeCode(definitionId))}/draft`) -} - -/** 新建流程定义与初始草稿 */ -export function createDefinitionApi(payload) { - return post('/workflow/definitions', payload) -} - -/** - * 保存草稿:事务内替换该版本的节点 / 连线 / 规则与画布布局。 - * expectedUpdatedAt 用于并发保护(§7.3,两种方案只选一种,这里用 b_updated_at)。 - */ -export function saveDraftApi(versionId, { draft, expectedUpdatedAt, requestId }) { - return put(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/draft`, { - draft, - expected_updated_at: expectedUpdatedAt ?? null, - request_id: requestId || '', - }) -} - -/** 服务端校验(前端校验只是体验,这里才是最终规则) */ -export function validateVersionApi(versionId, draft) { - return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/validate`, { draft }) -} - -/** 发布前模拟:输入业务样例,返回命中的绑定、条件、解析出的审批人与待办 */ -export function simulateVersionApi(versionId, sample) { - return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/simulate`, { sample }) -} - -/** 发布:固定版本摘要,写发布人与发布时间,之后禁止原地编辑 */ -export function publishVersionApi(versionId, { requestId, expectedUpdatedAt } = {}) { - return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/publish`, { - request_id: requestId || '', - expected_updated_at: expectedUpdatedAt ?? null, - }) -} - -/** 停用:不再接受新实例,运行中的实例继续走完 */ -export function retireVersionApi(versionId) { - return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/retire`, {}) -} - -/** 版本下的绑定列表 */ -export function fetchBindingsApi(versionId) { - return get(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/bindings`) -} - -/** 幂等键:重复点击保存 / 发布时后端据此去重(§12.1) */ -export function createRequestId(prefix = 'req') { - const random = Math.random().toString(36).slice(2, 10) - return `${prefix}-${Date.now()}-${random}` -} diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/constants.js b/code/fms-workflow-refactor-backup-20260923/workflow/designer/constants.js deleted file mode 100644 index 0cee66d2..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/constants.js +++ /dev/null @@ -1,308 +0,0 @@ -/** - * 流程设计器 —— 枚举与节点元数据。 - * - * 取值全部来自《FMS工作流与审批设计》与《FMS审批流程交互重构设计》: - * - 节点类型 b_node_type §4.4 / §6.4 - * - 审批方式 b_approval_mode §6.4 / §9 - * - 驳回策略 b_reject_mode §6.4 / §9.7 - * - 审批人解析 b_actor_type §6.6 - * - 条件操作符 §7.1 - * - 审批行为开关 §5.2(全部落 b_config_json,不新增列) - * - * 数据库存稳定编码,中文文案只在本文件出现一次,页面直接引用。 - * 唯一的例外是条件操作符文案:高级查询(fms-module-common/queryUtils.js)已经 - * 定义了同一批操作符的文案与多语言键,这里复用而不是复制第二份翻译。 - */ -import { operatorLabel } from '@/components/fms-module-common/queryUtils' - -/** 领域草稿结构版本(《FMS审批流程交互重构设计》§6.1) */ -export const SCHEMA_VERSION = 2 - -/** 工作流节点类型(落 wf_node.b_node_type) */ -export const NODE_TYPES = { - START: 'start', - USER_TASK: 'user_task', - CC_TASK: 'cc_task', - EXCLUSIVE_GATEWAY: 'exclusive_gateway', - PARALLEL_GATEWAY: 'parallel_gateway', - SERVICE_TASK: 'service_task', - END: 'end', -} - -/** - * 领域节点类型 ↔ LogicFlow 画布类型(《FMS工作流与审批设计》§19.5)。 - * 画布类型只是前端渲染类型,落库一律用领域类型。 - */ -export const NODE_TYPE_TO_CANVAS = { - [NODE_TYPES.START]: 'fms-start', - [NODE_TYPES.USER_TASK]: 'fms-user-task', - [NODE_TYPES.CC_TASK]: 'fms-cc-task', - [NODE_TYPES.EXCLUSIVE_GATEWAY]: 'fms-exclusive-gateway', - [NODE_TYPES.PARALLEL_GATEWAY]: 'fms-parallel-gateway', - [NODE_TYPES.SERVICE_TASK]: 'fms-service-task', - [NODE_TYPES.END]: 'fms-end', -} - -/** 画布类型 → 领域节点类型(事件回调里反查用) */ -export const CANVAS_TO_NODE_TYPE = Object.fromEntries( - Object.entries(NODE_TYPE_TO_CANVAS).map(([nodeType, canvasType]) => [canvasType, nodeType]), -) - -/** - * 卡片模式(钉钉式纵向卡片)允许的节点类型(§3.1)。 - * 出现其它类型说明流程不再是单链路,只能留在高级模式编辑。 - */ -export const LINEAR_NODE_TYPES = [NODE_TYPES.START, NODE_TYPES.USER_TASK, NODE_TYPES.CC_TASK, NODE_TYPES.END] - -/** 会让流程超出单链路的节点类型(§3.1 单链路判定) */ -export const BRANCH_NODE_TYPES = [ - NODE_TYPES.EXCLUSIVE_GATEWAY, - NODE_TYPES.PARALLEL_GATEWAY, - NODE_TYPES.SERVICE_TASK, -] - -/** - * 节点元数据:左侧面板顺序、显示名、形状、尺寸与配色。 - * - * 画布是 SVG,fill/stroke 走的是表现属性而不是 CSS,`var(--fms-*)` 在这里不生效, - * 因此配色写成字面量(与 tokens.css 同色系)。深色模式下的画布配色待后续统一处理。 - */ -export const NODE_META = { - [NODE_TYPES.START]: { - label: '开始', - shape: 'circle', - size: { r: 26 }, - style: { fill: '#ecfdf5', stroke: '#16a34a' }, - /** 开始节点不可删除、不可改属性 */ - fixed: true, - hint: '流程入口,有且只有一个', - }, - [NODE_TYPES.USER_TASK]: { - label: '人工审批', - shape: 'rect', - size: { width: 160, height: 48 }, - style: { fill: '#eef2ff', stroke: '#4f46e5' }, - hint: '由审批人处理的节点', - }, - [NODE_TYPES.CC_TASK]: { - label: '抄送', - shape: 'rect', - size: { width: 160, height: 48 }, - style: { fill: '#f0f9ff', stroke: '#0284c7' }, - hint: '只通知不产生待办,自动完成', - }, - [NODE_TYPES.EXCLUSIVE_GATEWAY]: { - label: '条件分支', - shape: 'diamond', - size: { rx: 34, ry: 34 }, - style: { fill: '#fff7ed', stroke: '#ea580c' }, - hint: '按连线条件择一分支,必须有默认出口', - }, - [NODE_TYPES.PARALLEL_GATEWAY]: { - label: '并行分支', - shape: 'diamond', - size: { rx: 34, ry: 34 }, - style: { fill: '#f5f3ff', stroke: '#7c3aed' }, - hint: '所有出线同时激活', - }, - [NODE_TYPES.SERVICE_TASK]: { - label: '服务节点', - shape: 'rect', - size: { width: 160, height: 48 }, - style: { fill: '#fefce8', stroke: '#ca8a04' }, - hint: '后端服务动作,第一阶段仅占位', - }, - [NODE_TYPES.END]: { - label: '结束', - shape: 'circle', - size: { r: 26 }, - style: { fill: '#f4f4f5', stroke: '#71717a' }, - fixed: true, - hint: '流程出口,可以有多个', - }, -} - -/** 左侧面板的展示顺序 */ -export const NODE_PALETTE_ORDER = [ - NODE_TYPES.START, - NODE_TYPES.USER_TASK, - NODE_TYPES.CC_TASK, - NODE_TYPES.EXCLUSIVE_GATEWAY, - NODE_TYPES.PARALLEL_GATEWAY, - NODE_TYPES.SERVICE_TASK, - NODE_TYPES.END, -] - -/** 卡片模式「插入节点」可选的类型(开始/结束是固定端点,不参与插入) */ -export const INSERTABLE_NODE_TYPES = [NODE_TYPES.USER_TASK, NODE_TYPES.CC_TASK] - -/** 需要配审批人/抄送人的节点类型 */ -export const ACTOR_NODE_TYPES = [NODE_TYPES.USER_TASK, NODE_TYPES.CC_TASK] - -/** - * 审批方式 b_approval_mode(§6.4)。 - * param 指向需要额外填写的参数:N 人通过要人数、按比例通过要比例。 - */ -export const APPROVAL_MODES = [ - { value: 'single', label: '单人审批', param: null }, - { value: 'any', label: '或签(任一人通过)', param: null }, - { value: 'all', label: '会签(全部通过)', param: null }, - { value: 'serial', label: '串行会签', param: null }, - { value: 'n_of_m', label: 'N 人通过', param: 'requiredCount' }, - { value: 'percentage', label: '按比例通过', param: 'requiredRatio' }, -] - -/** 驳回策略 b_reject_mode(§9.7) */ -export const REJECT_MODES = [ - { value: 'terminate', label: '驳回即结束(一票否决)' }, - { value: 'return_initiator', label: '退回发起人' }, - { value: 'return_previous', label: '退回上一节点' }, -] - -/** - * 审批人解析类型 b_actor_type(§6.6)。 - * control 决定属性抽屉用哪种控件取 b_actor_ref: - * user/position/org_role → 选择器(提交稳定编码) - * field → 字段选择器(字段必须来自当前模块 s_field) - * dept → 部门来源固定选项 - * resolver → 后端注册的解析器编码 - * none → 无参,靠流程上下文解析 - */ -export const ACTOR_TYPES = [ - { value: 'user', label: '指定用户', control: 'user' }, - { value: 'initiator', label: '发起人', control: 'none' }, - { value: 'initiator_manager', label: '发起人直属领导', control: 'none' }, - { value: 'dept_manager', label: '部门负责人', control: 'dept' }, - { value: 'position', label: '岗位', control: 'position' }, - { value: 'org_role', label: '组织角色', control: 'org_role' }, - { value: 'field_user', label: '业务字段用户', control: 'field' }, - { value: 'custom', label: '自定义解析器', control: 'resolver' }, -] - -/** 需要填 b_actor_ref 的解析类型(由 control 派生,避免两处维护) */ -export const ACTOR_TYPES_WITH_REF = ACTOR_TYPES.filter((item) => item.control !== 'none').map( - (item) => item.value, -) - -/** 部门负责人的部门来源(§5.2「可选择发起人部门或业务部门」,编码存 b_actor_ref) */ -export const DEPT_MANAGER_SOURCES = [ - { value: 'initiator_dept', label: '发起人所属部门' }, - { value: 'business_dept', label: '业务归属部门' }, -] - -/** 审批人解析为空时的兜底 b_fallback_type(§6.6) */ -export const FALLBACK_TYPES = [ - { value: 'none', label: '不兜底(阻塞并告警)' }, - { value: 'admin', label: '转交管理员' }, - { value: 'initiator', label: '转交发起人' }, - { value: 'custom', label: '自定义解析器' }, -] - -/** 兜底策略里需要填编码的类型 */ -export const FALLBACK_TYPES_WITH_REF = ['custom'] - -/** - * 节点审批行为开关(§5.2)。 - * 全部落在 wf_node.b_config_json,不为了每个开关新增一列(§7.2)。 - */ -export const NODE_BEHAVIOR_FLAGS = [ - { - key: 'allowInitiator', - label: '允许申请人自审', - hint: '默认关闭;关闭时发起人不会成为本节点审批人', - }, - { - key: 'excludePreviousActors', - label: '排除前序审批人', - hint: '已在前面节点审批过的人不再重复收到待办', - }, - { key: 'allowClaim', label: '允许抢占', hint: '候选人可主动认领待办' }, - { key: 'allowTransfer', label: '允许转交', hint: '审批人可把待办转交他人' }, - { key: 'allowDelegate', label: '允许委托', hint: '审批人可临时委托他人处理' }, - { key: 'allowAddApprover', label: '允许加签', hint: '审批人可向当前节点追加审批人' }, - { key: 'commentRequired', label: '审批意见必填', hint: '提交审批动作时必须填写意见' }, -] - -/** 业务事件 b_event(§6.3) */ -export const BINDING_EVENTS = [ - { value: 'submit', label: '送审' }, - { value: 'resubmit', label: '重新提交' }, - { value: 'change', label: '变更' }, -] - -/** 版本状态 b_status(§4.2) */ -export const VERSION_STATUS = [ - { value: 'draft', label: '草稿', tag: 'secondary' }, - { value: 'published', label: '已发布', tag: 'success' }, - { value: 'retired', label: '已停用', tag: 'default' }, -] - -/** 草稿保存状态(§8.2 工具栏),仅前端状态 */ -export const DRAFT_STATUS = { - IDLE: 'idle', - DIRTY: 'dirty', - SAVING: 'saving', - SAVED: 'saved', - ERROR: 'error', -} - -export const DRAFT_STATUS_LABEL = { - idle: '未修改', - dirty: '未保存', - saving: '保存中', - saved: '已保存', - error: '保存失败', -} - -/** 编辑器模式(§3.3)——只放组件状态,不落库、不进草稿 */ -export const EDITOR_MODES = { - STEP: 'step', - CANVAS: 'canvas', -} - -/** - * 条件 AST 的逻辑节点(§7.1)。 - * 连线条件为空表示默认出口,条件分支节点必须保留一条这样的连线。 - */ -export const CONDITION_LOGICS = [ - { value: 'all', label: '全部满足(且)' }, - { value: 'any', label: '任一满足(或)' }, -] - -/** - * 工作流条件支持的操作符(§7.1)。 - * 只列编码,文案由 queryUtils.operatorLabel 提供(高级查询与工作流共用一套翻译)。 - */ -export const CONDITION_OPERATORS = [ - 'eq', - 'ne', - 'gt', - 'ge', - 'lt', - 'le', - 'in', - 'not_in', - 'like', - 'is_null', - 'is_not_null', - 'between', -] - -/** 不需要填值的操作符 */ -export const OPERATORS_WITHOUT_VALUE = ['is_null', 'is_not_null'] - -/** 值是多选列表的操作符 */ -export const OPERATORS_WITH_LIST_VALUE = ['in', 'not_in'] - -/** 值是区间的操作符 */ -export const OPERATORS_WITH_RANGE_VALUE = ['between'] - -/** 操作符显示文案:多语言键优先,缺翻译回退中文(与高级查询同一份定义) */ -export function conditionOperatorLabel(operator, translate) { - return operatorLabel(operator, translate) -} - -/** 按编码取中文名,找不到回落到编码本身 */ -export function labelOf(list, value) { - return list.find((item) => item.value === value)?.label || value || '' -} diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/domain.js b/code/fms-workflow-refactor-backup-20260923/workflow/designer/domain.js deleted file mode 100644 index 8980ae8d..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/domain.js +++ /dev/null @@ -1,1489 +0,0 @@ -/** - * 流程设计器 —— 领域草稿、两种编辑器的适配,以及发布前校验。 - * - * 领域草稿是唯一真相(《FMS工作流与审批设计》§19.4),落库时拆到 - * wf_definition / wf_version / wf_binding / wf_node / wf_edge / wf_actor_rule; - * 画布模型(LogicFlow 的 GraphConfigData)只在编辑器里存在,不直接入库。 - * - * 草稿形状(schemaVersion: 2,《FMS审批流程交互重构设计》§6.1): - * { - * schemaVersion: 2, - * definition: { id, name, i18n, enabled, remark }, // → wf_definition - * version: { id, versionNo, status }, // → wf_version - * bindings: [{ id, moduleId, event, match, orgScope, // → wf_binding - * statusField, statusMap, priority, - * effectiveFrom, effectiveTo, enabled }], - * nodes: [{ nodeKey, nodeType, name, x, y, config, actorRules }], - * edges: [{ edgeKey, from, to, name, condition, priority }] - * } - * - * 编辑视图是递归流程树(§5.1),但它只是投影:workflowDraftToTree 派生、 - * workflowTreeToDraft 写回同一份草稿,树操作不能绕过这里自己拼 edges(§5.2.1)。 - * 无法安全表达的拓扑由投影返回 { ok: false, reason },UI 只读保护(§3.2)。 - * - * 坐标约定:x/y 在内存里挂在节点上(沿用现有前端形状,§6.1 的节点示例也带 x/y), - * 但**只**经 draftToCanvasJson 写进 wf_version.b_canvas_json,绝不写进 wf_node - * (§6.2「x/y 只进入 b_canvas_json」)。节点与连线的可执行字段一律走 wf_node / wf_edge。 - * - * 连线条件用结构化 AST(§7.1):null 表示默认出口,条件用 { all: [...] } / { any: [...] } - * 表达,叶子是 { field, operator, value },允许嵌套。前端只提交意图,SQL 由后端编译。 - */ -import { - ACTOR_NODE_TYPES, - ACTOR_TYPES, - APPROVAL_MODES, - BRANCH_NODE_TYPES, - CANVAS_TO_NODE_TYPE, - DEPT_MANAGER_SOURCES, - NODE_META, - NODE_TYPE_TO_CANVAS, - NODE_TYPES, - OPERATORS_WITHOUT_VALUE, - OPERATORS_WITH_LIST_VALUE, - OPERATORS_WITH_RANGE_VALUE, - SCHEMA_VERSION, - VERSION_STATUS, - conditionOperatorLabel, -} from './constants' - -/** 画布上无坐标时(卡片模式新建的节点)的默认落点 */ -const FALLBACK_POSITION = { baseX: 140, baseY: 240, stepX: 180 } - -/* ---------------------------------------------------------------------------- - 默认值 - ---------------------------------------------------------------------------- */ - -/** - * 节点默认配置(对应 wf_node 的列 + b_config_json 的可执行部分)。 - * 只有人工审批节点有审批设置:抄送节点只配抄送人规则,不配审批方式(§5.3)。 - */ -export function defaultNodeConfig(nodeType) { - if (nodeType !== NODE_TYPES.USER_TASK) return {} - return { - approvalMode: 'single', - rejectMode: 'terminate', - dueHours: null, - requiredCount: null, - requiredRatio: null, - // 审批行为开关(§5.2):全部落 b_config_json,不为每个开关新增列 - allowInitiator: false, - excludePreviousActors: true, - allowClaim: false, - allowTransfer: true, - allowDelegate: true, - allowAddApprover: true, - commentRequired: false, - formPolicy: { readonly: [], required: [] }, - } -} - -function emptyNode(nodeType, nodeKey, x, y) { - return { - nodeKey, - nodeType, - name: NODE_META[nodeType].label, - x, - y, - config: defaultNodeConfig(nodeType), - // 审批人规则默认留空:新建节点先由校验面板提示「未配置审批人」,由使用者显式补上 - actorRules: [], - } -} - -/** 一条空的流程绑定(新建流程向导用) */ -export function createBinding(moduleId = '', event = 'submit') { - return { - id: '', - moduleId, - event, - match: null, - orgScope: null, - statusField: '', - statusMap: null, - priority: 0, - effectiveFrom: null, - effectiveTo: null, - enabled: true, - } -} - -/** - * 新建草稿:一个开始节点 + 一个结束节点,并用一条默认连线串起来, - * 保证画布一打开就是可校验、可继续编辑的形态。 - */ -export function createEmptyDraft() { - return { - schemaVersion: SCHEMA_VERSION, - definition: { id: '', name: '', i18n: null, enabled: true, remark: '' }, - version: { id: '', versionNo: 1, status: 'draft' }, - bindings: [], - nodes: [ - emptyNode(NODE_TYPES.START, 'start', FALLBACK_POSITION.baseX, FALLBACK_POSITION.baseY), - emptyNode(NODE_TYPES.END, 'end', FALLBACK_POSITION.baseX + 480, FALLBACK_POSITION.baseY), - ], - edges: [{ edgeKey: 'edge_1', from: 'start', to: 'end', name: '', condition: null }], - } -} - -/** - * 新建流程向导产出的草稿(§4.2)。 - * useTemplate 为真时按「开始 → 人工审批(未配置审批人) → 结束」建默认线性模板; - * 为假时给空白高级流程(只有开始和结束),由使用者自己画。 - */ -export function createDefinitionDraft({ id, name, moduleId, event = 'submit', useTemplate = true } = {}) { - const draft = createEmptyDraft() - draft.definition.id = String(id || '').trim() - draft.definition.name = String(name || '').trim() - draft.bindings = [createBinding(moduleId, event)] - - if (useTemplate) { - const task = { - ...emptyNode(NODE_TYPES.USER_TASK, 'user_task_1', FALLBACK_POSITION.baseX + 240, FALLBACK_POSITION.baseY), - name: '审批', - } - draft.nodes = [draft.nodes[0], task, draft.nodes[1]] - draft.edges = [ - { edgeKey: 'edge_1', from: 'start', to: 'user_task_1', name: '', condition: null }, - { edgeKey: 'edge_2', from: 'user_task_1', to: 'end', name: '', condition: null }, - ] - } - return draft -} - -/** 深拷贝草稿:撤销栈与节点复制都用它,保证快照与当前草稿不共享引用 */ -export function cloneDraft(draft) { - return JSON.parse(JSON.stringify(draft ?? null)) -} - -/* ---------------------------------------------------------------------------- - key 生成 - ---------------------------------------------------------------------------- */ - -function nextKey(prefix, existing) { - let index = 1 - while (existing.has(`${prefix}_${index}`)) index += 1 - return `${prefix}_${index}` -} - -/** 生成版本内唯一的节点编码(§6.4:同一版本内 b_node_key 必须稳定) */ -export function createNodeKey(nodeType, nodes = []) { - return nextKey(nodeType, new Set(nodes.map((node) => node.nodeKey))) -} - -/** 生成连线编码 */ -export function createEdgeKey(edges = []) { - return nextKey('edge', new Set(edges.map((edge) => edge.edgeKey))) -} - -/** 新节点落点:在已有节点下方错开,避免叠在一起(节点面板拖拽用) */ -export function nextNodePosition(nodes = []) { - const baseX = 420 - const baseY = 140 - const step = 90 - return { x: baseX, y: baseY + (nodes.length % 6) * step } -} - -/** 卡片模式插入节点时的落点:取前后两个节点的中点,切到画布时不会挤在一起 */ -function positionBetween(from, to) { - if (!from || !to) return { x: FALLBACK_POSITION.baseX, y: FALLBACK_POSITION.baseY } - return { - x: Math.round(((Number(from.x) || 0) + (Number(to.x) || 0)) / 2), - y: Math.round(((Number(from.y) || 0) + (Number(to.y) || 0)) / 2) + 40, - } -} - -/* ---------------------------------------------------------------------------- - 连线条件 AST(§7.1) - ---------------------------------------------------------------------------- */ - -/** 条件是否为空(空对象、空数组、只有空逻辑组都算空) */ -export function isConditionEmpty(condition) { - if (!condition) return true - if (condition.all) return !(condition.all || []).length - if (condition.any) return !(condition.any || []).length - if (condition.not) return isConditionEmpty(condition.not) - return !condition.field -} - -/** 规范化条件:空条件一律回落成 null(默认出口),避免存进 b_condition_json 的噪音 */ -export function normalizeCondition(condition) { - return isConditionEmpty(condition) ? null : condition -} - -/** 单个条件叶子的展示文案 */ -function conditionLeafText(leaf, options) { - const label = conditionOperatorLabel(leaf.operator, options.translate) - const field = options.fieldName ? options.fieldName(leaf.field) : leaf.field - if (OPERATORS_WITHOUT_VALUE.includes(leaf.operator)) return `${field} ${label}` - if (OPERATORS_WITH_RANGE_VALUE.includes(leaf.operator)) { - const range = Array.isArray(leaf.value) ? leaf.value : [] - return `${field} ${label} ${range[0] ?? ''} ~ ${range[1] ?? ''}` - } - if (OPERATORS_WITH_LIST_VALUE.includes(leaf.operator)) { - const list = Array.isArray(leaf.value) ? leaf.value : String(leaf.value ?? '').split(',') - return `${field} ${label} ${list.filter((item) => item !== '').join('、')}` - } - return `${field} ${label} ${leaf.value ?? ''}`.trim() -} - -function conditionText(condition, options, nested) { - if (!condition) return '' - const logic = condition.all ? 'all' : condition.any ? 'any' : null - if (logic) { - const parts = (condition[logic] || []) - .map((item) => conditionText(item, options, true)) - .filter(Boolean) - if (!parts.length) return '' - const text = parts.join(logic === 'all' ? ' 且 ' : ' 或 ') - // 顶层不套括号(连线标签本就短),嵌套组套括号保持运算优先级可读 - return nested && parts.length > 1 ? `(${text})` : text - } - if (condition.not) { - const inner = conditionText(condition.not, options, false) - return inner ? `非(${inner})` : '' - } - if (condition.field) return conditionLeafText(condition, options) - return '' -} - -/** - * 条件 AST → 一句话(连线标签、卡片摘要、校验提示共用)。 - * @param options.translate 多语言函数,缺省回落中文 - * @param options.fieldName 字段编码 → 显示名(来自 s_field) - */ -export function describeCondition(condition, options = {}) { - return conditionText(condition, options, false) -} - -/* ---------------------------------------------------------------------------- - 节点摘要(卡片模式用) - ---------------------------------------------------------------------------- */ - -/** 审批方式摘要,带 N 人通过 / 按比例通过的参数 */ -export function describeApprovalMode(node) { - const config = node?.config || {} - const mode = APPROVAL_MODES.find((item) => item.value === config.approvalMode) - if (!mode) return '' - if (mode.param === 'requiredCount' && config.requiredCount != null) { - return `${mode.label} · ${config.requiredCount} 人` - } - if (mode.param === 'requiredRatio' && config.requiredRatio != null) { - return `${mode.label} · ${Math.round(Number(config.requiredRatio) * 100)}%` - } - return mode.label -} - -/** 单条审批人规则的展示文案 */ -export function describeActorRule(rule, options = {}) { - const meta = ACTOR_TYPES.find((item) => item.value === rule?.actorType) - if (!meta) return rule?.actorType || '' - if (meta.control === 'none') return meta.label - if (meta.control === 'dept') { - const source = DEPT_MANAGER_SOURCES.find((item) => item.value === rule.actorRef) - return source ? `${meta.label}(${source.label})` : meta.label - } - if (!rule.actorRef) return `${meta.label}:未选择` - const name = options.nameOf ? options.nameOf(meta.control, rule.actorRef) : '' - return `${meta.label}:${name || rule.actorRef}` -} - -/** 节点的审批人 / 抄送人摘要 */ -export function describeActorRules(node, options = {}) { - const rules = node?.actorRules || [] - if (!rules.length) return '' - return rules.map((rule) => describeActorRule(rule, options)).filter(Boolean).join('、') -} - -/** 时限摘要 */ -export function describeDue(node) { - const hours = node?.config?.dueHours - return hours == null || hours === '' ? '' : `时限 ${hours} 小时` -} - -/** 条件摘要:连线没有名称时按条件派生标签 */ -export function edgeLabel(edge, options = {}) { - return edge?.name || describeCondition(edge?.condition, options) -} - -/* ---------------------------------------------------------------------------- - 边优先级 - - b_priority 是「同一个来源节点内」的判断顺序(§6.5 / §5.2.3),不是全表序号。 - 草稿里的 priority 只表达相对顺序,落库前统一收敛成每个来源 1..n,避免出现 - 全局序号或跳号;相等时按数组位置兜底,保证结果稳定。 - ---------------------------------------------------------------------------- */ - -export function normalizeEdgePriorities(edges = []) { - const bySource = new Map() - edges.forEach((edge, index) => { - const list = bySource.get(edge.from) || [] - list.push({ edge, index }) - bySource.set(edge.from, list) - }) - - const priorityOf = new Map() - for (const list of bySource.values()) { - list - .toSorted((left, right) => { - const diff = (Number(left.edge.priority) || 0) - (Number(right.edge.priority) || 0) - return diff === 0 ? left.index - right.index : diff - }) - .forEach((item, position) => priorityOf.set(item.edge.edgeKey, position + 1)) - } - - return edges.map((edge) => ({ ...edge, priority: priorityOf.get(edge.edgeKey) })) -} - -/** 某个节点的出线,按判断顺序排列(树投影与校验共用同一套排序) */ -export function outgoingEdgesOf(draft, nodeKey) { - return normalizeEdgePriorities(draft?.edges || []) - .filter((edge) => edge.from === nodeKey) - .toSorted((left, right) => left.priority - right.priority) -} - -/* ---------------------------------------------------------------------------- - 数据库行 → 领域草稿 - - wf_version.b_canvas_json 只放布局(节点坐标,§6.2),可执行内容在 - wf_node / wf_edge / wf_actor_rule;wf_binding 的匹配条件与状态映射存 JSON。 - ---------------------------------------------------------------------------- */ - -/** 宽松解析 JSON 列:脏数据不该让整个设计器打不开 */ -function parseJson(text, fallback) { - if (text === null || text === undefined || text === '') return fallback - try { - const value = JSON.parse(text) - return value === null ? fallback : value - } catch { - return fallback - } -} - -/** 0/1、'0'/'1'、true/false 都当启用处理;空值按启用 */ -function isEnabled(value) { - if (value === null || value === undefined || value === '') return true - return !(value === 0 || value === '0' || value === false || value === 'false') -} - -function toNumberOrNull(value) { - if (value === null || value === undefined || value === '') return null - const number = Number(value) - return Number.isFinite(number) ? number : null -} - -/** - * 把 wf_definition / wf_version / wf_binding / wf_node / wf_edge / wf_actor_rule - * 的行拼成领域草稿。 - * @param {{ definition?: object, version?: object, bindings?: object[], nodes?: object[], edges?: object[], actorRules?: object[] }} rows - */ -export function rowsToWorkflowDraft(rows = {}) { - const { definition, version, bindings = [], nodes = [], edges = [], actorRules = [] } = rows - const canvas = parseJson(version?.b_canvas_json, {}) || {} - const positions = canvas.nodes || {} - - // 规则按节点分组;查询已按 b_priority 排序,串行会签的顺序因此天然保留 - const rulesByNode = new Map() - actorRules.forEach((row) => { - const list = rulesByNode.get(row.b_node_key) || [] - list.push({ - actorType: row.b_actor_type, - actorRef: row.b_actor_ref || '', - resolve: parseJson(row.b_resolve_json, null), - condition: parseJson(row.b_condition_json, null), - fallbackType: row.b_fallback_type || 'none', - fallbackRef: row.b_fallback_ref || '', - }) - rulesByNode.set(row.b_node_key, list) - }) - - const draftNodes = nodes.map((row, index) => { - const nodeType = row.b_node_type - const extra = parseJson(row.b_config_json, {}) || {} - const position = positions[row.b_node_key] || {} - const config = { ...defaultNodeConfig(nodeType), ...extra } - // 列上的值优先于 b_config_json;列为空时保留默认值,不要写成 null - if (row.b_approval_mode) config.approvalMode = row.b_approval_mode - if (row.b_reject_mode) config.rejectMode = row.b_reject_mode - config.dueHours = toNumberOrNull(row.b_due_hours) - - return { - nodeKey: row.b_node_key, - nodeType, - name: row.b_name || NODE_META[nodeType]?.label || row.b_node_key, - x: Number.isFinite(position.x) - ? position.x - : FALLBACK_POSITION.baseX + index * FALLBACK_POSITION.stepX, - y: Number.isFinite(position.y) ? position.y : FALLBACK_POSITION.baseY, - config, - actorRules: rulesByNode.get(row.b_node_key) || [], - } - }) - - return { - schemaVersion: SCHEMA_VERSION, - definition: { - id: definition?.b_id || version?.b_definition_id || '', - name: definition?.b_name || '', - i18n: definition?.b_i18n || null, - enabled: isEnabled(definition?.b_canuse), - remark: definition?.b_bz || '', - }, - version: { - id: version?.b_id || '', - versionNo: Number(version?.b_version_no ?? 1), - status: version?.b_status || 'draft', - }, - bindings: bindings.map((row) => ({ - id: row.b_id || '', - moduleId: row.b_module_id || '', - event: row.b_event || 'submit', - match: parseJson(row.b_match_json, null), - orgScope: parseJson(row.b_org_scope_json, null), - statusField: row.b_status_field || '', - statusMap: parseJson(row.b_status_map_json, null), - priority: Number(row.b_priority ?? 0), - effectiveFrom: row.b_effective_from || null, - effectiveTo: row.b_effective_to || null, - enabled: isEnabled(row.b_canuse), - })), - nodes: draftNodes, - // priority 是同一个来源节点上的判断顺序(§6.5),树投影按它排分支;缺值时按行序兜底 - edges: edges.map((row, index) => ({ - edgeKey: row.b_id, - from: row.b_from_node_key, - to: row.b_to_node_key, - name: row.b_name || '', - condition: normalizeCondition(parseJson(row.b_condition_json, null)), - priority: toNumberOrNull(row.b_priority) ?? (index + 1) * 10, - })), - } -} - -/* ---------------------------------------------------------------------------- - 领域草稿 → 数据库行 - - 保存时把草稿拆平到各表(§6.1 转换约定):actorRules 按顺序拆成 wf_actor_rule 行、 - 用 b_priority 保存顺序,坐标单独进 b_canvas_json。字段名保持数据库原样,不在 - 前端改名 —— API 封装层是边界。 - ---------------------------------------------------------------------------- */ - -/** 节点坐标 → wf_version.b_canvas_json(只放布局,不放可执行内容) */ -export function draftToCanvasJson(draft) { - const nodes = {} - for (const node of draft?.nodes || []) { - nodes[node.nodeKey] = { x: Number(node.x) || 0, y: Number(node.y) || 0 } - } - return { zoom: 1, nodes } -} - -/** - * 草稿 → 保存接口载荷。 - * 子表不带主键:保存接口在事务内整体替换该版本的节点 / 连线 / 规则(§6.3), - * 主键由后端生成;连线与节点编码(b_node_key)是前端保证稳定的业务编码。 - */ -export function draftToSavePayload(draft) { - const actorRules = [] - const nodes = (draft?.nodes || []).map((node, index) => { - const config = { ...node.config } - const approvalMode = config.approvalMode ?? null - const rejectMode = config.rejectMode ?? null - const dueHours = config.dueHours ?? null - // 列上的字段不重复塞进 b_config_json,避免两处真相 - delete config.approvalMode - delete config.rejectMode - delete config.dueHours - - for (const [ruleIndex, rule] of (node.actorRules || []).entries()) { - actorRules.push({ - b_node_key: node.nodeKey, - b_actor_type: rule.actorType, - b_actor_ref: rule.actorRef || null, - b_resolve_json: rule.resolve ? JSON.stringify(rule.resolve) : null, - b_condition_json: rule.condition ? JSON.stringify(rule.condition) : null, - b_fallback_type: rule.fallbackType || 'none', - b_fallback_ref: rule.fallbackRef || null, - b_priority: (ruleIndex + 1) * 10, - }) - } - - return { - b_node_key: node.nodeKey, - b_name: node.name, - b_node_type: node.nodeType, - b_approval_mode: approvalMode, - b_reject_mode: rejectMode, - b_due_hours: dueHours, - b_config_json: Object.keys(config).length ? JSON.stringify(config) : null, - b_xh: (index + 1) * 10, - } - }) - - return { - schema_version: SCHEMA_VERSION, - definition: { - b_id: draft?.definition?.id || '', - b_name: draft?.definition?.name || '', - b_i18n: draft?.definition?.i18n || null, - b_canuse: draft?.definition?.enabled === false ? 0 : 1, - b_bz: draft?.definition?.remark || null, - }, - version: { - b_id: draft?.version?.id || '', - b_definition_id: draft?.definition?.id || '', - b_status: draft?.version?.status || 'draft', - }, - bindings: (draft?.bindings || []).map((binding) => ({ - b_id: binding.id || null, - b_module_id: binding.moduleId, - b_event: binding.event, - b_match_json: binding.match ? JSON.stringify(binding.match) : null, - b_org_scope_json: binding.orgScope ? JSON.stringify(binding.orgScope) : null, - b_status_field: binding.statusField || null, - b_status_map_json: binding.statusMap ? JSON.stringify(binding.statusMap) : null, - b_priority: Number(binding.priority) || 0, - b_effective_from: binding.effectiveFrom || null, - b_effective_to: binding.effectiveTo || null, - b_canuse: binding.enabled === false ? 0 : 1, - })), - nodes, - edges: normalizeEdgePriorities(draft?.edges || []).map((edge) => ({ - b_id: edge.edgeKey, - b_from_node_key: edge.from, - b_to_node_key: edge.to, - b_name: edge.name || null, - b_condition_json: edge.condition ? JSON.stringify(edge.condition) : null, - b_priority: edge.priority * 10, - })), - actor_rules: actorRules, - canvas: draftToCanvasJson(draft), - } -} - -/* ---------------------------------------------------------------------------- - 领域模型 → 画布模型 - ---------------------------------------------------------------------------- */ - -/** LogicFlow 的 text 可能是字符串,也可能是 { value } 结构 */ -function textValue(text) { - if (text == null) return '' - if (typeof text === 'string') return text - return String(text.value ?? '') -} - -export function workflowDomainToLogicFlow(draft, options = {}) { - return { - nodes: (draft?.nodes || []).map((node, index) => ({ - id: node.nodeKey, - type: NODE_TYPE_TO_CANVAS[node.nodeType] || 'fms-user-task', - x: Number.isFinite(node.x) ? node.x : FALLBACK_POSITION.baseX + index * FALLBACK_POSITION.stepX, - y: Number.isFinite(node.y) ? node.y : FALLBACK_POSITION.baseY, - text: node.name, - })), - edges: (draft?.edges || []).map((edge) => ({ - id: edge.edgeKey, - type: 'polyline', - sourceNodeId: edge.from, - targetNodeId: edge.to, - text: edgeLabel(edge, options), - properties: {}, - })), - } -} - -/* ---------------------------------------------------------------------------- - 画布模型 → 领域模型 - - 画布只管结构(谁连谁、节点在哪),业务配置(审批方式、审批人规则、条件) - 不在画布上,因此从 baseDraft 里按 nodeKey / edgeKey 回填。 - ---------------------------------------------------------------------------- */ - -export function logicFlowToWorkflowDraft(graph, baseDraft) { - const baseNodes = new Map((baseDraft?.nodes || []).map((node) => [node.nodeKey, node])) - const baseEdges = new Map((baseDraft?.edges || []).map((edge) => [edge.edgeKey, edge])) - - const nodes = (graph?.nodes || []).map((node) => { - const nodeType = CANVAS_TO_NODE_TYPE[node.type] || NODE_TYPES.USER_TASK - const previous = baseNodes.get(node.id) - return { - nodeKey: node.id, - nodeType, - name: textValue(node.text) || NODE_META[nodeType].label, - x: node.x, - y: node.y, - config: previous ? { ...previous.config } : defaultNodeConfig(nodeType), - actorRules: previous ? previous.actorRules.map((rule) => ({ ...rule })) : [], - } - }) - - const edges = (graph?.edges || []).map((edge) => { - const previous = baseEdges.get(edge.id) - return { - edgeKey: edge.id, - from: edge.sourceNodeId, - to: edge.targetNodeId, - name: previous?.name || '', - condition: previous?.condition ?? null, - priority: previous?.priority ?? 0, - } - }) - - return { - schemaVersion: SCHEMA_VERSION, - definition: baseDraft?.definition - ? { ...baseDraft.definition } - : { id: '', name: '', i18n: null, enabled: true, remark: '' }, - version: baseDraft?.version ? { ...baseDraft.version } : { id: '', versionNo: 1, status: 'draft' }, - bindings: (baseDraft?.bindings || []).map((binding) => ({ ...binding })), - nodes, - edges, - } -} - -/* ---------------------------------------------------------------------------- - 单链路(卡片模式)判定与图操作(§3.1 / §8.3) - ---------------------------------------------------------------------------- */ - -function groupByEndpoint(edges, key) { - const map = new Map() - for (const edge of edges) { - const list = map.get(edge[key]) || [] - list.push(edge) - map.set(edge[key], list) - } - return map -} - -/** 单链路判定的失败结果 */ -function notLinear(reason) { - return { linear: false, reason, order: [] } -} - -/** - * 判定草稿能否用卡片模式编辑,并给出主链路顺序。 - * 单链路 = 只有一个开始节点、除开始结束外每个节点出入各一条、不含分支/服务节点、无环。 - * @returns {{ linear: boolean, reason: string, order: string[] }} - */ -export function inspectLinear(draft) { - const nodes = draft?.nodes || [] - const edges = draft?.edges || [] - - const starts = nodes.filter((node) => node.nodeType === NODE_TYPES.START) - const ends = nodes.filter((node) => node.nodeType === NODE_TYPES.END) - if (starts.length !== 1) return notLinear('流程必须有且只有一个开始节点') - if (!ends.length) return notLinear('流程缺少结束节点') - - const branch = nodes.find((node) => BRANCH_NODE_TYPES.includes(node.nodeType)) - if (branch) { - return notLinear( - `存在「${NODE_META[branch.nodeType]?.label || branch.nodeType}」节点,只能到高级画布编辑`, - ) - } - - const out = groupByEndpoint(edges, 'from') - const inc = groupByEndpoint(edges, 'to') - for (const node of nodes) { - const outList = out.get(node.nodeKey) || [] - const inList = inc.get(node.nodeKey) || [] - if (node.nodeType === NODE_TYPES.END) { - if (outList.length) return notLinear(`结束节点「${node.name}」不应有出线`) - } else if (outList.length > 1) { - return notLinear(`节点「${node.name}」有多条出线`) - } - if (node.nodeType !== NODE_TYPES.START && inList.length > 1) { - return notLinear(`节点「${node.name}」有多条入线`) - } - } - - const order = [] - const seen = new Set() - let current = starts[0].nodeKey - while (current) { - if (seen.has(current)) return notLinear('流程存在环形路径') - seen.add(current) - order.push(current) - current = (out.get(current) || [])[0]?.to - } - - if (order.length !== nodes.length) { - const missing = nodes.find((node) => !seen.has(node.nodeKey)) - return notLinear(`节点「${missing?.name || ''}」不在主链路上`) - } - const last = nodes.find((node) => node.nodeKey === order[order.length - 1]) - if (last?.nodeType !== NODE_TYPES.END) return notLinear('主链路没有走到结束节点') - - return { linear: true, reason: '', order } -} - -/** 主链路上的节点对象数组(非单链路时返回空数组) */ -export function listLinearSteps(draft) { - const info = inspectLinear(draft) - if (!info.linear) return [] - const byKey = new Map((draft.nodes || []).map((node) => [node.nodeKey, node])) - return info.order.map((key) => byKey.get(key)).filter(Boolean) -} - -/** - * 按给定顺序重建连线(§8.3 写图规则)。 - * 连线按位置复用原有编码与名称,节点顺序变化不影响连线数量以外的东西。 - * 这是插入 / 删除 / 移动 / 复制四个操作的共同底座。 - */ -function applyLinearOrder(draft, orderedKeys) { - const byKey = new Map((draft.nodes || []).map((node) => [node.nodeKey, node])) - const ordered = orderedKeys.map((key) => byKey.get(key)).filter(Boolean) - const needed = Math.max(0, ordered.length - 1) - const edges = [] - for (let index = 0; index < needed; index += 1) { - const source = draft.edges[index] - edges.push({ - edgeKey: source?.edgeKey || createEdgeKey([...draft.edges, ...edges]), - from: ordered[index].nodeKey, - to: ordered[index + 1].nodeKey, - name: source?.name || '', - condition: source?.condition ?? null, - }) - } - return { ...draft, edges } -} - -function findNode(draft, nodeKey) { - return (draft?.nodes || []).find((node) => node.nodeKey === nodeKey) || null -} - -/** - * 在 fromKey 与其后继之间插入一个节点:A → B 变成 A → 新节点 → B。 - * @returns {{ draft: object, ok: boolean, reason?: string, nodeKey?: string }} - */ -export function insertLinearStep(draft, fromKey, nodeType) { - const info = inspectLinear(draft) - if (!info.linear) return { draft, ok: false, reason: info.reason } - const index = info.order.indexOf(fromKey) - if (index < 0 || index >= info.order.length - 1) { - return { draft, ok: false, reason: '插入位置无效' } - } - - const nodeKey = createNodeKey(nodeType, draft.nodes) - const from = findNode(draft, fromKey) - const to = findNode(draft, info.order[index + 1]) - const node = { ...emptyNode(nodeType, nodeKey, 0, 0), ...positionBetween(from, to) } - const order = [...info.order.slice(0, index + 1), nodeKey, ...info.order.slice(index + 1)] - return { - draft: applyLinearOrder({ ...draft, nodes: [...draft.nodes, node] }, order), - ok: true, - nodeKey, - } -} - -/** 删除节点:A → B → C 变成 A → C;带分支的节点交给高级模式 */ -export function removeLinearStep(draft, nodeKey) { - const info = inspectLinear(draft) - if (!info.linear) return { draft, ok: false, reason: info.reason } - const node = findNode(draft, nodeKey) - if (!node) return { draft, ok: false, reason: '节点不存在' } - if (NODE_META[node.nodeType]?.fixed) { - return { draft, ok: false, reason: `${NODE_META[node.nodeType].label}节点不能删除` } - } - const order = info.order.filter((key) => key !== nodeKey) - return { - draft: applyLinearOrder( - { ...draft, nodes: draft.nodes.filter((item) => item.nodeKey !== nodeKey) }, - order, - ), - ok: true, - } -} - -/** 上移 / 下移(offset 为 -1 / 1):只改主链路顺序,画布布局与 b_xh 跟着重排 */ -export function moveLinearStep(draft, nodeKey, offset) { - const info = inspectLinear(draft) - if (!info.linear) return { draft, ok: false, reason: info.reason } - const index = info.order.indexOf(nodeKey) - if (index < 0) return { draft, ok: false, reason: '节点不存在' } - const target = index + offset - // 开始与结束是固定端点,不参与换位 - if (target < 0 || target >= info.order.length) return { draft, ok: false, reason: '已经到头了' } - const neighbour = findNode(draft, info.order[target]) - if (NODE_META[neighbour?.nodeType]?.fixed || NODE_META[findNode(draft, nodeKey)?.nodeType]?.fixed) { - return { draft, ok: false, reason: '开始与结束节点位置固定' } - } - - const order = [...info.order] - order[index] = info.order[target] - order[target] = info.order[index] - return { draft: applyLinearOrder(draft, order), ok: true } -} - -/** 复制节点并插在它后面:配置与审批人规则一起复制,编码重新生成 */ -export function duplicateLinearStep(draft, nodeKey) { - const info = inspectLinear(draft) - if (!info.linear) return { draft, ok: false, reason: info.reason } - const source = findNode(draft, nodeKey) - if (!source) return { draft, ok: false, reason: '节点不存在' } - if (NODE_META[source.nodeType]?.fixed) { - return { draft, ok: false, reason: `${NODE_META[source.nodeType].label}节点不能复制` } - } - - const nodeKey2 = createNodeKey(source.nodeType, draft.nodes) - const clone = { - ...cloneDraft(source), - nodeKey: nodeKey2, - name: `${source.name}(副本)`, - x: (Number(source.x) || 0) + FALLBACK_POSITION.stepX, - } - const index = info.order.indexOf(nodeKey) - const order = [...info.order.slice(0, index + 1), nodeKey2, ...info.order.slice(index + 1)] - return { - draft: applyLinearOrder({ ...draft, nodes: [...draft.nodes, clone] }, order), - ok: true, - nodeKey: nodeKey2, - } -} - -/* ---------------------------------------------------------------------------- - 流程树投影(《FMS审批流程设置器交互重构设计》§5.1 / §5.2) - - 锁定的执行契约(阶段 0 产出):后端流程服务尚未实现,树形交互需要的网关语义先在这里 - 定义清楚并用测试锁死,接入后逐项验收,不在组件里各猜一套。 - 1. 一个条件块 = 一个 exclusive_gateway;每条出边 = 一个分支,按 b_priority 升序判断; - 2. 恰有一条 condition 为 null 的出边,是「其他情况」,投影里固定排最后; - 3. 每个分支有专属子路径;所有分支在「最近的公共后继」汇合,该节点是条件块的 next; - 各分支各自走到结束、没有公共后继时 next 为 null,结束节点留在各自分支里; - 4. 分支优先级在投影内连续唯一,写回时按此顺序生成 b_priority。 - - 树只是编辑投影:它是 draft.nodes / edges 的派生视图,变更必须经 workflowTreeToDraft - 写回同一份草稿(§5.2.8),不新增第二套持久化结构、不改 wf_* 语义。 - 投影不成立时返回 { ok: false, reason },UI 据此只读保护,绝不压平或丢边保存(§3.2)。 - ---------------------------------------------------------------------------- */ - -/** 树形编辑器能安全表达的节点类型 */ -export const TREE_NODE_TYPES = [ - NODE_TYPES.START, - NODE_TYPES.USER_TASK, - NODE_TYPES.CC_TASK, - NODE_TYPES.EXCLUSIVE_GATEWAY, - NODE_TYPES.END, -] - -/** 深层递归里发现无法表达的拓扑时中断构树 */ -class UnsupportedTopologyError extends Error { - constructor(message, nodeKey = null) { - super(message) - this.name = 'UnsupportedTopologyError' - this.nodeKey = nodeKey - } -} - -function unsupported(reason, nodeKey = null) { - return { ok: false, reason, nodeKey } -} - -/** 从 startKey 出发能到达的全部节点(含自身) */ -function descendantsOf(startKey, outMap) { - const seen = new Set() - const stack = [startKey] - while (stack.length) { - const key = stack.pop() - if (seen.has(key)) continue - seen.add(key) - for (const edge of outMap.get(key) || []) stack.push(edge.to) - } - return seen -} - -/** - * 各分支的最近公共后继。 - * @returns {string|null|undefined} 汇合点编码;没有公共后继返回 null;汇合点不唯一返回 undefined - */ -function findMerge(targets, outMap) { - const sets = targets.map((key) => descendantsOf(key, outMap)) - const shared = [...sets[0]].filter((key) => sets.every((set) => set.has(key))) - if (!shared.length) return null - - const reach = new Map(shared.map((key) => [key, descendantsOf(key, outMap)])) - const candidates = shared.filter( - (key) => !shared.some((other) => other !== key && reach.get(other).has(key)), - ) - return candidates.length === 1 ? candidates[0] : undefined -} - -/** - * 领域草稿 → 递归流程树(§5.1 的 FlowItem)。 - * @returns {{ ok: true, tree: object, signature: string } | { ok: false, reason: string, nodeKey: string|null }} - */ -export function workflowDraftToTree(draft) { - const nodes = draft?.nodes || [] - const byKey = new Map(nodes.map((node) => [node.nodeKey, node])) - - const outMap = new Map() - for (const edge of normalizeEdgePriorities(draft?.edges || [])) { - if (!byKey.has(edge.from) || !byKey.has(edge.to)) { - return unsupported('存在两端节点已不存在的连线') - } - outMap.set(edge.from, [...(outMap.get(edge.from) || []), edge]) - } - for (const list of outMap.values()) list.sort((left, right) => left.priority - right.priority) - - const starts = nodes.filter((node) => node.nodeType === NODE_TYPES.START) - if (starts.length !== 1) return unsupported('流程必须有且只有一个开始节点') - if (!nodes.some((node) => node.nodeType === NODE_TYPES.END)) return unsupported('流程缺少结束节点') - - // 节点类型与出入度:树形表达不了的一律拒绝,不做压平猜测(§3.2 / §5.2.7) - // 注意只约束出边(§5.2.6):入边不限制 —— 分支汇合点天生就有多条入线, - // 把入度锁成 1 会把「分支后公共审批」这种正常流程误判成不可表达。 - for (const node of nodes) { - const outList = outMap.get(node.nodeKey) || [] - const label = node.name || node.nodeKey - - if (!TREE_NODE_TYPES.includes(node.nodeType)) { - return unsupported( - `存在「${NODE_META[node.nodeType]?.label || node.nodeType}」节点,树形编辑器无法安全表达`, - node.nodeKey, - ) - } - if (node.nodeType === NODE_TYPES.START) { - if (outList.length !== 1) return unsupported('开始节点必须有且只有一条出线', node.nodeKey) - if (outList[0].condition) return unsupported('开始节点的出线不应带条件', node.nodeKey) - continue - } - if (node.nodeType === NODE_TYPES.END) { - if (outList.length) return unsupported('结束节点不应有出线', node.nodeKey) - continue - } - if (node.nodeType === NODE_TYPES.EXCLUSIVE_GATEWAY) { - if (outList.length < 2) return unsupported(`条件分支「${label}」至少要有两条分支`, node.nodeKey) - const defaults = outList.filter((edge) => !edge.condition) - if (defaults.length !== 1) { - return unsupported( - `条件分支「${label}」必须有且仅有一个「其他情况」分支(当前 ${defaults.length} 个)`, - node.nodeKey, - ) - } - continue - } - if (outList.length !== 1) return unsupported(`节点「${label}」必须有且只有一条出线`, node.nodeKey) - if (outList[0].condition) { - return unsupported(`节点「${label}」不是条件分支,其出线不应带条件`, node.nodeKey) - } - } - - // 环:出边约束下环只能经由网关形成,必须在构树前拦掉,否则递归不终止 - const cycle = findCycle(nodes, draft?.edges || []) - if (cycle) return unsupported(`流程存在环形路径(${cycle}),树形编辑器无法安全表达`) - - // 可达性:从开始出发必须覆盖全部节点 - const reachable = descendantsOf(starts[0].nodeKey, outMap) - if (reachable.size !== nodes.length) { - const orphan = nodes.find((node) => !reachable.has(node.nodeKey)) - return unsupported( - `节点「${orphan?.name || ''}」不在主流程上,树形编辑器无法安全表达`, - orphan?.nodeKey || null, - ) - } - - const visiting = new Set() - const build = (nodeKey, stopKey = null) => { - if (!nodeKey || nodeKey === stopKey) return null - if (visiting.has(nodeKey)) { - throw new UnsupportedTopologyError('流程存在环形路径,树形编辑器无法安全表达', nodeKey) - } - visiting.add(nodeKey) - try { - return buildItem(nodeKey, stopKey) - } finally { - visiting.delete(nodeKey) - } - } - - const buildItem = (nodeKey, stopKey) => { - const node = byKey.get(nodeKey) - if (!node) return null - - if (node.nodeType === NODE_TYPES.EXCLUSIVE_GATEWAY) { - const branchEdges = outMap.get(nodeKey) || [] - const targets = branchEdges.map((edge) => edge.to) - const merge = findMerge(targets, outMap) - if (merge === undefined) { - throw new UnsupportedTopologyError( - `条件分支「${node.name}」的分支汇合点不唯一,树形编辑器无法安全表达`, - nodeKey, - ) - } - // 交叉边:某条分支的入口落在另一条分支的专属路径里,会和「分支汇合」语义冲突 - const crossed = targets.find((target) => - targets.some( - (other) => - other !== target && target !== merge && descendantsOf(other, outMap).has(target), - ), - ) - if (crossed) { - throw new UnsupportedTopologyError( - `条件分支「${node.name}」存在交叉连线(分支入口「${byKey.get(crossed)?.name || crossed}」在另一条分支里),树形编辑器无法安全表达`, - nodeKey, - ) - } - - // 显式条件保持相对顺序,「其他情况」固定排最后(§5.2.3);优先级归一化成显示顺序 - const ordered = [ - ...branchEdges.filter((edge) => edge.condition), - ...branchEdges.filter((edge) => !edge.condition), - ] - return { - kind: 'branch', - gatewayKey: nodeKey, - branches: ordered.map((edge, index) => ({ - edgeKey: edge.edgeKey, - label: edge.name || '', - priority: index + 1, - condition: edge.condition ?? null, - child: build(edge.to, merge), - })), - next: merge ? build(merge, stopKey) : null, - } - } - - const nextEdge = (outMap.get(nodeKey) || [])[0] - return { kind: 'step', nodeKey, next: nextEdge ? build(nextEdge.to, stopKey) : null } - } - - let tree - try { - tree = build(starts[0].nodeKey) - } catch (error) { - if (error instanceof UnsupportedTopologyError) return unsupported(error.message, error.nodeKey) - throw error - } - - return { ok: true, tree, signature: treeSignature(tree) } -} - -/** 结构签名:只含拓扑、编码、条件与优先级,不含坐标 —— 用于往返一致性断言 */ -export function treeSignature(tree) { - const walk = (item) => { - if (!item) return null - if (item.kind === 'step') return { s: item.nodeKey, next: walk(item.next) } - return { - g: item.gatewayKey, - b: item.branches.map((branch) => ({ - e: branch.edgeKey, - p: branch.priority, - c: describeCondition(branch.condition), - child: walk(branch.child), - })), - next: walk(item.next), - } - } - return JSON.stringify(walk(tree)) -} - -/** 某个 FlowItem 的入口节点:分支块的入口是它的网关 */ -function entryKeysOf(item) { - if (!item) return [] - return item.kind === 'branch' ? [item.gatewayKey] : [item.nodeKey] -} - -/** - * 递归流程树 → 领域草稿(§5.2.8:写回同一份草稿,语义与 wf_* 保持不变)。 - * - * 节点与连线的编码优先复用 baseDraft 里已有的(§6.3:编码在本版本内必须稳定): - * 同名关系(from→to 相同)复用原 edgeKey,分支边直接复用分支自己的 edgeKey, - * 因此「加载 → 构树 → 编译」不会产生编码漂移。 - * - * @param tree workflowDraftToTree 产出的树,或经树操作修改过的树 - * @param baseDraft 原草稿,提供定义/版本/绑定与已有节点配置 - * @param extraNodes 树里新引入、baseDraft 中还没有的节点定义(树操作新增节点时传入) - */ -export function workflowTreeToDraft(tree, baseDraft, extraNodes = []) { - const knownNodes = new Map( - [...(baseDraft?.nodes || []), ...extraNodes].map((node) => [node.nodeKey, node]), - ) - const baseEdges = new Map((baseDraft?.edges || []).map((edge) => [edge.edgeKey, edge])) - - const nodes = [] - const edges = [] - const usedEdgeKeys = new Set() - - // 同名关系(from→to)的旧编码池:编译时优先复用,保证编码稳定 - const pairPool = new Map() - for (const edge of baseDraft?.edges || []) { - const pair = `${edge.from}->${edge.to}` - pairPool.set(pair, [...(pairPool.get(pair) || []), edge.edgeKey]) - } - - const ensureNode = (nodeKey) => { - if (nodes.some((node) => node.nodeKey === nodeKey)) return - const known = knownNodes.get(nodeKey) - // 未知节点按人工审批兜底:宁可留下一个待配置节点,也不静默丢结构 - nodes.push(known ? cloneDraft(known) : emptyNode(NODE_TYPES.USER_TASK, nodeKey, 0, 0)) - } - - const takeEdgeKey = (from, to) => { - const pair = `${from}->${to}` - const pool = pairPool.get(pair) || [] - const reused = pool.shift() - pairPool.set(pair, pool) - return reused || createEdgeKey([...(baseDraft?.edges || []), ...edges]) - } - - const addEdge = (from, to, condition, priority, preferredKey) => { - const edgeKey = - preferredKey && !usedEdgeKeys.has(preferredKey) ? preferredKey : takeEdgeKey(from, to) - usedEdgeKeys.add(edgeKey) - const base = baseEdges.get(edgeKey) - edges.push({ - edgeKey, - from, - to, - name: base?.name || '', - condition: condition ?? null, - priority, - }) - } - - /** 编译一个子树,返回它还需要接入父级后续的「出口节点」 */ - const compile = (item) => { - if (!item) return [] - - if (item.kind === 'step') { - ensureNode(item.nodeKey) - if (!item.next) return [item.nodeKey] - const entries = entryKeysOf(item.next) - const exits = compile(item.next) - entries.forEach((toKey) => addEdge(item.nodeKey, toKey, null, 1)) - return exits - } - - ensureNode(item.gatewayKey) - const branchExits = [] - item.branches.forEach((branch, index) => { - const entries = entryKeysOf(branch.child) - const childExits = compile(branch.child) - const targets = entries.length ? entries : entryKeysOf(item.next) - - if (!targets.length) { - // 既没有子节点也没有公共后续:保留原边,绝不静默丢边(§5.2.7) - const base = baseEdges.get(branch.edgeKey) - if (base) { - usedEdgeKeys.add(base.edgeKey) - edges.push({ ...base, condition: branch.condition ?? base.condition ?? null, priority: index + 1 }) - } - return - } - targets.forEach((toKey, position) => { - addEdge(item.gatewayKey, toKey, branch.condition, index + 1, position === 0 ? branch.edgeKey : null) - }) - if (entries.length) branchExits.push(...childExits) - }) - - if (!item.next) return branchExits - const nextEntries = entryKeysOf(item.next) - const nextExits = compile(item.next) - branchExits.forEach((fromKey) => { - nextEntries.forEach((toKey) => addEdge(fromKey, toKey, null, 1)) - }) - return nextExits - } - - compile(tree) - - return { - schemaVersion: SCHEMA_VERSION, - definition: baseDraft?.definition - ? { ...baseDraft.definition } - : { id: '', name: '', i18n: null, enabled: true, remark: '' }, - version: baseDraft?.version ? { ...baseDraft.version } : { id: '', versionNo: 1, status: 'draft' }, - bindings: (baseDraft?.bindings || []).map((binding) => ({ ...binding })), - nodes, - edges: normalizeEdgePriorities(edges), - } -} - -/* ---------------------------------------------------------------------------- - 发布前校验 - - §9.2「发布前必须再次执行服务端校验」里前端能判定的部分;后端在保存与发布时会 - 再校验一次,这里只是即时反馈(《FMS工作流与审批设计》§19.6)。 - ---------------------------------------------------------------------------- */ - -function issue(level, message, nodeKey = null) { - return { level, message, nodeKey } -} - -/** 从开始节点出发能走到的节点集合 */ -function reachableFrom(startKey, edges) { - const seen = new Set() - const queue = [startKey] - while (queue.length) { - const current = queue.shift() - if (seen.has(current)) continue - seen.add(current) - edges.filter((edge) => edge.from === current).forEach((edge) => queue.push(edge.to)) - } - return seen -} - -/** 有向图找环(返回任意一个环上的节点编码,用于提示) */ -function findCycle(nodes, edges) { - const out = groupByEndpoint(edges, 'from') - const state = new Map() - let hit = null - const walk = (key, path) => { - if (hit) return - const current = state.get(key) - if (current === 'visiting') { - hit = path.slice(path.indexOf(key)).join(' → ') - return - } - if (current === 'done') return - state.set(key, 'visiting') - for (const edge of out.get(key) || []) walk(edge.to, [...path, edge.to]) - state.set(key, 'done') - } - for (const node of nodes) walk(node.nodeKey, [node.nodeKey]) - return hit -} - -/** 条件里用到的字段编码 */ -function conditionFields(condition, result = []) { - if (!condition) return result - for (const logic of ['all', 'any']) { - if (condition[logic]) condition[logic].forEach((item) => conditionFields(item, result)) - } - if (condition.not) conditionFields(condition.not, result) - if (condition.field) result.push(condition.field) - return result -} - -/** - * 校验领域草稿。 - * @param options.fieldCodes 绑定模块可用字段编码集合(来自 s_field);不传则跳过字段存在性校验 - * @returns {{ level: 'error'|'warn', message: string, nodeKey: string|null }[]} - */ -export function validateWorkflowDomain(draft, options = {}) { - const nodes = draft?.nodes || [] - const edges = draft?.edges || [] - const bindings = draft?.bindings || [] - const issues = [] - const push = (level, message, nodeKey = null) => issues.push(issue(level, message, nodeKey)) - const nodeKeys = new Set(nodes.map((node) => node.nodeKey)) - const fieldCodes = options.fieldCodes ? new Set(options.fieldCodes) : null - - /* 流程定义与绑定(§6.2 / §9.2) */ - if (!String(draft?.definition?.id || '').trim()) push('error', '流程编码为空') - if (!String(draft?.definition?.name || '').trim()) push('error', '流程名称为空') - if (!bindings.length) { - push('error', '未配置流程绑定:流程无法被任何业务单据触发') - } - bindings.forEach((binding, index) => { - const label = `绑定 ${index + 1}` - if (!binding.moduleId) push('error', `${label}:未选择业务模块`) - if (!binding.event) push('error', `${label}:未选择业务事件`) - if (binding.effectiveFrom && binding.effectiveTo && binding.effectiveFrom > binding.effectiveTo) { - push('error', `${label}:生效开始时间晚于结束时间`) - } - }) - // 同一模块 + 事件的多个启用绑定,最终由后端按条件与优先级择一,前端只能提示复核 - const bindingGroups = new Map() - bindings - .filter((binding) => binding.enabled !== false && binding.moduleId && binding.event) - .forEach((binding) => { - const key = `${binding.moduleId}|${binding.event}` - bindingGroups.set(key, (bindingGroups.get(key) || 0) + 1) - }) - bindingGroups.forEach((count, key) => { - if (count > 1) { - push('warn', `模块 ${key.split('|')[0]} 的事件存在 ${count} 条启用绑定,需确认条件与优先级不会冲突`) - } - }) - - /* 结构(§6.2) */ - if (nodeKeys.size !== nodes.length) push('error', '存在重复的节点编码') - - const starts = nodes.filter((node) => node.nodeType === NODE_TYPES.START) - if (!starts.length) { - push('error', '缺少开始节点') - } else if (starts.length > 1) { - push('error', `存在 ${starts.length} 个开始节点,只允许一个`, starts[1].nodeKey) - } - - const ends = nodes.filter((node) => node.nodeType === NODE_TYPES.END) - if (!ends.length) push('error', '缺少结束节点') - - edges.forEach((edge) => { - if (!nodeKeys.has(edge.from) || !nodeKeys.has(edge.to)) { - push('error', '存在两端节点已不存在的连线') - } - }) - - if (starts.length) { - const reachable = reachableFrom(starts[0].nodeKey, edges) - nodes.forEach((node) => { - if (!reachable.has(node.nodeKey)) { - push('warn', `节点「${node.name}」从开始节点不可达`, node.nodeKey) - } - }) - } - - const cycle = findCycle(nodes, edges) - if (cycle) push('error', `存在环形路径:${cycle}`) - - /* 出入度(§5.2.6:树形编辑器支持的流程里,开始/审批/抄送必须恰有一条出边,结束没有出边) */ - nodes.forEach((node) => { - const outgoing = outgoingEdgesOf(draft, node.nodeKey) - if (node.nodeType === NODE_TYPES.END) { - if (outgoing.length) push('error', `结束节点「${node.name}」不应有出线`, node.nodeKey) - return - } - if (!outgoing.length) { - push('error', `节点「${node.name}」没有出线,流程会卡在这里`, node.nodeKey) - return - } - if (node.nodeType !== NODE_TYPES.EXCLUSIVE_GATEWAY && outgoing.length > 1) { - push('error', `节点「${node.name}」有多条出线,只有条件分支可以分支`, node.nodeKey) - } - }) - - /* 节点配置(§6.4 / §6.6 / §9.2) */ - nodes - .filter((node) => ACTOR_NODE_TYPES.includes(node.nodeType)) - .forEach((node) => { - const who = node.nodeType === NODE_TYPES.CC_TASK ? '抄送人' : '审批人' - const rules = node.actorRules || [] - if (!rules.length) { - push('error', `「${node.name}」未配置${who}规则`, node.nodeKey) - return - } - rules.forEach((rule) => { - const meta = ACTOR_TYPES.find((item) => item.value === rule.actorType) - if (!meta) { - push('error', `「${node.name}」存在未知的${who}解析类型`, node.nodeKey) - return - } - if (meta.control === 'none') return - if (!rule.actorRef) { - push('error', `「${node.name}」的${meta.label}规则未选择具体对象`, node.nodeKey) - return - } - if (meta.control === 'field' && fieldCodes && !fieldCodes.has(rule.actorRef)) { - push('error', `「${node.name}」引用的字段 ${rule.actorRef} 不在绑定模块的字段里`, node.nodeKey) - } - }) - - // 自审风险(§9.2):只有人工审批节点才谈得上「申请人自审」,抄送发起人自己是正常的 - if ( - node.nodeType === NODE_TYPES.USER_TASK && - rules.some((rule) => rule.actorType === 'initiator') && - !node.config?.allowInitiator - ) { - push('warn', `「${node.name}」把发起人作为审批人,但未开启「允许申请人自审」`, node.nodeKey) - } - - const config = node.config || {} - if (config.approvalMode === 'n_of_m') { - const count = Number(config.requiredCount) - if (!Number.isFinite(count) || count <= 0) { - push('error', `「${node.name}」选择 N 人通过但未填写通过人数`, node.nodeKey) - } - } - if (config.approvalMode === 'percentage') { - const ratio = Number(config.requiredRatio) - if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1) { - push('error', `「${node.name}」的通过比例必须在 0 到 1 之间`, node.nodeKey) - } - } - const dueHours = config.dueHours - if (dueHours != null && dueHours !== '' && !(Number(dueHours) > 0)) { - push('error', `「${node.name}」的审批时限必须大于 0`, node.nodeKey) - } - }) - - nodes - .filter((node) => BRANCH_NODE_TYPES.includes(node.nodeType)) - .forEach((node) => { - if (node.nodeType !== NODE_TYPES.EXCLUSIVE_GATEWAY) { - push('warn', `「${node.name}」在第一阶段仅占位,发布后不会执行`, node.nodeKey) - } - }) - - /* 连线条件(§5.2.3 / §5.2.6 / §6.5 / §7.1) */ - nodes - .filter((node) => node.nodeType === NODE_TYPES.EXCLUSIVE_GATEWAY) - .forEach((node) => { - const outgoing = outgoingEdgesOf(draft, node.nodeKey) - if (!outgoing.length) { - push('error', `条件分支「${node.name}」没有出线`, node.nodeKey) - return - } - if (outgoing.length < 2) { - push('error', `条件分支「${node.name}」至少要有两条分支`, node.nodeKey) - } - const defaults = outgoing.filter((edge) => !edge.condition) - if (!defaults.length) { - push('error', `条件分支「${node.name}」缺少默认出口(需且仅需一条无条件连线)`, node.nodeKey) - } else if (defaults.length > 1) { - push('error', `条件分支「${node.name}」有 ${defaults.length} 条默认出口,只允许一条`, node.nodeKey) - } - // 原始 b_priority 重复时判断顺序只能退化成连线顺序(保存时按规范重排),提示复核 - const rawPriorities = edges - .filter((edge) => edge.from === node.nodeKey) - .map((edge) => Number(edge.priority) || 0) - if (new Set(rawPriorities).size !== rawPriorities.length) { - push('warn', `条件分支「${node.name}」的分支优先级有重复,将按连线顺序确定判断顺序`, node.nodeKey) - } - // 显式条件按优先级依次判断,「其他情况」必须排在最后(§5.2.3) - if (defaults.length === 1 && outgoing[outgoing.length - 1].condition) { - push('warn', `条件分支「${node.name}」的「其他情况」不在最后,保存时会按规范重排`, node.nodeKey) - } - }) - - edges.forEach((edge) => { - const from = nodes.find((node) => node.nodeKey === edge.from) - if (edge.condition && from && from.nodeType !== NODE_TYPES.EXCLUSIVE_GATEWAY) { - push('error', `「${from.name}」不是条件分支,其出线不应带条件`, from.nodeKey) - } - for (const field of conditionFields(edge.condition)) { - if (fieldCodes && !fieldCodes.has(field)) { - push('error', `连线条件引用的字段 ${field} 不在绑定模块的字段里`) - } - } - walkConditionIssues(edge.condition, push) - }) - - return issues -} - -/** 条件叶子本身的完整性(缺字段、缺操作符、缺比较值) */ -function walkConditionIssues(condition, push) { - if (!condition) return - for (const logic of ['all', 'any']) { - if (condition[logic]) condition[logic].forEach((item) => walkConditionIssues(item, push)) - } - if (condition.not) walkConditionIssues(condition.not, push) - if (!condition.field) { - if (!condition.all && !condition.any && !condition.not) push('error', '存在缺少字段的条件') - return - } - if (!condition.operator) { - push('error', `条件字段 ${condition.field} 未选择操作符`) - return - } - if (OPERATORS_WITHOUT_VALUE.includes(condition.operator)) return - if (OPERATORS_WITH_RANGE_VALUE.includes(condition.operator)) { - const range = Array.isArray(condition.value) ? condition.value : [] - if (range.filter((item) => item !== '' && item != null).length < 2) { - push('error', `条件字段 ${condition.field} 的区间不完整`) - } - return - } - if (OPERATORS_WITH_LIST_VALUE.includes(condition.operator)) { - const list = Array.isArray(condition.value) ? condition.value : [] - if (!list.length) push('error', `条件字段 ${condition.field} 未填写比较值`) - return - } - if (condition.value === '' || condition.value == null) { - push('error', `条件字段 ${condition.field} 未填写比较值`) - } -} - -/** 版本状态文案(列表与工具栏共用) */ -export function versionStatusLabel(status) { - return VERSION_STATUS.find((item) => item.value === status)?.label || status || '' -} diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/index.js b/code/fms-workflow-refactor-backup-20260923/workflow/designer/index.js deleted file mode 100644 index e8ffbab2..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 流程设计器出口。 - * - * 页面按 `import { WorkflowDesigner } from '@/views/system/workflow/designer'` 使用。 - * 领域模型适配(domain.js)、接口(api.js)与节点注册(nodes.js)按需单独引用, - * 不从本文件透出,避免页面误用内部实现。 - */ -import WorkflowDesigner from './WorkflowDesigner.vue' -import WorkflowCreateDialog from './WorkflowCreateDialog.vue' - -export { WorkflowDesigner, WorkflowCreateDialog } diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/designer/nodes.js b/code/fms-workflow-refactor-backup-20260923/workflow/designer/nodes.js deleted file mode 100644 index ab1796e4..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/designer/nodes.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 流程设计器 —— LogicFlow 自定义节点注册。 - * - * 7 种画布节点与 wf_node.b_node_type 一一映射(§19.5): - * fms-start / fms-user-task / fms-cc-task / fms-exclusive-gateway - * fms-parallel-gateway / fms-service-task / fms-end - * - * 形状与尺寸在模型层用 setAttributes() 定(矩形 width/height、圆 r、菱形 rx/ry), - * 配色覆写 getNodeStyle()。本项目没装 @logicflow/vue-node-registry, - * 因此节点一律用 LogicFlow 原生 SVG 形状渲染,不内嵌 Vue 组件。 - */ -import { - CircleNode, - CircleNodeModel, - DiamondNode, - DiamondNodeModel, - RectNode, - RectNodeModel, -} from '@logicflow/core' -import { CANVAS_TO_NODE_TYPE, NODE_META } from './constants' - -const TEXT_STYLE = { - color: '#1f2329', - fontSize: 12, - overflowMode: 'autoWrap', - wrapPadding: '6,8', -} - -/** 画布类型 → 节点元数据 */ -function metaOf(canvasType) { - return NODE_META[CANVAS_TO_NODE_TYPE[canvasType]] -} - -/** - * 连线规则(§19.6 编辑器侧的即时校验): - * 开始节点只能作为起点,结束节点只能作为终点,避免连出语义不成立的流程。 - * 返回值是 LogicFlow 的 ConnectRule:{ message, validate }。 - */ -function connectionRules(canvasType) { - if (canvasType === 'fms-start') { - return { target: { message: '开始节点不能作为连线目标', validate: () => false } } - } - if (canvasType === 'fms-end') { - return { source: { message: '结束节点不能有出线', validate: () => false } } - } - return {} -} - -/** 圆形节点(开始 / 结束) */ -function circleConfig(canvasType) { - const meta = metaOf(canvasType) - const rules = connectionRules(canvasType) - class Model extends CircleNodeModel { - setAttributes() { - this.r = meta.size.r - } - - getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } - } - - getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } - } - - getConnectedSourceRules() { - const base = super.getConnectedSourceRules() - return rules.source ? [...base, rules.source] : base - } - - getConnectedTargetRules() { - const base = super.getConnectedTargetRules() - return rules.target ? [...base, rules.target] : base - } - } - class View extends CircleNode {} - return { type: canvasType, view: View, model: Model } -} - -/** 矩形节点(人工审批 / 抄送 / 服务节点) */ -function rectConfig(canvasType) { - const meta = metaOf(canvasType) - class Model extends RectNodeModel { - setAttributes() { - this.width = meta.size.width - this.height = meta.size.height - this.radius = 6 - } - - getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } - } - - getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } - } - } - class View extends RectNode {} - return { type: canvasType, view: View, model: Model } -} - -/** 菱形节点(条件分支 / 并行分支) */ -function diamondConfig(canvasType) { - const meta = metaOf(canvasType) - class Model extends DiamondNodeModel { - setAttributes() { - this.rx = meta.size.rx - this.ry = meta.size.ry - } - - getNodeStyle() { - return { ...super.getNodeStyle(), ...meta.style, strokeWidth: 2 } - } - - getTextStyle() { - return { ...super.getTextStyle(), ...TEXT_STYLE } - } - } - class View extends DiamondNode {} - return { type: canvasType, view: View, model: Model } -} - -/** 把 7 种工作流节点注册到 LogicFlow 实例 */ -export function registerWorkflowNodes(lf) { - lf.batchRegister([ - circleConfig('fms-start'), - circleConfig('fms-end'), - rectConfig('fms-user-task'), - rectConfig('fms-cc-task'), - rectConfig('fms-service-task'), - diamondConfig('fms-exclusive-gateway'), - diamondConfig('fms-parallel-gateway'), - ]) -} diff --git a/code/fms-workflow-refactor-backup-20260923/workflow/index.vue b/code/fms-workflow-refactor-backup-20260923/workflow/index.vue deleted file mode 100644 index 0d0fb50e..00000000 --- a/code/fms-workflow-refactor-backup-20260923/workflow/index.vue +++ /dev/null @@ -1,250 +0,0 @@ - - - - - diff --git a/code/fms/FMS通用保存接口条件更新设计.md b/code/fms/FMS通用保存接口条件更新设计.md index ae59f917..0b240c4f 100644 --- a/code/fms/FMS通用保存接口条件更新设计.md +++ b/code/fms/FMS通用保存接口条件更新设计.md @@ -1,7 +1,7 @@ # FMS 通用保存接口(saveobjt)条件更新设计 > 状态:设计稿(已补齐首期实现约束) -> 涉及:`fms-api` 通用保存链路、`fms-vue` 审批类动作 +> 涉及:`fms-api` 通用保存链路、`fms-vue` 审批类动作、workflow 草稿插件式接入评估 --- @@ -822,6 +822,154 @@ select ... from dbo.demo_order with (UPDLOCK, HOLDLOCK) where b_id = ? --- +## 14. Workflow 插件式接入评估 + +### 14.1 结论 + +可以把 workflow 的**前端调用入口**做成插件,并让插件把「草稿配置持久化」编排成一个 +`saveobjt` 请求;但不能把 workflow 后端实现整体搬到 `fms-vue/src/services`,也不能让前端 +直接用 `saveobjt` 改运行时表。 + +这里要区分两件事: + +| 层 | 可以插件化的内容 | 不能下沉到前端的内容 | +| --- | --- | --- | +| 前端 services | 请求构造、草稿 diff、多个表块合并、错误码映射、能力发现 | 权限判定、事务、锁、幂等的最终保证 | +| `saveobjt` | draft 配置表的原子增删改、`guards`、`$original` 冲突检测 | 流程校验、版本发布、节点推进、消息副作用 | +| workflow 后端 | 配置策略适配器、发布和运行命令 | 不能被浏览器代码替代 | + +因此,所谓“移动到前端 services”应理解为**移动调用适配层**,不是移动业务权威。 +前端插件只是 `saveobjt` 和现有 workflow command API 的统一外观;数据库权限、审计列和最终 +约束仍由服务端负责。 + +### 14.2 接口分类和迁移边界 + +| 现有接口 / 能力 | 插件方案 | 原因 | +| --- | --- | --- | +| `definition/save` | 可改为 `saveobjt` 草稿配置适配器,但要保留定义编码校验和创建初始版本的后端策略 | 新建定义会同时创建 `wf_version`,不能让两次独立请求代替一个事务 | +| `version/draft/save` | **首选迁移对象**:一个 `saveobjt` 请求更新版本、删除旧节点/连线/规则并插入新快照 | 这是纯配置快照写入,但必须有 draft guard 和并发冲突检测 | +| `version/validate` | 保留后端命令;前端可先做同构校验 | 需要读取模块字段白名单,前端校验不能成为最终规则 | +| `version/publish` / `version/retire` | 保留后端命令 | 发布校验、checksum、不可变版本和状态迁移必须在同一事务内完成 | +| `version/copy` | 保留后端命令 | 需要分配新版本号并复制三张子表,存在并发唯一性问题 | +| `binding/save/delete` | 默认保留后端命令;仅在增加 workflow 专用 save policy 后再评估 | 绑定要求已发布版本、优先级唯一,并会创建动作权限点 | +| `runtime/submit`、`task/action`、`claim`、`transfer`、`add-approver`、`withdraw`、`cancel`、`urge` | **禁止迁移为 `saveobjt` 直写** | 这些动作会锁定实例/任务,写审计,推进节点,投影业务状态,写 outbox,并依赖 `request_id` 幂等 | +| `todo/done/graph/detail/cc` 查询 | 可以继续由插件封装现有查询 API | 查询不改变运行状态 | +| `cc/read` | 默认保留专用接口 | 需要校验当前用户是抄送接收人;不能用无权限的通用更新替代 | + +运行时表 `wf_instance`、`wf_node_run`、`wf_task`、`wf_action`、`wf_cc`、`wf_outbox` 不得登记为 +普通 `saveobjt` 数据模块。前端传入这些表时,服务端应在保存入口直接拒绝,而不是依赖插件代码 +“自觉不调用”。这条是安全边界,不是 UI 约定。 + +### 14.3 草稿插件的服务契约 + +插件不直接暴露数据库表给业务页面,而是提供稳定的领域方法: + +```js +export const workflowPlugin = { + code: 'workflow', + capabilities: { + draftPersistence: 'saveobjt', + publish: 'command', + runtime: 'command', + }, + loadDraft, + saveDraft, + validateVersion, + publishVersion, + submit, + act, +} +``` + +页面只依赖 `workflowPlugin`,不依赖 `saveObjectApi`,也不拼 `wf_*` 表块。插件内部的 +`saveDraft` 才负责把领域草稿转换成保存请求;这样将来从 `saveobjt` 切回专用接口时,页面 +无需改动。 + +建议的最小请求形状如下(示意,子表删除行必须是完整唯一键): + +```json +[ + { + "table": "wf_version", + "key_field": "b_id", + "updates": [ + { + "b_id": "expense_v1", + "b_canvas_json": "{...}", + "$original": { "b_row_version": 7 } + } + ], + "guards": { + "updates": [ + { + "condition": "b_status = 'draft'", + "message": "流程草稿已发布或停用,不能继续修改", + "retryable": false + } + ] + } + }, + { + "table": "wf_node", + "key_field": "b_id", + "deletes": [{ "b_id": "old_node_1" }], + "inserts": [{ "b_id": "new_node_1", "b_version_id": "expense_v1", "...": "..." }], + "guards": { + "deletes": [ + { + "condition": "exists (select 1 from dbo.wf_version v where v.b_id = {b_version_id} and v.b_status = 'draft')", + "refs": ["wf_version"], + "message": "流程草稿已发布,不能删除节点", + "retryable": false + } + ], + "inserts": [ + { + "condition": "exists (select 1 from dbo.wf_version v where v.b_id = {b_version_id} and v.b_status = 'draft')", + "refs": ["wf_version"], + "message": "流程草稿已发布,不能新增节点", + "retryable": false + } + ] + } + } +] +``` + +上例要求 `wf_version` 有可比较的 `b_row_version`。当前运行表已有该列,但配置版本表还没有; +若启用草稿插件,应给 `wf_version` 增加版本列,并在每次草稿保存成功后递增。否则只能保护 +“仍是 draft”,不能防止两个设计器互相覆盖整个快照。节点、连线和审批人规则的删除与插入 +必须和版本行在同一 `saveobjt` 请求中完成,不能拆成多个请求。 + +### 14.4 服务端前置条件 + +草稿插件上线前必须增加 workflow 专用保存策略(可以是 `DataSaveService` 内的白名单适配器, +不要求新增一套 HTTP 协议): + +1. 只允许 `wf_definition`、`wf_version`、`wf_node`、`wf_edge`、`wf_actor_rule` 的 draft 写入; + `wf_binding` 先继续走专用服务,运行时表一律拒绝。 +2. 校验表块之间的 `b_version_id`、节点/连线引用和主版本存在性;不能只依赖外键或前端。 +3. `wf_version.b_status = 'draft'` 是必需 guard;已发布版本不得通过普通局部更新修改配置。 +4. 由服务端继续补审计列、校验当前用户的 workflow 管理权限,并统一返回 2002/2003。 +5. 发布前仍必须调用后端 validator;`saveDraft` 成功不等于流程可发布。 +6. 记录插件写入的 request id,至少能定位版本、操作者和保存结果;可重试动作仍使用后端幂等键。 + +未满足以上条件时,前端应继续调用 `version/draft/save`,不要为了“插件化”而绕过已有 +`WorkflowConfigService`。 + +### 14.5 推荐迁移顺序和验收 + +1. 先新增 `workflowPlugin` 外观,内部仍调用现有 `/workflow/**`,保证页面调用面先稳定。 +2. 给 `wf_version` 增加 `b_row_version`,实现草稿保存的 `saveobjt` 适配器和表白名单;保留旧接口作为回滚开关。 +3. 用并发设计器、已发布版本、半成品节点引用、重复提交和事务回滚用例验证后,再切换默认实现。 +4. 最后再评估 `definition/save`;`publish`、`runtime` 和 `cc/read` 不纳入本次迁移。 + +验收必须至少包括:两个设计器同时保存时一成一败并返回 2003;发布与草稿保存并发时不能修改已发布版本; +节点删除/插入任一步失败时版本和全部子表都回滚;前端无法通过 `saveobjt` 写入 `wf_task` 或 `wf_instance`; +运行时审批的锁、幂等、审计和业务状态投影回归用例全部通过。 + +--- + ## 附录 A:Demo(可直接执行) ### A.1 建表 diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/data/DataSaveService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/data/DataSaveService.java index 84bff40a..6b576595 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/data/DataSaveService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/data/DataSaveService.java @@ -25,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.StringJoiner; @Service @@ -46,6 +47,19 @@ public class DataSaveService { private static final String AUDIT_UPDATED_BY = "b_updated_by"; private static final String AUDIT_UPDATED_AT = "b_updated_at"; + /** + * Workflow 运行表只能由 WorkflowRuntimeService 写入。若允许通用 saveobjt 直写, + * 客户端可以绕过任务归属、动作权限、实例锁和 outbox,伪造审批状态。 + */ + private static final Set WORKFLOW_RUNTIME_TABLES = Set.of( + "wf_instance", + "wf_node_run", + "wf_task", + "wf_action", + "wf_cc", + "wf_outbox" + ); + @Resource private DataSource dataSource; @@ -482,6 +496,11 @@ public class DataSaveService { String tableName = ParamUtils.getRequiredString(request, "table"); String keyField = ParamUtils.getRequiredString(request, "key_field"); + if (isWorkflowRuntimeTable(tableName)) { + throw new BusinessException( + "Workflow 运行表必须通过流程服务写入,不能使用通用 saveobjt: " + tableName + ); + } DbUtils.TableMetadata table = loadMetadata( connection, tableName, @@ -594,6 +613,14 @@ public class DataSaveService { ); } + private boolean isWorkflowRuntimeTable(String tableName) { + String normalized = tableName == null ? "" : tableName.trim().toLowerCase(Locale.ROOT); + if (normalized.startsWith("dbo.")) { + normalized = normalized.substring("dbo.".length()); + } + return WORKFLOW_RUNTIME_TABLES.contains(normalized); + } + /** * 执行请求声明的依赖行删除(设计《删除策略》§6.2)。 * diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowConfigService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowConfigService.java index de550d77..0bb1abb4 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowConfigService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowConfigService.java @@ -59,6 +59,7 @@ public class WorkflowConfigService { new ActionPower("approve", "通过"), new ActionPower("reject", "驳回"), new ActionPower("return", "退回"), + new ActionPower("claim", "抢占"), new ActionPower("withdraw", "撤回"), new ActionPower("transfer", "转交"), new ActionPower("delegate", "委托"), @@ -335,7 +336,7 @@ public class WorkflowConfigService { connection.setAutoCommit(false); try { Map version = queryOne(connection, - "select b_id, b_definition_id, b_status from dbo.wf_version where b_id = ?", versionId); + "select b_id, b_definition_id, b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); if (version == null) { throw new BusinessException("版本「" + versionId + "」不存在"); } @@ -387,7 +388,7 @@ public class WorkflowConfigService { connection.setAutoCommit(false); try { Map version = queryOne(connection, - "select b_status from dbo.wf_version where b_id = ?", versionId); + "select b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); if (version == null) { throw new BusinessException("版本「" + versionId + "」不存在"); } @@ -740,10 +741,13 @@ public class WorkflowConfigService { ) throws SQLException { if (versionId != null && !versionId.isBlank()) { Map version = queryOne(connection, - "select b_id, b_version_no, b_status from dbo.wf_version where b_id = ?", versionId); + "select b_id, b_definition_id, b_version_no, b_status from dbo.wf_version where b_id = ?", versionId); if (version == null) { throw new BusinessException("版本「" + versionId + "」不存在"); } + if (!definitionId.equals(text(version.get("b_definition_id")))) { + throw new BusinessException("版本「" + versionId + "」不属于流程定义「" + definitionId + "」"); + } if (!STATUS_DRAFT.equals(String.valueOf(version.get("b_status")))) { throw new BusinessException("已发布或已停用的版本不能修改,请先复制出新版本"); } @@ -751,8 +755,9 @@ public class WorkflowConfigService { } Map draft = queryOne(connection, """ - select top 1 b_id, b_version_no, b_status from dbo.wf_version - where b_definition_id = ? and b_status = ? order by b_version_no desc + select top 1 b_id, b_version_no, b_status from dbo.wf_version with (UPDLOCK, HOLDLOCK) + where b_definition_id = ? and b_status = ? + order by b_version_no desc """, definitionId, STATUS_DRAFT); if (draft != null) { return draft; diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowRuntimeService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowRuntimeService.java index b07e0543..c8d22ae7 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowRuntimeService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/workflow/WorkflowRuntimeService.java @@ -162,7 +162,19 @@ public class WorkflowRuntimeService { connection.commit(); return submittedResult(connection, instance.instanceId(), false); - } catch (SQLException | RuntimeException exception) { + } catch (SQLException exception) { + rollback(connection, exception); + if (isUniqueViolation(exception) && !idempotencyKey.isEmpty()) { + Map duplicate = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_instance + where b_module_id = ? and b_business_id = ? and b_idempotency_key = ? + """, moduleId, businessId, idempotencyKey); + if (duplicate != null) { + return submittedResult(connection, String.valueOf(duplicate.get("b_id")), true); + } + } + throw exception; + } catch (RuntimeException exception) { rollback(connection, exception); throw exception; } finally { @@ -273,12 +285,12 @@ public class WorkflowRuntimeService { cancelSiblingTasks(connection, instance, text(nodeRun.get("b_id")), text(taskId), "同节点其余待办已自动取消"); writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", - userId, "pending", "approved", comment, idempotencyKey, null); + userId, status, "approved", comment, idempotencyKey, null); advance(connection, instance, text(nodeRun.get("b_node_key")), variables, 0); projectBusinessStatus(connection, instance, currentStatus(connection, instance.instanceId())); } else { writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", - userId, "pending", "running", comment, idempotencyKey, null); + userId, status, "running", comment, idempotencyKey, null); updateInstanceSummary(connection, instance, "审批中:" + nodeName); } } else { @@ -295,22 +307,22 @@ public class WorkflowRuntimeService { if ("return".equals(action)) { writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "return", - userId, "pending", "returned", comment, idempotencyKey, null); + userId, status, "returned", comment, idempotencyKey, null); finishInstance(connection, instance, "returned", nodeName); } else if ("return_previous".equals(rejectMode)) { writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", - userId, "pending", "rejected", comment, idempotencyKey, + userId, status, "rejected", comment, idempotencyKey, toJson(Map.of("rejectMode", rejectMode))); reenterPreviousNode(connection, instance, text(nodeRun.get("b_node_key")), variables); } else if ("return_initiator".equals(rejectMode)) { writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", - userId, "pending", "returned", comment, idempotencyKey, + userId, status, "returned", comment, idempotencyKey, toJson(Map.of("rejectMode", rejectMode))); finishInstance(connection, instance, "returned", nodeName); } else { completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", - userId, "pending", "rejected", comment, idempotencyKey, + userId, status, "rejected", comment, idempotencyKey, toJson(Map.of("rejectMode", "terminate"))); finishInstance(connection, instance, "rejected", nodeName); } @@ -318,7 +330,18 @@ public class WorkflowRuntimeService { connection.commit(); return submittedResult(connection, instance.instanceId(), false); - } catch (SQLException | RuntimeException exception) { + } catch (SQLException exception) { + rollback(connection, exception); + if (isUniqueViolation(exception) && !idempotencyKey.isEmpty()) { + Map duplicate = WorkflowSql.queryOne(connection, + "select top 1 b_id from dbo.wf_action where b_request_id = ?", + idempotencyKey); + if (duplicate != null) { + return Map.of("duplicated", true, "message", "重复请求已忽略"); + } + } + throw exception; + } catch (RuntimeException exception) { rollback(connection, exception); throw exception; } finally { @@ -415,7 +438,8 @@ public class WorkflowRuntimeService { connection.setAutoCommit(false); try { Map task = WorkflowSql.queryOne(connection, """ - select b_id, b_instance_id, b_status, b_claimed_by from dbo.wf_task with (UPDLOCK, ROWLOCK) + select b_id, b_instance_id, b_node_key, b_node_run_id, b_status, b_claimed_by + from dbo.wf_task with (UPDLOCK, ROWLOCK) where b_id = ? """, uuidParam(taskId)); if (task == null) { @@ -425,12 +449,23 @@ public class WorkflowRuntimeService { throw new BusinessException("任务已被处理或已被占用"); } InstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能抢占待办"); + } + requireAction(connection, instance.moduleId(), userId, "claim"); + Map node = WorkflowSql.queryOne(connection, """ + select b_config_json from dbo.wf_node + where b_version_id = ? and b_node_key = ? + """, instance.versionId(), text(task.get("b_node_key"))); + if (!configFlag(node == null ? null : node.get("b_config_json"), "allowClaim")) { + throw new BusinessException("当前节点未开启抢占待办"); + } WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'claimed', b_claimed_by = ?, b_claimed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() where b_id = ? """, userId, uuidParam(taskId)); - writeAction(connection, instance, null, taskId, "claim", userId, "pending", "pending", + writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "claim", userId, "pending", "claimed", "抢占待办", null, null); connection.commit(); return Map.of("taskId", taskId, "status", "claimed"); @@ -472,6 +507,10 @@ public class WorkflowRuntimeService { } requireEnabledUser(connection, toUserId); InstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能转交待办"); + } + requireAction(connection, instance.moduleId(), userId, "transfer"); WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_id = ? @@ -542,10 +581,21 @@ public class WorkflowRuntimeService { throw new BusinessException("该节点的待办已处理完,不能加签"); } InstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能加签"); + } requireAction(connection, instance.moduleId(), userId, "add_approver"); int seq = task.get("b_seq") instanceof Number number ? number.intValue() : 0; for (String target : userIds) { requireEnabledUser(connection, target); + Map activeTask = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_task + where b_node_run_id = ? and b_assignee_id = ? + and b_status in ('pending', 'claimed', 'waiting') + """, uuidParam(text(task.get("b_node_run_id"))), target); + if (activeTask != null) { + throw new BusinessException("用户「" + target + "」已经是该节点的活动审批人"); + } seq += 1; WorkflowSql.update(connection, """ insert into dbo.wf_task @@ -582,15 +632,22 @@ public class WorkflowRuntimeService { try { Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id, b_node_run_id, b_assignee_id, b_status - from dbo.wf_task where b_id = ? + from dbo.wf_task with (UPDLOCK, ROWLOCK) where b_id = ? """, uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在"); } + String status = text(task.get("b_status")); + if (!"pending".equals(status) && !"claimed".equals(status)) { + throw new BusinessException("只有待处理任务可以催办"); + } InstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + if (!"running".equals(instance.status())) { + throw new BusinessException("流程已结束,不能催办"); + } requireAction(connection, instance.moduleId(), userId, "urge"); writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "urge", userId, - text(task.get("b_status")), text(task.get("b_status")), comment, null, null); + status, status, comment, null, null); writeOutbox(connection, "task.urged", "task", taskId, Map.of("assigneeId", text(task.get("b_assignee_id")))); connection.commit(); @@ -731,8 +788,12 @@ public class WorkflowRuntimeService { String nodeRunId = recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, required); Integer dueMinutes = dueMinutesOf(node); + List taskUsers = resolved.userIds(); + if ("single".equals(mode) && taskUsers.size() > 1) { + taskUsers = List.of(taskUsers.get(0)); + } int seq = 0; - for (String userId : resolved.userIds()) { + for (String userId : taskUsers) { seq += 10; // 串行会签:同一时刻只有第一名是 pending,其余 waiting,通过一位激活下一位(§9.6) String status = "serial".equals(mode) && seq > 10 ? "waiting" : "pending"; @@ -1256,6 +1317,17 @@ public class WorkflowRuntimeService { return fallback; } + private boolean configFlag(Object json, String key) { + Object value = parseJsonMap(json).get(key); + if (value instanceof Boolean flag) { + return flag; + } + if (value instanceof Number number) { + return number.intValue() != 0; + } + return value instanceof String text && Boolean.parseBoolean(text.trim()); + } + private Object uuidParam(String value) { String text = text(value); if (text.isEmpty()) { @@ -1322,6 +1394,18 @@ public class WorkflowRuntimeService { } } + private boolean isUniqueViolation(SQLException exception) { + SQLException current = exception; + while (current != null) { + if (current.getErrorCode() == 2601 || current.getErrorCode() == 2627 + || "23000".equals(current.getSQLState())) { + return true; + } + current = current.getNextException(); + } + return false; + } + /** 实例运行上下文(一次动作内复用,避免反复回表) */ private record InstanceRow( String instanceId, diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/data/DataSaveServiceTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/data/DataSaveServiceTests.java index c21d3e50..639a2891 100644 --- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/data/DataSaveServiceTests.java +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/data/DataSaveServiceTests.java @@ -107,6 +107,22 @@ class DataSaveServiceTests { verify(fixture.insertStatement).setObject(2, ""); } + @Test + void rejectsWorkflowRuntimeTablesBeforeLoadingMetadata() throws Exception { + JdbcFixture fixture = new JdbcFixture("b_id", "b_status"); + DataSaveService service = fixture.createService(); + + assertThatThrownBy(() -> service.save(List.of(Map.of( + "table", "dbo.wf_task", + "key_field", "b_id", + "updates", List.of(Map.of("b_id", "T001", "b_status", "approved")) + )))).isInstanceOf(BusinessException.class) + .hasMessageContaining("Workflow 运行表必须通过流程服务写入"); + + verify(fixture.connection).rollback(); + verify(fixture.connection, org.mockito.Mockito.never()).createStatement(); + } + @Test void sqlFailureRollsBackAndReportsOperationLocation() throws Exception { JdbcFixture fixture = new JdbcFixture("b_id", "b_name"); diff --git a/code/fms/sql/fms_workflow.sql b/code/fms/sql/fms_workflow.sql index 38e55c8e..6767b971 100644 --- a/code/fms/sql/fms_workflow.sql +++ b/code/fms/sql/fms_workflow.sql @@ -90,6 +90,11 @@ create index ix_wf_version_status on dbo.wf_version (b_definition_id, b_status, b_version_no); GO +create unique index ux_wf_version_draft + on dbo.wf_version (b_definition_id) + where b_status = 'draft'; +GO + -- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程版本表(draft/published/retired,已发布版本不可原地修改)', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_version'; @@ -152,6 +157,11 @@ create index ix_wf_binding_select on dbo.wf_binding (b_module_id, b_event, b_canuse, b_priority, b_effective_from, b_effective_to); GO +create unique index ux_wf_binding_active_priority + on dbo.wf_binding (b_module_id, b_event, b_priority) + where b_canuse = 1; +GO + -- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程绑定表(模块 + 业务事件 + 条件 → 流程版本)', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_binding'; @@ -392,6 +402,11 @@ create index ix_wf_instance_version on dbo.wf_instance (b_version_id, b_started_at, b_id); GO +create unique index ux_wf_instance_idempotency + on dbo.wf_instance (b_module_id, b_business_id, b_idempotency_key) + where b_idempotency_key is not null; +GO + -- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'流程实例表(某张业务单据某次提交的运行流程)', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_instance'; @@ -601,6 +616,11 @@ create index ix_wf_action_operator on dbo.wf_action (b_operator_id, b_occurdatetime, b_id); GO +create unique index ux_wf_action_request + on dbo.wf_action (b_request_id) + where b_request_id is not null; +GO + -- 说明属性(MS_Description):SSMS 表设计器与列属性说明的来源 exec sys.sp_addextendedproperty @name=N'MS_Description', @value=N'审批动作表(只追加,不更新历史动作)', @level0type=N'SCHEMA', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'wf_action'; diff --git a/code/fms/workflow-saveobjt-demo.html b/code/fms/workflow-saveobjt-demo.html new file mode 100644 index 00000000..550307d2 --- /dev/null +++ b/code/fms/workflow-saveobjt-demo.html @@ -0,0 +1,417 @@ + + + + + + Workflow 与 saveobjt 并发演示 + + + +
+
+

Workflow 与 saveobjt 并发演示

+

先让 A、B 同时读取同一个流程快照,再分别提交。每次提交本身都是原子事务,但 A、B 是两个不同事务。

+
看结果时记住:“一个请求要么全成功要么全失败”不等于“多个请求会自动合并成一个流程状态机”。
+
+ +
+
+
+
+

1. 选择保存策略

+ +
+
+ + + + +
+

+
+ +
+
+

2. 两个审批人读取快照

+
+ + +
+
+
+
+
审批人 A未读取
+ 点击“读取 A” + +
+
+
审批人 B未读取
+ 点击“读取 B” + +
+
+
+ +
+
+

3. 提交审批

+
+ + + +
+
+

必须先读取快照。若 A、B 都先读取,再点击“按快照依次提交 A、B”,可以复现并发结果。

+
+ +
+

请求预览

当前选中策略生成的请求
+
先读取一个审批人的快照。
+
+
+ + +
+ + +
+ + + + diff --git a/code/test/form-js b/code/test/form-js index 39406918..d00e32ab 160000 --- a/code/test/form-js +++ b/code/test/form-js @@ -1 +1 @@ -Subproject commit 39406918f6d8b1b600e84c60fb67e15b5a65a513 +Subproject commit d00e32ab9237404e54f4897b19342fe7c7ed0efd diff --git a/code/test/vue-vben-admin b/code/test/vue-vben-admin index edbc42ca..2d708760 160000 --- a/code/test/vue-vben-admin +++ b/code/test/vue-vben-admin @@ -1 +1 @@ -Subproject commit edbc42caaaa5b33fe61349063cc94cd36a1c5f30 +Subproject commit 2d7087602b732e6c23c7696ee8430eb1e8221b56