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