251 lines
10 KiB
JavaScript
251 lines
10 KiB
JavaScript
/**
|
||
* 流程设计器 —— 数据读取与流程服务接口。
|
||
*
|
||
* 分两类:
|
||
* 1. 只读查询走通用接口(/data/loaddata),不新增后端接口。读表没有「必须走流程服务」
|
||
* 的约束,直接查表最省事。
|
||
* 2. 草稿保存、校验、模拟、发布、停用走流程服务(《FMS工作流与审批设计》§13 /
|
||
* 《FMS审批流程交互重构设计》§6.3)。wf_* 的写入**不允许**走通用保存接口,
|
||
* 否则发布校验、版本快照、发布后不可改、幂等全部落空。
|
||
*
|
||
* 流程服务尚未实现,接口会以 404 失败。调用方用 isWorkflowServiceMissing 识别后
|
||
* 明确提示「流程服务未接入」,绝不能把失败伪装成保存成功(§12.7)。
|
||
*/
|
||
import { get, post, put } from '@/services/http'
|
||
import { loadDataApi } from '@/services/api'
|
||
import { rowsToWorkflowDraft } from './domain'
|
||
|
||
/**
|
||
* 流程编码会拼进查询条件里,先净化再做字符串拼接。
|
||
* 合法编码形如 expense_approval,只保留字母数字下划线连字符。
|
||
*/
|
||
function safeCode(value) {
|
||
return String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '')
|
||
}
|
||
|
||
function quote(value) {
|
||
return `N'${String(value).replaceAll("'", "''")}'`
|
||
}
|
||
|
||
async function query(viewName, condition, orderBy, searchColumns) {
|
||
const response = await loadDataApi(viewName, condition, orderBy, searchColumns)
|
||
return response?.data || []
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
流程服务可用性
|
||
---------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 判断错误是否属于「流程服务未接入」。
|
||
* 后端没有对应映射时返回 404;服务完全不可达时 axios 给不出 status。
|
||
*/
|
||
export function isWorkflowServiceMissing(error) {
|
||
const code = error?.code
|
||
return code === 404 || code === -1 || code === undefined || code === null
|
||
}
|
||
|
||
/** 流程服务未接入时统一的提示文案 */
|
||
export const WORKFLOW_SERVICE_MISSING_MESSAGE =
|
||
'流程服务未接入:保存 / 校验 / 模拟 / 发布需要后端工作流服务(见《FMS审批流程交互重构设计》§6.3),当前只在本页内存中编辑'
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
读取流程草稿
|
||
---------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 按流程编码读回领域草稿。
|
||
* 版本选择:优先取已发布版本,没有则取版本号最大的那条(草稿也能打开来编辑)。
|
||
* @param {string} definitionId wf_definition.b_id
|
||
* @returns {Promise<object>} 领域草稿(形状见 domain.js)
|
||
* @throws {Error} 流程或版本不存在时抛出,调用方自行兜底
|
||
*/
|
||
export async function loadWorkflowDraft(definitionId) {
|
||
const code = safeCode(definitionId)
|
||
if (!code) throw new Error('流程编码为空')
|
||
|
||
const definitions = await query('wf_definition', `b_id = ${quote(code)}`, null)
|
||
if (!definitions.length) throw new Error(`流程「${definitionId}」不存在`)
|
||
const definition = definitions[0]
|
||
|
||
const versions = await query('wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC')
|
||
if (!versions.length) throw new Error(`流程「${definitionId}」还没有版本`)
|
||
const version = versions.find((row) => row.b_status === 'published') || versions[0]
|
||
|
||
const versionId = version.b_id
|
||
const [bindings, nodes, edges, actorRules] = await Promise.all([
|
||
query('wf_binding', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
|
||
query('wf_node', `b_version_id = ${quote(versionId)}`, 'b_xh ASC, b_node_key ASC'),
|
||
query('wf_edge', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
|
||
query('wf_actor_rule', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
|
||
])
|
||
|
||
return rowsToWorkflowDraft({ definition, version, bindings, nodes, edges, actorRules })
|
||
}
|
||
|
||
/**
|
||
* 流程定义下的版本列表(版本记录页签、列表页的版本记录弹窗)。
|
||
* 走 v_wf_version 视图:节点数、定义名、状态中文都是视图派生列,不用前端再算一遍。
|
||
*/
|
||
export async function loadVersionList(definitionId) {
|
||
const code = safeCode(definitionId)
|
||
if (!code) return []
|
||
return query('v_wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC')
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
选择器数据源(§8.4)
|
||
---------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 可绑定流程的业务模块。
|
||
* 只取有保存表的 data / virtual 模块:没有保存表的模块(纯视图)不会产生单据,
|
||
* 也就不需要送审;工作流自己的模块(/workflow/ 路径下)不能绑定自己。
|
||
*/
|
||
export async function loadBindableModules() {
|
||
const condition =
|
||
"b_canuse = 1 and b_module_type in ('data','virtual') and b_save_table is not null and b_path not like '/workflow/%'"
|
||
return query('s_module', condition, 'b_path ASC, b_xh ASC, b_id ASC', [
|
||
'b_id',
|
||
'b_name',
|
||
'b_path',
|
||
'b_module_type',
|
||
'b_save_table',
|
||
])
|
||
}
|
||
|
||
/**
|
||
* 模块字段清单(条件字段、业务字段用户、业务状态字段都用它)。
|
||
* 字段编码与显示名来自 s_field,是条件 AST 里 field 的合法取值来源。
|
||
*/
|
||
export async function loadModuleFields(moduleId) {
|
||
const code = safeCode(moduleId)
|
||
if (!code) return []
|
||
return query('s_field', `b_module_id = ${quote(code)} and b_canuse = 1`, 'b_xh ASC, b_field ASC', [
|
||
'b_field',
|
||
'b_name',
|
||
'b_type',
|
||
'b_i18n',
|
||
])
|
||
}
|
||
|
||
/**
|
||
* 用户选择器的候选用户。
|
||
* 直接读 b_user 而不是 v_b_user —— 后者带明文密码列,选择器不需要也不该拿到。
|
||
*/
|
||
export async function loadUserOptions(keyword = '') {
|
||
const text = String(keyword ?? '').trim().replace(/[%_[\]^]/g, '')
|
||
const condition = text
|
||
? `b_canuse = 1 and (b_id like N'%${text}%' or b_name like N'%${text}%')`
|
||
: 'b_canuse = 1'
|
||
return query('b_user', condition, 'b_id ASC', ['b_id', 'b_name', 'b_dept_id'])
|
||
}
|
||
|
||
/** 部门清单:给用户选择器补部门名、给「部门负责人」来源做展示 */
|
||
export async function loadDeptOptions() {
|
||
return query('b_dept', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC', ['b_id', 'b_name'])
|
||
}
|
||
|
||
/**
|
||
* 用户选择器候选项(编码 + 姓名 + 部门名)。
|
||
* 整份清单只拉一次并在整个设计器内共享:Select 自带本地筛选,够用且少一次往返。
|
||
* 失败不缓存,下次调用重新尝试。
|
||
*/
|
||
let userOptionCache = null
|
||
let userOptionPromise = null
|
||
|
||
export function loadUserOptionCache() {
|
||
if (userOptionCache) return Promise.resolve(userOptionCache)
|
||
if (!userOptionPromise) {
|
||
userOptionPromise = (async () => {
|
||
const [users, depts] = await Promise.all([loadUserOptions(), loadDeptOptions()])
|
||
const deptName = new Map(depts.map((row) => [row.b_id, row.b_name]))
|
||
return users.map((row) => {
|
||
const name = row.b_name || row.b_id
|
||
const dept = row.b_dept_id ? deptName.get(row.b_dept_id) || row.b_dept_id : ''
|
||
return { value: row.b_id, label: dept ? `${name}(${dept})` : name }
|
||
})
|
||
})()
|
||
userOptionPromise = userOptionPromise.catch((error) => {
|
||
userOptionPromise = null
|
||
throw error
|
||
})
|
||
}
|
||
return userOptionPromise.then((options) => {
|
||
userOptionCache = options
|
||
return options
|
||
})
|
||
}
|
||
|
||
/** 测试与主体切换时清缓存(避免换机构后还显示上一个机构的人) */
|
||
export function clearWorkflowCaches() {
|
||
userOptionCache = null
|
||
userOptionPromise = null
|
||
}
|
||
|
||
/** 组织角色 / 岗位主数据尚未建表(见 §7.4 缺口清单),只能返回空列表并提示手动填编码 */
|
||
export const ORG_MASTER_DATA_MISSING = {
|
||
position: '岗位主数据(s_position)尚未建表',
|
||
org_role: '组织角色主数据尚未建表',
|
||
}
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
流程服务(§6.3)
|
||
---------------------------------------------------------------------------- */
|
||
|
||
/** 读取草稿(带绑定),流程服务版本 */
|
||
export function fetchDraftApi(definitionId) {
|
||
return get(`/workflow/definitions/${encodeURIComponent(safeCode(definitionId))}/draft`)
|
||
}
|
||
|
||
/** 新建流程定义与初始草稿 */
|
||
export function createDefinitionApi(payload) {
|
||
return post('/workflow/definitions', payload)
|
||
}
|
||
|
||
/**
|
||
* 保存草稿:事务内替换该版本的节点 / 连线 / 规则与画布布局。
|
||
* expectedUpdatedAt 用于并发保护(§7.3,两种方案只选一种,这里用 b_updated_at)。
|
||
*/
|
||
export function saveDraftApi(versionId, { draft, expectedUpdatedAt, requestId }) {
|
||
return put(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/draft`, {
|
||
draft,
|
||
expected_updated_at: expectedUpdatedAt ?? null,
|
||
request_id: requestId || '',
|
||
})
|
||
}
|
||
|
||
/** 服务端校验(前端校验只是体验,这里才是最终规则) */
|
||
export function validateVersionApi(versionId, draft) {
|
||
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/validate`, { draft })
|
||
}
|
||
|
||
/** 发布前模拟:输入业务样例,返回命中的绑定、条件、解析出的审批人与待办 */
|
||
export function simulateVersionApi(versionId, sample) {
|
||
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/simulate`, { sample })
|
||
}
|
||
|
||
/** 发布:固定版本摘要,写发布人与发布时间,之后禁止原地编辑 */
|
||
export function publishVersionApi(versionId, { requestId, expectedUpdatedAt } = {}) {
|
||
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/publish`, {
|
||
request_id: requestId || '',
|
||
expected_updated_at: expectedUpdatedAt ?? null,
|
||
})
|
||
}
|
||
|
||
/** 停用:不再接受新实例,运行中的实例继续走完 */
|
||
export function retireVersionApi(versionId) {
|
||
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/retire`, {})
|
||
}
|
||
|
||
/** 版本下的绑定列表 */
|
||
export function fetchBindingsApi(versionId) {
|
||
return get(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/bindings`)
|
||
}
|
||
|
||
/** 幂等键:重复点击保存 / 发布时后端据此去重(§12.1) */
|
||
export function createRequestId(prefix = 'req') {
|
||
const random = Math.random().toString(36).slice(2, 10)
|
||
return `${prefix}-${Date.now()}-${random}`
|
||
}
|