/** * 流程设计器 —— 领域草稿、两种编辑器的适配,以及发布前校验。 * * 领域草稿是唯一真相(《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 || '' }