Files
workspace/code/fms-workflow-refactor-backup-20260923/workflow/designer/domain.js
T
2026-09-23 16:54:54 +08:00

1490 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 流程设计器 —— 领域草稿、两种编辑器的适配,以及发布前校验。
*
* 领域草稿是唯一真相(《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 || ''
}