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