diff --git a/admin-web/src/ProcessDesigner.tsx b/admin-web/src/ProcessDesigner.tsx index d364816..ee20430 100644 --- a/admin-web/src/ProcessDesigner.tsx +++ b/admin-web/src/ProcessDesigner.tsx @@ -5,6 +5,7 @@ type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL'; type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId'; type Step = { id: string; name: string; assigneeVariable: Assignee }; type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string }; +type ProcessHistory = { key: string; version: number; name: string; mode: Mode; status: string; createdAt: string; template: { key: string; name: string; mode: Mode; steps: { name: string; assigneeVariable: Assignee }[]; conditionThreshold: number } }; const fallbackRules: ApprovalRule[] = [ { variable: 'approverId', label: '发起人所在部门主管', sourceType: 'POSITION', selector: 'manager', scope: 'APPLICANT_DEPARTMENT', emptyPolicy: 'REJECT_SUBMISSION', description: '根据发起人的主任职解析部门主管。' }, { variable: 'oaAdministratorId', label: '租户 OA 管理员', sourceType: 'ROLE', selector: 'oa_admin', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 OA 管理员。' }, @@ -28,7 +29,17 @@ export function ProcessDesigner() { const [selected, setSelected] = useState(steps[0].id); const [message, setMessage] = useState(''); const [rules, setRules] = useState(fallbackRules); - useEffect(() => { api('/admin/process-configuration/approver-rules').then(setRules).catch(error => setMessage(error instanceof Error ? error.message : '审批规则加载失败')); }, []); + const [history, setHistory] = useState([]); + async function loadConfiguration() { + try { + const [ruleResult, historyResult] = await Promise.all([ + api('/admin/process-configuration/approver-rules'), + api('/admin/process-configuration/processes'), + ]); + setRules(ruleResult); setHistory(historyResult); + } catch (error) { setMessage(error instanceof Error ? error.message : '流程配置加载失败'); } + } + useEffect(() => { void loadConfiguration(); }, []); const selectedStep = steps.find(step => step.id === selected); const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps; const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]); @@ -49,6 +60,12 @@ export function ProcessDesigner() { if (to < 0 || to >= steps.length) return; const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next); } + function loadTemplate(item: ProcessHistory) { + setKey(item.template.key); setName(item.template.name); setMode(item.template.mode); + const restored = item.template.steps.map(step => ({ id: crypto.randomUUID(), name: step.name, assigneeVariable: step.assigneeVariable })); + setSteps(restored); setSelected(restored[0]?.id ?? ''); setThresholdDays(item.template.conditionThreshold / 480); + setMessage(`${item.key} v${item.version} 已加载,可修改后部署新版本`); + } async function deploy() { try { const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({ @@ -56,6 +73,7 @@ export function ProcessDesigner() { conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480), }) }); setMessage(`${result.key} v${result.version} 已部署到 Flowable`); + await loadConfiguration(); } catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); } } @@ -71,7 +89,7 @@ export function ProcessDesigner() {
{effectiveSteps.map((step, index) =>
{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && ${thresholdDays}天` : undefined} />}
)}
{mode === 'PARALLEL' && <>} - + ; } diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 19750e7..f52434f 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -1 +1,2 @@ :root{font-family:Inter,"PingFang SC",system-ui,sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{margin:0}button{border:0;border-radius:10px;background:#425aa5;color:white;padding:10px 14px;font-weight:650;cursor:pointer}button:hover{filter:brightness(.95)}button:disabled{opacity:.5;cursor:not-allowed}input,textarea,select{width:100%;border:1px solid #d7dce8;border-radius:9px;padding:10px;background:white;font:inherit}.center,.login{min-height:100vh;display:grid;place-content:center;text-align:center;gap:12px}.login{max-width:460px;margin:auto}.brandMark{width:72px;height:72px;border-radius:22px;display:grid;place-content:center;margin:auto;background:#dce3ff;color:#304887;font-size:36px;font-weight:900}header{height:68px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}header strong{font-size:24px;margin-right:16px}header span{color:#6e7688}header nav{display:flex;gap:6px;background:#f1f3f8;padding:4px;border-radius:12px}.navButton,.navActive{background:transparent;color:#687087}.navActive{background:#fff;color:#304887;box-shadow:0 2px 8px #20263615}.ghost{background:#eef1f7;color:#334}.subHeader{height:66px;padding:0 24px;background:#fff;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e8ef}.subHeader strong{display:block}.subHeader span{display:block;color:#80889a;font-size:12px;margin-top:4px}.meta{display:flex;gap:18px;padding:16px 24px;background:#fff}.meta label{display:grid;grid-template-columns:auto 230px;gap:8px;align-items:center}.designer{display:grid;grid-template-columns:220px minmax(380px,1fr) 300px;min-height:calc(100vh - 200px)}aside{padding:18px;background:#fff;border-right:1px solid #e3e6ee}aside h2{font-size:15px;margin:10px 0}.palette{display:block;width:100%;text-align:left;margin-bottom:8px;background:#eef2ff;color:#304887}.canvas{padding:28px;overflow:auto}.phone{width:390px;min-height:690px;margin:auto;background:#f9fafc;border:10px solid #202636;border-radius:40px;padding:26px 18px;box-shadow:0 20px 50px #29304a26}.phoneHeader{text-align:center;font-size:20px;font-weight:800;margin-bottom:22px}.field{position:relative;background:white;border:2px solid transparent;border-radius:14px;padding:14px;margin-bottom:12px;box-shadow:0 3px 12px #26304a12}.field.selected{border-color:#5c70bb}.field label{display:block;font-size:13px;font-weight:700;margin-bottom:8px}.fieldActions{position:absolute;right:7px;top:6px;display:none;gap:3px}.field:hover .fieldActions,.field.selected .fieldActions{display:flex}.fieldActions button{padding:3px 7px;border-radius:6px}.field small{position:absolute;left:-9px;top:-9px;background:#425aa5;color:white;border-radius:50%;width:20px;height:20px;text-align:center}.properties{border:0;border-left:1px solid #e3e6ee}.properties label{display:block;margin-bottom:14px;font-size:13px;font-weight:650}.properties .check{display:flex;gap:8px;align-items:center}.properties .check input{width:auto}.properties textarea{min-height:110px}.properties pre{font-size:10px;white-space:pre-wrap;background:#151b2b;color:#dce6ff;padding:10px;border-radius:8px;max-height:260px;overflow:auto}.version{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee;font-size:12px}.version button{padding:5px 8px}.toast{position:fixed;z-index:10;top:78px;left:50%;transform:translateX(-50%);background:#172033;color:white;padding:10px 18px;border-radius:10px}.bindingPage{padding:30px;max-width:1280px;margin:auto}.bindingIntro{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.bindingIntro h1{margin:0 0 8px}.bindingIntro p{margin:0;color:#737b8d}.safeBadge{background:#e7f8ee;color:#247348;padding:8px 12px;border-radius:20px;font-size:12px;font-weight:700}.bindingGrid{display:grid;grid-template-columns:440px 1fr;gap:20px}.configCard,.flowCard,.bindingList{background:#fff;border:1px solid #e5e8ef;border-radius:16px;padding:22px;box-shadow:0 8px 24px #28324b0a}.configCard h2,.flowCard h2,.bindingList h2{font-size:17px;margin:0 0 18px}.configCard label{display:block;font-size:12px;font-weight:700;margin-bottom:13px}.configCard label input,.configCard label select{margin-top:6px}.twoColumns{display:grid;grid-template-columns:1fr 1fr;gap:12px}.rulePreview{background:#f2f5ff;border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:5px;margin:4px 0 14px;font-size:12px}.rulePreview span{color:#5d6680}.primaryWide{width:100%}.flowPreview{display:flex;align-items:center;justify-content:center;gap:12px;min-height:180px;background:#f8f9fc;border-radius:14px;padding:20px}.flowPreview i{font-style:normal;color:#8c94a7;font-size:24px}.flowNode{min-width:130px;text-align:center;padding:16px 12px;border:2px solid #dbe1ef;border-radius:14px;font-weight:750;background:#fff}.flowNode span{display:block;font-size:10px;font-weight:500;color:#778095;margin-top:6px}.flowNode.decision{border-color:#e8bc5e}.flowNode.target{border-color:#5871c3}.guardrails{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:16px;color:#4d5870;font-size:12px}.guardrails p{margin:0;background:#f6f8fb;padding:10px;border-radius:8px}.bindingList{margin-top:20px}.bindingList article{display:grid;grid-template-columns:1.3fr 1.5fr auto;gap:20px;align-items:center;padding:14px 0;border-top:1px solid #edf0f5}.bindingList article.inactive{opacity:.55}.bindingList article div:first-child span{display:block;color:#7a8293;font-size:12px;margin-top:5px}.conditions{display:flex;gap:8px;flex-wrap:wrap}.conditions span{background:#eef2fb;color:#415374;padding:5px 8px;border-radius:8px;font-size:11px}.dangerGhost{background:#fff0f0;color:#a23b3b}.empty{color:#8a91a0}.processDesignerPage{padding:28px;max-width:1380px;margin:auto}.processTop{display:flex;align-items:center;justify-content:space-between}.processTop h1{margin:0 0 7px}.processTop p{margin:0;color:#737b8d}.processMeta{display:flex;gap:16px;background:#fff;border:1px solid #e5e8ef;border-radius:14px;padding:16px;margin:20px 0}.processMeta label{display:grid;grid-template-columns:auto 240px;align-items:center;gap:10px;font-size:12px;font-weight:700}.modeSelector{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:18px}.modeButton,.modeActive{text-align:left;background:#fff;color:#374158;border:2px solid #e3e7ef}.modeActive{border-color:#5269b4;background:#f4f6ff}.modeSelector strong,.modeSelector span{display:block}.modeSelector span{font-size:11px;color:#7a8293;margin-top:5px}.processWorkspace{display:grid;grid-template-columns:1fr 320px;min-height:560px}.processCanvas{background:#eef1f7;padding:24px;border-radius:16px 0 0 16px;overflow:auto}.templateHint{display:flex;justify-content:space-between;color:#657087;font-size:12px}.processGraph{min-width:680px;min-height:430px;display:flex;align-items:center;justify-content:center;gap:12px}.templateNode,.approvalNode{background:#fff;border:2px solid #d7ddea;border-radius:14px;min-width:125px;padding:16px 12px;text-align:center;color:#263149}.templateNode small,.approvalNode small{display:block;color:#7e8799;font-size:10px;margin-top:6px}.templateNode.start{border-radius:30px;border-color:#55a77d}.templateNode.end{border-radius:30px;border-color:#ce6570}.templateNode.gateway{transform:rotate(45deg);min-width:100px;width:100px;height:100px;display:grid;place-content:center}.templateNode.gateway>*{transform:rotate(-45deg)}.graphArrow{position:relative;color:#7e8799;font-size:24px;text-align:center}.graphArrow span{position:absolute;white-space:nowrap;font-size:9px;top:-14px;left:50%;transform:translateX(-50%);color:#b06e24}.linearNodes{display:flex;align-items:center;gap:12px}.graphStep{display:flex;align-items:center;gap:12px}.approvalNode{position:relative}.approvalNode>span{position:absolute;left:-8px;top:-8px;background:#5269b4;color:#fff;border-radius:50%;width:20px;height:20px;display:grid;place-content:center;font-size:10px}.approvalNode.nodeSelected{border-color:#5269b4;box-shadow:0 0 0 4px #5269b419}.parallelBranches{display:flex;flex-direction:column;gap:14px}.processProperties{border:1px solid #e5e8ef;border-radius:0 16px 16px 0}.processProperties label{display:block;font-size:12px;font-weight:700;margin-bottom:14px}.processProperties label input,.processProperties label select{margin-top:6px}.nodeActions{display:flex;gap:6px;margin-bottom:18px}.nodeActions button{padding:7px 9px;font-size:11px}.ruleDetail{background:#f3f6fb;border-radius:10px;padding:10px;margin:-5px 0 14px;font-size:11px}.ruleDetail>div{display:flex;justify-content:space-between;margin-bottom:6px}.ruleDetail span{color:#7d8596}.ruleDetail p{line-height:1.5;color:#526078}.ruleDetail small{color:#9a6429}.validationError{background:#fff0f0;color:#9d3939;border-radius:9px;padding:10px;font-size:11px;margin-top:12px}.securityNote{background:#fff8e8;border-radius:10px;padding:12px;margin-top:20px;font-size:11px;color:#695933}.securityNote p{margin:6px 0 0;line-height:1.6}@media(max-width:1000px){.designer{grid-template-columns:180px 1fr}.properties{grid-column:1/-1}.meta{flex-direction:column}.meta label{grid-template-columns:100px 1fr}.bindingGrid{grid-template-columns:1fr}.bindingPage{padding:18px}.flowPreview{flex-direction:column}.flowPreview i{transform:rotate(90deg)}.processWorkspace{grid-template-columns:1fr}.processProperties{border-radius:0 0 16px 16px}.modeSelector{grid-template-columns:1fr}.processMeta{flex-direction:column}} +.processHistory{border-top:1px solid #e7eaf0;margin-top:18px;padding-top:10px}.processHistory>p{font-size:11px;color:#858d9e}.processHistory>button{width:100%;display:flex;justify-content:space-between;align-items:center;text-align:left;background:#f4f6fa;color:#303b52;margin-bottom:7px;padding:9px}.processHistory>button span,.processHistory>button strong,.processHistory>button small{display:block}.processHistory>button small{font-size:9px;color:#7c8597;margin-top:4px}.processHistory>button b{font-size:10px;color:#4660ad} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt index 94307ee..839555a 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/admin/configuration/ProcessConfigurationController.kt @@ -26,6 +26,17 @@ class ProcessConfigurationController( @GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} } @GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} } @GetMapping("/approver-rules") fun approverRules(@AuthenticationPrincipal jwt:Jwt):List { actor(jwt);return approvalRuleCatalog() } + @GetMapping("/processes") fun processes(@AuthenticationPrincipal jwt:Jwt):List> { + val a=actor(jwt) + return dsl.fetch("SELECT process_definition_key,version,process_definition_id,deployment_id,name,mode,status,template_spec::text template_spec,created_at FROM workflow.process_template WHERE tenant_id=? ORDER BY created_at DESC",a.tenantId).map { record -> + mapOf( + "key" to record.get("process_definition_key",String::class.java),"version" to record.get("version",Int::class.java), + "processDefinitionId" to record.get("process_definition_id",String::class.java),"deploymentId" to record.get("deployment_id",String::class.java), + "name" to record.get("name",String::class.java),"mode" to record.get("mode",String::class.java),"status" to record.get("status",String::class.java), + "template" to mapper.readValue(record.get("template_spec",String::class.java),Map::class.java),"createdAt" to record.get("created_at"), + ) + } + } @PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map { val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1) dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema)) @@ -46,11 +57,12 @@ class ProcessConfigurationController( audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE") } @PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) } - @PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map { + @PostMapping("/processes/deploy") @Transactional fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map { val a=actor(jwt);val xml=generateProcessTemplate(body) val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy() val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult() ?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败") + dsl.execute("INSERT INTO workflow.process_template(tenant_id,process_definition_key,version,process_definition_id,deployment_id,name,mode,template_spec,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSONB),?)",a.tenantId,definition.key,definition.version,definition.id,deployment.id,body.name,body.mode,mapper.writeValueAsString(body),a.id) audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode)) return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id) } diff --git a/backend/boot/src/main/resources/db/migration/V14__create_process_templates.sql b/backend/boot/src/main/resources/db/migration/V14__create_process_templates.sql new file mode 100644 index 0000000..59646f5 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V14__create_process_templates.sql @@ -0,0 +1,18 @@ +CREATE TABLE workflow.process_template ( + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + process_definition_key VARCHAR(64) NOT NULL, + version INTEGER NOT NULL CHECK (version > 0), + process_definition_id VARCHAR(255) NOT NULL, + deployment_id VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + mode VARCHAR(20) NOT NULL CHECK (mode IN ('SERIAL', 'PARALLEL', 'CONDITIONAL')), + template_spec JSONB NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DEPLOYED' CHECK (status IN ('DEPLOYED', 'SUSPENDED')), + created_by UUID NOT NULL REFERENCES identity.user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (tenant_id, process_definition_key, version), + UNIQUE (process_definition_id) +); + +CREATE INDEX idx_process_template_tenant_created + ON workflow.process_template (tenant_id, created_at DESC); diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 372704b..b3ea58f 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: AIOA API - version: 0.16.0 + version: 0.17.0 servers: - url: /api/v1 security: @@ -19,6 +19,8 @@ paths: put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } } /admin/process-configuration/processes/deploy: post: { operationId: deployProcessTemplate, summary: 校验并部署受约束的可视化流程模板, responses: { "200": { description: Flowable 流程定义已部署 } } } + /admin/process-configuration/processes: + get: { operationId: listProcessTemplates, summary: 查询可重新编辑的可视化流程部署历史, responses: { "200": { description: 流程模板版本列表 } } } /admin/process-configuration/approver-rules: get: { operationId: listApproverRules, summary: 查询流程设计器可用的组织审批人规则, responses: { "200": { description: 审批人规则目录 } } } /admin/organization/departments: diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index d9078fb..99d5df2 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -60,6 +60,7 @@ - [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证 - [x] Web 流程选择与表单绑定、条件路由预览和绑定启停 - [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署 +- [x] 可视化流程设计源数据持久化、部署历史和版本重新加载 ## Definition of Done diff --git a/docs/engineering/workflow-capabilities.md b/docs/engineering/workflow-capabilities.md index faef109..2d07e16 100644 --- a/docs/engineering/workflow-capabilities.md +++ b/docs/engineering/workflow-capabilities.md @@ -64,3 +64,5 @@ Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowab 审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin`、`hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。 自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。 + +每次从设计器部署时,平台同时保存租户、流程 Key、Flowable 版本、部署标识和受约束模板 JSON。管理员可从部署历史重新加载任意版本,修改后以相同 Key 部署为新版本;运行中实例仍引用原有 `processDefinitionId`。