增强审批流管理能力,新增审批环节的 stageKey 区分关键环节与过路审批节点,完善钉钉回调日志记录,停用部分 HTTP 回调接口,改由集成方案驱动审批流,优化审批注册中心的查询逻辑。

This commit is contained in:
geht
2026-06-05 19:05:48 +08:00
parent fc4e3211ad
commit 1d0b4c9fbb
95 changed files with 8385 additions and 457 deletions

View File

@@ -708,3 +708,115 @@ jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslA
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】修复审批注册启用列不显示、清空启用环节无法保存 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心操作列新增查看明细抽屉 -----
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】查看明细按钮改用注册中心list权限并放宽接口鉴权 -----
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心环节同步/回退执行器+密炼PS无代码方案 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java
jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批流设计环节改读审批注册中心 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java
jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts
jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue
jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue
jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts
jeecgboot-vue3/src/components/ApprovalDesign/index.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】集成方案与审批注册中心环节绑定 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_137__mes_xsl_integration_plan_stage_bind.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】钉钉校对通过后原单状态同步修复 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批环节同步前置状态改字典下拉 -----
jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】VisualActionEditor兼容Flyway扁平actionConfig -----
jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】集成方案动作管理复用可视化编辑器 -----
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】密炼PS操作接口停用+流程节点改绑集成方案 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue
jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts
jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue
jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心新增查看明细菜单按钮权限并自动授权 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_136__mes_xsl_biz_doc_registry_trace_perm.sql
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】末节点批准方案可在流程设计器下拉中选择 -----
jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue
jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue
jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java
jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】识别环节支持手选且可为空 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java
jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】按实际流程节点生成并展示环节配置状态 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java
jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue
jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】按审批流程节点一键生成默认集成方案 -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts
jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】多轮审批集成幂等修复(台账recordId+回退强制重跑) -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java
-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】钉钉回调全链路统一日志前缀[钉钉回调] -----
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java

View File

@@ -0,0 +1,668 @@
# 审核集成功能 — 整体方案计划
> **版本:** V1.0
> **日期:** 2026-06-05
> **定位:** 在现有 MES 审批引擎之上,新增**审批后业务编排层**,实现「哪些单要审、审完自动生成/更新下游单」的可配置化集成。
> **原则:** 审批归审批、编排归编排、复杂逻辑归代码;与 Online 增强模型对齐、与现有 `@ApprovalBizAction` / Gate 台账兼容。
---
## 目录
- [一、背景与目标](#一背景与目标)
- [二、总体架构](#二总体架构)
- [三、功能模块设计](#三功能模块设计)
- [四、数据模型设计](#四数据模型设计)
- [五、核心引擎设计](#五核心引擎设计)
- [六、业务流程](#六业务流程)
- [七、前端设计](#七前端设计)
- [八、Online 表单集成策略](#八online-表单集成策略)
- [九、与现有机制的关系](#九与现有机制的关系)
- [十、分期实施计划](#十分期实施计划)
- [十一、技术规范](#十一技术规范)
- [十二、风险与应对](#十二风险与应对)
- [十三、验收标准](#十三验收标准)
- [十四、资源与依赖](#十四资源与依赖)
- [十五、首个试点场景](#十五首个试点场景)
- [十六、下一步行动](#十六下一步行动)
---
## 一、背景与目标
### 1.1 现状
项目已具备较完整的审批底座:
| 能力 | 现有实现 |
|-----|---------|
| 审批流设计 | `MesXslApprovalFlow` + 前端 `ApprovalDesign` |
| 审批办理 | `MesXslApprovalHandleServiceImpl` |
| 业务回调 | `IApprovalBizCallback` / `@ApprovalBizAction` |
| 跨通道门禁 | `MesXslApprovalGateService` + `MesXslApprovalRecord` |
| 钉钉/OA | Stream 回调 + 流程模板 |
**缺口:** 下游单据生成、跨单字段回写仍依赖各模块硬编码 Callback无法由实施/用户在界面配置。
### 1.2 建设目标
| 目标 | 说明 |
|-----|------|
| **G1 可配置** | 配置源单 → 审批策略 → 审后动作,减少重复 Java 开发 |
| **G2 可编排** | 支持 CREATE / UPDATE / CALL_API / CALL_HANDLER 多动作顺序执行 |
| **G3 可观测** | 每次编排有日志、幂等、失败可重试、台账可追踪 |
| **G4 可兼容** | 不破坏现有 CallbackOnline 表与 Codegen 表统一接入 |
| **G5 可扩展** | 复杂业务配方计算、BOM 展开)仍走 Java Handler |
### 1.3 不在本期范围(明确边界)
- 不替换现有审批流设计器(人怎么审仍用 Flow
- 不重做 Online BPM / Flowable 流程引擎
- 不做通用 ETL / 消息中间件级集成平台
- 不做前端 JS 编排引擎(审后动作必须服务端执行)
---
## 二、总体架构
```mermaid
flowchart TB
subgraph L1["L1 配置层"]
R[单据注册中心]
P[审核策略 Policy]
I[集成方案 Plan]
A[集成动作 Action]
end
subgraph L2["L2 审批层(已有)"]
G[ApprovalGate 门禁]
F[ApprovalFlow 审批流]
REC[ApprovalRecord 台账]
H[HandleService 办理引擎]
end
subgraph L3["L3 编排层(新建)"]
O[IntegrationOrchestrator]
M[FieldMappingEngine]
E[ActionExecutor 策略池]
L[IntegrationLog 执行日志]
end
subgraph L4["L4 执行通道"]
SQL[SQL_UPDATE]
API[CALL_API]
HD[CALL_HANDLER]
CR[CREATE/UPDATE Doc]
ONL[Online Form API]
end
R --> P --> G
P --> I --> A
G --> F --> H --> REC
H -->|终态事件| O
A --> O --> M --> E
E --> SQL & API & HD & CR
CR --> ONL
O --> L
```
### 三层职责划分
| 层 | 职责 | 配置主体 |
|----|------|---------|
| L1 配置层 | 定义单据元数据、审核策略、审后编排 | 实施/管理员 |
| L2 审批层 | 谁审、几级审、MES/钉钉通道 | 已有 Flow 设计器 |
| L3 编排层 | 审完自动生成/改数/调接口 | 集成方案设计器 |
---
## 三、功能模块设计
### 3.1 模块清单
| 模块 | 包路径建议 | 说明 |
|-----|-----------|------|
| 单据注册 | `approval.integration.registry` | 管理可参与集成的单据元数据 |
| 审核策略 | `approval.integration.policy` | 哪些单、何时必须审、绑哪条流 |
| 集成方案 | `approval.integration.plan` | 审后编排方案(含动作明细) |
| 编排引擎 | `approval.integration.orchestrator` | 统一调度入口 |
| 字段映射 | `approval.integration.mapping` | 变量解析、映射计算 |
| 动作执行器 | `approval.integration.executor.*` | 各 action_type 实现 |
| 执行日志 | `approval.integration.log` | 审计、重试、幂等 |
| 管理 API | `approval.integration.controller` | CRUD + 测试预览 + 手动重试 |
| 前端页面 | `views/xslmes/approval/integration/` | 4 个管理页 + 1 个设计器 |
### 3.2 与现有代码的挂接点(最小侵入)
在以下位置增加编排调用(**不替换**现有逻辑):
```
MesXslApprovalHandleServiceImpl
├─ 节点通过 → callbackDispatcher.fireNodeApproved()
│ └─ + integrationOrchestrator.execute(ctx, NODE_APPROVED)
├─ 最终通过 → callbackDispatcher.fireApproved()
│ └─ + integrationOrchestrator.execute(ctx, APPROVED)
└─ 驳回 → callbackDispatcher.fireRejected()
└─ + integrationOrchestrator.execute(ctx, REJECTED)
DingBpmsEventProcessor钉钉终态
└─ finishByExternalInstance 成功后
└─ + integrationOrchestrator.executeByRecord(record, APPROVED/REJECTED)
```
**执行顺序(同一事务内):**
1. 现有 `IApprovalBizCallback`(兼容老逻辑)
2. 现有节点 `callbackActions`HTTP 调用)
3. **新增**集成编排(按 `exec_order` 顺序)
任一环节抛异常 → 整笔审批动作回滚(与现有 Callback 事务策略一致)。
---
## 四、数据模型设计
### 4.1 表结构6 张)
#### ① `mes_xsl_biz_doc_registry` — 单据注册
| 字段 | 类型 | 说明 |
|-----|------|------|
| doc_code | varchar(64) | 业务编码,如 `formula_spec` |
| table_name | varchar(128) | 物理表名 |
| display_name | varchar(128) | 中文名 |
| doc_source | varchar(16) | `online` / `codegen` / `manual` |
| online_head_id | varchar(32) | Online 表 headId可空 |
| title_field | varchar(64) | 标题字段 |
| status_field | varchar(64) | 状态字段 |
| route_path | varchar(256) | 前端路由 |
| is_master | tinyint | 是否主表 |
| child_config | json | 子表 `[{table,fkField,docCode}]` |
| field_schema | json | 字段缓存(可从 Online/实体同步) |
| enabled | tinyint | 启用 |
#### ② `mes_xsl_approval_policy` — 审核策略
| 字段 | 说明 |
|-----|------|
| policy_code / policy_name | 策略编码/名称 |
| source_doc_code | 源单据 |
| enabled | 启用 |
| trigger_event | `manual` / `on_submit` / `on_status_change` |
| match_condition | 条件表达式Online exp 语法) |
| approval_channel | `mes` / `dingtalk` / `auto` |
| flow_id | MES 审批流 ID |
| ding_tpl_id | 钉钉模板 ID |
| integration_plan_id | 绑定的集成方案 |
| priority | 同单据多策略优先级 |
| tenant_id | 租户 |
#### ③ `mes_xsl_approval_integration_plan` — 集成方案
| 字段 | 说明 |
|-----|------|
| plan_code / plan_name | 方案编码/名称 |
| source_doc_code | 源单据 |
| match_condition | 方案级条件(可空=默认) |
| trigger_phase | `onApprove` / `onReject` / `onNodeApprove` |
| version | 版本号 |
| status | `0草稿 1已发布 2已停用` |
| remark | 备注 |
#### ④ `mes_xsl_approval_integration_action` — 集成动作
| 字段 | 说明 |
|-----|------|
| plan_id | 所属方案 |
| action_code / action_name | 动作编码/名称 |
| action_type | 见下表 |
| trigger_phase | 可覆盖方案级 phase |
| target_doc_code | 目标单据 |
| target_lookup | 定位已有单的 JSON 规则 |
| field_mappings | 字段映射 JSON |
| exec_config | 动作扩展配置 JSON |
| exec_order | 执行顺序 |
| on_fail | `stop` / `continue` |
| idempotent_key | 幂等键表达式 |
| enabled | 启用 |
**action_type 枚举:**
| 类型 | 说明 | 对标 Online |
|-----|------|------------|
| `SQL_UPDATE` | 执行 UPDATE SQL | SQL 增强 |
| `UPDATE_DOC` | 结构化更新单据 | — |
| `CREATE_DOC` | 生成主+子表 | Online form add |
| `CALL_API` | HTTP 调业务接口 | Java http 增强 |
| `CALL_HANDLER` | Spring Bean / Class | Java spring/class |
| `DELEGATE_ONLINE` | 委托 Online 增强 | 复用已有增强 |
#### ⑤ `mes_xsl_approval_integration_log` — 执行日志
| 字段 | 说明 |
|-----|------|
| record_id | 审批台账 ID |
| instance_id | MES 实例 ID可空 |
| plan_id / action_id | 方案/动作 |
| idempotent_key | 幂等键 |
| status | `success` / `failed` / `skipped` |
| source_biz_id | 源单 ID |
| target_biz_id | 目标单 ID |
| request_snapshot | 映射后 payload |
| response_snapshot | 执行结果 |
| error_message | 错误信息 |
| retry_count | 重试次数 |
| exec_time_ms | 耗时 |
#### ⑥ `mes_xsl_approval_record` 扩展字段
| 新增字段 | 说明 |
|---------|------|
| integration_status | `0未执行 1成功 2部分失败 3失败` |
| integration_remark | 编排摘要/错误 |
---
## 五、核心引擎设计
### 5.1 编排上下文 `IntegrationContext`
在现有 `ApprovalCallbackContext` 基础上扩展:
```java
IntegrationContext {
ApprovalCallbackContext approvalCtx; // 审批上下文
MesXslApprovalRecord record; // 台账
Map<String,Object> sourceRecord; // 源单主表数据
Map<String,List<Map>> sourceChildren; // 源单子表
Map<String,Object> vars; // 运行时变量池
Map<String,String> actionResults; // 前序动作产出(如 targetId
}
```
### 5.2 变量体系(对齐 Online SQL 增强)
| 变量 | 含义 |
|-----|------|
| `#{source.id}` / `#{id}` | 源单 ID |
| `#{source.字段名}` | 源单字段 |
| `#{sys_user_code}` | 当前操作人 |
| `#{sys_date}` / `#{sys_time}` | 系统日期时间 |
| `#{approval.instance_id}` | 审批实例 |
| `#{approval.apply_user}` | 发起人 |
| `#{action.xxx.target_id}` | 前序动作生成的目标单 ID |
### 5.3 字段映射 JSON 规范
```json
{
"masterMappings": [
{ "target": "spec_no", "type": "direct", "source": "formula_no" },
{ "target": "status", "type": "constant", "value": "1" },
{ "target": "approved_by", "type": "var", "value": "#{sys_user_code}" }
],
"childMappings": [
{
"targetChildTable": "mes_xsl_mixing_spec_line",
"sourceChildTable": "mes_xsl_formula_spec_line",
"fkField": "main_id",
"rowFilter": "stage_no <= 3",
"mappings": [
{ "target": "material_code", "type": "direct", "source": "material_code" }
]
}
]
}
```
**映射类型:** `direct` / `constant` / `var` / `expression` / `lookup` / `ref_action`
### 5.4 动作执行器接口
```java
public interface IIntegrationActionExecutor {
String supportActionType();
IntegrationActionResult execute(IntegrationContext ctx, IntegrationAction action);
}
// Handler 扩展(复杂场景)
public interface IIntegrationActionHandler {
String supportDocCode(); // "*" 通用
IntegrationActionResult execute(IntegrationContext ctx, JSONObject config);
}
```
### 5.5 幂等与重试
- **幂等键**:默认 `record_id + action_id`CREATE 可配 `source_id + target_doc_code`
- **已 success**跳过status=skipped
- **failed**管理端可「手动重试」retry_count+1
- **钉钉/MES 双通道**:以 `record_id` 为统一键,避免重复生成
---
## 六、业务流程
### 6.1 发起审批
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端/Online JS
participant G as ApprovalGate
participant P as PolicyService
participant L as LaunchController
participant R as ApprovalRecord
U->>FE: 点击「提交审核」
FE->>G: checkCanLaunch(bizTable, bizDataId)
G->>R: 查最新台账
G-->>FE: allowed / reason
FE->>P: 匹配审核策略(可选)
FE->>L: 发起审批flowId / dingTpl
L->>R: createRunningRecord
L->>H: enterFirstNode
```
### 6.2 审批通过 → 编排执行
```mermaid
sequenceDiagram
participant H as HandleService
participant CB as CallbackDispatcher
participant O as Orchestrator
participant E as ActionExecutor
participant L as IntegrationLog
H->>CB: fireApproved(ctx)
H->>O: execute(ctx, APPROVED)
O->>O: 匹配 Plan + Actions
loop 按 exec_order
O->>O: 检查幂等
O->>E: execute(action)
E-->>O: result / exception
O->>L: 写日志
end
O-->>H: 全部成功 / 抛异常回滚
```
### 6.3 配置发布流程
```
草稿方案 → 测试预览选源单IDdry-run 不落库)→ 发布 → 生效
↑ ↓
└──────── 版本回滚 ← 停用旧版本 ←────┘
```
---
## 七、前端设计
### 7.1 菜单结构
```
MES 审批管理
├── 审批流设计(已有)
├── 审批台账(已有)
├── 审核集成(新建)
│ ├── 单据注册中心
│ ├── 审核策略配置
│ ├── 集成方案管理
│ └── 集成执行日志
```
### 7.2 页面说明
| 页面 | 核心功能 |
|-----|---------|
| **单据注册中心** | 列表 CRUD从 Online 同步;从实体扫描;字段预览 |
| **审核策略配置** | 选源单 → 条件 → 绑 Flow/钉钉模板 → 绑集成方案 |
| **集成方案设计器** | 基本信息 → 动作列表 → 字段映射(主/子 Tab→ 测试预览 → 发布 |
| **集成执行日志** | 按台账/源单查;看 snapshot失败重试 |
### 7.3 集成方案设计器(核心 UI
**Step 1 — 基本信息**
源单据、触发时机、匹配条件(可视化 exp 构建器)
**Step 2 — 动作列表**
拖拽排序;增删动作;选 action_type
**Step 3 — 字段映射**
左:源单字段树(来自 registry / Online API
右:目标单字段树
中间:映射连线 + 类型选择
**Step 4 — 测试预览**
输入源单 ID → 返回映射后的 JSON + SQL 预览 → 不执行
**Step 5 — 发布**
版本号递增;旧版自动停用(同 source + phase 仅一个 published
---
## 八、Online 表单集成策略
| 场景 | 做法 |
|-----|------|
| 源单是 Online 表 | 注册时 `doc_source=online`,字段从 `listByHeadId` 同步 |
| 目标单是 Online 表 | `CREATE_DOC``POST /online/cgform/api/form/{code}` |
| 已有 SQL/Java 增强 | `DELEGATE_ONLINE` 动作,填 buttonCode + event |
| 发起端 | Online JS `beforeSubmit` 调 Gate API自定义「提交审核」按钮 |
| 与 Online BPM | **不混用**Online 表统一走 MES Gate + Policy |
### Online 增强机制借鉴对照
| Online 增强 | 审核集成模块 | 现有审批 |
|------------|-------------|---------|
| `buttonCode` | `action_code` | `@ApprovalBizAction.name` |
| `event: start/end` | `trigger_phase` | `phase: onApprove/onReject` |
| SQL 增强 | `UPDATE_DOC` / `SQL_UPDATE` | 节点 callbackActions |
| Java spring/class/http | `CALL_HANDLER` / `CALL_API` | Callback / HttpExecutor |
| 自定义按钮 `exp` | `match_condition` | 流程条件分支 |
| JS `beforeSubmit` | 发起审批前门禁 | ApprovalGate |
| `onl_cgform_field` | 字段映射数据源 | Flow.titleField |
---
## 九、与现有机制的关系
```
优先级(同一 trigger_phase
1. IApprovalBizCallback代码 SPI长期保留
2. Flow 节点 callbackActions精细节点控制
3. IntegrationOrchestrator配置化编排新增
迁移策略:
- 新单据:优先配置集成方案
- 老 Callback逐步迁移不强制一次性改
- @ApprovalBizAction作为 CALL_API 动作的数据源(复用 Registry
```
---
## 十、分期实施计划
### Phase 0 — 基础骨架(约 2 周)
**目标:** 跑通「审通过后 SQL 改字段」最小闭环
| 交付物 | 内容 |
|-------|------|
| Flyway | 6 张表 DDL + 字典项 |
| 后端 | registry / policy / plan / action CRUD |
| 引擎 | `IntegrationOrchestrator` + `SQL_UPDATE` 执行器 |
| 挂接 | `HandleService` 终态调用编排 |
| 日志 | 幂等 + integration_log |
| 前端 | 策略列表 + 方案列表JSON 编辑,暂无可视化映射) |
| 试点 | 选 1 个简单单据:审批通过 UPDATE status |
**验收:** 配置一条方案 → 审批通过 → 源单字段被更新 → 日志可查 → 重复回调不重复执行
---
### Phase 1 — 单据编排(约 3 周)
**目标:** 支持 CREATE / UPDATE 主表 + 子表
| 交付物 | 内容 |
|-------|------|
| 引擎 | `FieldMappingEngine` + `CREATE_DOC` + `UPDATE_DOC` |
| 注册 | Online 同步 + Codegen 手工注册 |
| 执行 | Codegen 走 ServiceOnline 走 form API |
| 前端 | 单据注册页 + 方案动作配置(表单式) |
| 试点 | 1 条「源单 → 生成下游单」业务链 |
**验收:** 审批通过自动生成主子表下游单;源单回写下游 ID
---
### Phase 2 — 扩展与对接(约 2 周)
**目标:** 对接现有 `@ApprovalBizAction`、钉钉通道、Handler
| 交付物 | 内容 |
|-------|------|
| 执行器 | `CALL_API`(复用 ApprovalActionHttpExecutor |
| 执行器 | `CALL_HANDLER`spring/class |
| 执行器 | `DELEGATE_ONLINE` |
| 挂接 | DingBpmsEventProcessor 终态触发编排 |
| 台账 | record.integration_status 展示 |
| 前端 | 执行日志页 + 手动重试 |
**验收:** MES/钉钉双通道审通过后编排一致;失败可重试
---
### Phase 3 — 可视化设计器(约 3 周)
**目标:** 实施人员可自助配置,无需写 JSON
| 交付物 | 内容 |
|-------|------|
| 前端 | 字段映射可视化(双树连线) |
| 前端 | 条件表达式构建器Online exp 语法) |
| 后端 | dry-run 测试预览 API |
| 后端 | 方案版本管理 + 发布/回滚 |
| 文档 | 实施配置手册 |
**验收:** 实施独立完成一条新集成链路,无需改 Java 代码
---
### Phase 4 — 增强与治理(持续)
| 内容 |
|-----|
| 监控告警(编排失败 IM/钉钉通知) |
| 批量迁移老 Callback 到配置 |
| expression / lookup 增强 |
| 性能优化(大子表批量 insert |
| 权限:集成方案按角色授权 |
---
## 十一、技术规范
### 11.1 代码规范
- 包路径:`org.jeecg.modules.xslmes.approval.integration.*`
- 遵循 JeecgBoot 实体/Controller/Service 模式
- 所有改动加 `update-begin/end` 注释(需 bug 号/需求号)
- 表名/字段名白名单校验(复用现有 `IDENTIFIER` Pattern
### 11.2 字典项(新增)
| 字典 | 值 |
|-----|-----|
| mes_xsl_integration_action_type | SQL_UPDATE / CREATE_DOC / UPDATE_DOC / CALL_API / CALL_HANDLER / DELEGATE_ONLINE |
| mes_xsl_integration_trigger_phase | onApprove / onReject / onNodeApprove |
| mes_xsl_integration_plan_status | 0草稿 / 1已发布 / 2已停用 |
| mes_xsl_integration_log_status | success / failed / skipped |
| mes_xsl_biz_doc_source | online / codegen / manual |
### 11.3 安全
- SQL_UPDATE 仅允许 UPDATE/INSERT禁止 DROP/DELETE 全表)
- 变量替换后 SQL 预编译或严格转义
- CALL_API 仅允许内部白名单路径(复用 BizActionRegistry
- 编排执行默认用「系统机器人」账号,可配置为「最后审批人」
---
## 十二、风险与应对
| 风险 | 影响 | 应对 |
|-----|------|------|
| 编排失败导致审批回滚 | 用户困惑 | 默认同事务;可选 afterCommit 异步 + 补偿Phase 4 |
| 主子表映射配置错误 | 脏数据 | dry-run 预览 + 发布前校验 + 日志 snapshot |
| 与现有 Callback 重复执行 | 双写 | 迁移期文档明确;方案级开关 `skip_legacy_callback` |
| Online 与 Codegen 执行路径不一致 | 行为差异 | 统一 DocExecutor 抽象,按 doc_source 路由 |
| 大子表性能 | 超时 | 批量 insert + 异步Phase 4 |
| 实施配置门槛高 | 推广慢 | Phase 3 可视化 + 模板库(常用方案复制) |
---
## 十三、验收标准
1. **配置化**:至少 3 条不同业务链路通过界面配置完成,无新增 Java Callback
2. **双通道**MES 审批、钉钉审批各至少 1 条链路编排正常
3. **幂等**:同一审批终态重复回调,下游单不重复生成
4. **可观测**:台账 + 集成日志可定位每次执行 input/output/error
5. **兼容**:现有 `RubberQuickTestStdApprovalCallback` 等老逻辑不受影响
6. **回滚**:编排失败时审批状态与业务数据一致(不回滚一半)
---
## 十四、资源与依赖
| 角色 | 职责 |
|-----|------|
| 后端 1 | 引擎 + 执行器 + 挂接 |
| 后端 0.5 | CRUD + Online 对接 |
| 前端 1 | 4 管理页 + 设计器 |
| 实施/业务 | 试点场景定义 + 验收 |
| DBA | Flyway 评审 |
**外部依赖:** MySQL、Redis可选缓存 registry、Online 模块 API、现有审批模块稳定运行。
---
## 十五、首个试点场景
选一条**链路清晰、主子表、价值明显**的业务做 Phase 0~1 试点:
> **配合示方**`mes_xsl_formula_spec`)审批通过 →
> ① UPDATE 源单 `audit_status=已批准`
> ② CREATE 下游**密炼示方**(主表 + 明细映射)
> ③ UPDATE 源单 `approved_mixing_id=下游ID`
该场景覆盖 CREATE + UPDATE + 主子映射,且与现有审批流已打通,最适合验证整体方案。
---
## 十六、下一步行动
1. **确认试点业务**(配合示方 or 其他)及需求号/bug 号(用于 update-begin 注释)
2. **评审本方案** — 确认 Phase 划分、表结构、是否与 Online BPM 边界
3. **启动 Phase 0** — 先出 Flyway DDL + `IntegrationOrchestrator` 骨架 + 1 条 SQL_UPDATE 试点
---
## 附录:相关现有代码索引
| 文件 | 说明 |
|-----|------|
| `approval/entity/MesXslApprovalFlow.java` | 审批流定义 |
| `approval/entity/MesXslApprovalRecord.java` | 审批台账 |
| `approval/callback/ApprovalCallbackContext.java` | 回调上下文 |
| `approval/callback/IApprovalBizCallback.java` | 业务回调 SPI |
| `approval/action/ApprovalBizAction.java` | 可发现业务动作注解 |
| `approval/service/impl/MesXslApprovalHandleServiceImpl.java` | 审批办理引擎 |
| `approval/service/impl/MesXslApprovalGateServiceImpl.java` | 跨通道门禁 |
| `approval/callback/ApprovalActionHttpExecutor.java` | 节点回调 HTTP 执行器 |
| `views/approval/flow/` | 前端审批流设计器 |
---
*文档维护:随 Phase 推进更新版本号与交付状态。*

View File

@@ -19,13 +19,13 @@
<artifactId>jeecg-boot-base-core</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 钉钉 Stream SDK: 官方 Stream 模式事件订阅,无需公网回调地址 GHT 20260604 -->
<!-- dingtalk-stream SDK GHT 20260604 -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<!-- 复用打印模板模块打印机枚举、业务绑定、PDF 提交队列 -->
<!-- jeecg-module-print 打印模板 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-system-biz</artifactId>

View File

@@ -60,6 +60,16 @@ public class ApprovalCallbackContext implements Serializable {
/** 当前/刚处理的节点名称 */
private String nodeName;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】新增stageKey区分关键环节节点与纯过路审批节点-----------
/**
* 节点绑定的审批环节(来自流程设计 props.stageKey
* null = 节点未配置 stageKey旧数据/手动添加),走降级启发式匹配。
* "" = 节点显式设为「纯过路审批」,不触发任何集成动作。
* 其他值 = 具体环节proofread / audit / approve直接作为集成方案匹配依据。
*/
private String stageKey;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】新增stageKey区分关键环节节点与纯过路审批节点-----------
/** 操作人 username系统自动处理时为 null/system */
private String operatorUsername;

View File

@@ -2,6 +2,7 @@ package org.jeecg.modules.xslmes.approval.callback;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@@ -28,6 +29,8 @@ public class ApprovalCallbackDispatcher {
/** 监听所有业务表的通配符 */
private static final String ANY_TABLE = "*";
private static final String DING_LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
private final ObjectProvider<List<IApprovalBizCallback>> callbacksProvider;
private final ApplicationEventPublisher eventPublisher;
@@ -60,17 +63,40 @@ public class ApprovalCallbackDispatcher {
private void dispatch(ApprovalCallbackContext ctx) {
if (ctx == null || oConvertUtils.isEmpty(ctx.getBizTable())) {
if (isDingTalkCallback(ctx)) {
log.info("{} 分发跳过ctx 或 bizTable 为空 action={}", DING_LOG_TAG,
ctx == null ? null : ctx.getAction());
}
return;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调分发器全量日志-----------
List<IApprovalBizCallback> callbacks = matchedCallbacks(ctx.getBizTable());
if (isDingTalkCallback(ctx)) {
log.info("{} 开始分发 action={} recordId={} bizTable={} bizDataId={} nodeId={} nodeName={} "
+ "finalResult={} callbackCount={} comment={}",
DING_LOG_TAG, ctx.getAction(), ctx.getInstanceId(), ctx.getBizTable(), ctx.getBizDataId(),
ctx.getNodeId(), ctx.getNodeName(), ctx.isFinalResult(), callbacks.size(), ctx.getComment());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调分发器全量日志-----------
// 1) 强类型回调:按表路由 + 通配
for (IApprovalBizCallback cb : matchedCallbacks(ctx.getBizTable())) {
for (IApprovalBizCallback cb : callbacks) {
invoke(cb, ctx);
}
// 2) 领域事件:松耦合监听(同步、同事务)
try {
eventPublisher.publishEvent(new ApprovalActionEvent(this, ctx));
if (isDingTalkCallback(ctx)) {
log.info("{} 分发完成 action={} bizTable={} bizDataId={}", DING_LOG_TAG,
ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId());
}
} catch (RuntimeException e) {
log.error("审批领域事件处理失败 table={}, bizId={}, action={}", ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
if (isDingTalkCallback(ctx)) {
log.error("{} 领域事件处理失败 action={} table={} bizId={}", DING_LOG_TAG,
ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId(), e);
} else {
log.error("审批领域事件处理失败 table={}, bizId={}, action={}",
ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
}
throw e;
}
}
@@ -91,6 +117,12 @@ public class ApprovalCallbackDispatcher {
}
private void invoke(IApprovalBizCallback cb, ApprovalCallbackContext ctx) {
boolean dingTalk = isDingTalkCallback(ctx);
String callbackName = cb.getClass().getSimpleName();
if (dingTalk) {
log.info("{} 执行业务回调 {} action={} bizTable={} bizDataId={}",
DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId());
}
try {
switch (ctx.getAction()) {
case NODE_APPROVED:
@@ -105,11 +137,26 @@ public class ApprovalCallbackDispatcher {
default:
break;
}
if (dingTalk) {
log.info("{} 业务回调完成 {} action={} bizTable={} bizDataId={}",
DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId());
}
} catch (RuntimeException e) {
log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}",
cb.getClass().getSimpleName(), ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
if (dingTalk) {
log.error("{} 业务回调失败 {} action={} table={} bizId={}",
DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId(), e);
} else {
log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}",
callbackName, ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e);
}
// 抛出以回滚整个审批动作,保证审批与业务数据一致
throw e;
}
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别钉钉Stream来源回调-----------
private boolean isDingTalkCallback(ApprovalCallbackContext ctx) {
return ctx != null && "dingtalk".equals(ctx.getOperatorUsername());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别钉钉Stream来源回调-----------
}

View File

@@ -18,6 +18,9 @@ import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizActionRegistry;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizActionVo;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -57,21 +60,13 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页字段解析-----
@Autowired
private JdbcTemplate jdbcTemplate;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】流程设计环节改读审批注册中心-----
@Autowired
private IMesXslBizDocRegistryService bizDocRegistryService;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】流程设计环节改读审批注册中心-----
/** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$");
/**
* 审批阶段关键字配置有序key=阶段标识name=阶段中文nodeType=对应节点类型keywords=列注释匹配关键字。
* 解析顺序即默认流程顺序:校对 -> 审核 -> 审批 -> 分发 -> 抄送。
*/
private static final String[][] STAGE_DEFS = new String[][]{
{"proofread", "校对", "approver", "校对"},
{"review", "审核", "approver", "审核|审查"},
{"approve", "审批", "approver", "审批|批准|核准"},
{"distribute", "分发", "approver", "分发|发放"},
{"cc", "抄送", "cc", "抄送"},
};
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页字段解析-----
/**
@@ -209,7 +204,7 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
/**
* 设计上下文:供全局"审批流程设计"悬浮按钮调用。
* 1) 根据当前功能页路由反查绑定的业务表;
* 2) 解析该表的字段,识别"校对/审核/审批/分发/抄送"等阶段字段(不存在不报错,存在即解析)
* 2) 从审批注册中心读取该单据已启用的审批环节及对应人员字段
* 3) 取/建该业务表的草稿审批流,返回流程记录(含id)供直接进入设计器。
*
* @param routePath 当前功能页前端路由(如 /xslmes/mesXslFormulaSpec/MesXslFormulaSpecList)
@@ -236,8 +231,8 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
String bizTableName = resolveBizTableName(table);
data.put("bizTableName", bizTableName);
// 3) 解析阶段字段
data.put("stages", parseStageFields(table));
// 3) 从审批注册中心解析启用环节
data.put("stages", parseRegistryStages(table));
// 4) 取/建草稿审批流
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
@@ -265,6 +260,18 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
return Result.OK(data);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按业务表查询审批注册中心启用环节-----
/**
* 供审批流列表「设计」入口调用:按业务表返回注册中心已启用环节(供左侧候选面板点选追加)。
*/
@Operation(summary = "审批流设计-注册中心启用环节")
@RequiresPermissions("approval:flow:design")
@GetMapping(value = "/registryStages")
public Result<List<Map<String, Object>>> registryStages(@RequestParam(name = "bizTable") String bizTable) {
return Result.OK(parseRegistryStages(bizTable));
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按业务表查询审批注册中心启用环节-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按业务表查可选回调动作-----
/**
* 查询某业务表已标注 @ApprovalBizAction 的可选回调动作,供设计器节点「回调接口」下拉选择。
@@ -355,8 +362,12 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
}
}
/** 业务表中文名:优先字典 mes_xsl_approval_biz_doc次表注释 */
/** 业务表中文名:优先审批注册中心,其次字典 mes_xsl_approval_biz_doc次表注释 */
private String resolveBizTableName(String table) {
MesXslBizDocRegistry registry = bizDocRegistryService.findActiveByTableName(table);
if (registry != null && oConvertUtils.isNotEmpty(registry.getDisplayName())) {
return registry.getDisplayName();
}
List<DictModel> items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc");
if (items != null) {
for (DictModel item : items) {
@@ -378,69 +389,39 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
return null;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】从审批注册中心解析启用环节-----
/**
* 解析表字段,识别审批阶段字段。每个阶段最多取一个字段(优先列注释含"人/员"的人员字段)
* 从审批注册中心读取已启用环节,映射为流程设计器候选节点
* 返回有序列表:[{stageKey, stageName, nodeType, field, fieldComment}]
*/
private List<Map<String, Object>> parseStageFields(String table) {
private List<Map<String, Object>> parseRegistryStages(String table) {
List<Map<String, Object>> stages = new ArrayList<>();
List<Map<String, Object>> columns;
try {
columns = jdbcTemplate.queryForList(
"SELECT column_name AS name, column_comment AS comment FROM information_schema.columns "
+ "WHERE table_schema = (SELECT DATABASE()) AND table_name = ? ORDER BY ordinal_position",
table);
} catch (Exception e) {
log.warn("查询表字段失败 table={}", table, e);
MesXslBizDocRegistry registry = bizDocRegistryService.findActiveByTableName(table);
if (registry == null || oConvertUtils.isEmpty(registry.getEnabledStages())) {
return stages;
}
for (String[] def : STAGE_DEFS) {
String stageKey = def[0];
String stageName = def[1];
String nodeType = def[2];
String[] keywords = def[3].split("\\|");
Map<String, Object> hit = matchStageColumn(columns, keywords);
if (hit != null) {
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stageKey", stageKey);
stage.put("stageName", stageName);
stage.put("nodeType", nodeType);
stage.put("field", hit.get("name"));
stage.put("fieldComment", hit.get("comment"));
stages.add(stage);
java.util.Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
String[][] ordered = new String[][]{
{ApprovalStageResolver.STAGE_PROOFREAD, "校对", registry.getProofreadByField()},
{ApprovalStageResolver.STAGE_AUDIT, "审核", registry.getAuditByField()},
{ApprovalStageResolver.STAGE_APPROVE, "批准", registry.getApproveByField()},
};
for (String[] item : ordered) {
String stageKey = item[0];
if (!enabled.contains(stageKey) || oConvertUtils.isEmpty(item[2])) {
continue;
}
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stageKey", stageKey);
stage.put("stageName", item[1]);
stage.put("nodeType", "approver");
stage.put("field", item[2]);
stage.put("fieldComment", item[1] + "");
stages.add(stage);
}
return stages;
}
/** 在列集合中按关键字匹配阶段字段,优先返回注释含"人/员"的人员字段 */
private Map<String, Object> matchStageColumn(List<Map<String, Object>> columns, String[] keywords) {
Map<String, Object> firstMatch = null;
for (Map<String, Object> col : columns) {
String comment = col.get("comment") == null ? "" : String.valueOf(col.get("comment"));
if (oConvertUtils.isEmpty(comment)) {
continue;
}
boolean matched = false;
for (String kw : keywords) {
if (comment.contains(kw)) {
matched = true;
break;
}
}
if (!matched) {
continue;
}
if (firstMatch == null) {
firstMatch = col;
}
// 人员字段优先(如"校对人""审核员"
if (comment.contains("") || comment.contains("")) {
return col;
}
}
return firstMatch;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】从审批注册中心解析启用环节-----
/** 按业务表+租户查找审批流(取最近一条) */
private MesXslApprovalFlow findFlowByTable(String table, Integer tenantId) {

View File

@@ -99,6 +99,15 @@ public class MesXslApprovalRecord extends JeecgEntity implements Serializable {
@Schema(description = "备注")
private String remark;
//update-begin---author:GHT ---date:2026-06-05 for【审核集成Phase0】台账增加编排执行状态字段-----
@Schema(description = "编排执行状态 0未执行 1成功 2部分失败 3失败")
@Dict(dicCode = "mes_xsl_integration_orch_status")
private String integrationStatus;
@Schema(description = "编排摘要/错误信息")
private String integrationRemark;
//update-end---author:GHT ---date:2026-06-05 for【审核集成Phase0】台账增加编排执行状态字段-----
@Schema(description = "逻辑删除 0正常 1已删除")
@TableLogic
private Integer delFlag;

View File

@@ -0,0 +1,72 @@
package org.jeecg.modules.xslmes.approval.integration.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 审批痕迹明细
*
* @author GHT
* @date 2026-06-05 for【XSLMES-20260605-K8R2】审批痕迹查询
*/
@Tag(name = "审批痕迹")
@RestController
@RequestMapping("/xslmes/mesXslApprovalTrace")
@Slf4j
public class MesXslApprovalTraceController extends JeecgController<MesXslApprovalTrace, IMesXslApprovalTraceService> {
@Autowired
private IMesXslApprovalTraceService traceService;
@Operation(summary = "审批痕迹-分页列表")
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
@GetMapping("/list")
public Result<IPage<MesXslApprovalTrace>> queryPageList(
MesXslApprovalTrace model,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslApprovalTrace> qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
qw.orderByDesc("update_time").orderByDesc("create_time");
return Result.OK(traceService.page(new Page<>(pageNo, pageSize), qw));
}
@Operation(summary = "审批痕迹-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_approval_trace:list")
@GetMapping("/queryById")
public Result<MesXslApprovalTrace> queryById(@RequestParam String id) {
MesXslApprovalTrace entity = traceService.getById(id);
return entity != null ? Result.OK(entity) : Result.error("未找到对应数据");
}
@Operation(summary = "审批痕迹-按业务表与单据ID查询供业务页关联展示")
@RequiresPermissions("xslmes:mes_xsl_approval_trace:list")
@GetMapping("/queryByBiz")
public Result<MesXslApprovalTrace> queryByBiz(
@RequestParam String bizTable,
@RequestParam String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("业务表与单据ID不能为空");
}
MesXslApprovalTrace entity = traceService.getByBiz(bizTable, bizDataId);
return Result.OK(entity);
}
}

View File

@@ -0,0 +1,84 @@
package org.jeecg.modules.xslmes.approval.integration.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 审批注册中心
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】单据注册
*/
@Tag(name = "审批注册中心")
@RestController
@RequestMapping("/xslmes/mesXslBizDocRegistry")
@Slf4j
public class MesXslBizDocRegistryController extends JeecgController<MesXslBizDocRegistry, IMesXslBizDocRegistryService> {
@Autowired
private IMesXslBizDocRegistryService service;
@Operation(summary = "审批注册-分页列表")
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:list")
@GetMapping("/list")
public Result<IPage<MesXslBizDocRegistry>> queryPageList(
MesXslBizDocRegistry model,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslBizDocRegistry> qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
qw.orderByAsc("doc_code");
return Result.OK(service.page(new Page<>(pageNo, pageSize), qw));
}
@Operation(summary = "审批注册-新增")
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:add")
@PostMapping("/add")
public Result<String> add(@RequestBody MesXslBizDocRegistry entity) {
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
service.normalizeBeforeSave(entity);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
service.save(entity);
return Result.OK("添加成功");
}
@Operation(summary = "审批注册-编辑")
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:edit")
@PutMapping("/edit")
public Result<String> edit(@RequestBody MesXslBizDocRegistry entity) {
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
service.normalizeBeforeSave(entity);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
service.updateById(entity);
return Result.OK("编辑成功");
}
@Operation(summary = "审批注册-通过id删除")
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:delete")
@DeleteMapping("/delete")
public Result<String> delete(@RequestParam String id) {
service.removeById(id);
return Result.OK("删除成功");
}
@Operation(summary = "审批注册-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:list")
@GetMapping("/queryById")
public Result<MesXslBizDocRegistry> queryById(@RequestParam String id) {
MesXslBizDocRegistry entity = service.getById(id);
return entity != null ? Result.OK(entity) : Result.error("未找到对应数据");
}
}

View File

@@ -0,0 +1,61 @@
package org.jeecg.modules.xslmes.approval.integration.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 审核集成执行日志
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成执行日志
*/
@Tag(name = "审核集成执行日志")
@RestController
@RequestMapping("/xslmes/mesXslIntegrationLog")
@Slf4j
public class MesXslIntegrationLogController extends JeecgController<MesXslIntegrationLog, IMesXslIntegrationLogService> {
@Autowired
private IMesXslIntegrationLogService logService;
@Operation(summary = "集成日志-分页列表")
@RequiresPermissions("xslmes:mes_xsl_integration_log:list")
@GetMapping("/list")
public Result<IPage<MesXslIntegrationLog>> queryPageList(
MesXslIntegrationLog model,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslIntegrationLog> qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
qw.orderByDesc("create_time");
return Result.OK(logService.page(new Page<>(pageNo, pageSize), qw));
}
@Operation(summary = "集成日志-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_integration_log:list")
@GetMapping("/queryById")
public Result<MesXslIntegrationLog> queryById(@RequestParam String id) {
MesXslIntegrationLog entity = logService.getById(id);
return entity != null ? Result.OK(entity) : Result.error("未找到对应数据");
}
@Operation(summary = "集成日志-手动重试失败动作")
@RequiresPermissions("xslmes:mes_xsl_integration_log:retry")
@PostMapping("/retry")
public Result<String> retry(@RequestParam String id) {
return logService.retry(id);
}
}

View File

@@ -0,0 +1,216 @@
package org.jeecg.modules.xslmes.approval.integration.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService;
import org.jeecg.modules.xslmes.approval.integration.service.IntegrationPlanGenerator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 审核集成方案(含内嵌动作管理)
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成方案管理
*/
@Tag(name = "审核集成方案")
@RestController
@RequestMapping("/xslmes/mesXslIntegrationPlan")
@Slf4j
public class MesXslIntegrationPlanController extends JeecgController<MesXslIntegrationPlan, IMesXslIntegrationPlanService> {
@Autowired
private IMesXslIntegrationPlanService planService;
@Autowired
private IMesXslIntegrationActionService actionService;
@Autowired
private IMesXslBizDocRegistryService registryService;
//update-begin---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增表字段元数据查询接口可视化配置向导用-----------
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IntegrationPlanGenerator planGenerator;
//update-end---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增表字段元数据查询接口可视化配置向导用-----------
@Operation(summary = "集成方案-分页列表")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/list")
public Result<IPage<MesXslIntegrationPlan>> queryPageList(
MesXslIntegrationPlan model,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslIntegrationPlan> qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
qw.orderByDesc("create_time");
return Result.OK(planService.page(new Page<>(pageNo, pageSize), qw));
}
@Operation(summary = "集成方案-新增")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:add")
@PostMapping("/add")
@Transactional(rollbackFor = Exception.class)
//update-begin---author:GHT ---date:2026-06-05 for【审核集成Phase0】改为返回实体前端向导保存动作时需要 planId-----------
public Result<MesXslIntegrationPlan> add(@RequestBody MesXslIntegrationPlan entity) {
Result<String> validate = planService.normalizeAndValidate(entity);
if (!validate.isSuccess()) {
return Result.error(validate.getMessage());
}
planService.save(entity);
return Result.OK("添加成功", entity);
}
//update-end---author:GHT ---date:2026-06-05 for【审核集成Phase0】改为返回实体前端向导保存动作时需要 planId-----------
@Operation(summary = "集成方案-编辑")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PutMapping("/edit")
public Result<String> edit(@RequestBody MesXslIntegrationPlan entity) {
Result<String> validate = planService.normalizeAndValidate(entity);
if (!validate.isSuccess()) {
return validate;
}
planService.updateById(entity);
return Result.OK("编辑成功");
}
@Operation(summary = "集成方案-删除(同时删除动作)")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:delete")
@DeleteMapping("/delete")
@Transactional(rollbackFor = Exception.class)
public Result<String> delete(@RequestParam String id) {
actionService.removeByPlanId(id);
planService.removeById(id);
return Result.OK("删除成功");
}
@Operation(summary = "集成方案-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/queryById")
public Result<MesXslIntegrationPlan> queryById(@RequestParam String id) {
MesXslIntegrationPlan entity = planService.getById(id);
return entity != null ? Result.OK(entity) : Result.error("未找到对应数据");
}
@Operation(summary = "集成方案-发布")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:publish")
@PostMapping("/publish")
public Result<String> publish(@RequestParam String id) {
return planService.publish(id);
}
@Operation(summary = "集成方案-停用")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:publish")
@PostMapping("/disable")
public Result<String> disable(@RequestParam String id) {
return planService.disable(id);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按表名查询审批注册中心配置-----------
@Operation(summary = "按表名查询审批注册中心(集成方案绑定环节用)")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/registryByTable")
public Result<MesXslBizDocRegistry> registryByTable(@RequestParam String tableName) {
MesXslBizDocRegistry registry = registryService.findActiveByTableName(tableName);
return registry != null ? Result.OK(registry) : Result.error("该表未在审批注册中心启用");
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按表名查询审批注册中心配置-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按审批流程节点生成默认集成方案-----------
@Operation(summary = "预览按流程生成的默认方案")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/previewDefaultFromFlow")
public Result<Map<String, Object>> previewDefaultFromFlow(
@RequestParam String sourceTable,
@RequestParam(required = false) String flowId) {
return planGenerator.preview(sourceTable, flowId);
}
@Operation(summary = "按审批流程节点生成默认方案与动作")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PostMapping("/generateDefaultFromFlow")
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Object>> generateDefaultFromFlow(@RequestBody Map<String, Object> body) {
String sourceTable = body == null ? null : String.valueOf(body.get("sourceTable"));
String flowId = body != null && body.get("flowId") != null ? String.valueOf(body.get("flowId")) : null;
boolean overwriteDraft = body != null && Boolean.TRUE.equals(body.get("overwriteDraft"));
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】生成时支持手选识别环节-----------
@SuppressWarnings("unchecked")
List<Map<String, Object>> nodeBindings = body != null
? (List<Map<String, Object>>) body.get("nodeBindings") : null;
return planGenerator.generate(sourceTable, flowId, overwriteDraft,
IntegrationPlanGenerator.parseStageOverrides(nodeBindings));
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】生成时支持手选识别环节-----------
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按审批流程节点生成默认集成方案-----------
//update-begin---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增表字段元数据查询接口可视化配置向导用-----------
@Operation(summary = "查询表字段元数据(可视化配置向导)")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/tableColumns")
public Result<List<Map<String, Object>>> getTableColumns(@RequestParam String tableName) {
if (!tableName.matches("^[a-z][a-z0-9_]{0,63}$")) {
return Result.error("非法表名");
}
List<Map<String, Object>> cols = jdbcTemplate.queryForList(
"SELECT COLUMN_NAME columnName, DATA_TYPE dataType, COLUMN_COMMENT `comment`, " +
"COLUMN_KEY columnKey " +
"FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? " +
"ORDER BY ORDINAL_POSITION",
tableName
);
return Result.OK(cols);
}
//update-end---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增表字段元数据查询接口可视化配置向导用-----------
// ============ 动作(内嵌在方案下)============
@Operation(summary = "动作-按方案查询")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:list")
@GetMapping("/action/listByPlanId")
public Result<List<MesXslIntegrationAction>> listActions(@RequestParam String planId) {
return Result.OK(actionService.listByPlanId(planId));
}
@Operation(summary = "动作-新增")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PostMapping("/action/add")
public Result<String> addAction(@RequestBody MesXslIntegrationAction action) {
actionService.save(action);
return Result.OK("添加成功");
}
@Operation(summary = "动作-编辑")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PutMapping("/action/edit")
public Result<String> editAction(@RequestBody MesXslIntegrationAction action) {
actionService.updateById(action);
return Result.OK("编辑成功");
}
@Operation(summary = "动作-删除")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@DeleteMapping("/action/delete")
public Result<String> deleteAction(@RequestParam String id) {
actionService.removeById(id);
return Result.OK("删除成功");
}
}

View File

@@ -0,0 +1,156 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* 审批环节解析与匹配(对接审批注册中心 enabled_stages
*/
public final class ApprovalStageResolver {
public static final String STAGE_PROOFREAD = "proofread";
public static final String STAGE_AUDIT = "audit";
public static final String STAGE_APPROVE = "approve";
private ApprovalStageResolver() {
}
public static Set<String> parseEnabledStages(String enabledStages) {
if (oConvertUtils.isEmpty(enabledStages)) {
return Collections.emptySet();
}
Set<String> set = new HashSet<>();
for (String part : enabledStages.split(",")) {
if (oConvertUtils.isNotEmpty(part)) {
set.add(part.trim());
}
}
return set;
}
public static boolean containsStage(String enabledStages, String stage) {
return parseEnabledStages(enabledStages).contains(stage);
}
/**
* 根据回调上下文与注册配置,解析当前刚完成的审批环节。
* <p>
* 优先级:
* 1. ctx.stageKey 不为 null → 直接采用(来自流程节点 props.stageKey最权威
* 空串表示「纯过路审批」,返回 null由编排引擎守卫跳过集成。
* 2. APPROVED 终态 → STAGE_APPROVE
* 3. REJECTED → 从源单 status 字段推断
* 4. 兜底:源单 status 字段 → 节点名称关键字
*/
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】stageKey最高优先区分关键节点与纯过路审批-----------
public static String resolveCurrentStage(ApprovalCallbackContext ctx,
MesXslBizDocRegistry registry,
Map<String, Object> sourceRecord) {
if (ctx == null) {
return null;
}
// 1. 节点显式绑定了 stageKey流程设计 props.stageKey
// null → 节点未设置,走降级启发式(向后兼容)
// "" → 纯过路审批,返回 null 让编排引擎跳过集成
// 其他值 → 直接作为环节 key 返回
String nodeStageKey = ctx.getStageKey();
if (nodeStageKey != null) {
return nodeStageKey.isEmpty() ? null : nodeStageKey;
}
// 2. 终态:全程通过
if (ctx.getAction() == ApprovalCallbackContext.Action.APPROVED) {
return STAGE_APPROVE;
}
// 3. 驳回:从源单 status 反推被驳回的环节
if (ctx.getAction() == ApprovalCallbackContext.Action.REJECTED) {
return resolveStageFromStatus(registry, sourceRecord);
}
// 4. 节点通过的降级启发式stageKey 未设置的旧数据兼容路径)
String fromStatus = resolveStageFromStatus(registry, sourceRecord);
if (oConvertUtils.isNotEmpty(fromStatus)) {
return fromStatus;
}
return resolveStageFromNodeName(ctx.getNodeName());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】stageKey最高优先区分关键节点与纯过路审批-----------
public static String resolveStageFromStatus(MesXslBizDocRegistry registry, Map<String, Object> sourceRecord) {
if (registry == null || sourceRecord == null || sourceRecord.isEmpty()) {
return null;
}
String statusField = oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField();
Object statusVal = sourceRecord.get(statusField);
if (statusVal == null) {
return null;
}
String status = String.valueOf(statusVal).trim();
if (Arrays.asList(STAGE_PROOFREAD, STAGE_AUDIT, STAGE_APPROVE).contains(status)) {
return status;
}
return null;
}
public static String resolveStageFromNodeName(String nodeName) {
if (oConvertUtils.isEmpty(nodeName)) {
return null;
}
if (nodeName.contains("校对")) {
return STAGE_PROOFREAD;
}
if (nodeName.contains("审核")) {
return STAGE_AUDIT;
}
if (nodeName.contains("批准") || nodeName.contains("审批")) {
return STAGE_APPROVE;
}
return null;
}
/**
* 判断方案绑定的环节是否与当前回调匹配。
*/
public static boolean matchesTriggerStage(String planTriggerPhase,
String planTriggerStage,
TriggerPhase currentPhase,
String currentStage) {
if (currentPhase == TriggerPhase.ON_APPROVE) {
if (oConvertUtils.isEmpty(planTriggerStage)) {
return true;
}
return STAGE_APPROVE.equals(planTriggerStage);
}
if (currentPhase == TriggerPhase.ON_REJECT) {
if (oConvertUtils.isEmpty(planTriggerStage)) {
return true;
}
return planTriggerStage.equals(currentStage);
}
if (currentPhase == TriggerPhase.ON_NODE_APPROVE) {
if (oConvertUtils.isEmpty(planTriggerStage)) {
return true;
}
return planTriggerStage.equals(currentStage);
}
return false;
}
public static String stageLabel(String stage) {
if (STAGE_PROOFREAD.equals(stage)) {
return "校对";
}
if (STAGE_AUDIT.equals(stage)) {
return "审核";
}
if (STAGE_APPROVE.equals(stage)) {
return "批准";
}
return stage;
}
}

View File

@@ -0,0 +1,81 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback;
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 审核集成编排回调。
* 注册为 {@link IApprovalBizCallback}(支持所有表 "*"
* 在现有 Callback 之后自动触发集成编排,无需改动 HandleService。
* <p>
* Order(100):确保在业务 Callback默认 Order之后执行
* 编排是「审批后」动作,不应干扰业务回调的结果。
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】自动触发集成编排
*/
@Slf4j
@Component
@Order(100)
public class IntegrationBizCallback implements IApprovalBizCallback {
private static final String DING_LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
@Autowired
private IntegrationOrchestrator orchestrator;
@Override
public String supportTable() {
return "*";
}
@Override
public void onApproved(ApprovalCallbackContext ctx) {
dispatchWithLog(ctx, TriggerPhase.ON_APPROVE);
}
@Override
public void onRejected(ApprovalCallbackContext ctx) {
dispatchWithLog(ctx, TriggerPhase.ON_REJECT);
}
@Override
public void onNodeApproved(ApprovalCallbackContext ctx) {
dispatchWithLog(ctx, TriggerPhase.ON_NODE_APPROVE);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调集成编排入口日志-----------
private void dispatchWithLog(ApprovalCallbackContext ctx, TriggerPhase phase) {
boolean dingTalk = isDingTalkCallback(ctx);
if (dingTalk) {
log.info("{} 进入集成编排 phase={} bizTable={} bizDataId={} nodeName={} comment={}",
DING_LOG_TAG, phase.getValue(), ctx.getBizTable(), ctx.getBizDataId(),
ctx.getNodeName(), ctx.getComment());
}
try {
orchestrator.dispatch(ctx, phase);
if (dingTalk) {
log.info("{} 集成编排派发完成 phase={} bizTable={} bizDataId={}",
DING_LOG_TAG, phase.getValue(), ctx.getBizTable(), ctx.getBizDataId());
}
} catch (Exception e) {
if (dingTalk) {
log.error("{} 集成编排派发异常 phase={} table={} bizId={}",
DING_LOG_TAG, phase.getValue(), ctx.getBizTable(), ctx.getBizDataId(), e);
} else {
log.error("[集成引擎] {} 分发异常 table={} bizId={}", phase.getValue(),
ctx.getBizTable(), ctx.getBizDataId(), e);
}
}
}
private boolean isDingTalkCallback(ApprovalCallbackContext ctx) {
return ctx != null && "dingtalk".equals(ctx.getOperatorUsername());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调集成编排入口日志-----------
}

View File

@@ -0,0 +1,42 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import lombok.Data;
import lombok.experimental.Accessors;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import java.util.HashMap;
import java.util.Map;
/**
* 集成编排执行上下文,在 ApprovalCallbackContext 基础上扩展
*/
@Data
@Accessors(chain = true)
public class IntegrationContext {
/** 原审批回调上下文 */
private ApprovalCallbackContext approvalCtx;
/** 关联审批台账(可能为 null钉钉通道时已填 */
private MesXslApprovalRecord record;
/** 源单表名 */
private String sourceBizTable;
/** 源单 ID */
private String sourceBizId;
/** 触发时机 */
private TriggerPhase triggerPhase;
/** 当前执行的集成方案(供环节同步等动作读取 triggerStage */
private MesXslIntegrationPlan plan;
/** 从 DB 加载的源单主表字段(懒加载) */
private Map<String, Object> sourceRecord = new HashMap<>();
/** 前序动作输出结果actionId → 产出值) */
private Map<String, String> actionResults = new HashMap<>();
}

View File

@@ -0,0 +1,515 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.integration.engine.executor.IIntegrationActionExecutor;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 审核集成编排引擎。
* <p>
* 执行流程:
* <ol>
* <li>根据 (source_table, trigger_phase) 查找已发布方案</li>
* <li>加载源单主表字段到 IntegrationContext</li>
* <li>按 exec_order 依次执行动作,幂等检查、写日志</li>
* <li>更新审批台账 integration_status</li>
* </ol>
* exec_mode=async在审批事务提交后异步执行审批不因编排失败回滚。
* exec_mode=sync :与审批同事务,编排失败回滚审批(慎用)。
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成编排引擎
*/
@Slf4j
@Service
public class IntegrationOrchestrator {
private static final String DING_LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
@Autowired
private IMesXslIntegrationPlanService planService;
@Autowired
private IMesXslBizDocRegistryService registryService;
@Autowired
private IMesXslIntegrationActionService actionService;
@Autowired
private IMesXslIntegrationLogService logService;
@Autowired
private IMesXslApprovalRecordService recordService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private List<IIntegrationActionExecutor> executors;
// ==================== 外部入口 ====================
/**
* 由 IntegrationBizCallback 在审批回调时调用。
* 自动按 exec_mode 决定同步还是异步执行。
*/
public void dispatch(ApprovalCallbackContext approvalCtx, TriggerPhase phase) {
String bizTable = approvalCtx.getBizTable();
String bizDataId = approvalCtx.getBizDataId();
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return;
}
List<MesXslIntegrationPlan> plans = planService.lambdaQuery()
.eq(MesXslIntegrationPlan::getSourceTable, bizTable)
.eq(MesXslIntegrationPlan::getTriggerPhase, phase.getValue())
.eq(MesXslIntegrationPlan::getStatus, "1")
.list();
if (plans.isEmpty()) {
if (isDingTalkCallback(approvalCtx)) {
log.info("{} 集成引擎无已发布方案 table={} phase={}", DING_LOG_TAG, bizTable, phase.getValue());
}
log.info("[集成引擎] 无已发布方案 table={} phase={}", bizTable, phase.getValue());
return;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按审批注册中心绑定环节过滤集成方案-----------
MesXslBizDocRegistry registry = registryService.findActiveByTableName(bizTable);
Map<String, Object> sourceRecord = loadSourceRecord(bizTable, bizDataId);
String currentStage = ApprovalStageResolver.resolveCurrentStage(approvalCtx, registry, sourceRecord);
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】stageKey显式为空串纯过路审批节点直接跳过所有集成-----------
// stageKey="" → 用户在流程设计中明确标记「纯过路审批」,任何集成方案都不应执行
// stageKey=null → 节点未配置(老数据),走原有启发式匹配(向后兼容)
String nodeStageKey = approvalCtx.getStageKey();
if (phase == TriggerPhase.ON_NODE_APPROVE && nodeStageKey != null && nodeStageKey.isEmpty()) {
if (isDingTalkCallback(approvalCtx)) {
log.info("{} 集成引擎跳过:节点 stageKey 显式为空(纯过路审批) nodeName={} table={} bizId={}",
DING_LOG_TAG, approvalCtx.getNodeName(), bizTable, bizDataId);
} else {
log.info("[集成引擎] 跳过:纯过路审批节点 nodeName={} table={}", approvalCtx.getNodeName(), bizTable);
}
return;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】stageKey显式为空串纯过路审批节点直接跳过所有集成-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】onNodeApprove 增加按源单status+expectedFrom 兜底匹配-----------
plans = plans.stream()
.filter(plan -> matchesPlan(plan, phase, currentStage, sourceRecord, registry))
.toList();
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】onNodeApprove 增加按源单status+expectedFrom 兜底匹配-----------
if (plans.isEmpty()) {
if (isDingTalkCallback(approvalCtx)) {
log.info("{} 集成引擎无匹配方案 table={} phase={} stage={} nodeName={}",
DING_LOG_TAG, bizTable, phase.getValue(), currentStage, approvalCtx.getNodeName());
}
log.info("[集成引擎] 无匹配绑定环节的方案 table={} phase={} stage={}", bizTable, phase.getValue(), currentStage);
return;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按审批注册中心绑定环节过滤集成方案-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调集成引擎方案匹配日志-----------
if (isDingTalkCallback(approvalCtx)) {
log.info("{} 集成引擎命中方案 phase={} stage={} nodeName={} plans=[{}]",
DING_LOG_TAG, phase.getValue(), currentStage, approvalCtx.getNodeName(),
plans.stream().map(MesXslIntegrationPlan::getPlanCode).collect(Collectors.joining(",")));
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调集成引擎方案匹配日志-----------
// 查关联台账MES通道通过instanceId
MesXslApprovalRecord record = findRecord(approvalCtx);
for (MesXslIntegrationPlan plan : plans) {
if ("sync".equals(plan.getExecMode())) {
// 同步:当前事务内执行
executePlan(plan, approvalCtx, record);
} else {
// 异步(默认):事务提交后执行,捕获所有变量避免闭包延迟问题
final MesXslIntegrationPlan finalPlan = plan;
final ApprovalCallbackContext finalCtx = approvalCtx;
final MesXslApprovalRecord finalRecord = record;
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
executePlanInNewTx(finalPlan, finalCtx, finalRecord);
} catch (Exception e) {
log.error("[集成引擎] 异步执行方案失败 plan={} bizTable={} bizId={}",
finalPlan.getPlanCode(), bizTable, bizDataId, e);
}
}
});
} else {
// 无活跃事务(如钉钉 Stream 回调),直接在新事务执行
executePlanInNewTx(plan, approvalCtx, record);
}
}
}
}
// ==================== 执行方案(同步,当前事务) ====================
private void executePlan(MesXslIntegrationPlan plan, ApprovalCallbackContext approvalCtx, MesXslApprovalRecord record) {
IntegrationContext ctx = buildContext(plan, approvalCtx, record);
doExecute(plan, ctx, record);
}
// ==================== 执行方案(新事务,异步场景) ====================
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void executePlanInNewTx(MesXslIntegrationPlan plan, ApprovalCallbackContext approvalCtx, MesXslApprovalRecord record) {
IntegrationContext ctx = buildContext(plan, approvalCtx, record);
doExecute(plan, ctx, record);
}
// ==================== 核心执行逻辑 ====================
private void doExecute(MesXslIntegrationPlan plan, IntegrationContext ctx, MesXslApprovalRecord record) {
List<MesXslIntegrationAction> actions = actionService.listByPlanId(plan.getId());
if (actions.isEmpty()) {
log.info("[集成引擎] 方案 {} 无启用动作,跳过", plan.getPlanCode());
return;
}
int successCount = 0;
int failCount = 0;
StringBuilder remarkBuf = new StringBuilder();
for (MesXslIntegrationAction action : actions) {
String idempotentKey = buildIdempotentKey(ctx, action);
String snapshot = JSON.toJSONString(Map.of(
"sourceId", ctx.getSourceBizId(),
"sourceTable", ctx.getSourceBizTable(),
"phase", ctx.getTriggerPhase().getValue()));
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】多轮审批幂等按台账隔离+回退未达目标强制重跑-----------
// 幂等检查REGISTRY_STAGE_REVERT 若源单仍未回到目标状态,忽略历史 success 重新执行)
if (logService.isAlreadySuccess(idempotentKey) && !shouldBypassIdempotentSkip(ctx, action)) {
log.info("[集成引擎] 幂等命中,跳过 action={} key={}", action.getActionName(), idempotentKey);
writeLog(ctx, action, idempotentKey, "skipped", null, null, snapshot, null, 0L);
continue;
}
if (logService.isAlreadySuccess(idempotentKey)) {
log.info("[集成引擎] 幂等命中但源单未达目标,重新执行 action={} key={}", action.getActionName(), idempotentKey);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】多轮审批幂等按台账隔离+回退未达目标强制重跑-----------
long t0 = System.currentTimeMillis();
try {
IIntegrationActionExecutor executor = findExecutor(action.getActionType());
String response = executor.execute(ctx, action);
long ms = System.currentTimeMillis() - t0;
writeLog(ctx, action, idempotentKey, "success", null, response, snapshot, null, ms);
successCount++;
} catch (Exception e) {
long ms = System.currentTimeMillis() - t0;
String errMsg = e.getMessage();
log.error("[集成引擎] 动作执行失败 action={} plan={}", action.getActionName(), plan.getPlanCode(), e);
writeLog(ctx, action, idempotentKey, "failed", errMsg, null, snapshot, null, ms);
failCount++;
remarkBuf.append("[").append(action.getActionName()).append("]").append(errMsg).append("; ");
if ("stop".equals(action.getOnFail())) {
break;
}
}
}
// 更新台账 integration_status
if (record != null && oConvertUtils.isNotEmpty(record.getId())) {
String orchStatus;
if (failCount == 0) {
orchStatus = "1"; // 全部成功
} else if (successCount > 0) {
orchStatus = "2"; // 部分失败
} else {
orchStatus = "3"; // 全部失败
}
recordService.lambdaUpdate()
.eq(MesXslApprovalRecord::getId, record.getId())
.set(MesXslApprovalRecord::getIntegrationStatus, orchStatus)
.set(MesXslApprovalRecord::getIntegrationRemark,
remarkBuf.length() > 0 ? remarkBuf.toString() : null)
.update();
}
}
// ==================== 工具方法 ====================
private IntegrationContext buildContext(MesXslIntegrationPlan plan,
ApprovalCallbackContext approvalCtx,
MesXslApprovalRecord record) {
String bizTable = approvalCtx.getBizTable();
String bizDataId = approvalCtx.getBizDataId();
TriggerPhase phase = switch (plan.getTriggerPhase()) {
case "onReject" -> TriggerPhase.ON_REJECT;
case "onNodeApprove" -> TriggerPhase.ON_NODE_APPROVE;
default -> TriggerPhase.ON_APPROVE;
};
IntegrationContext ctx = new IntegrationContext()
.setApprovalCtx(approvalCtx)
.setRecord(record)
.setPlan(plan)
.setSourceBizTable(bizTable)
.setSourceBizId(bizDataId)
.setTriggerPhase(phase);
Map<String, Object> sourceRecord = loadSourceRecord(bizTable, bizDataId);
if (sourceRecord != null) {
ctx.setSourceRecord(sourceRecord);
}
return ctx;
}
private Map<String, Object> loadSourceRecord(String bizTable, String bizDataId) {
try {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM `" + bizTable + "` WHERE id = ?", bizDataId);
if (!rows.isEmpty()) {
return rows.get(0);
}
} catch (Exception e) {
log.warn("[集成引擎] 加载源单字段失败 table={} id={}: {}", bizTable, bizDataId, e.getMessage());
}
return null;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】修复台账查找兼容钉钉recordId与MES外部实例ID-----------
private MesXslApprovalRecord findRecord(ApprovalCallbackContext approvalCtx) {
try {
String instanceId = approvalCtx.getInstanceId();
if (oConvertUtils.isNotEmpty(instanceId)) {
// MES 通道instanceId = 审批实例ID对应台账 external_instance_id
MesXslApprovalRecord byExternal = recordService.lambdaQuery()
.eq(MesXslApprovalRecord::getExternalInstanceId, instanceId)
.orderByDesc(MesXslApprovalRecord::getCreateTime)
.last("LIMIT 1")
.one();
if (byExternal != null) {
return byExternal;
}
// 钉钉 StreaminstanceId = 台账主键 record.id
MesXslApprovalRecord byId = recordService.getById(instanceId);
if (byId != null && recordMatchesBiz(byId, approvalCtx)) {
return byId;
}
}
return findLatestRecordByBiz(approvalCtx);
} catch (Exception e) {
log.warn("[集成引擎] 查找台账失败 instanceId={}: {}", approvalCtx.getInstanceId(), e.getMessage());
return null;
}
}
private MesXslApprovalRecord findLatestRecordByBiz(ApprovalCallbackContext approvalCtx) {
if (oConvertUtils.isEmpty(approvalCtx.getBizTable()) || oConvertUtils.isEmpty(approvalCtx.getBizDataId())) {
return null;
}
return recordService.lambdaQuery()
.eq(MesXslApprovalRecord::getBizTable, approvalCtx.getBizTable())
.eq(MesXslApprovalRecord::getBizDataId, approvalCtx.getBizDataId())
.orderByDesc(MesXslApprovalRecord::getCreateTime)
.last("LIMIT 1")
.one();
}
private boolean recordMatchesBiz(MesXslApprovalRecord record, ApprovalCallbackContext approvalCtx) {
if (record == null) {
return false;
}
if (oConvertUtils.isNotEmpty(approvalCtx.getBizTable())
&& !approvalCtx.getBizTable().equalsIgnoreCase(record.getBizTable())) {
return false;
}
return oConvertUtils.isEmpty(approvalCtx.getBizDataId())
|| approvalCtx.getBizDataId().equals(record.getBizDataId());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】修复台账查找兼容钉钉recordId与MES外部实例ID-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】幂等键优先按审批台账recordId隔离各轮审批-----------
private String buildIdempotentKey(IntegrationContext ctx, MesXslIntegrationAction action) {
if (oConvertUtils.isNotEmpty(action.getIdempotentKey())) {
return VariableResolver.resolve(action.getIdempotentKey(), ctx);
}
// 默认:本轮审批台账 recordId + actionId同一单据多轮审批互不干扰
String prefix = resolveRecordIdForIdempotent(ctx);
return prefix + "_" + action.getId();
}
private String resolveRecordIdForIdempotent(IntegrationContext ctx) {
if (ctx.getRecord() != null && oConvertUtils.isNotEmpty(ctx.getRecord().getId())) {
return ctx.getRecord().getId();
}
ApprovalCallbackContext approvalCtx = ctx.getApprovalCtx();
if (approvalCtx != null && "dingtalk".equals(approvalCtx.getOperatorUsername())
&& oConvertUtils.isNotEmpty(approvalCtx.getInstanceId())) {
return approvalCtx.getInstanceId();
}
return ctx.getSourceBizId();
}
/**
* 回退类动作:历史 success 但源单 status 仍未到 targetStage 时,允许再次执行。
*/
private boolean shouldBypassIdempotentSkip(IntegrationContext ctx, MesXslIntegrationAction action) {
if (!"REGISTRY_STAGE_REVERT".equals(action.getActionType())) {
return false;
}
String targetStage = resolveRevertTargetStage(action);
String currentStatus = readSourceStatus(ctx);
if (oConvertUtils.isEmpty(currentStatus)) {
return true;
}
return !targetStage.equals(currentStatus);
}
private String resolveRevertTargetStage(MesXslIntegrationAction action) {
String targetStage = "compile";
if (oConvertUtils.isNotEmpty(action.getActionConfig())) {
try {
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) {
targetStage = cfg.getString("targetStage").trim();
}
} catch (Exception ignored) {
// 默认 compile
}
}
return targetStage;
}
private String readSourceStatus(IntegrationContext ctx) {
Map<String, Object> sourceRecord = ctx.getSourceRecord();
if (sourceRecord == null || sourceRecord.isEmpty()) {
sourceRecord = loadSourceRecord(ctx.getSourceBizTable(), ctx.getSourceBizId());
if (sourceRecord != null) {
ctx.setSourceRecord(sourceRecord);
}
}
if (sourceRecord == null) {
return null;
}
MesXslBizDocRegistry registry = registryService.findActiveByTableName(ctx.getSourceBizTable());
String statusField = RegistryStageFieldHelper.statusField(registry);
Object val = sourceRecord.get(statusField);
return val == null ? null : String.valueOf(val).trim();
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】幂等键优先按审批台账recordId隔离各轮审批-----------
private IIntegrationActionExecutor findExecutor(String actionType) {
return executors.stream()
.filter(e -> e.supportActionType().equals(actionType))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException("不支持的动作类型: " + actionType));
}
private void writeLog(IntegrationContext ctx, MesXslIntegrationAction action,
String idempotentKey, String status, String error, String response,
String requestSnapshot, String responseSnapshot, long execTimeMs) {
try {
MesXslIntegrationLog logEntry = new MesXslIntegrationLog()
.setRecordId(ctx.getRecord() != null ? ctx.getRecord().getId() : null)
.setPlanId(action.getPlanId())
.setActionId(action.getId())
.setIdempotentKey(idempotentKey)
.setStatus(status)
.setSourceBizId(ctx.getSourceBizId())
.setSourceBizTable(ctx.getSourceBizTable())
.setErrorMessage(error)
.setRetryCount(0)
.setExecTimeMs(execTimeMs)
.setRequestSnapshot(requestSnapshot)
.setResponseSnapshot(response)
.setCreateTime(new Date());
logService.save(logEntry);
} catch (Exception e) {
log.error("[集成引擎] 写执行日志失败", e);
}
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案环节匹配增强(节点名+源单status)-----------
/**
* 方案是否匹配当前回调:优先节点名/环节解析onNodeApprove 时兜底用「源单当前 status == 动作 expectedFrom」。
*/
private boolean matchesPlan(MesXslIntegrationPlan plan, TriggerPhase phase, String resolvedStage,
Map<String, Object> sourceRecord, MesXslBizDocRegistry registry) {
if (ApprovalStageResolver.matchesTriggerStage(
plan.getTriggerPhase(), plan.getTriggerStage(), phase, resolvedStage)) {
return true;
}
if (phase == TriggerPhase.ON_NODE_APPROVE) {
return matchPlanBySourceExpectedFrom(plan, sourceRecord, registry);
}
return false;
}
/** 源单当前 status 等于方案动作 expectedFrom 时,视为本节点应执行的环节方案 */
private boolean matchPlanBySourceExpectedFrom(MesXslIntegrationPlan plan,
Map<String, Object> sourceRecord,
MesXslBizDocRegistry registry) {
if (registry == null || oConvertUtils.isEmpty(plan.getTriggerStage())) {
return false;
}
String statusField = RegistryStageFieldHelper.statusField(registry);
Object statusVal = sourceRecord != null ? sourceRecord.get(statusField) : null;
String currentStatus = statusVal == null ? "" : String.valueOf(statusVal).trim();
List<MesXslIntegrationAction> actions = actionService.listByPlanId(plan.getId());
if (actions == null || actions.isEmpty()) {
return false;
}
String expectedFrom = resolveExpectedFromFromAction(actions.get(0), plan.getTriggerStage());
if (oConvertUtils.isEmpty(expectedFrom)) {
return false;
}
return expectedFrom.equals(currentStatus);
}
private String resolveExpectedFromFromAction(MesXslIntegrationAction action, String triggerStage) {
if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) {
try {
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
JSONObject registryStage = cfg.getJSONObject("registryStage");
if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("expectedFrom"))) {
return registryStage.getString("expectedFrom").trim();
}
if (cfg.containsKey("expectedFrom")) {
String v = cfg.getString("expectedFrom");
return oConvertUtils.isEmpty(v) ? null : v.trim();
}
} catch (Exception ignored) {
// fallback
}
}
return RegistryStageFieldHelper.defaultExpectedFrom(triggerStage);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案环节匹配增强(节点名+源单status)-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别钉钉Stream来源回调-----------
private boolean isDingTalkCallback(ApprovalCallbackContext ctx) {
return ctx != null && "dingtalk".equals(ctx.getOperatorUsername());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别钉钉Stream来源回调-----------
}

View File

@@ -0,0 +1,69 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
/**
* 审批注册中心环节与业务表字段映射辅助
*/
public final class RegistryStageFieldHelper {
private RegistryStageFieldHelper() {
}
public static String statusField(MesXslBizDocRegistry registry) {
return oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField();
}
public static String byField(MesXslBizDocRegistry registry, String stage) {
if (registry == null || oConvertUtils.isEmpty(stage)) {
return null;
}
switch (stage) {
case ApprovalStageResolver.STAGE_PROOFREAD:
return registry.getProofreadByField();
case ApprovalStageResolver.STAGE_AUDIT:
return registry.getAuditByField();
case ApprovalStageResolver.STAGE_APPROVE:
return registry.getApproveByField();
default:
return null;
}
}
public static String timeField(MesXslBizDocRegistry registry, String stage) {
if (registry == null || oConvertUtils.isEmpty(stage)) {
return null;
}
switch (stage) {
case ApprovalStageResolver.STAGE_PROOFREAD:
return registry.getProofreadTimeField();
case ApprovalStageResolver.STAGE_AUDIT:
return registry.getAuditTimeField();
case ApprovalStageResolver.STAGE_APPROVE:
return registry.getApproveTimeField();
default:
return null;
}
}
/** 环节默认前置状态proofread←compile, audit←proofread, approve←audit */
public static String defaultExpectedFrom(String stage) {
switch (stage) {
case ApprovalStageResolver.STAGE_PROOFREAD:
return "compile";
case ApprovalStageResolver.STAGE_AUDIT:
return ApprovalStageResolver.STAGE_PROOFREAD;
case ApprovalStageResolver.STAGE_APPROVE:
return ApprovalStageResolver.STAGE_AUDIT;
default:
return null;
}
}
public static void assertIdentifier(String name) {
if (oConvertUtils.isEmpty(name) || !name.matches("^[a-z][a-z0-9_]{0,63}$")) {
throw new IllegalArgumentException("非法字段名: " + name);
}
}
}

View File

@@ -0,0 +1,20 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
/**
* 集成方案触发时机,与 ApprovalCallbackContext.Action 对应
*/
public enum TriggerPhase {
ON_APPROVE("onApprove"),
ON_REJECT("onReject"),
ON_NODE_APPROVE("onNodeApprove");
private final String value;
TriggerPhase(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,125 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import org.jeecg.common.util.oConvertUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 模板变量解析器。
* 将 #{变量名} 替换为实际值,用于 SQL 模板、幂等键等场景。
* <p>
* 支持的变量:
* <ul>
* <li>#{source.id} / #{id} — 源单 ID</li>
* <li>#{source.字段名} — 源单主表字段</li>
* <li>#{sys_user_code} — 当前操作人 username</li>
* <li>#{sys_date} — 当前日期 yyyy-MM-dd</li>
* <li>#{sys_datetime} — 当前时间 yyyy-MM-dd HH:mm:ss</li>
* <li>#{approval.instance_id} — 审批实例 ID</li>
* <li>#{approval.apply_user} — 审批发起人</li>
* </ul>
*/
public class VariableResolver {
private static final Pattern PLACEHOLDER = Pattern.compile("#\\{([^}]+)}");
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private VariableResolver() {}
/**
* 解析模板,返回替换后的字符串(不做 SQL 转义,纯文本场景用)。
*/
public static String resolve(String template, IntegrationContext ctx) {
if (oConvertUtils.isEmpty(template)) {
return template;
}
Matcher m = PLACEHOLDER.matcher(template);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String key = m.group(1).trim();
String val = resolveKey(key, ctx);
m.appendReplacement(sb, Matcher.quoteReplacement(val));
}
m.appendTail(sb);
return sb.toString();
}
/**
* 解析 SQL 模板:字符串值自动加单引号并转义内部单引号。
* 数值/null 不加引号。
*/
public static String resolveSql(String template, IntegrationContext ctx) {
if (oConvertUtils.isEmpty(template)) {
return template;
}
Matcher m = PLACEHOLDER.matcher(template);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String key = m.group(1).trim();
String rawVal = resolveKey(key, ctx);
String sqlVal = toSqlLiteral(rawVal);
m.appendReplacement(sb, Matcher.quoteReplacement(sqlVal));
}
m.appendTail(sb);
return sb.toString();
}
private static String resolveKey(String key, IntegrationContext ctx) {
// 系统变量
switch (key) {
case "sys_date":
return LocalDate.now().format(DATE_FMT);
case "sys_datetime":
return LocalDateTime.now().format(DATETIME_FMT);
case "sys_user_code":
return safeStr(ctx.getApprovalCtx() != null ? ctx.getApprovalCtx().getOperatorUsername() : null);
case "id":
case "source.id":
return safeStr(ctx.getSourceBizId());
case "approval.instance_id":
return safeStr(ctx.getApprovalCtx() != null ? ctx.getApprovalCtx().getInstanceId() : null);
case "approval.apply_user":
return safeStr(ctx.getApprovalCtx() != null ? ctx.getApprovalCtx().getApplyUser() : null);
default:
break;
}
// source.字段名
if (key.startsWith("source.")) {
String field = key.substring("source.".length());
Map<String, Object> rec = ctx.getSourceRecord();
if (rec != null && rec.containsKey(field)) {
Object v = rec.get(field);
return v == null ? "" : v.toString();
}
return "";
}
// action.xxx.target_idPhase1 预留)
if (key.startsWith("action.")) {
return safeStr(ctx.getActionResults().get(key));
}
return "";
}
/** 将解析值转为 SQL 字面量,字符串用单引号包裹并转义 */
private static String toSqlLiteral(String raw) {
if (raw == null || raw.isEmpty()) {
return "NULL";
}
// 若值是纯数字,不加引号
if (raw.matches("-?\\d+(\\.\\d+)?")) {
return raw;
}
// 字符串:转义单引号后加引号
return "'" + raw.replace("'", "''") + "'";
}
private static String safeStr(String s) {
return s == null ? "" : s;
}
}

View File

@@ -0,0 +1,19 @@
package org.jeecg.modules.xslmes.approval.integration.engine.executor;
import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
/**
* 集成动作执行器 SPI
*/
public interface IIntegrationActionExecutor {
/** 支持的 action_type */
String supportActionType();
/**
* 执行动作,返回结果描述(成功时)。
* 失败时抛出 RuntimeException由编排器捕获并写日志。
*/
String execute(IntegrationContext ctx, MesXslIntegrationAction action);
}

View File

@@ -0,0 +1,105 @@
package org.jeecg.modules.xslmes.approval.integration.engine.executor;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext;
import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 审批驳回回退:按注册中心配置将源单 status 回退并清空环节痕迹(默认回 compile
*/
@Slf4j
@Component
public class RegistryStageRevertExecutor implements IIntegrationActionExecutor {
@Autowired
private IMesXslBizDocRegistryService registryService;
@Autowired
private IApprovalTraceSyncService approvalTraceSyncService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public String supportActionType() {
return "REGISTRY_STAGE_REVERT";
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节回退执行器-----------
@Override
public String execute(IntegrationContext ctx, MesXslIntegrationAction action) {
String bizTable = ctx.getSourceBizTable();
String bizId = ctx.getSourceBizId();
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizId)) {
throw new IllegalArgumentException("缺少源单表名或ID");
}
MesXslBizDocRegistry registry = registryService.findActiveByTableName(bizTable);
if (registry == null) {
throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable);
}
String targetStage = "compile";
if (oConvertUtils.isNotEmpty(action.getActionConfig())) {
try {
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) {
targetStage = cfg.getString("targetStage").trim();
}
} catch (Exception ignored) {
// 使用默认 compile
}
}
String statusField = RegistryStageFieldHelper.statusField(registry);
RegistryStageFieldHelper.assertIdentifier(statusField);
RegistryStageFieldHelper.assertIdentifier(bizTable);
StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `")
.append(statusField).append("`=?");
java.util.List<Object> params = new java.util.ArrayList<>();
params.add(targetStage);
clearField(sql, params, registry.getProofreadByField());
clearField(sql, params, registry.getProofreadTimeField());
clearField(sql, params, registry.getAuditByField());
clearField(sql, params, registry.getAuditTimeField());
clearField(sql, params, registry.getApproveByField());
clearField(sql, params, registry.getApproveTimeField());
if ("compile".equals(targetStage)) {
// 已全部清空
} else if ("proofread".equals(targetStage)) {
// 保留 proofread清空 audit/approve — 上面已全清需按目标环节保留简化compile 场景为主)
}
sql.append(" WHERE id=?");
params.add(bizId);
int affected = jdbcTemplate.update(sql.toString(), params.toArray());
if (affected == 0) {
throw new IllegalStateException("源单不存在或回退失败 id=" + bizId);
}
approvalTraceSyncService.revertToStage(bizTable, bizId, targetStage);
log.info("[集成引擎][REGISTRY_STAGE_REVERT] table={} id={} targetStage={}", bizTable, bizId, targetStage);
return "环节回退成功: " + targetStage;
}
private void clearField(StringBuilder sql, java.util.List<Object> params, String field) {
if (oConvertUtils.isEmpty(field)) {
return;
}
RegistryStageFieldHelper.assertIdentifier(field);
sql.append(", `").append(field).append("`=?");
params.add(null);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节回退执行器-----------
}

View File

@@ -0,0 +1,155 @@
package org.jeecg.modules.xslmes.approval.integration.engine.executor;
import com.alibaba.fastjson2.JSONObject;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver;
import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext;
import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 审批注册中心环节同步:无需手写 SQL按注册配置更新源单 status/操作人/时间,并双写审批痕迹。
*/
@Slf4j
@Component
public class RegistryStageSyncExecutor implements IIntegrationActionExecutor {
@Autowired
private IMesXslBizDocRegistryService registryService;
@Autowired
private IApprovalTraceSyncService approvalTraceSyncService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public String supportActionType() {
return "REGISTRY_STAGE_SYNC";
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节同步执行器-----------
@Override
public String execute(IntegrationContext ctx, MesXslIntegrationAction action) {
String bizTable = ctx.getSourceBizTable();
String bizId = ctx.getSourceBizId();
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizId)) {
throw new IllegalArgumentException("缺少源单表名或ID");
}
MesXslBizDocRegistry registry = registryService.findActiveByTableName(bizTable);
if (registry == null) {
throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable);
}
String stage = resolveStage(ctx, action);
String stageErr = approvalTraceSyncService.checkStageAllowed(bizTable, stage);
if (stageErr != null) {
throw new IllegalStateException(stageErr);
}
String expectedFrom = resolveExpectedFrom(action, stage);
String statusField = RegistryStageFieldHelper.statusField(registry);
String byField = RegistryStageFieldHelper.byField(registry, stage);
String timeField = RegistryStageFieldHelper.timeField(registry, stage);
RegistryStageFieldHelper.assertIdentifier(statusField);
if (oConvertUtils.isNotEmpty(byField)) {
RegistryStageFieldHelper.assertIdentifier(byField);
}
if (oConvertUtils.isNotEmpty(timeField)) {
RegistryStageFieldHelper.assertIdentifier(timeField);
}
String operator = resolveOperator(ctx);
Date now = new Date();
if (oConvertUtils.isNotEmpty(expectedFrom)) {
Object current = jdbcTemplate.queryForObject(
"SELECT `" + statusField + "` FROM `" + bizTable + "` WHERE id = ?",
Object.class, bizId);
String currentStr = current == null ? "" : String.valueOf(current).trim();
if (!expectedFrom.equals(currentStr)) {
return "跳过:当前状态=" + currentStr + ",期望=" + expectedFrom;
}
}
StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `")
.append(statusField).append("`=?");
java.util.List<Object> params = new java.util.ArrayList<>();
params.add(stage);
if (oConvertUtils.isNotEmpty(byField)) {
sql.append(", `").append(byField).append("`=?");
params.add(operator);
}
if (oConvertUtils.isNotEmpty(timeField)) {
sql.append(", `").append(timeField).append("`=?");
params.add(now);
}
sql.append(" WHERE id=?");
params.add(bizId);
int affected = jdbcTemplate.update(sql.toString(), params.toArray());
if (affected == 0) {
throw new IllegalStateException("源单不存在或更新失败 id=" + bizId);
}
approvalTraceSyncService.syncStage(bizTable, bizId, stage, operator, now);
log.info("[集成引擎][REGISTRY_STAGE_SYNC] table={} id={} stage={} operator={}",
bizTable, bizId, stage, operator);
return "环节同步成功: " + ApprovalStageResolver.stageLabel(stage);
}
private String resolveStage(IntegrationContext ctx, MesXslIntegrationAction action) {
if (oConvertUtils.isNotEmpty(action.getActionConfig())) {
try {
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
String stage = cfg.getString("stage");
if (oConvertUtils.isNotEmpty(stage)) {
return stage.trim();
}
} catch (Exception ignored) {
// 继续 fallback
}
}
MesXslIntegrationPlan plan = ctx.getPlan();
if (plan != null && oConvertUtils.isNotEmpty(plan.getTriggerStage())) {
return plan.getTriggerStage();
}
throw new IllegalArgumentException("动作未配置审批环节(stage),且方案未绑定 triggerStage");
}
private String resolveExpectedFrom(MesXslIntegrationAction action, String stage) {
if (oConvertUtils.isNotEmpty(action.getActionConfig())) {
try {
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
if (cfg.containsKey("expectedFrom")) {
String v = cfg.getString("expectedFrom");
return oConvertUtils.isEmpty(v) ? null : v.trim();
}
} catch (Exception ignored) {
// fallback
}
}
return RegistryStageFieldHelper.defaultExpectedFrom(stage);
}
private String resolveOperator(IntegrationContext ctx) {
ApprovalCallbackContext ac = ctx.getApprovalCtx();
if (ac != null && oConvertUtils.isNotEmpty(ac.getOperatorName())) {
return ac.getOperatorName();
}
if (ac != null && oConvertUtils.isNotEmpty(ac.getOperatorUsername())) {
return ac.getOperatorUsername();
}
return "系统";
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节同步执行器-----------
}

View File

@@ -0,0 +1,70 @@
package org.jeecg.modules.xslmes.approval.integration.engine.executor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext;
import org.jeecg.modules.xslmes.approval.integration.engine.VariableResolver;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.regex.Pattern;
/**
* SQL_UPDATE 动作执行器。
* 支持 UPDATE / INSERT 语句,变量用 #{...} 占位。
* 安全约束:
* 1. SQL 必须以 UPDATE 或 INSERT 开头(不区分大小写)
* 2. 禁止含 DROP / TRUNCATE / DELETE无 WHERE 条件的批量删除风险)
* 3. 变量值经过 SQL 字面量转义
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】SQL_UPDATE执行器
*/
@Slf4j
@Component
public class SqlUpdateActionExecutor implements IIntegrationActionExecutor {
private static final Pattern ALLOWED_START = Pattern.compile("^(UPDATE|INSERT)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern DANGEROUS = Pattern.compile("\\b(DROP|TRUNCATE|DELETE\\s+FROM)\\b", Pattern.CASE_INSENSITIVE);
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public String supportActionType() {
return "SQL_UPDATE";
}
@Override
public String execute(IntegrationContext ctx, MesXslIntegrationAction action) {
String template = action.getSqlTemplate();
if (oConvertUtils.isEmpty(template)) {
throw new IllegalArgumentException("动作 [" + action.getActionName() + "] sql_template 为空");
}
// 变量替换
String resolvedSql = VariableResolver.resolveSql(template.trim(), ctx);
// 安全校验
validate(resolvedSql, action.getActionName());
log.info("[集成引擎][SQL_UPDATE] 执行 action={} sql={}", action.getActionName(), resolvedSql);
int affected = jdbcTemplate.update(resolvedSql);
String result = "影响行数: " + affected;
log.info("[集成引擎][SQL_UPDATE] 完成 action={} {}", action.getActionName(), result);
return result;
}
private void validate(String sql, String actionName) {
if (!ALLOWED_START.matcher(sql).find()) {
throw new IllegalArgumentException(
"集成动作 [" + actionName + "] SQL 必须以 UPDATE 或 INSERT 开头,实际: " + sql.substring(0, Math.min(50, sql.length())));
}
if (DANGEROUS.matcher(sql).find()) {
throw new IllegalArgumentException(
"集成动作 [" + actionName + "] SQL 含有危险关键字DROP/TRUNCATE/DELETE FROM已拒绝执行");
}
}
}

View File

@@ -0,0 +1,76 @@
package org.jeecg.modules.xslmes.approval.integration.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.system.base.entity.JeecgEntity;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 审批痕迹明细(每业务单据一行)
*
* @author GHT
* @date 2026-06-05 for【XSLMES-20260605-K8R2】审批痕迹明细
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_trace")
@Schema(description = "审批痕迹明细")
public class MesXslApprovalTrace extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "审批注册配置ID")
private String registryId;
@Schema(description = "业务表名")
private String bizTable;
@Schema(description = "业务单据ID")
private String bizDataId;
@Schema(description = "校对人")
private String proofreadBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "校对时间")
private Date proofreadTime;
@Schema(description = "审核人")
private String auditBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "审核时间")
private Date auditTime;
@Schema(description = "批准人")
private String approveBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "批准时间")
private Date approveTime;
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除 0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,84 @@
package org.jeecg.modules.xslmes.approval.integration.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import org.jeecg.common.aspect.annotation.Dict;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 审批注册中心
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】单据注册
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_biz_doc_registry")
@Schema(description = "审批注册中心")
public class MesXslBizDocRegistry extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务编码,如 formula_spec")
private String docCode;
@Schema(description = "物理表名")
private String tableName;
@Schema(description = "中文名")
private String displayName;
@Dict(dicCode = "yn")
@Schema(description = "启用 0否 1是")
private Integer enabled;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批环节与字段映射配置-----------
@Dict(dicCode = "mes_xsl_approval_stage")
@Schema(description = "启用环节(多选逗号分隔 proofread,audit,approve)")
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String enabledStages;
@Schema(description = "业务状态字段名")
private String statusField;
@Schema(description = "校对人字段名")
private String proofreadByField;
@Schema(description = "校对时间字段名")
private String proofreadTimeField;
@Schema(description = "审核人字段名")
private String auditByField;
@Schema(description = "审核时间字段名")
private String auditTimeField;
@Schema(description = "批准人字段名")
private String approveByField;
@Schema(description = "批准时间字段名")
private String approveTimeField;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批环节与字段映射配置-----------
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除 0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,66 @@
package org.jeecg.modules.xslmes.approval.integration.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 审核集成动作
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成动作
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_integration_action")
@Schema(description = "审核集成动作")
public class MesXslIntegrationAction extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "所属方案ID")
private String planId;
@Schema(description = "动作名称")
private String actionName;
@Schema(description = "动作类型")
@Dict(dicCode = "mes_xsl_integration_action_type")
private String actionType;
@Schema(description = "SQL模板SQL_UPDATE动作用")
private String sqlTemplate;
@Schema(description = "执行顺序(升序)")
private Integer execOrder;
@Schema(description = "失败策略 stop/continue")
@Dict(dicCode = "mes_xsl_integration_on_fail")
private String onFail;
@Schema(description = "幂等键表达式(空=record_id+action_id")
private String idempotentKey;
@Schema(description = "启用 0否 1是")
private Integer enabled;
//update-begin---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增可视化配置字段-----------
@Schema(description = "可视化配置JSON可视化编辑器专用")
private String actionConfig;
//update-end---author:GHT ---date:2026-06-05 for【审核集成Phase0】新增可视化配置字段-----------
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除")
@TableLogic
private Integer delFlag;
}

View File

@@ -0,0 +1,76 @@
package org.jeecg.modules.xslmes.approval.integration.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 审核集成执行日志(只写不改,无 JeecgEntity 基类)
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成执行日志
*/
@Data
@Accessors(chain = true)
@TableName("mes_xsl_integration_log")
@Schema(description = "审核集成执行日志")
public class MesXslIntegrationLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键")
private String id;
@Schema(description = "审批台账ID")
private String recordId;
@Schema(description = "方案ID")
private String planId;
@Schema(description = "动作ID")
private String actionId;
@Schema(description = "幂等键")
private String idempotentKey;
@Schema(description = "执行状态 success/failed/skipped")
@Dict(dicCode = "mes_xsl_integration_log_status")
private String status;
@Schema(description = "源单ID")
private String sourceBizId;
@Schema(description = "源单表名")
private String sourceBizTable;
@Schema(description = "错误信息")
private String errorMessage;
@Schema(description = "重试次数")
private Integer retryCount;
@Schema(description = "耗时ms")
private Long execTimeMs;
@Schema(description = "执行前变量快照")
private String requestSnapshot;
@Schema(description = "执行结果快照")
private String responseSnapshot;
@Schema(description = "创建人")
private String createBy;
@Schema(description = "创建时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
}

View File

@@ -0,0 +1,76 @@
package org.jeecg.modules.xslmes.approval.integration.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 审核集成方案
*
* @author GHT
* @date 2026-06-05 for【审核集成Phase0】集成方案
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_integration_plan")
@Schema(description = "审核集成方案")
public class MesXslIntegrationPlan extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "方案编码(唯一)")
private String planCode;
@Schema(description = "方案名称")
private String planName;
@Schema(description = "源单表名")
private String sourceTable;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
@Schema(description = "审批注册中心ID")
private String registryId;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
@Schema(description = "触发时机 onApprove/onReject/onNodeApprove")
@Dict(dicCode = "mes_xsl_integration_trigger_phase")
private String triggerPhase;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
@Schema(description = "绑定审批环节 proofread/audit/approve")
@Dict(dicCode = "mes_xsl_approval_stage")
private String triggerStage;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
@Schema(description = "执行模式 sync/async")
@Dict(dicCode = "mes_xsl_integration_exec_mode")
private String execMode;
@Schema(description = "匹配条件(空=无条件)")
private String matchCondition;
@Schema(description = "状态 0草稿 1已发布 2已停用")
@Dict(dicCode = "mes_xsl_integration_plan_status")
private String status;
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,10 @@
package org.jeecg.modules.xslmes.approval.integration.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
/**
* 审批痕迹明细
*/
public interface MesXslApprovalTraceMapper extends BaseMapper<MesXslApprovalTrace> {
}

View File

@@ -0,0 +1,7 @@
package org.jeecg.modules.xslmes.approval.integration.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
public interface MesXslBizDocRegistryMapper extends BaseMapper<MesXslBizDocRegistry> {
}

View File

@@ -0,0 +1,7 @@
package org.jeecg.modules.xslmes.approval.integration.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
public interface MesXslIntegrationActionMapper extends BaseMapper<MesXslIntegrationAction> {
}

View File

@@ -0,0 +1,7 @@
package org.jeecg.modules.xslmes.approval.integration.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
public interface MesXslIntegrationLogMapper extends BaseMapper<MesXslIntegrationLog> {
}

View File

@@ -0,0 +1,7 @@
package org.jeecg.modules.xslmes.approval.integration.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
public interface MesXslIntegrationPlanMapper extends BaseMapper<MesXslIntegrationPlan> {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.approval.integration.mapper.MesXslApprovalTraceMapper">
</mapper>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.approval.integration.mapper.MesXslBizDocRegistryMapper">
</mapper>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationActionMapper">
</mapper>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationLogMapper">
</mapper>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationPlanMapper">
</mapper>

View File

@@ -0,0 +1,26 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import java.util.Date;
/**
* 审批痕迹双写同步服务
*/
public interface IApprovalTraceSyncService {
/**
* 校验业务表是否已启用指定审批环节;未注册配置时返回 null不拦截业务
*/
String checkStageAllowed(String bizTable, String stage);
/**
* 环节通过后同步痕迹upsert 每单据一行)
*/
void syncStage(String bizTable, String bizDataId, String stage, String operatorBy, Date operatorTime);
/**
* 逆向回退时同步清空高于目标环节的痕迹字段
*
* @param targetStage compile / proofread / audit
*/
void revertToStage(String bizTable, String bizDataId, String targetStage);
}

View File

@@ -0,0 +1,15 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
/**
* 审批痕迹明细
*/
public interface IMesXslApprovalTraceService extends IService<MesXslApprovalTrace> {
/**
* 按业务表 + 单据ID 查询痕迹(供业务页关联展示)
*/
MesXslApprovalTrace getByBiz(String bizTable, String bizDataId);
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
public interface IMesXslBizDocRegistryService extends IService<MesXslBizDocRegistry> {
/**
* 保存前规范化字段映射与环节配置
*/
void normalizeBeforeSave(MesXslBizDocRegistry entity);
/**
* 按物理表名查询已启用的审批注册配置
*/
MesXslBizDocRegistry findActiveByTableName(String tableName);
}

View File

@@ -0,0 +1,15 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import java.util.List;
public interface IMesXslIntegrationActionService extends IService<MesXslIntegrationAction> {
/** 按方案ID查询启用动作按 exec_order 升序 */
List<MesXslIntegrationAction> listByPlanId(String planId);
/** 删除方案下所有动作 */
void removeByPlanId(String planId);
}

View File

@@ -0,0 +1,14 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
public interface IMesXslIntegrationLogService extends IService<MesXslIntegrationLog> {
/** 幂等检查:同一 idempotentKey 是否已 success */
boolean isAlreadySuccess(String idempotentKey);
/** 手动重试失败日志(重新触发 Orchestrator 执行对应 action */
Result<String> retry(String logId);
}

View File

@@ -0,0 +1,19 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
public interface IMesXslIntegrationPlanService extends IService<MesXslIntegrationPlan> {
/** 发布方案status 0→1 */
Result<String> publish(String planId);
/** 停用方案status 1→2 */
Result<String> disable(String planId);
/**
* 保存前绑定注册中心并校验环节
*/
Result<String> normalizeAndValidate(MesXslIntegrationPlan plan);
}

View File

@@ -0,0 +1,657 @@
package org.jeecg.modules.xslmes.approval.integration.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver;
import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 按审批流程节点 + 业务状态字典,一键生成默认集成方案与动作。
*
* @author GHT
* @date 2026-06-05 for【XSLMES-20260605-K8R2】按流程生成默认集成方案
*/
@Slf4j
@Service
public class IntegrationPlanGenerator {
private static final Pattern DICT_IN_COMMENT = Pattern.compile("字典[:\\s]?([a-zA-Z][a-zA-Z0-9_]*)");
private static final Map<String, String> TABLE_STATUS_DICT_FALLBACK = Map.of(
"mes_xsl_mixer_ps_compile", "xslmes_mixer_ps_status",
"mes_xsl_formula_spec", "xslmes_formula_spec_status"
);
@Autowired
private IMesXslBizDocRegistryService registryService;
@Autowired
private IMesXslApprovalFlowService flowService;
@Autowired
private IMesXslIntegrationPlanService planService;
@Autowired
private IMesXslIntegrationActionService actionService;
@Autowired
private JdbcTemplate jdbcTemplate;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】预览按流程生成的默认方案-----------
public Result<Map<String, Object>> preview(String sourceTable, String flowId) {
return preview(sourceTable, flowId, null);
}
public Result<Map<String, Object>> preview(String sourceTable, String flowId, Map<String, String> stageOverrides) {
try {
return Result.OK(buildPreview(sourceTable, flowId, stageOverrides));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】预览按流程生成的默认方案-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按流程生成默认集成方案与动作-----------
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Object>> generate(String sourceTable, String flowId, boolean overwriteDraft) {
return generate(sourceTable, flowId, overwriteDraft, null);
}
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Object>> generate(String sourceTable, String flowId, boolean overwriteDraft,
Map<String, String> stageOverrides) {
Map<String, Object> preview = buildPreview(sourceTable, flowId, stageOverrides);
MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable);
String codePrefix = planCodePrefix(registry);
if (overwriteDraft) {
removeDraftAutoPlans(sourceTable, codePrefix);
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> planDefs = (List<Map<String, Object>>) preview.get("plans");
int created = 0;
int skipped = 0;
List<String> planCodes = new ArrayList<>();
for (Map<String, Object> def : planDefs) {
String planCode = String.valueOf(def.get("planCode"));
if (planService.lambdaQuery()
.eq(MesXslIntegrationPlan::getPlanCode, planCode)
.exists()) {
skipped++;
continue;
}
MesXslIntegrationPlan plan = new MesXslIntegrationPlan();
plan.setPlanCode(planCode);
plan.setPlanName(String.valueOf(def.get("planName")));
plan.setSourceTable(sourceTable);
plan.setRegistryId(registry.getId());
plan.setTriggerPhase(String.valueOf(def.get("triggerPhase")));
Object triggerStage = def.get("triggerStage");
if (triggerStage != null && oConvertUtils.isNotEmpty(String.valueOf(triggerStage))) {
plan.setTriggerStage(String.valueOf(triggerStage));
}
plan.setExecMode("async");
plan.setStatus("0");
plan.setRemark(String.valueOf(def.get("remark")));
Result<String> validate = planService.normalizeAndValidate(plan);
if (!validate.isSuccess()) {
throw new IllegalStateException("方案校验失败[" + planCode + "]: " + validate.getMessage());
}
planService.save(plan);
@SuppressWarnings("unchecked")
Map<String, Object> actionDef = (Map<String, Object>) def.get("action");
MesXslIntegrationAction action = new MesXslIntegrationAction();
action.setPlanId(plan.getId());
action.setActionName(String.valueOf(actionDef.get("actionName")));
action.setActionType(String.valueOf(actionDef.get("actionType")));
action.setActionConfig(JSON.toJSONString(actionDef.get("actionConfig")));
action.setExecOrder(1);
action.setOnFail("stop");
action.setEnabled(1);
actionService.save(action);
created++;
planCodes.add(planCode);
}
Map<String, Object> result = new LinkedHashMap<>(preview);
result.put("created", created);
result.put("skipped", skipped);
result.put("planCodes", planCodes);
return Result.OK("生成完成:新增 " + created + " 个方案,跳过 " + skipped + " 个已存在方案", result);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按流程生成默认集成方案与动作-----------
private Map<String, Object> buildPreview(String sourceTable, String flowId, Map<String, String> stageOverrides) {
if (oConvertUtils.isEmpty(sourceTable)) {
throw new IllegalArgumentException("请选择业务表");
}
MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable);
if (registry == null) {
throw new IllegalArgumentException("业务表未在审批注册中心启用: " + sourceTable);
}
MesXslApprovalFlow flow = resolveFlow(sourceTable, flowId);
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) {
throw new IllegalArgumentException("未找到已配置的审批流程,请先在审批流设计中保存流程");
}
List<String> enabledStages = orderedEnabledStages(registry);
List<FlowNode> flowNodes = parseApproverNodes(flow.getFlowConfig());
if (flowNodes.isEmpty()) {
throw new IllegalArgumentException("审批流程中无审批人节点,请先设计流程");
}
List<StatusDictItem> statusChain = loadStatusChain(registry);
if (statusChain.isEmpty()) {
throw new IllegalArgumentException("无法解析业务状态字典,请检查 status 字段注释或字典配置");
}
String initialStatus = resolveInitialStatus(statusChain, enabledStages);
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按实际流程节点生成并标注环节配置状态-----------
List<StageBinding> bindings = bindAllFlowNodes(flowNodes, registry, enabledStages, statusChain, initialStatus, stageOverrides);
List<StageBinding> configuredBindings = bindings.stream().filter(StageBinding::stageConfigured).toList();
if (configuredBindings.isEmpty()) {
throw new IllegalArgumentException("流程审批节点均未在审批注册中心配置对应环节,请先在注册中心启用环节并配置人员字段");
}
String codePrefix = planCodePrefix(registry);
String displayName = oConvertUtils.isNotEmpty(registry.getDisplayName()) ? registry.getDisplayName() : sourceTable;
List<Map<String, Object>> plans = new ArrayList<>();
List<Map<String, Object>> nodePreview = new ArrayList<>();
for (int i = 0; i < bindings.size(); i++) {
StageBinding b = bindings.get(i);
boolean willGenerate = false;
String phase = null;
if (b.stageConfigured) {
int cfgIdx = configuredBindings.indexOf(b);
if (cfgIdx >= 0) {
willGenerate = true;
// 末节点也用 onNodeApprove便于在流程设计器「本节点通过」下拉中绑定终态 onApprove 由引擎按环节匹配兜底
phase = "onNodeApprove";
}
}
Map<String, Object> node = new LinkedHashMap<>();
node.put("nodeIndex", i + 1);
node.put("nodeId", b.nodeId);
node.put("nodeName", b.nodeName);
node.put("nodeNameDisplay", b.nodeName + (b.stageConfigured ? "(已配置该环节)" : "(未配置该环节)"));
node.put("stageConfigured", b.stageConfigured);
node.put("configuredText", b.stageConfigured ? "已配置该环节" : "未配置该环节");
node.put("stage", b.stage);
node.put("suggestedStage", b.suggestedStage);
node.put("stageLabel", oConvertUtils.isNotEmpty(b.stageLabel) ? b.stageLabel : "-");
node.put("willGenerate", willGenerate);
node.put("triggerPhase", phase);
node.put("expectedFrom", b.expectedFrom);
node.put("expectedFromLabel", oConvertUtils.isNotEmpty(b.expectedFrom) ? labelOf(statusChain, b.expectedFrom) : "-");
if (!b.stageConfigured && oConvertUtils.isNotEmpty(b.unconfiguredReason)) {
node.put("unconfiguredReason", b.unconfiguredReason);
}
nodePreview.add(node);
if (!willGenerate) {
continue;
}
String planCode = codePrefix + "_reg_" + b.stage;
String planName = displayName + "-" + b.stageLabel + "通过(流程生成)";
Map<String, Object> actionConfig = new LinkedHashMap<>();
actionConfig.put("visualType", "REGISTRY_STAGE_SYNC");
actionConfig.put("stage", b.stage);
actionConfig.put("expectedFrom", b.expectedFrom);
Map<String, Object> action = new LinkedHashMap<>();
action.put("actionName", b.stageLabel + "环节同步");
action.put("actionType", "REGISTRY_STAGE_SYNC");
action.put("actionConfig", actionConfig);
Map<String, Object> plan = new LinkedHashMap<>();
plan.put("planCode", planCode);
plan.put("planName", planName);
plan.put("triggerPhase", phase);
plan.put("triggerStage", b.stage);
plan.put("remark", "按审批流程节点「" + b.nodeName + "」自动生成");
plan.put("action", action);
plans.add(plan);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】按实际流程节点生成并标注环节配置状态-----------
String rejectCode = codePrefix + "_reg_reject";
Map<String, Object> rejectActionConfig = new LinkedHashMap<>();
rejectActionConfig.put("visualType", "REGISTRY_STAGE_REVERT");
rejectActionConfig.put("targetStage", initialStatus);
Map<String, Object> rejectAction = new LinkedHashMap<>();
rejectAction.put("actionName", "驳回回退" + labelOf(statusChain, initialStatus));
rejectAction.put("actionType", "REGISTRY_STAGE_REVERT");
rejectAction.put("actionConfig", rejectActionConfig);
Map<String, Object> rejectPlan = new LinkedHashMap<>();
rejectPlan.put("planCode", rejectCode);
rejectPlan.put("planName", displayName + "-驳回回退(流程生成)");
rejectPlan.put("triggerPhase", "onReject");
rejectPlan.put("triggerStage", null);
rejectPlan.put("remark", "驳回时回退至初始状态「" + labelOf(statusChain, initialStatus) + "」并清空痕迹");
rejectPlan.put("action", rejectAction);
plans.add(rejectPlan);
Map<String, Object> preview = new LinkedHashMap<>();
preview.put("sourceTable", sourceTable);
preview.put("registryId", registry.getId());
preview.put("flowId", flow.getId());
preview.put("flowName", flow.getFlowName());
preview.put("statusDictCode", resolveStatusDictCode(registry));
preview.put("initialStatus", initialStatus);
preview.put("initialStatusLabel", labelOf(statusChain, initialStatus));
preview.put("statusChain", statusChain.stream().map(StatusDictItem::toMap).toList());
preview.put("flowNodes", flowNodes.stream().map(FlowNode::toMap).toList());
preview.put("nodeBindings", nodePreview);
preview.put("flowNodeCount", flowNodes.size());
preview.put("configuredNodeCount", configuredBindings.size());
preview.put("unconfiguredNodeCount", flowNodes.size() - configuredBindings.size());
preview.put("enabledStages", enabledStages);
preview.put("stageOptions", buildStageOptions(statusChain));
preview.put("stageMeta", buildStageMeta(registry, statusChain));
preview.put("plans", plans);
return preview;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别环节可选手选并支持环节元数据-----------
private List<Map<String, Object>> buildStageOptions(List<StatusDictItem> statusChain) {
List<Map<String, Object>> options = new ArrayList<>();
for (String stage : new String[]{
ApprovalStageResolver.STAGE_PROOFREAD,
ApprovalStageResolver.STAGE_AUDIT,
ApprovalStageResolver.STAGE_APPROVE}) {
Map<String, Object> opt = new LinkedHashMap<>();
opt.put("value", stage);
opt.put("label", labelOf(statusChain, stage));
options.add(opt);
}
return options;
}
private Map<String, Object> buildStageMeta(MesXslBizDocRegistry registry, List<StatusDictItem> statusChain) {
Map<String, Object> meta = new LinkedHashMap<>();
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
for (String stage : new String[]{
ApprovalStageResolver.STAGE_PROOFREAD,
ApprovalStageResolver.STAGE_AUDIT,
ApprovalStageResolver.STAGE_APPROVE}) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("label", labelOf(statusChain, stage));
item.put("enabled", enabled.contains(stage));
item.put("configured", isStageConfigured(registry, stage));
meta.put(stage, item);
}
return meta;
}
public static Map<String, String> parseStageOverrides(List<Map<String, Object>> nodeBindings) {
if (nodeBindings == null || nodeBindings.isEmpty()) {
return null;
}
Map<String, String> overrides = new LinkedHashMap<>();
for (Map<String, Object> binding : nodeBindings) {
if (binding == null || binding.get("nodeId") == null) {
continue;
}
String nodeId = String.valueOf(binding.get("nodeId"));
Object stageVal = binding.get("stage");
if (stageVal == null || oConvertUtils.isEmpty(String.valueOf(stageVal))) {
overrides.put(nodeId, null);
} else {
overrides.put(nodeId, String.valueOf(stageVal).trim());
}
}
return overrides;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】识别环节可选手选并支持环节元数据-----------
private MesXslApprovalFlow resolveFlow(String sourceTable, String flowId) {
if (oConvertUtils.isNotEmpty(flowId)) {
MesXslApprovalFlow flow = flowService.getById(flowId);
if (flow != null && sourceTable.equals(flow.getBizTable())) {
return flow;
}
throw new IllegalArgumentException("审批流与业务表不匹配");
}
return flowService.lambdaQuery()
.eq(MesXslApprovalFlow::getBizTable, sourceTable)
.orderByDesc(MesXslApprovalFlow::getUpdateTime)
.orderByDesc(MesXslApprovalFlow::getCreateTime)
.last("LIMIT 1")
.one();
}
private List<String> orderedEnabledStages(MesXslBizDocRegistry registry) {
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
List<String> ordered = new ArrayList<>();
for (String key : new String[]{
ApprovalStageResolver.STAGE_PROOFREAD,
ApprovalStageResolver.STAGE_AUDIT,
ApprovalStageResolver.STAGE_APPROVE}) {
if (enabled.contains(key)) {
ordered.add(key);
}
}
return ordered;
}
private List<FlowNode> parseApproverNodes(String flowConfig) {
List<FlowNode> nodes = new ArrayList<>();
collectApproverNodes(JSONObject.parseObject(flowConfig), nodes);
return nodes;
}
private void collectApproverNodes(JSONObject node, List<FlowNode> out) {
if (node == null) {
return;
}
if ("approver".equals(node.getString("type"))) {
JSONObject props = node.getJSONObject("properties");
if (props == null) {
props = new JSONObject();
}
String name = props.getString("name");
if (oConvertUtils.isEmpty(name)) {
name = node.getString("name");
}
out.add(new FlowNode(
oConvertUtils.isNotEmpty(name) ? name : "审批节点" + (out.size() + 1),
node.getString("id"),
props));
}
JSONArray branches = node.getJSONArray("conditionNodes");
if (branches != null && !branches.isEmpty()) {
Object first = branches.get(0);
if (first instanceof JSONObject branch) {
collectApproverNodes(branch.getJSONObject("childNode"), out);
}
}
collectApproverNodes(node.getJSONObject("childNode"), out);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】遍历全部流程节点解析环节并判断注册中心是否已配置-----------
private List<StageBinding> bindAllFlowNodes(List<FlowNode> flowNodes,
MesXslBizDocRegistry registry,
List<String> enabledStages,
List<StatusDictItem> statusChain,
String initialStatus,
Map<String, String> stageOverrides) {
List<StageBinding> bindings = new ArrayList<>();
for (int i = 0; i < flowNodes.size(); i++) {
FlowNode node = flowNodes.get(i);
String suggestedStage = resolveStageFromNode(node, registry, enabledStages, i);
String stage = suggestedStage;
if (stageOverrides != null && stageOverrides.containsKey(node.nodeId)) {
stage = stageOverrides.get(node.nodeId);
}
boolean configured = isStageConfigured(registry, stage);
String unconfiguredReason = null;
if (!configured) {
unconfiguredReason = buildUnconfiguredReason(registry, stage, enabledStages);
}
String stageLabel = "-";
if (oConvertUtils.isNotEmpty(stage)) {
stageLabel = labelOf(statusChain, stage);
if (oConvertUtils.isEmpty(stageLabel) || stage.equals(stageLabel)) {
stageLabel = ApprovalStageResolver.stageLabel(stage);
}
}
bindings.add(new StageBinding(
node.name, node.nodeId, stage, stageLabel, null, configured, unconfiguredReason, suggestedStage));
}
for (int i = 0; i < bindings.size(); i++) {
StageBinding b = bindings.get(i);
String expectedFrom = b.stageConfigured
? resolveExpectedFromForBinding(bindings, i, statusChain, initialStatus)
: null;
bindings.set(i, b.withExpectedFrom(expectedFrom));
}
return bindings;
}
private String resolveStageFromNode(FlowNode node, MesXslBizDocRegistry registry,
List<String> enabledStages, int nodeIndex) {
JSONObject props = node.props;
if (props != null) {
String stageKey = props.getString("stageKey");
if (oConvertUtils.isNotEmpty(stageKey)) {
return stageKey.trim();
}
String fromField = mapFieldToStage(registry, props.getString("fieldName"));
if (oConvertUtils.isNotEmpty(fromField)) {
return fromField;
}
}
String fromName = ApprovalStageResolver.resolveStageFromNodeName(node.name);
if (oConvertUtils.isNotEmpty(fromName)) {
return fromName;
}
if (nodeIndex < enabledStages.size()) {
return enabledStages.get(nodeIndex);
}
return null;
}
private String mapFieldToStage(MesXslBizDocRegistry registry, String fieldName) {
if (oConvertUtils.isEmpty(fieldName) || registry == null) {
return null;
}
if (fieldName.equals(registry.getProofreadByField())) {
return ApprovalStageResolver.STAGE_PROOFREAD;
}
if (fieldName.equals(registry.getAuditByField())) {
return ApprovalStageResolver.STAGE_AUDIT;
}
if (fieldName.equals(registry.getApproveByField())) {
return ApprovalStageResolver.STAGE_APPROVE;
}
return null;
}
private boolean isStageConfigured(MesXslBizDocRegistry registry, String stage) {
if (registry == null || oConvertUtils.isEmpty(stage)) {
return false;
}
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
if (!enabled.contains(stage)) {
return false;
}
return oConvertUtils.isNotEmpty(RegistryStageFieldHelper.byField(registry, stage));
}
private String buildUnconfiguredReason(MesXslBizDocRegistry registry, String stage, List<String> enabledStages) {
if (oConvertUtils.isEmpty(stage)) {
return "未选择审批环节";
}
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
if (!enabled.contains(stage)) {
return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未在注册中心启用";
}
if (oConvertUtils.isEmpty(RegistryStageFieldHelper.byField(registry, stage))) {
return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未配置操作人字段";
}
return "环节未完整配置";
}
private String resolveExpectedFromForBinding(List<StageBinding> bindings, int index,
List<StatusDictItem> statusChain, String initialStatus) {
StageBinding current = bindings.get(index);
if (oConvertUtils.isEmpty(current.stage)) {
return initialStatus;
}
int stageIdx = indexOfValue(statusChain, current.stage);
if (stageIdx > 0) {
return statusChain.get(stageIdx - 1).value;
}
for (int j = index - 1; j >= 0; j--) {
StageBinding prev = bindings.get(j);
if (prev.stageConfigured && oConvertUtils.isNotEmpty(prev.stage)) {
return prev.stage;
}
}
return initialStatus;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】遍历全部流程节点解析环节并判断注册中心是否已配置-----------
private String resolveInitialStatus(List<StatusDictItem> chain, List<String> enabledStages) {
Set<String> enabledSet = new LinkedHashSet<>(enabledStages);
int firstStageIdx = -1;
for (int i = 0; i < chain.size(); i++) {
if (enabledSet.contains(chain.get(i).value)) {
firstStageIdx = i;
break;
}
}
if (firstStageIdx > 0) {
return chain.get(firstStageIdx - 1).value;
}
for (StatusDictItem item : chain) {
if (!enabledSet.contains(item.value)) {
return item.value;
}
}
return chain.get(0).value;
}
private List<StatusDictItem> loadStatusChain(MesXslBizDocRegistry registry) {
String dictCode = resolveStatusDictCode(registry);
if (oConvertUtils.isEmpty(dictCode)) {
return List.of();
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT item_value AS value, item_text AS label, sort_order AS sortOrder "
+ "FROM sys_dict_item WHERE dict_id=(SELECT id FROM sys_dict WHERE dict_code=?) "
+ "AND status=1 ORDER BY sort_order ASC, item_value ASC",
dictCode);
List<StatusDictItem> chain = new ArrayList<>();
for (Map<String, Object> row : rows) {
chain.add(new StatusDictItem(
String.valueOf(row.get("value")),
String.valueOf(row.get("label"))));
}
return chain;
}
private String resolveStatusDictCode(MesXslBizDocRegistry registry) {
String statusField = oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField();
String table = registry.getTableName();
if (!table.matches("^[a-z][a-z0-9_]{0,63}$")) {
return TABLE_STATUS_DICT_FALLBACK.getOrDefault(table, null);
}
try {
List<String> comments = jdbcTemplate.queryForList(
"SELECT COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS "
+ "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?",
String.class, table, statusField);
if (!comments.isEmpty()) {
Matcher m = DICT_IN_COMMENT.matcher(comments.get(0));
if (m.find()) {
return m.group(1);
}
}
} catch (Exception e) {
log.warn("[集成方案生成] 读取字段注释失败 table={} field={}", table, statusField, e);
}
return TABLE_STATUS_DICT_FALLBACK.getOrDefault(table, null);
}
private String planCodePrefix(MesXslBizDocRegistry registry) {
if (oConvertUtils.isNotEmpty(registry.getDocCode())) {
return registry.getDocCode().replaceAll("[^a-zA-Z0-9_]", "_");
}
return registry.getTableName().replaceAll("^mes_xsl_", "").replaceAll("[^a-zA-Z0-9_]", "_");
}
private void removeDraftAutoPlans(String sourceTable, String codePrefix) {
List<MesXslIntegrationPlan> drafts = planService.lambdaQuery()
.eq(MesXslIntegrationPlan::getSourceTable, sourceTable)
.eq(MesXslIntegrationPlan::getStatus, "0")
.likeRight(MesXslIntegrationPlan::getPlanCode, codePrefix + "_reg_")
.list();
for (MesXslIntegrationPlan plan : drafts) {
actionService.removeByPlanId(plan.getId());
planService.removeById(plan.getId());
}
}
private static int indexOfValue(List<StatusDictItem> chain, String value) {
for (int i = 0; i < chain.size(); i++) {
if (value.equals(chain.get(i).value)) {
return i;
}
}
return -1;
}
private static String labelOf(List<StatusDictItem> chain, String value) {
for (StatusDictItem item : chain) {
if (value.equals(item.value)) {
return item.label;
}
}
return ApprovalStageResolver.stageLabel(value);
}
private record FlowNode(String name, String nodeId, JSONObject props) {
Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("nodeName", name);
m.put("nodeId", nodeId);
if (props != null && !props.isEmpty()) {
m.put("fieldName", props.getString("fieldName"));
m.put("stageKey", props.getString("stageKey"));
}
return m;
}
}
private record StatusDictItem(String value, String label) {
Map<String, Object> toMap() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("value", value);
m.put("label", label);
return m;
}
}
private record StageBinding(String nodeName, String nodeId, String stage, String stageLabel,
String expectedFrom, boolean stageConfigured, String unconfiguredReason,
String suggestedStage) {
StageBinding withExpectedFrom(String expectedFrom) {
return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, stageConfigured, unconfiguredReason, suggestedStage);
}
}
}

View File

@@ -0,0 +1,144 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 审批痕迹双写同步
*
* @author GHT
* @date 2026-06-05 for【XSLMES-20260605-K8R2】审批痕迹双写
*/
@Service
public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService {
private static final String STAGE_PROOFREAD = "proofread";
private static final String STAGE_AUDIT = "audit";
private static final String STAGE_APPROVE = "approve";
@Autowired
private IMesXslBizDocRegistryService registryService;
@Autowired
private IMesXslApprovalTraceService traceService;
@Override
public String checkStageAllowed(String bizTable, String stage) {
MesXslBizDocRegistry registry = findActiveRegistry(bizTable);
if (registry == null || oConvertUtils.isEmpty(registry.getEnabledStages())) {
return null;
}
if (!containsStage(registry.getEnabledStages(), stage)) {
return "业务表[" + registry.getDisplayName() + "]未启用「" + stageLabel(stage) + "」环节";
}
return null;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncStage(String bizTable, String bizDataId, String stage, String operatorBy, Date operatorTime) {
MesXslBizDocRegistry registry = findActiveRegistry(bizTable);
if (registry == null || !containsStage(registry.getEnabledStages(), stage)) {
return;
}
MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId);
if (trace == null) {
trace = new MesXslApprovalTrace()
.setRegistryId(registry.getId())
.setBizTable(bizTable)
.setBizDataId(bizDataId);
}
Date opTime = operatorTime == null ? new Date() : operatorTime;
switch (stage) {
case STAGE_PROOFREAD:
trace.setProofreadBy(operatorBy);
trace.setProofreadTime(opTime);
break;
case STAGE_AUDIT:
trace.setAuditBy(operatorBy);
trace.setAuditTime(opTime);
break;
case STAGE_APPROVE:
trace.setApproveBy(operatorBy);
trace.setApproveTime(opTime);
break;
default:
return;
}
traceService.saveOrUpdate(trace);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void revertToStage(String bizTable, String bizDataId, String targetStage) {
MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId);
if (trace == null) {
return;
}
LambdaUpdateWrapper<MesXslApprovalTrace> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(MesXslApprovalTrace::getId, trace.getId());
if ("compile".equals(targetStage)) {
wrapper.set(MesXslApprovalTrace::getProofreadBy, null)
.set(MesXslApprovalTrace::getProofreadTime, null)
.set(MesXslApprovalTrace::getAuditBy, null)
.set(MesXslApprovalTrace::getAuditTime, null)
.set(MesXslApprovalTrace::getApproveBy, null)
.set(MesXslApprovalTrace::getApproveTime, null);
} else if (STAGE_PROOFREAD.equals(targetStage)) {
wrapper.set(MesXslApprovalTrace::getAuditBy, null)
.set(MesXslApprovalTrace::getAuditTime, null)
.set(MesXslApprovalTrace::getApproveBy, null)
.set(MesXslApprovalTrace::getApproveTime, null);
} else if (STAGE_AUDIT.equals(targetStage)) {
wrapper.set(MesXslApprovalTrace::getApproveBy, null)
.set(MesXslApprovalTrace::getApproveTime, null);
} else {
return;
}
traceService.update(wrapper);
}
private MesXslBizDocRegistry findActiveRegistry(String bizTable) {
if (oConvertUtils.isEmpty(bizTable)) {
return null;
}
return registryService.lambdaQuery()
.eq(MesXslBizDocRegistry::getTableName, bizTable)
.eq(MesXslBizDocRegistry::getEnabled, 1)
.last("LIMIT 1")
.one();
}
private boolean containsStage(String enabledStages, String stage) {
if (oConvertUtils.isEmpty(enabledStages) || oConvertUtils.isEmpty(stage)) {
return false;
}
Set<String> set = new HashSet<>(Arrays.asList(enabledStages.split(",")));
return set.contains(stage.trim());
}
private String stageLabel(String stage) {
switch (stage) {
case STAGE_PROOFREAD:
return "校对";
case STAGE_AUDIT:
return "审核";
case STAGE_APPROVE:
return "批准";
default:
return stage;
}
}
}

View File

@@ -0,0 +1,27 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslApprovalTraceMapper;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService;
import org.springframework.stereotype.Service;
/**
* 审批痕迹明细
*/
@Service
public class MesXslApprovalTraceServiceImpl extends ServiceImpl<MesXslApprovalTraceMapper, MesXslApprovalTrace>
implements IMesXslApprovalTraceService {
@Override
public MesXslApprovalTrace getByBiz(String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return null;
}
return lambdaQuery()
.eq(MesXslApprovalTrace::getBizTable, bizTable)
.eq(MesXslApprovalTrace::getBizDataId, bizDataId)
.one();
}
}

View File

@@ -0,0 +1,71 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslBizDocRegistryMapper;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.springframework.stereotype.Service;
/**
* 审批注册中心
*/
@Service
public class MesXslBizDocRegistryServiceImpl extends ServiceImpl<MesXslBizDocRegistryMapper, MesXslBizDocRegistry>
implements IMesXslBizDocRegistryService {
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
@Override
public void normalizeBeforeSave(MesXslBizDocRegistry entity) {
if (entity == null) {
return;
}
if (entity.getEnabled() == null) {
entity.setEnabled(1);
}
entity.setEnabledStages(normalizeStages(entity.getEnabledStages()));
entity.setStatusField(defaultField(entity.getStatusField(), "status"));
entity.setProofreadByField(defaultField(entity.getProofreadByField(), "proofread_by"));
entity.setProofreadTimeField(defaultField(entity.getProofreadTimeField(), "proofread_time"));
entity.setAuditByField(defaultField(entity.getAuditByField(), "audit_by"));
entity.setAuditTimeField(defaultField(entity.getAuditTimeField(), "audit_time"));
entity.setApproveByField(defaultField(entity.getApproveByField(), "approve_by"));
entity.setApproveTimeField(defaultField(entity.getApproveTimeField(), "approve_time"));
}
private String normalizeStages(String stages) {
if (oConvertUtils.isEmpty(stages)) {
return null;
}
String[] parts = stages.split(",");
StringBuilder sb = new StringBuilder();
for (String part : parts) {
if (oConvertUtils.isEmpty(part)) {
continue;
}
String val = part.trim();
if (sb.length() > 0) {
sb.append(',');
}
sb.append(val);
}
return sb.length() == 0 ? null : sb.toString();
}
private String defaultField(String value, String fallback) {
return oConvertUtils.isEmpty(value) ? fallback : value.trim();
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】保存前规范化审批注册配置-----------
@Override
public MesXslBizDocRegistry findActiveByTableName(String tableName) {
if (oConvertUtils.isEmpty(tableName)) {
return null;
}
return lambdaQuery()
.eq(MesXslBizDocRegistry::getTableName, tableName)
.eq(MesXslBizDocRegistry::getEnabled, 1)
.last("LIMIT 1")
.one();
}
}

View File

@@ -0,0 +1,30 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationActionMapper;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MesXslIntegrationActionServiceImpl extends ServiceImpl<MesXslIntegrationActionMapper, MesXslIntegrationAction>
implements IMesXslIntegrationActionService {
@Override
public List<MesXslIntegrationAction> listByPlanId(String planId) {
return lambdaQuery()
.eq(MesXslIntegrationAction::getPlanId, planId)
.eq(MesXslIntegrationAction::getEnabled, 1)
.orderByAsc(MesXslIntegrationAction::getExecOrder)
.list();
}
@Override
public void removeByPlanId(String planId) {
remove(new LambdaQueryWrapper<MesXslIntegrationAction>()
.eq(MesXslIntegrationAction::getPlanId, planId));
}
}

View File

@@ -0,0 +1,29 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationLogMapper;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class MesXslIntegrationLogServiceImpl extends ServiceImpl<MesXslIntegrationLogMapper, MesXslIntegrationLog>
implements IMesXslIntegrationLogService {
@Override
public boolean isAlreadySuccess(String idempotentKey) {
return lambdaQuery()
.eq(MesXslIntegrationLog::getIdempotentKey, idempotentKey)
.eq(MesXslIntegrationLog::getStatus, "success")
.exists();
}
@Override
public Result<String> retry(String logId) {
// Phase 0 先留钩子Phase 2 完善手动重试
return Result.error("手动重试功能将在 Phase 2 完善");
}
}

View File

@@ -0,0 +1,103 @@
package org.jeecg.modules.xslmes.approval.integration.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslIntegrationPlanMapper;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class MesXslIntegrationPlanServiceImpl extends ServiceImpl<MesXslIntegrationPlanMapper, MesXslIntegrationPlan>
implements IMesXslIntegrationPlanService {
@Autowired
private IMesXslBizDocRegistryService registryService;
@Override
public Result<String> publish(String planId) {
MesXslIntegrationPlan plan = getById(planId);
if (plan == null) {
return Result.error("方案不存在");
}
if ("1".equals(plan.getStatus())) {
return Result.error("方案已是发布状态");
}
Result<String> validate = normalizeAndValidate(plan);
if (!validate.isSuccess()) {
return validate;
}
boolean ok = lambdaUpdate()
.eq(MesXslIntegrationPlan::getId, planId)
.set(MesXslIntegrationPlan::getStatus, "1")
.update();
return ok ? Result.OK("发布成功") : Result.error("发布失败");
}
@Override
public Result<String> disable(String planId) {
MesXslIntegrationPlan plan = getById(planId);
if (plan == null) {
return Result.error("方案不存在");
}
boolean ok = lambdaUpdate()
.eq(MesXslIntegrationPlan::getId, planId)
.set(MesXslIntegrationPlan::getStatus, "2")
.update();
return ok ? Result.OK("已停用") : Result.error("操作失败");
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
@Override
public Result<String> normalizeAndValidate(MesXslIntegrationPlan plan) {
if (plan == null) {
return Result.error("方案不能为空");
}
if (oConvertUtils.isEmpty(plan.getSourceTable())) {
return Result.error("请选择触发业务表");
}
MesXslBizDocRegistry registry = registryService.findActiveByTableName(plan.getSourceTable());
if (registry == null) {
return Result.error("源单表未在审批注册中心启用,请先在审批注册中心配置");
}
plan.setRegistryId(registry.getId());
Set<String> enabledStages = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
if (enabledStages.isEmpty()) {
return Result.error("审批注册中心未配置启用环节,无法绑定集成方案");
}
String phase = plan.getTriggerPhase();
if ("onApprove".equals(phase)) {
if (oConvertUtils.isEmpty(plan.getTriggerStage())) {
plan.setTriggerStage(ApprovalStageResolver.STAGE_APPROVE);
}
if (!enabledStages.contains(plan.getTriggerStage())) {
return Result.error("绑定环节「" + ApprovalStageResolver.stageLabel(plan.getTriggerStage())
+ "」未在审批注册中心启用");
}
} else if ("onNodeApprove".equals(phase)) {
if (oConvertUtils.isEmpty(plan.getTriggerStage())) {
return Result.error("节点通过时必须选择绑定的审批环节");
}
if (!enabledStages.contains(plan.getTriggerStage())) {
return Result.error("绑定环节「" + ApprovalStageResolver.stageLabel(plan.getTriggerStage())
+ "」未在审批注册中心启用");
}
} else if ("onReject".equals(phase)) {
if (oConvertUtils.isNotEmpty(plan.getTriggerStage()) && !enabledStages.contains(plan.getTriggerStage())) {
return Result.error("绑定环节「" + ApprovalStageResolver.stageLabel(plan.getTriggerStage())
+ "」未在审批注册中心启用");
}
}
return Result.OK("校验通过");
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】集成方案绑定审批注册中心环节-----------
}

View File

@@ -20,6 +20,8 @@ import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -93,6 +95,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
// 驳回统一回退:按业务表自动发现 @ApprovalBizAction(onReject) 动作,无需在每个流程节点配置
@Autowired
private ApprovalBizActionRegistry bizActionRegistry;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】驳回回退改由集成方案 onReject 驱动-----------
@Autowired
private IMesXslIntegrationPlanService integrationPlanService;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】驳回回退改由集成方案 onReject 驱动-----------
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调-----
// ==================== 发起后进入首节点 ====================
@@ -208,9 +215,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
// 节点完成 -> 先回调业务(节点通过,中间态),再推进
inst.setNodeProgress(progress.toJSONString());
callbackDispatcher.fireNodeApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment));
if (root != null) {
actionHttpExecutor.run(findNodeById(root, progress.getString("nodeId")), "onNodeApprove", inst);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
// if (root != null) {
// actionHttpExecutor.run(findNodeById(root, progress.getString("nodeId")), "onNodeApprove", inst);
// }
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
if (flow == null || root == null) {
inst.setStatus("1");
inst.setCurrentHandlers(null);
@@ -422,7 +431,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentNodeName(nodeName);
// 该节点自动通过(中间态) -> 回调业务
callbackDispatcher.fireNodeApproved(buildContext(inst, nodeId, nodeName, null, "审批人为空,自动通过"));
actionHttpExecutor.run(node, "onNodeApprove", inst);
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
// actionHttpExecutor.run(node, "onNodeApprove", inst);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
advanceAfter(inst, flow, root, nodeId, null);
return;
}
@@ -530,7 +541,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
syncApprovalRecord(inst, null);
// 流程最终通过 -> 回调业务(终态)
callbackDispatcher.fireApproved(buildContext(inst, currentNodeId, inst.getCurrentNodeName(), actingUser, null));
actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst);
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
// actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】停用节点 HTTP 回调,改由集成方案编排-----------
String actor = actingUser == null ? null : actingUser.getUsername();
notifyApplicant(inst, actor, "您发起的「" + safeTitle(inst) + "」审批已全部通过。");
}
@@ -846,6 +859,12 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
return;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】单据仍在发起前状态时驳回:跳过业务「拒绝」接口,避免「无需拒绝」报错-----
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】已配置集成 onReject 方案时跳过 HTTP 业务拒绝-----------
if (hasPublishedIntegrationPlan(inst.getBizTable(), "onReject")) {
log.debug("[审批驳回] 表 {} 已配置集成方案 onReject跳过 HTTP 业务拒绝接口", inst.getBizTable());
return;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】已配置集成 onReject 方案时跳过 HTTP 业务拒绝-----------
List<ApprovalBizActionVo> rejectActions = bizActionRegistry.getByTableAndPhase(inst.getBizTable(), "onReject");
if (rejectActions != null && !rejectActions.isEmpty()) {
for (ApprovalBizActionVo action : rejectActions) {
@@ -871,6 +890,20 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】判断业务单据是否仍处于发起前(最初)状态-----
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】判断业务表是否已发布集成方案-----------
/** 业务表是否已配置并发布指定触发时机的集成方案 */
private boolean hasPublishedIntegrationPlan(String bizTable, String triggerPhase) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(triggerPhase)) {
return false;
}
return integrationPlanService.lambdaQuery()
.eq(MesXslIntegrationPlan::getSourceTable, bizTable)
.eq(MesXslIntegrationPlan::getTriggerPhase, triggerPhase)
.eq(MesXslIntegrationPlan::getStatus, "1")
.count() > 0;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】判断业务表是否已发布集成方案-----------
/**
* 驳回/撤销/终止时,将业务单据状态字段直接回写为发起时原值。
* 直接 UPDATE不触发业务级联同步确保单据回到“可重新提交”的初始态。

View File

@@ -11,18 +11,15 @@ import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.query.QueryRuleEnum;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysDepart;
import org.jeecg.modules.system.service.ISysDepartService;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction;
import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile;
import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -171,6 +168,11 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
return super.importExcel(request, response, MesXslMixerPsCompile.class);
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】密炼PS操作接口停用,改由集成方案无代码驱动-----------
/*
* 原校对/审核/批准/拒绝/撤回接口已停用审批联动改由「集成方案」REGISTRY_STAGE_SYNC/REVERT 执行)。
* 代码保留备后期可能恢复,勿删。
*
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】校对/审核/批准-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@ApprovalBizAction(name = "校对", table = "mes_xsl_mixer_ps_compile", phase = {"onNodeApprove", "onApprove"}, order = 1)
@@ -218,8 +220,6 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
@ApprovalBizAction(name = "拒绝", table = "mes_xsl_mixer_ps_compile", phase = {"onReject"}, order = 4)
@AutoLog(value = "MES密炼PS编制-拒绝")
@Operation(summary = "MES密炼PS编制-拒绝(一步退回编制并撤销关联操作)")
// 拒绝主要由审批流 onReject 回调触发(执行人为当前审批节点处理人)不强制要求其具备密炼PS业务权限码
// 是否允许驳回已由审批节点本身控制,故此处不加 @RequiresPermissions。
@PostMapping(value = "/reject")
public Result<String> reject(@RequestParam(name = "ids") String ids) {
String err = mesXslMixerPsCompileService.rejectBatch(ids, getOperatorName());
@@ -258,6 +258,8 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
return loginUser.getUsername();
}
//update-end---author:jiangxh ---date:20260520 for【密炼PS编制】校对/审核/批准-----------
*/
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】密炼PS操作接口停用,改由集成方案无代码驱动-----------
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】保存前校验与冗余回填-----------
private String validateAndFill(MesXslMixerPsCompile model) {

View File

@@ -20,6 +20,7 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 钉钉审批事件处理器。
@@ -39,6 +40,8 @@ import java.util.List;
@Component
public class DingBpmsEventProcessor {
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Autowired
@@ -59,15 +62,23 @@ public class DingBpmsEventProcessor {
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
public void onInstanceChange(JSONObject data) {
if (data == null) return;
if (data == null) {
log.warn("{} bpms_instance_change data=null跳过", LOG_TAG);
return;
}
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
log.info("[DingBpms] bpms_instance_change instanceId={} type={} result={}",
processInstanceId, type, result);
log.info("{} bpms_instance_change 入参 instanceId={} type={} result={} payload={}",
LOG_TAG, processInstanceId, type, result, data.toJSONString());
if (oConvertUtils.isEmpty(processInstanceId) || "start".equals(type)) {
if (oConvertUtils.isEmpty(processInstanceId)) {
log.info("{} bpms_instance_change 跳过processInstanceId 为空", LOG_TAG);
return;
}
if ("start".equals(type)) {
log.info("{} bpms_instance_change 跳过:审批发起(start) instanceId={}", LOG_TAG, processInstanceId);
return;
}
@@ -82,154 +93,206 @@ public class DingBpmsEventProcessor {
status = ApprovalRecordConstants.STATUS_REJECTED;
remark = "钉钉审批拒绝";
} else {
log.info("[DingBpms] 审批转交 result={},不处理 instanceId={}", result, processInstanceId);
log.info("{} bpms_instance_change 跳过:审批转交 result={} instanceId={}", LOG_TAG, result, processInstanceId);
return;
}
} else if ("terminate".equals(type)) {
status = ApprovalRecordConstants.STATUS_CANCELLED;
remark = "钉钉审批已终止";
} else {
log.info("{} bpms_instance_change 跳过:未识别 type={} instanceId={}", LOG_TAG, type, processInstanceId);
return;
}
log.info("{} bpms_instance_change 映射终态 instanceId={} mesStatus={} remark={}",
LOG_TAG, processInstanceId, status, remark);
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
// ② 更新台账乐观条件WHERE status=RUNNING返回 false 表示已是终态,本次为重复事件,直接跳过)
try {
boolean updated = approvalGateService.finishByExternalInstance(
ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark);
if (!updated) {
log.info("[DingBpms] instanceId={} 台账已是终态,跳过重复的终态事件", processInstanceId);
log.info("{} bpms_instance_change 跳过:台账已是终态(重复事件) instanceId={}", LOG_TAG, processInstanceId);
return;
}
log.info("[DingBpms] 台账已更新 instanceId={} -> status={}", processInstanceId, status);
log.info("{} 台账已更新 instanceId={} -> status={}", LOG_TAG, processInstanceId, status);
} catch (Exception e) {
log.error("[DingBpms] 台账更新失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
log.error("{} 台账更新失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
log.info("{} bpms_instance_change 终止态不触发业务回调 instanceId={}", LOG_TAG, processInstanceId);
return;
}
// ③ 拉取完整审批实例
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
if (record == null) {
log.warn("{} bpms_instance_change 跳过:未找到台账 instanceId={}", LOG_TAG, processInstanceId);
return;
}
if (oConvertUtils.isEmpty(record.getBizTable())) {
log.warn("{} bpms_instance_change 跳过:台账无 bizTable recordId={} instanceId={}",
LOG_TAG, record.getId(), processInstanceId);
return;
}
log.info("{} 台账命中 recordId={} bizTable={} bizDataId={} flowId={} flowName={} originStatus={}",
LOG_TAG, record.getId(), record.getBizTable(), record.getBizDataId(),
record.getFlowId(), record.getFlowName(), record.getOriginStatus());
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
if (instance == null) {
log.warn("{} 拉取钉钉实例详情失败 instanceId={}", LOG_TAG, processInstanceId);
}
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
log.info("{} 实例解析 instanceId={} taskOpCount={} mesApproverNodeCount={} mesNodeNames={} taskOps={}",
LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(),
summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps));
ApprovalCallbackContext ctx = buildContext(record, remark);
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
// 最终通过:执行最后一个节点的 onApprove用最后审批人的 token
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
String lastDtUserId = lastOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
actionHttpExecutor.run(lastNode, "onApprove", record.getBizDataId(), token);
log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}",
LOG_TAG, lastNode.getString("id"), lastNode.getString("name"),
lastDtUserId, oConvertUtils.isNotEmpty(token));
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节-----------
if (!mesNodes.isEmpty()) {
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
ctx.setNodeId(lastNode.getString("id")).setNodeName(lastNode.getString("name"));
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节-----------
logCallbackDispatch("fireApproved", ctx);
callbackDispatcher.fireApproved(ctx);
} else {
// 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑
// 台账已在发起时快照了 originStatus若单据状态仍为原值说明未被推进跳过 onReject
JSONObject refuseOp = findRefuseOp(taskOps);
if (refuseOp != null) {
int refuseIndex = taskOps.indexOf(refuseOp);
boolean bizAtOrigin = isBizAtOriginStatus(record);
String currentBizStatus = readBizStatus(record);
log.info("{} 终态驳回refuseIndex={} bizAtOrigin={} originStatus={} currentBizStatus={} refuseOp={}",
LOG_TAG, refuseIndex, bizAtOrigin, record.getOriginStatus(), currentBizStatus,
refuseOp.toJSONString());
if (!bizAtOrigin && refuseIndex < mesNodes.size()) {
// 单据已被前面节点推进,需要调 onReject 回退
String dtUserId = refuseOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(dtUserId);
try {
actionHttpExecutor.run(mesNodes.get(refuseIndex), "onReject",
record.getBizDataId(), token);
} catch (Exception e) {
log.error("[DingBpms] onReject HTTP 回调失败: {}", e.getMessage());
}
JSONObject refuseNode = mesNodes.get(refuseIndex);
log.info("{} 终态驳回:业务已推进,触发 onReject 集成 nodeId={} nodeName={} tokenGenerated={}",
LOG_TAG, refuseNode.getString("id"), refuseNode.getString("name"),
oConvertUtils.isNotEmpty(token));
} else {
log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}",
processInstanceId);
log.info("{} 终态驳回:跳过业务 onReject单据仍在发起前状态 instanceId={}",
LOG_TAG, processInstanceId);
}
} else {
log.info("{} 终态驳回operationRecords 中未找到 REFUSE 记录 instanceId={}",
LOG_TAG, processInstanceId);
}
if (!mesNodes.isEmpty() && refuseOp != null) {
int refuseIndex = taskOps.indexOf(refuseOp);
if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) {
JSONObject refuseNode = mesNodes.get(refuseIndex);
ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name"));
}
}
logCallbackDispatch("fireRejected", ctx);
callbackDispatcher.fireRejected(ctx);
}
log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}",
record.getBizTable(), record.getBizDataId(), status);
log.info("{} bpms_instance_change 完成 instanceId={} bizTable={} bizDataId={} mesStatus={}",
LOG_TAG, processInstanceId, record.getBizTable(), record.getBizDataId(), status);
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
// ==================== bpms_task_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
/**
* 节点任务变更:每个审批人操作时触发。
* <p>
* 节点通过时:
* <ol>
* <li>拉取审批实例详情,从 operationRecords 得到"已完成任务列表"</li>
* <li>最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;</li>
* <li>用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;</li>
* <li>触发 IApprovalBizCallback.onNodeApproved。</li>
* </ol>
*/
public void onTaskChange(JSONObject data) {
if (data == null) return;
if (data == null) {
log.warn("{} bpms_task_change data=null跳过", LOG_TAG);
return;
}
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
String actionerDtUserId = data.getString("actionerUserId");
log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}",
processInstanceId, type, result, actionerDtUserId);
log.info("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} payload={}",
LOG_TAG, processInstanceId, type, result, actionerDtUserId, data.toJSONString());
// 只处理节点"完成-通过"
if (!"finish".equals(type) || !"agree".equals(result)) {
// 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发
return;
}
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
log.info("{} bpms_task_change 跳过:非节点通过(finish+agree) type={} result={} instanceId={}",
LOG_TAG, type, result, processInstanceId);
return;
}
// 拉取实例详情
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null) {
log.warn("{} bpms_task_change 跳过:未找到台账 instanceId={}", LOG_TAG, processInstanceId);
return;
}
if (oConvertUtils.isEmpty(record.getBizTable())) {
log.warn("{} bpms_task_change 跳过:台账无 bizTable recordId={} instanceId={}",
LOG_TAG, record.getId(), processInstanceId);
return;
}
log.info("{} 台账命中 recordId={} bizTable={} bizDataId={} flowId={} processedOpCount={} currentBizStatus={}",
LOG_TAG, record.getId(), record.getBizTable(), record.getBizDataId(),
record.getFlowId(), record.getProcessedOpCount(), readBizStatus(record));
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
if (instance == null) {
log.warn("[DingBpms] 获取审批实例详情失败 instanceId={},跳过节点回调", processInstanceId);
log.warn("{} bpms_task_change 跳过:获取审批实例详情失败 instanceId={}", LOG_TAG, processInstanceId);
return;
}
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
if (taskOps.isEmpty() || mesNodes.isEmpty()) {
log.info("{} 实例解析 instanceId={} taskOpCount={} mesApproverNodeCount={} mesNodeNames={} taskOps={}",
LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(),
summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps));
if (taskOps.isEmpty()) {
log.info("{} bpms_task_change 跳过taskOps 为空 instanceId={}", LOG_TAG, processInstanceId);
return;
}
if (mesNodes.isEmpty()) {
log.info("{} bpms_task_change 跳过MES 审批节点为空 flowId={} instanceId={}",
LOG_TAG, record.getFlowId(), processInstanceId);
return;
}
// 刚完成的是最后一条操作index = taskOps.size()-1
int nodeIndex = taskOps.size() - 1;
if (nodeIndex >= mesNodes.size()) {
log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size());
log.info("{} bpms_task_change 跳过:节点索引越界 nodeIndex={} mesNodeCount={} instanceId={}",
LOG_TAG, nodeIndex, mesNodes.size(), processInstanceId);
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// tryMarkNodeProcessedUPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count<nodeIndex+1
// MySQL 行锁保证原子性:并发两个事件只有一个成功,另一个返回 false
boolean claimed = approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
if (!claimed) {
log.info("[DingBpms] 节点{} 回调已执行,跳过重复事件 instanceId={}", nodeIndex + 1, processInstanceId);
log.info("{} bpms_task_change 跳过:节点{} 已处理(幂等) instanceId={} recordId={}",
LOG_TAG, nodeIndex + 1, processInstanceId, record.getId());
return;
}
log.info("{} 节点幂等占位成功 nodeIndex={} recordId={} instanceId={}",
LOG_TAG, nodeIndex, record.getId(), processInstanceId);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// 操作人:优先用 operationRecords 里的 userId兜底用事件里的 actionerUserId
JSONObject lastOp = taskOps.get(nodeIndex);
String dtUserId = oConvertUtils.isNotEmpty(lastOp.getString("userId"))
? lastOp.getString("userId") : actionerDtUserId;
@@ -239,27 +302,46 @@ public class DingBpmsEventProcessor {
String token = workflowService.generateTokenByDtUserId(dtUserId);
JSONObject node = mesNodes.get(nodeIndex);
// 执行该节点的 onNodeApprove HTTP 回调
actionHttpExecutor.run(node, "onNodeApprove", record.getBizDataId(), token);
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】节点映射日志补充 stageKey-----------
JSONObject _nodeProps = node.getJSONObject("props");
String _stageKeyLog = (_nodeProps != null) ? _nodeProps.getString("stageKey") : null;
log.info("{} 节点映射 nodeIndex={}/{} nodeId={} nodeName={} stageKey={} actioner={} dtUserId={} tokenGenerated={} lastOp={}",
LOG_TAG, nodeIndex + 1, mesNodes.size(), node.getString("id"), node.getString("name"),
_stageKeyLog, actioner, dtUserId, oConvertUtils.isNotEmpty(token), lastOp.toJSONString());
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】节点映射日志补充 stageKey-----------
// 触发 IApprovalBizCallback.onNodeApproved
try {
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉节点回调补充 nodeId/nodeName 供集成方案匹配校对/审核/批准环节-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】提取节点 props.stageKey区分关键环节节点与纯过路审批节点-----------
JSONObject nodeProps = node.getJSONObject("props");
String stageKey = (nodeProps != null) ? nodeProps.getString("stageKey") : null;
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R3】提取节点 props.stageKey区分关键环节节点与纯过路审批节点-----------
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + "")
.setOperatorName(actioner);
.setOperatorName(actioner)
.setNodeId(node.getString("id"))
.setNodeName(node.getString("name"))
.setStageKey(stageKey);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉节点回调补充 nodeId/nodeName 供集成方案匹配校对/审核/批准环节-----------
logCallbackDispatch("fireNodeApproved", ctx);
callbackDispatcher.fireNodeApproved(ctx);
log.info("{} fireNodeApproved 成功 instanceId={} nodeName={}", LOG_TAG, processInstanceId, ctx.getNodeName());
} catch (Exception e) {
log.error("[DingBpms] 节点 Bean 回调失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
log.error("{} fireNodeApproved 失败 instanceId={} nodeName={}: {}",
LOG_TAG, processInstanceId, node.getString("name"), e.getMessage(), e);
}
log.info("[DingBpms] 节点{}/{} 回调完成 actioner={} bizTable={} bizDataId={}",
nodeIndex + 1, mesNodes.size(), actioner, record.getBizTable(), record.getBizDataId());
log.info("{} bpms_task_change 完成 node={}/{} actioner={} bizTable={} bizDataId={} instanceId={}",
LOG_TAG, nodeIndex + 1, mesNodes.size(), actioner,
record.getBizTable(), record.getBizDataId(), processInstanceId);
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
// ==================== 内部辅助 ====================
private MesXslApprovalRecord findRecord(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) return null;
if (oConvertUtils.isEmpty(processInstanceId)) {
return null;
}
List<MesXslApprovalRecord> list = approvalRecordService.list(
new LambdaQueryWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, processInstanceId)
@@ -271,19 +353,27 @@ public class DingBpmsEventProcessor {
private List<JSONObject> loadApproverNodes(String flowId) {
List<JSONObject> result = new ArrayList<>();
if (oConvertUtils.isEmpty(flowId)) return result;
if (oConvertUtils.isEmpty(flowId)) {
log.info("{} 加载流程节点跳过flowId 为空", LOG_TAG);
return result;
}
try {
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result;
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) {
log.info("{} 加载流程节点跳过:流程不存在或无 flowConfig flowId={}", LOG_TAG, flowId);
return result;
}
collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result);
} catch (Exception e) {
log.warn("[DingBpms] 加载流程节点失败 flowId={}: {}", flowId, e.getMessage());
log.warn("{} 加载流程节点失败 flowId={}: {}", LOG_TAG, flowId, e.getMessage());
}
return result;
}
private void collectApproverNodes(JSONObject node, List<JSONObject> out) {
if (node == null) return;
if (node == null) {
return;
}
if ("approver".equals(node.getString("type"))) {
out.add(node);
}
@@ -297,7 +387,6 @@ public class DingBpmsEventProcessor {
collectApproverNodes(node.getJSONObject("childNode"), out);
}
/** 找第一条 result=REFUSE 的操作记录 */
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
for (JSONObject op : taskOps) {
String r = op.getString("result");
@@ -308,16 +397,10 @@ public class DingBpmsEventProcessor {
return null;
}
/**
* 判断业务单据是否仍处于发起审批前的原始状态。
* 与 MesXslApprovalHandleServiceImpl.isBizAtOriginStatus(MesXslApprovalInstance) 逻辑完全一致,
* 此处基于 MesXslApprovalRecord钉钉通道实现使驳回逻辑在两通道间真正复用。
*/
private boolean isBizAtOriginStatus(MesXslApprovalRecord record) {
String statusField = record.getStatusField();
String originStatus = record.getOriginStatus();
if (oConvertUtils.isEmpty(statusField) || originStatus == null) {
// 无快照信息(旧数据兼容):退化为 false走 onReject 调用
return false;
}
if (!record.getBizTable().matches("^[A-Za-z0-9_]+$")
@@ -327,14 +410,35 @@ public class DingBpmsEventProcessor {
try {
List<String> vals = jdbcTemplate.queryForList(
"SELECT " + statusField + " FROM " + record.getBizTable()
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
return !vals.isEmpty() && java.util.Objects.equals(originStatus, vals.get(0));
} catch (Exception e) {
log.warn("[DingBpms] 读取业务状态失败 table={}: {}", record.getBizTable(), e.getMessage());
log.warn("{} 读取业务状态失败 table={} id={}: {}",
LOG_TAG, record.getBizTable(), record.getBizDataId(), e.getMessage());
return false;
}
}
private String readBizStatus(MesXslApprovalRecord record) {
if (record == null || oConvertUtils.isEmpty(record.getBizTable()) || oConvertUtils.isEmpty(record.getBizDataId())) {
return null;
}
String statusField = oConvertUtils.isEmpty(record.getStatusField()) ? "status" : record.getStatusField();
if (!record.getBizTable().matches("^[A-Za-z0-9_]+$") || !statusField.matches("^[A-Za-z0-9_]+$")) {
return null;
}
try {
List<String> vals = jdbcTemplate.queryForList(
"SELECT " + statusField + " FROM " + record.getBizTable()
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
return vals.isEmpty() ? null : vals.get(0);
} catch (Exception e) {
log.warn("{} 读取业务当前状态失败 table={} id={}: {}",
LOG_TAG, record.getBizTable(), record.getBizDataId(), e.getMessage());
return null;
}
}
private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) {
return new ApprovalCallbackContext()
.setInstanceId(record.getId())
@@ -349,4 +453,35 @@ public class DingBpmsEventProcessor {
.setOperatorUsername("dingtalk")
.setOperatorName("钉钉审批");
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调统一日志辅助-----------
private void logCallbackDispatch(String action, ApprovalCallbackContext ctx) {
if (ctx == null) {
log.info("{} 触发业务回调 action={} ctx=null", LOG_TAG, action);
return;
}
log.info("{} 触发业务回调 action={} recordId={} bizTable={} bizDataId={} nodeId={} nodeName={} operator={} comment={}",
LOG_TAG, action, ctx.getInstanceId(), ctx.getBizTable(), ctx.getBizDataId(),
ctx.getNodeId(), ctx.getNodeName(), ctx.getOperatorName(), ctx.getComment());
}
private String summarizeNodeNames(List<JSONObject> mesNodes) {
if (mesNodes == null || mesNodes.isEmpty()) {
return "[]";
}
return mesNodes.stream()
.map(n -> n.getString("name"))
.collect(Collectors.joining(" -> ", "[", "]"));
}
private String summarizeTaskOps(List<JSONObject> taskOps) {
if (taskOps == null || taskOps.isEmpty()) {
return "[]";
}
return taskOps.stream()
.map(op -> String.format("{userId=%s,showName=%s,result=%s}",
op.getString("userId"), op.getString("showName"), op.getString("result")))
.collect(Collectors.joining(", ", "[", "]"));
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调统一日志辅助-----------
}

View File

@@ -1,10 +1,5 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.dingtalk.open.app.api.GenericEventListener;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.dingtalk.open.app.stream.protocol.event.EventAckStatus;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
@@ -19,6 +14,7 @@ import org.springframework.stereotype.Component;
* 官方 SDK 内部自动维护重连与心跳。
* <p>
* 启动时机:{@link SmartLifecycle}phase=MAX-100确保 Spring 上下文完全就绪后再建连。
* SDK 实际启动委托给 {@link DingTalkStreamSdkRunner},避免本类直接引用钉钉 SDK 类型。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于官方SDK的Stream客户端
@@ -27,6 +23,8 @@ import org.springframework.stereotype.Component;
@Component
public class DingTalkStreamClient implements SmartLifecycle {
private static final String SDK_RUNNER_CLASS = "org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner";
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@@ -57,7 +55,7 @@ public class DingTalkStreamClient implements SmartLifecycle {
public void stop() {
running = false;
// SDK 内部使用 daemon 线程JVM 退出时自动终止
log.info("[DingStream] 钉钉 Stream 客户端已停止");
log.info("{} Stream 客户端已停止", DingTalkStreamSdkRunner.LOG_TAG);
}
@Override
@@ -66,86 +64,29 @@ public class DingTalkStreamClient implements SmartLifecycle {
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
// ==================== SDK 初始化(官方写法====================
// ==================== SDK 初始化(反射委托,避免 LiteFlow 扫描期加载钉钉类====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
//update-begin---author:GHT ---date:2026-06-05 for【钉钉Stream回调】反射调用SDK启动器避免LiteFlow扫描触发DingTalkCredential加载失败-----
private void initSdkClient() {
try {
String[] creds = dingtalkService.getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingStream] 钉钉 AppKey/AppSecret 未配置Stream 连接未启动。"
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。");
log.warn("{} AppKey/AppSecret 未配置Stream 连接未启动。"
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。", DingTalkStreamSdkRunner.LOG_TAG);
return;
}
log.info("[DingStream] 正在建立钉钉 Stream 连接AppKey={}", creds[0]);
// 官方写法build().start() 链式调用SDK 内部管理长连接与重连
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(creds[0], creds[1]))
.registerAllEventListener(new GenericEventListener() {
@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
return handleEvent(event);
}
})
.build()
.start();
log.info("[DingStream] 钉钉 Stream 客户端已启动,等待审批事件推送");
Class<?> runnerClass = Class.forName(SDK_RUNNER_CLASS);
runnerClass
.getMethod("start", String.class, String.class, DingBpmsEventProcessor.class)
.invoke(null, creds[0], creds[1], bpmsEventProcessor);
} catch (ClassNotFoundException e) {
log.warn("{} Stream SDK 未在 classpath 中dingtalk-stream连接未启动。"
+ "请执行 Maven 刷新/重新编译后重试。", DingTalkStreamSdkRunner.LOG_TAG);
} catch (Exception e) {
log.error("[DingStream] SDK 启动失败,请检查钉钉配置: {}", e.getMessage(), e);
log.error("{} SDK 启动失败,请检查钉钉配置: {}", DingTalkStreamSdkRunner.LOG_TAG, e.getMessage(), e);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
// ==================== 事件处理 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
private EventAckStatus handleEvent(GenericOpenDingTalkEvent event) {
try {
String eventType = event.getEventType();
log.debug("[DingStream] 收到事件 eventId={} eventType={} bornTime={}",
event.getEventId(), eventType, event.getEventBornTime());
if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) {
return EventAckStatus.SUCCESS;
}
// getData() 返回 fastjson2 JSONObject
com.alibaba.fastjson2.JSONObject data = toJsonObject(event.getData());
if (data == null) {
log.warn("[DingStream] 事件 data 为空eventType={}", eventType);
return EventAckStatus.SUCCESS;
}
if ("bpms_instance_change".equals(eventType)) {
bpmsEventProcessor.onInstanceChange(data);
} else {
bpmsEventProcessor.onTaskChange(data);
}
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("[DingStream] 事件处理异常 eventType={}: {}", event.getEventType(), e.getMessage(), e);
// LATER通知钉钉稍后重推避免丢失事件
return EventAckStatus.LATER;
}
}
private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) {
if (raw == null) return null;
if (raw instanceof com.alibaba.fastjson2.JSONObject) {
return (com.alibaba.fastjson2.JSONObject) raw;
}
try {
return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw));
} catch (Exception e) {
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
//update-end---author:GHT ---date:2026-06-05 for【钉钉Stream回调】反射调用SDK启动器避免LiteFlow扫描触发DingTalkCredential加载失败-----
}

View File

@@ -0,0 +1,109 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.dingtalk.open.app.api.GenericEventListener;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.dingtalk.open.app.stream.protocol.event.EventAckStatus;
import lombok.extern.slf4j.Slf4j;
/**
* 钉钉 Stream SDK 启动器(非 Spring Bean
* <p>
* 与 {@link DingTalkStreamClient} 分离LiteFlow 在上下文初始化早期会扫描所有 {@code @Component}
* 并调用 {@code getDeclaredMethods()},若 Bean 类字节码直接引用钉钉 SDK 类型,会提前加载
* {@code DingTalkCredential} 等类;本类不参与 Spring 扫描,仅在后台线程中按需加载。
*
* @author GHT
* @date 2026-06-05 for【钉钉Stream回调】隔离SDK类避免LiteFlow启动期加载失败
*/
@Slf4j
public final class DingTalkStreamSdkRunner {
/** 统一日志前缀,便于 grep钉钉回调 */
public static final String LOG_TAG = "[钉钉回调]";
private DingTalkStreamSdkRunner() {
}
//update-begin---author:GHT ---date:2026-06-05 for【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离-----
/**
* 建立钉钉 Stream 长连接并开始接收事件。
*
* @param appKey 钉钉 AppKey
* @param appSecret 钉钉 AppSecret
* @param processor 审批事件处理器
*/
public static void start(String appKey, String appSecret, DingBpmsEventProcessor processor) throws Exception {
log.info("{} Stream 正在建连 AppKey={}", LOG_TAG, appKey);
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(appKey, appSecret))
.registerAllEventListener(new GenericEventListener() {
@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
String eventType = event != null ? event.getEventType() : null;
String eventId = event != null ? event.getEventId() : null;
Long bornTime = event != null ? event.getEventBornTime() : null;
long startMs = System.currentTimeMillis();
try {
com.alibaba.fastjson2.JSONObject data = event != null ? toJsonObject(event.getData()) : null;
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉Stream入站全量日志-----------
log.info("{} Stream入站 eventId={} eventType={} bornTime={} data={}",
LOG_TAG, eventId, eventType, bornTime,
data != null ? data.toJSONString() : "null");
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉Stream入站全量日志-----------
if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) {
log.info("{} 非审批BPMS事件已忽略 eventType={}", LOG_TAG, eventType);
return EventAckStatus.SUCCESS;
}
if (data == null) {
log.warn("{} 事件 data 为空,无法处理 eventType={} eventId={}", LOG_TAG, eventType, eventId);
return EventAckStatus.SUCCESS;
}
String instanceId = data.getString("processInstanceId");
log.info("{} 开始处理 eventType={} instanceId={}", LOG_TAG, eventType, instanceId);
if ("bpms_instance_change".equals(eventType)) {
processor.onInstanceChange(data);
} else {
processor.onTaskChange(data);
}
log.info("{} 处理完成 eventType={} instanceId={} costMs={}",
LOG_TAG, eventType, instanceId, System.currentTimeMillis() - startMs);
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("{} 事件处理异常 eventId={} eventType={} costMs={}: {}",
LOG_TAG, eventId, eventType, System.currentTimeMillis() - startMs,
e.getMessage(), e);
return EventAckStatus.LATER;
}
}
})
.build()
.start();
log.info("{} Stream 客户端已启动,等待审批事件推送", LOG_TAG);
}
//update-end---author:GHT ---date:2026-06-05 for【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离-----
private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) {
if (raw == null) {
return null;
}
if (raw instanceof com.alibaba.fastjson2.JSONObject) {
return (com.alibaba.fastjson2.JSONObject) raw;
}
try {
return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw));
} catch (Exception e) {
return null;
}
}
}

View File

@@ -34,6 +34,8 @@ import java.util.List;
@Service
public class DingTalkWorkflowService {
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
private static final String PROCESS_INSTANCE_URL =
"https://api.dingtalk.com/v1.0/workflow/processInstances";
@@ -60,12 +62,16 @@ public class DingTalkWorkflowService {
*/
public JSONObject getProcessInstance(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) {
log.info("{} 拉取审批实例跳过processInstanceId 为空", LOG_TAG);
return null;
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调拉取实例详情全量日志-----------
log.info("{} 开始拉取审批实例详情 instanceId={}", LOG_TAG, processInstanceId);
long startMs = System.currentTimeMillis();
// 后台线程无 TenantContext必须用绕过租户校验的专用方法
String accessToken = dingtalkService.getAccessTokenForBackground();
if (oConvertUtils.isEmpty(accessToken)) {
log.warn("[DingWorkflow] AccessToken 获取失败,无法查询审批实例 {}", processInstanceId);
log.warn("{} AccessToken 获取失败,无法查询审批实例 instanceId={}", LOG_TAG, processInstanceId);
return null;
}
try {
@@ -81,16 +87,29 @@ public class DingTalkWorkflowService {
String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
JSONObject resp = JSONObject.parseObject(body);
if (resp.containsKey("code")) {
log.warn("[DingWorkflow] 查询审批实例失败 instanceId={} code={} msg={}",
processInstanceId, resp.getString("code"), resp.getString("message"));
log.warn("{} 查询审批实例失败 instanceId={} code={} msg={} costMs={}",
LOG_TAG, processInstanceId, resp.getString("code"), resp.getString("message"),
System.currentTimeMillis() - startMs);
return null;
}
return resp.getJSONObject("result");
JSONObject result = resp.getJSONObject("result");
if (result == null) {
log.warn("{} 审批实例 result 为空 instanceId={} costMs={}", LOG_TAG, processInstanceId,
System.currentTimeMillis() - startMs);
return null;
}
JSONArray opRecords = result.getJSONArray("operationRecords");
int opCount = opRecords == null ? 0 : opRecords.size();
log.info("{} 拉取审批实例成功 instanceId={} status={} result={} operationRecords={} costMs={}",
LOG_TAG, processInstanceId, result.getString("status"), result.getString("result"),
opCount, System.currentTimeMillis() - startMs);
return result;
} catch (Exception e) {
log.error("[DingWorkflow] 调用钉钉审批实例详情接口异常 instanceId={}: {}",
processInstanceId, e.getMessage(), e);
log.error("{} 调用钉钉审批实例详情接口异常 instanceId={} costMs={}: {}",
LOG_TAG, processInstanceId, System.currentTimeMillis() - startMs, e.getMessage(), e);
return null;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调拉取实例详情全量日志-----------
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
@@ -106,10 +125,13 @@ public class DingTalkWorkflowService {
public List<JSONObject> getTaskOperations(JSONObject instanceResult) {
List<JSONObject> ops = new ArrayList<>();
if (instanceResult == null) {
log.info("{} 解析 operationRecords 跳过instanceResult=null", LOG_TAG);
return ops;
}
String instanceId = instanceResult.getString("processInstanceId");
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
log.info("{} 解析 operationRecords 为空 instanceId={}", LOG_TAG, instanceId);
return ops;
}
for (int i = 0; i < records.size(); i++) {
@@ -121,6 +143,10 @@ public class DingTalkWorkflowService {
ops.add(rec);
}
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调解析节点操作序列日志-----------
log.info("{} 解析 operationRecords instanceId={} rawCount={} taskOpCount={}",
LOG_TAG, instanceId, records.size(), ops.size());
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调解析节点操作序列日志-----------
return ops;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
@@ -141,6 +167,7 @@ public class DingTalkWorkflowService {
*/
public String generateTokenByDtUserId(String dtUserId) {
if (oConvertUtils.isEmpty(dtUserId)) {
log.info("{} 生成操作人Token跳过dtUserId 为空", LOG_TAG);
return null;
}
try {
@@ -150,17 +177,21 @@ public class DingTalkWorkflowService {
"WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, dtUserId);
if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) {
log.debug("[DingWorkflow] 钉钉用户 {} 未绑定 MES 账号,降级使用 admin token", dtUserId);
log.info("{} 钉钉用户未绑定MES账号降级admin token dtUserId={}", LOG_TAG, dtUserId);
return generateAdminToken();
}
// ② sys_user_id → username + password
SysUser user = sysUserService.getById(userIds.get(0));
if (user == null || oConvertUtils.isEmpty(user.getPassword())) {
log.info("{} 绑定用户无效降级admin token dtUserId={} sysUserId={}", LOG_TAG, dtUserId, userIds.get(0));
return generateAdminToken();
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调操作人Token映射日志-----------
log.info("{} 生成操作人Token成功 dtUserId={} mesUsername={}", LOG_TAG, dtUserId, user.getUsername());
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】钉钉回调操作人Token映射日志-----------
return signAndCache(user.getUsername(), user.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成用户 token 失败 dtUserId={}: {}", dtUserId, e.getMessage());
log.warn("{} 生成用户Token失败降级admin dtUserId={}: {}", LOG_TAG, dtUserId, e.getMessage());
return generateAdminToken();
}
}
@@ -170,12 +201,13 @@ public class DingTalkWorkflowService {
try {
SysUser admin = sysUserService.getUserByName("admin");
if (admin == null || oConvertUtils.isEmpty(admin.getPassword())) {
log.warn("[DingWorkflow] admin 用户不存在,无法生成系统 token");
log.warn("{} admin 用户不存在,无法生成系统 token", LOG_TAG);
return null;
}
log.info("{} 使用 admin 兜底Token", LOG_TAG);
return signAndCache(admin.getUsername(), admin.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成 admin token 失败: {}", e.getMessage());
log.warn("{} 生成 admin token 失败: {}", LOG_TAG, e.getMessage());
return null;
}
}

View File

@@ -14,6 +14,7 @@ import org.jeecg.modules.xslmes.mapper.MesXslMixerPsCompileMapper;
import org.jeecg.modules.xslmes.service.IMesXslFormulaSpecService;
import org.jeecg.modules.xslmes.service.IMesXslMixingSpecService;
import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService;
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
import org.jeecg.modules.xslmes.service.IMesXslRubberQuickTestStdService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -26,6 +27,8 @@ import org.springframework.transaction.annotation.Transactional;
public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCompileMapper, MesXslMixerPsCompile>
implements IMesXslMixerPsCompileService {
private static final String BIZ_TABLE_MIXER_PS = "mes_xsl_mixer_ps_compile";
@Autowired
private IMesXslFormulaSpecService mesXslFormulaSpecService;
@@ -35,6 +38,9 @@ public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCo
@Autowired
private IMesXslRubberQuickTestStdService mesXslRubberQuickTestStdService;
@Autowired
private IApprovalTraceSyncService approvalTraceSyncService;
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】批量流转状态-----------
@Override
@Transactional(rollbackFor = Exception.class)
@@ -43,6 +49,12 @@ public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCo
if (oConvertUtils.isEmpty(ids)) {
return "请选择要" + actionLabel + "的记录";
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节启用校验-----------
String stageErr = approvalTraceSyncService.checkStageAllowed(BIZ_TABLE_MIXER_PS, targetStatus);
if (stageErr != null) {
return stageErr;
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批注册中心环节启用校验-----------
List<String> idList = Arrays.asList(ids.split(","));
Date now = new Date();
//update-begin---author:jiangxh ---date:20260525 for【MES】原材料检验标准PS批准后关联实验标准置已批准-----------
@@ -70,6 +82,10 @@ public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCo
//update-begin---author:cursor ---date:20260526 for【XSLMES-20260526-A61】混炼示方密炼PS审批联动同步审批人-----------
mesXslMixingSpecService.syncFromMixerPsWorkflow(entity, targetStatus);
//update-end---author:cursor ---date:20260526 for【XSLMES-20260526-A61】混炼示方密炼PS审批联动同步审批人-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批痕迹双写-----------
approvalTraceSyncService.syncStage(
BIZ_TABLE_MIXER_PS, entity.getId(), targetStatus, operatorName, now);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批痕迹双写-----------
//update-begin---author:jiangxh ---date:20260525 for【MES】原材料检验标准PS批准后关联实验标准置已批准-----------
if ("approve".equals(targetStatus)
&& XslMesBizConstants.PS_TYPE_RAW_INSPECT_STD.equals(entity.getPsType())) {
@@ -171,6 +187,10 @@ public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCo
if (leavingApprove && XslMesBizConstants.PS_TYPE_RAW_INSPECT_STD.equals(entity.getPsType())) {
mesXslRubberQuickTestStdService.markAuditDraftByPsCompileIds(Collections.singletonList(entity.getId()));
}
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批痕迹逆向回退同步-----------
approvalTraceSyncService.revertToStage(BIZ_TABLE_MIXER_PS, entity.getId(), targetStatus);
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】审批痕迹逆向回退同步-----------
}
/** 上一环节映射approve→audit→proofread→compilecompile 无上一环节返回 null */

View File

@@ -34,6 +34,12 @@
<artifactId>jeecg-module-xslmes</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 钉钉 Stream SDK显式声明确保 IDE 调试运行时 classpath 包含 dingtalk-stream GHT 20260605 -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-device-sync</artifactId>

View File

@@ -0,0 +1,115 @@
-- 审核集成 Phase0新建4张集成表 + 扩展审批台账
-- author: GHT date: 2026-06-05
SET NAMES utf8mb4;
-- 单据注册中心
CREATE TABLE IF NOT EXISTS `mes_xsl_biz_doc_registry` (
`id` varchar(32) NOT NULL COMMENT '主键',
`doc_code` varchar(64) NOT NULL COMMENT '业务编码 formula_spec',
`table_name` varchar(128) NOT NULL COMMENT '物理表名',
`display_name` varchar(128) DEFAULT NULL COMMENT '中文名',
`enabled` tinyint DEFAULT 1 COMMENT '启用 0否 1是',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` int DEFAULT 0 COMMENT '逻辑删除',
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL,
`create_by` varchar(50) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(50) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_doc_code_tenant` (`doc_code`, `tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES单据注册中心';
-- 预置常用单据
INSERT IGNORE INTO `mes_xsl_biz_doc_registry`
(`id`, `doc_code`, `table_name`, `display_name`, `enabled`, `del_flag`, `tenant_id`, `create_by`, `create_time`)
VALUES
(REPLACE(UUID(),'-',''), 'formula_spec', 'mes_xsl_formula_spec', '配合示方', 1, 0, 0, 'admin', NOW()),
(REPLACE(UUID(),'-',''), 'mixing_spec', 'mes_xsl_mixing_spec', '密炼示方', 1, 0, 0, 'admin', NOW()),
(REPLACE(UUID(),'-',''), 'mixer_ps_compile', 'mes_xsl_mixer_ps_compile', '密炼PS编制', 1, 0, 0, 'admin', NOW()),
(REPLACE(UUID(),'-',''), 'rubber_quick_test_std', 'mes_xsl_rubber_quick_test_std', '快检实验标准', 1, 0, 0, 'admin', NOW()),
(REPLACE(UUID(),'-',''), 'raw_material_entry', 'mes_xsl_raw_material_entry', '原料入场记录', 1, 0, 0, 'admin', NOW());
-- 集成方案
CREATE TABLE IF NOT EXISTS `mes_xsl_integration_plan` (
`id` varchar(32) NOT NULL COMMENT '主键',
`plan_code` varchar(64) NOT NULL COMMENT '方案编码唯一',
`plan_name` varchar(128) NOT NULL COMMENT '方案名称',
`source_table` varchar(128) NOT NULL COMMENT '源单表名',
`trigger_phase` varchar(20) NOT NULL COMMENT '触发时机 onApprove/onReject/onNodeApprove',
`exec_mode` varchar(10) DEFAULT 'async' COMMENT '执行模式 sync同步/async异步提交后',
`match_condition` varchar(500) DEFAULT NULL COMMENT '匹配条件=无条件',
`status` varchar(1) DEFAULT '0' COMMENT '0草稿 1已发布 2已停用',
`remark` varchar(500) DEFAULT NULL,
`del_flag` int DEFAULT 0,
`tenant_id` int DEFAULT NULL,
`sys_org_code` varchar(64) DEFAULT NULL,
`create_by` varchar(50) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(50) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_int_plan_table_phase` (`source_table`, `trigger_phase`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核集成方案';
-- 集成动作
CREATE TABLE IF NOT EXISTS `mes_xsl_integration_action` (
`id` varchar(32) NOT NULL COMMENT '主键',
`plan_id` varchar(32) NOT NULL COMMENT '所属方案ID',
`action_name` varchar(128) NOT NULL COMMENT '动作名称',
`action_type` varchar(30) NOT NULL DEFAULT 'SQL_UPDATE' COMMENT '动作类型 Phase0仅SQL_UPDATE',
`sql_template` text DEFAULT NULL COMMENT 'SQL_UPDATE模板支持#{source.xxx}变量',
`exec_order` int DEFAULT 0 COMMENT '执行顺序升序',
`on_fail` varchar(10) DEFAULT 'stop' COMMENT '失败策略 stop终止/continue继续',
`idempotent_key` varchar(200) DEFAULT NULL COMMENT '幂等键表达式=record_id+action_id',
`enabled` tinyint DEFAULT 1 COMMENT '启用',
`remark` varchar(500) DEFAULT NULL,
`del_flag` int DEFAULT 0,
`create_by` varchar(50) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(50) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_int_action_plan` (`plan_id`, `exec_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核集成动作';
-- 集成执行日志
CREATE TABLE IF NOT EXISTS `mes_xsl_integration_log` (
`id` varchar(32) NOT NULL COMMENT '主键',
`record_id` varchar(32) DEFAULT NULL COMMENT '审批台账ID',
`plan_id` varchar(32) DEFAULT NULL COMMENT '方案ID',
`action_id` varchar(32) DEFAULT NULL COMMENT '动作ID',
`idempotent_key` varchar(200) DEFAULT NULL COMMENT '幂等键',
`status` varchar(10) DEFAULT NULL COMMENT 'success/failed/skipped',
`source_biz_id` varchar(64) DEFAULT NULL COMMENT '源单ID',
`source_biz_table` varchar(128) DEFAULT NULL COMMENT '源单表名',
`error_message` text DEFAULT NULL COMMENT '错误信息',
`retry_count` int DEFAULT 0 COMMENT '重试次数',
`exec_time_ms` bigint DEFAULT NULL COMMENT '耗时ms',
`request_snapshot` text DEFAULT NULL COMMENT '执行前变量快照',
`response_snapshot` text DEFAULT NULL COMMENT '执行结果快照',
`create_by` varchar(50) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_intlog_record` (`record_id`),
KEY `idx_intlog_idempotent` (`idempotent_key`(191)),
KEY `idx_intlog_plan_action` (`plan_id`, `action_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核集成执行日志';
-- 扩展 mes_xsl_approval_record幂等列已存在则跳过避免重复执行报 1060
SET @db = DATABASE();
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_approval_record' AND COLUMN_NAME = 'integration_status') = 0,
'ALTER TABLE `mes_xsl_approval_record` ADD COLUMN `integration_status` varchar(2) DEFAULT ''0'' COMMENT ''编排状态 0未执行 1成功 2部分失败 3失败'' AFTER `finish_time`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_approval_record' AND COLUMN_NAME = 'integration_remark') = 0,
'ALTER TABLE `mes_xsl_approval_record` ADD COLUMN `integration_remark` varchar(500) DEFAULT NULL COMMENT ''编排摘要/错误信息'' AFTER `integration_status`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,74 @@
-- 审核集成 Phase0字典数据
-- author: GHT date: 2026-06-05
SET NAMES utf8mb4;
-- 集成动作类型
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000360','集成动作类型','mes_xsl_integration_action_type','审核集成动作类型',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000361','1995000000000000360','SQL更新','SQL_UPDATE','执行UPDATE/INSERT SQL',1,1,'admin',NOW()),
('1995000000000000362','1995000000000000360','更新单据','UPDATE_DOC','结构化更新目标单据Phase1',2,1,'admin',NOW()),
('1995000000000000363','1995000000000000360','生成单据','CREATE_DOC','生成目标主+子表Phase1',3,1,'admin',NOW()),
('1995000000000000364','1995000000000000360','调用接口','CALL_API','HTTP调用业务接口Phase2',4,1,'admin',NOW()),
('1995000000000000365','1995000000000000360','调用Handler','CALL_HANDLER','Spring Bean处理Phase2',5,1,'admin',NOW());
-- 集成触发时机
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000366','集成触发时机','mes_xsl_integration_trigger_phase','审核集成方案触发时机',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000367','1995000000000000366','审批通过','onApprove','整个流程最终通过时触发',1,1,'admin',NOW()),
('1995000000000000368','1995000000000000366','审批驳回','onReject','任一节点驳回时触发',2,1,'admin',NOW()),
('1995000000000000369','1995000000000000366','节点通过','onNodeApprove','单个节点通过中间态时触发',3,1,'admin',NOW());
-- 集成方案状态
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000370','集成方案状态','mes_xsl_integration_plan_status','审核集成方案状态',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000371','1995000000000000370','草稿','0','未发布不生效',1,1,'admin',NOW()),
('1995000000000000372','1995000000000000370','已发布','1','生效中',2,1,'admin',NOW()),
('1995000000000000373','1995000000000000370','已停用','2','已停用',3,1,'admin',NOW());
-- 集成执行日志状态
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000374','集成执行状态','mes_xsl_integration_log_status','审核集成执行日志状态',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000375','1995000000000000374','成功','success','执行成功',1,1,'admin',NOW()),
('1995000000000000376','1995000000000000374','失败','failed','执行失败',2,1,'admin',NOW()),
('1995000000000000377','1995000000000000374','已跳过','skipped','幂等命中跳过',3,1,'admin',NOW());
-- 集成执行模式
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000378','集成执行模式','mes_xsl_integration_exec_mode','审批后编排执行模式',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000379','1995000000000000378','异步推荐','async','事务提交后异步执行审批不因编排失败回滚',1,1,'admin',NOW()),
('1995000000000000380','1995000000000000378','同步','sync','与审批同事务编排失败回滚审批',2,1,'admin',NOW());
-- 编排执行状态台账扩展字段
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000381','编排执行状态','mes_xsl_integration_orch_status','审批台账的编排执行状态',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000382','1995000000000000381','未执行','0','未触发编排',1,1,'admin',NOW()),
('1995000000000000383','1995000000000000381','全部成功','1','所有动作均成功',2,1,'admin',NOW()),
('1995000000000000384','1995000000000000381','部分失败','2','存在on_fail=continue的失败动作',3,1,'admin',NOW()),
('1995000000000000385','1995000000000000381','失败','3','遇到on_fail=stop的失败动作中止',4,1,'admin',NOW());
-- 失败策略
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000386','动作失败策略','mes_xsl_integration_on_fail','集成动作失败后的处理策略',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000387','1995000000000000386','停止','stop','失败后停止后续动作',1,1,'admin',NOW()),
('1995000000000000388','1995000000000000386','继续','continue','失败后继续执行后续动作',2,1,'admin',NOW());

View File

@@ -0,0 +1,50 @@
-- 审核集成 Phase0菜单 + 权限挂在 MESToDing审批配置 父菜单 178046026420801
-- author: GHT date: 2026-06-05
SET NAMES utf8mb4;
-- 单据注册中心
INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external)
VALUES('178046026420838','178046026420801','单据注册中心','/xslmes/mesXslBizDocRegistryList','xslmes/approval/integration/MesXslBizDocRegistryList',NULL,NULL,0,NULL,'1',4.00,0,'ant-design:database-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420839','178046026420838','查询单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420840','178046026420838','新增单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:add','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420841','178046026420838','编辑单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:edit','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420842','178046026420838','删除单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:delete','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
-- 集成方案管理含内嵌动作管理
INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external)
VALUES('178046026420830','178046026420801','集成方案管理','/xslmes/mesXslIntegrationPlanList','xslmes/approval/integration/MesXslIntegrationPlanList',NULL,NULL,0,NULL,'1',5.00,0,'ant-design:node-index-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420831','178046026420830','查询集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420832','178046026420830','新增集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:add','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420833','178046026420830','编辑集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:edit','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420834','178046026420830','删除集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:delete','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420843','178046026420830','发布集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:publish','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
-- 集成执行日志
INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external)
VALUES('178046026420835','178046026420801','集成执行日志','/xslmes/mesXslIntegrationLogList','xslmes/approval/integration/MesXslIntegrationLogList',NULL,NULL,0,NULL,'1',6.00,0,'ant-design:file-search-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420836','178046026420835','查询集成日志',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_log:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420837','178046026420835','重试集成动作',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_log:retry','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0);
-- 授权给超管角色f6817f48af4fb3af11b9e8bf182f618b
INSERT INTO sys_role_permission (id,role_id,permission_id,data_rule_ids,operate_date,operate_ip)
SELECT REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b',id,NULL,'2026-06-05 00:00:00','127.0.0.1'
FROM sys_permission
WHERE id IN (
'178046026420830','178046026420831','178046026420832','178046026420833','178046026420834','178046026420843',
'178046026420835','178046026420836','178046026420837',
'178046026420838','178046026420839','178046026420840','178046026420841','178046026420842'
);

View File

@@ -0,0 +1,106 @@
-- 审核集成 Phase0密炼PS编制 集成方案演示数据
-- 功能说明
-- 将密炼PS现有三节点审批校对审核批准的状态流转逻辑
-- 转化为可视化的集成方案配置验证 SQL_UPDATE 动作执行器的实际效果
-- 注意
-- 方案初始状态为"草稿(0)"不会自动触发
-- 需要在集成方案管理页面手动"发布"后才生效
-- 现有 @ApprovalBizAction HTTP 回调仍然生效两套并行SQL AND status=? 条件防重
-- author: GHT date: 2026-06-05
SET NAMES utf8mb4;
-- =============================================================
-- 方案一审批全部通过 推进主表 + 配合示方到最终批准态
-- 触发时机onApprove最终通过整个流程时
-- =============================================================
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
VALUES
('mxpsonapproveplan000001','mixer_ps_on_approve','密炼PS-审批通过批准态同步','mes_xsl_mixer_ps_compile','onApprove','async',NULL,'0',
'最终审批通过后主表 statusapprove配合示方 statusrecognition_pass',
0,0,'admin',NOW());
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsonapprove_act001','mxpsonapproveplan000001',
'主表批准态','SQL_UPDATE',
'UPDATE mes_xsl_mixer_ps_compile SET status=''approve'', approve_time=NOW() WHERE id=#{source.id} AND status=''audit''',
1,'stop',1,'只在 audit 态才更新防止重复触发',0,'admin',NOW()),
('mxpsonapprove_act002','mxpsonapproveplan000001',
'配合示方认定通过','SQL_UPDATE',
'UPDATE mes_xsl_formula_spec SET status=''recognition_pass'', approve_time=NOW() WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL',
2,'continue',1,'通过 issue_number 级联同步issue_number 为空时影响0行自动跳过',0,'admin',NOW()),
('mxpsonapprove_act003','mxpsonapproveplan000001',
'混炼示方同步批准时间','SQL_UPDATE',
'UPDATE mes_xsl_mixing_spec SET approve_time=NOW() WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL',
3,'continue',1,'仅同步时间戳不改变混炼示方 status',0,'admin',NOW());
-- =============================================================
-- 方案二审批驳回 全量回退到编制态清空所有痕迹
-- 触发时机onReject任一节点被驳回时
-- =============================================================
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
VALUES
('mxpsonrejectplan000002','mixer_ps_on_reject','密炼PS-审批驳回恢复编制态','mes_xsl_mixer_ps_compile','onReject','async',NULL,'0',
'驳回后主表+配合示方回到 compile清空校对/审核/批准全部痕迹字段',
0,0,'admin',NOW());
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsonreject_act001','mxpsonrejectplan000002',
'主表回退编制态','SQL_UPDATE',
'UPDATE mes_xsl_mixer_ps_compile SET status=''compile'', proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE id=#{source.id}',
1,'stop',1,'无条件回退清空所有审批痕迹',0,'admin',NOW()),
('mxpsonreject_act002','mxpsonrejectplan000002',
'配合示方回退编制态','SQL_UPDATE',
'UPDATE mes_xsl_formula_spec SET status=''compile'', proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL',
2,'continue',1,'级联回退配合示方状态',0,'admin',NOW()),
('mxpsonreject_act003','mxpsonrejectplan000002',
'混炼示方清空痕迹','SQL_UPDATE',
'UPDATE mes_xsl_mixing_spec SET proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL',
3,'continue',1,'混炼示方只清痕迹不改 status',0,'admin',NOW());
-- =============================================================
-- 方案三节点逐级通过 利用条件 WHERE 区分校对/审核两个节点
-- 触发时机onNodeApprove每通过一个中间节点时触发一次
-- 关键设计每个 UPDATE AND status='当前期望状态' 条件
-- status 不匹配时影响 0 自动跳过实现节点自动识别
-- =============================================================
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
VALUES
('mxpsnodeapprplan000003','mixer_ps_node_approve','密炼PS-节点通过逐级状态推进','mes_xsl_mixer_ps_compile','onNodeApprove','async',NULL,'0',
'利用条件 WHERE 区分校对/审核两节点每次只有一条 SQL 真正命中影响1行另一条影响0行自动跳过',
0,0,'admin',NOW());
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`)
VALUES
-- 第12条校对节点通过当前 status=compile 时命中
('mxpsnodeappr_act001','mxpsnodeapprplan000003',
'校对通过-主表proofread','SQL_UPDATE',
'UPDATE mes_xsl_mixer_ps_compile SET status=''proofread'', proofread_time=NOW() WHERE id=#{source.id} AND status=''compile''',
1,'continue',1,'status=compile 才命中校对节点status=proofread 时影响0行自动跳过',0,'admin',NOW()),
('mxpsnodeappr_act002','mxpsnodeapprplan000003',
'校对通过-配合示方submit','SQL_UPDATE',
'UPDATE mes_xsl_formula_spec SET status=''submit'', proofread_time=NOW() WHERE issue_number=#{source.issue_number} AND status=''compile'' AND issue_number IS NOT NULL',
2,'continue',1,'配合示方校对态同步',0,'admin',NOW()),
-- 第34条审核节点通过当前 status=proofread 时命中
('mxpsnodeappr_act003','mxpsnodeapprplan000003',
'审核通过-主表audit','SQL_UPDATE',
'UPDATE mes_xsl_mixer_ps_compile SET status=''audit'', audit_time=NOW() WHERE id=#{source.id} AND status=''proofread''',
3,'continue',1,'status=proofread 才命中审核节点校对节点时影响0行跳过',0,'admin',NOW()),
('mxpsnodeappr_act004','mxpsnodeapprplan000003',
'审核通过-配合示方review_pass','SQL_UPDATE',
'UPDATE mes_xsl_formula_spec SET status=''review_pass'', audit_time=NOW() WHERE issue_number=#{source.issue_number} AND status=''submit'' AND issue_number IS NOT NULL',
4,'continue',1,'配合示方审核态同步',0,'admin',NOW());

View File

@@ -0,0 +1,5 @@
-- 审核集成 Phase0新增动作可视化配置字段
-- 用于存储可视化编辑器的配置 JSON支持重新打开时还原可视化状态
-- author: GHT date: 2026-06-05
ALTER TABLE `mes_xsl_integration_action`
ADD COLUMN `action_config` TEXT DEFAULT NULL COMMENT '可视化配置JSON可视化编辑器专用用于重新打开时还原配置' AFTER `sql_template`;

View File

@@ -0,0 +1,132 @@
-- 审批注册中心扩展注册配置 + 审批痕迹明细表每单据一行
-- author: GHT date: 2026-06-05 forXSLMES-20260605-K8R2
SET NAMES utf8mb4;
SET @db = DATABASE();
-- 扩展 mes_xsl_biz_doc_registry幂等列已存在则跳过
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'enabled_stages') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `enabled_stages` varchar(128) DEFAULT NULL COMMENT ''启用环节(多选逗号分隔 proofread,audit,approve)'' AFTER `enabled`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'status_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `status_field` varchar(64) DEFAULT ''status'' COMMENT ''业务状态字段名'' AFTER `enabled_stages`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'proofread_by_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `proofread_by_field` varchar(64) DEFAULT ''proofread_by'' COMMENT ''校对人字段名'' AFTER `status_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'proofread_time_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `proofread_time_field` varchar(64) DEFAULT ''proofread_time'' COMMENT ''校对时间字段名'' AFTER `proofread_by_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'audit_by_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `audit_by_field` varchar(64) DEFAULT ''audit_by'' COMMENT ''审核人字段名'' AFTER `proofread_time_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'audit_time_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `audit_time_field` varchar(64) DEFAULT ''audit_time'' COMMENT ''审核时间字段名'' AFTER `audit_by_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'approve_by_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `approve_by_field` varchar(64) DEFAULT ''approve_by'' COMMENT ''批准人字段名'' AFTER `audit_time_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'approve_time_field') = 0,
'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `approve_time_field` varchar(64) DEFAULT ''approve_time'' COMMENT ''批准时间字段名'' AFTER `approve_by_field`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE `mes_xsl_biz_doc_registry` COMMENT='MES审批注册中心';
-- 密炼PS编制默认开启三环节
UPDATE `mes_xsl_biz_doc_registry`
SET `enabled_stages` = 'proofread,audit,approve',
`status_field` = 'status',
`proofread_by_field` = 'proofread_by',
`proofread_time_field` = 'proofread_time',
`audit_by_field` = 'audit_by',
`audit_time_field` = 'audit_time',
`approve_by_field` = 'approve_by',
`approve_time_field` = 'approve_time',
`update_by` = 'admin',
`update_time` = NOW()
WHERE `doc_code` = 'mixer_ps_compile' AND `del_flag` = 0;
-- 审批痕迹明细每业务单据一行 biz_table + biz_data_id 唯一
CREATE TABLE IF NOT EXISTS `mes_xsl_approval_trace` (
`id` varchar(32) NOT NULL COMMENT '主键',
`registry_id` varchar(32) DEFAULT NULL COMMENT '审批注册配置ID',
`biz_table` varchar(128) NOT NULL COMMENT '业务表名',
`biz_data_id` varchar(32) NOT NULL COMMENT '业务单据ID',
`proofread_by` varchar(80) DEFAULT NULL COMMENT '校对人',
`proofread_time` datetime DEFAULT NULL COMMENT '校对时间',
`audit_by` varchar(80) DEFAULT NULL COMMENT '审核人',
`audit_time` datetime DEFAULT NULL COMMENT '审核时间',
`approve_by` varchar(80) DEFAULT NULL COMMENT '批准人',
`approve_time` datetime DEFAULT NULL COMMENT '批准时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`del_flag` int DEFAULT 0 COMMENT '逻辑删除',
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL,
`create_by` varchar(50) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(50) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_approval_trace_biz` (`biz_table`, `biz_data_id`, `tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批痕迹明细(每单据一行)';
-- 审批环节字典注册中心多选
INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`)
VALUES ('1995000000000000390','审批环节','mes_xsl_approval_stage','审批注册中心可启用环节',0,'admin',NOW(),0,0);
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000391','1995000000000000390','校对','proofread','校对环节',1,1,'admin',NOW()),
('1995000000000000392','1995000000000000390','审核','audit','审核环节',2,1,'admin',NOW()),
('1995000000000000393','1995000000000000390','批准','approve','批准环节',3,1,'admin',NOW());
-- 菜单重命名 + 新增审批痕迹
UPDATE `sys_permission`
SET `name` = '审批注册中心', `update_by` = 'admin', `update_time` = NOW()
WHERE `id` = '178046026420838';
INSERT IGNORE INTO `sys_permission`
(`id`,`parent_id`,`name`,`url`,`component`,`is_route`,`component_name`,`redirect`,`menu_type`,`perms`,`perms_type`,`sort_no`,`always_show`,`icon`,`is_leaf`,`keep_alive`,`hidden`,`hide_tab`,`description`,`status`,`del_flag`,`rule_flag`,`create_by`,`create_time`)
VALUES
('178046026420843','178046026420801','审批痕迹','/xslmes/mesXslApprovalTraceList','xslmes/approval/integration/MesXslApprovalTraceList',1,NULL,0,NULL,1,4.50,0,'ant-design:history-outlined',1,0,0,0,0,NULL,1,0,0,'admin',NOW());
INSERT IGNORE INTO `sys_permission`
(`id`,`parent_id`,`name`,`perms`,`perms_type`,`menu_type`,`sort_no`,`is_leaf`,`create_by`,`create_time`)
VALUES
('178046026420844','178046026420843','查询审批痕迹','xslmes:mes_xsl_approval_trace:list',1,2,1,1,'admin',NOW());
INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`)
VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420843',NOW(),'127.0.0.1');
INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`)
VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420844',NOW(),'127.0.0.1');

View File

@@ -0,0 +1,26 @@
-- 审批注册中心新增查看明细按钮权限并授权给已有注册中心权限的角色
-- author: GHT date: 2026-06-05 forXSLMES-20260605-K8R2
SET NAMES utf8mb4;
-- 审批注册中心下新增按钮权限查看审批明细列格式与 V3.9.2_132 按钮权限一致
INSERT IGNORE INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external)
VALUES('178046026420845','178046026420838','查看审批明细',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:trace','1',5,0,NULL,1,0,0,0,'审批注册中心操作列查看审批痕迹明细','admin',NOW(),NULL,NULL,0,0,'1',0);
-- 超管角色授权
INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`)
VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420845',NOW(),'127.0.0.1');
-- 已有审批注册中心任意按钮权限的角色自动补查看明细权限
INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`)
SELECT REPLACE(UUID(),'-',''), rp.`role_id`, '178046026420845', NOW(), '127.0.0.1'
FROM `sys_role_permission` rp
WHERE rp.`permission_id` IN (
'178046026420839',
'178046026420840',
'178046026420841',
'178046026420842'
)
AND NOT EXISTS (
SELECT 1 FROM `sys_role_permission` x
WHERE x.`role_id` = rp.`role_id` AND x.`permission_id` = '178046026420845'
);

View File

@@ -0,0 +1,32 @@
-- 集成方案与审批注册中心环节绑定
-- author: GHT date: 2026-06-05 forXSLMES-20260605-K8R2
SET NAMES utf8mb4;
SET @db = DATABASE();
-- 集成方案关联注册中心 + 绑定审批环节
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_integration_plan' AND COLUMN_NAME = 'registry_id') = 0,
'ALTER TABLE `mes_xsl_integration_plan` ADD COLUMN `registry_id` varchar(32) DEFAULT NULL COMMENT ''审批注册中心ID'' AFTER `source_table`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_integration_plan' AND COLUMN_NAME = 'trigger_stage') = 0,
'ALTER TABLE `mes_xsl_integration_plan` ADD COLUMN `trigger_stage` varchar(32) DEFAULT NULL COMMENT ''绑定审批环节 proofread/audit/approve'' AFTER `trigger_phase`',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 回填已有方案的注册中心与环节 source_table 关联
UPDATE `mes_xsl_integration_plan` p
INNER JOIN `mes_xsl_biz_doc_registry` r ON r.table_name = p.source_table AND r.del_flag = 0 AND r.enabled = 1
SET p.registry_id = r.id,
p.trigger_stage = CASE p.trigger_phase
WHEN 'onApprove' THEN 'approve'
WHEN 'onNodeApprove' THEN NULL
ELSE NULL
END,
p.update_by = 'admin',
p.update_time = NOW()
WHERE p.del_flag = 0 AND (p.registry_id IS NULL OR p.trigger_stage IS NULL);

View File

@@ -0,0 +1,87 @@
-- 集成方案审批注册中心环节同步动作 + 密炼PS无代码审批方案示例
-- author: GHT date: 2026-06-05 forXSLMES-20260605-K8R2
SET NAMES utf8mb4;
-- 集成动作类型字典扩展
INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`)
VALUES
('1995000000000000394','1995000000000000360','审批环节同步','REGISTRY_STAGE_SYNC','按审批注册中心更新源单状态/操作人/痕迹',6,1,'admin',NOW()),
('1995000000000000395','1995000000000000360','审批环节回退','REGISTRY_STAGE_REVERT','驳回时回退源单状态并清空痕迹',7,1,'admin',NOW());
-- 停用旧版 SQL 手写演示方案保留数据改为已停用
UPDATE `mes_xsl_integration_plan`
SET `status` = '2', `update_by` = 'admin', `update_time` = NOW()
WHERE `plan_code` IN ('mixer_ps_on_approve','mixer_ps_on_reject','mixer_ps_node_approve')
AND `del_flag` = 0;
-- 密炼PS 校对环节通过
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
SELECT
'mxpsregproofread00001','mixer_ps_reg_proofread','密炼PS-校对通过(注册中心同步)',
'mes_xsl_mixer_ps_compile', r.id, 'onNodeApprove', 'proofread', 'async', '0',
'无需Java回调更新status=proofread并写校对人/时间+审批痕迹', 0, 0, 'admin', NOW()
FROM `mes_xsl_biz_doc_registry` r
WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1
LIMIT 1;
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsregproofreadact01','mxpsregproofread00001','校对环节同步','REGISTRY_STAGE_SYNC',
'{"visualType":"REGISTRY_STAGE_SYNC","stage":"proofread","expectedFrom":"compile"}',
1,'stop',1,0,'admin',NOW());
-- 密炼PS 审核环节通过
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
SELECT
'mxpsregaudit00000002','mixer_ps_reg_audit','密炼PS-审核通过(注册中心同步)',
'mes_xsl_mixer_ps_compile', r.id, 'onNodeApprove', 'audit', 'async', '0',
'无需Java回调更新status=audit并写审核人/时间+审批痕迹', 0, 0, 'admin', NOW()
FROM `mes_xsl_biz_doc_registry` r
WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1
LIMIT 1;
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsregauditact00001','mxpsregaudit00000002','审核环节同步','REGISTRY_STAGE_SYNC',
'{"visualType":"REGISTRY_STAGE_SYNC","stage":"audit","expectedFrom":"proofread"}',
1,'stop',1,0,'admin',NOW());
-- 密炼PS 批准(全流程通过)
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
SELECT
'mxpsregapprove000003','mixer_ps_reg_approve','密炼PS-批准通过(注册中心同步)',
'mes_xsl_mixer_ps_compile', r.id, 'onApprove', 'approve', 'async', '0',
'无需Java回调更新status=approve并写批准人/时间+审批痕迹', 0, 0, 'admin', NOW()
FROM `mes_xsl_biz_doc_registry` r
WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1
LIMIT 1;
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsregapproveact01','mxpsregapprove000003','批准环节同步','REGISTRY_STAGE_SYNC',
'{"visualType":"REGISTRY_STAGE_SYNC","stage":"approve","expectedFrom":"audit"}',
1,'stop',1,0,'admin',NOW());
-- 密炼PS 驳回回退编制
INSERT IGNORE INTO `mes_xsl_integration_plan`
(`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`)
SELECT
'mxpsregreject000004','mixer_ps_reg_reject','密炼PS-驳回回退(注册中心同步)',
'mes_xsl_mixer_ps_compile', r.id, 'onReject', NULL, 'async', '0',
'无需Java回调回退status=compile并清空环节痕迹', 0, 0, 'admin', NOW()
FROM `mes_xsl_biz_doc_registry` r
WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1
LIMIT 1;
INSERT IGNORE INTO `mes_xsl_integration_action`
(`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`)
VALUES
('mxpsregrejectact001','mxpsregreject000004','驳回回退编制','REGISTRY_STAGE_REVERT',
'{"visualType":"REGISTRY_STAGE_REVERT","targetStage":"compile"}',
1,'stop',1,0,'admin',NOW());

View File

@@ -2,8 +2,8 @@
全局审批流程设计悬浮按钮
拥有 approval:flow:design 权限的用户在任意功能页点击即可
1后端按当前页路由反查绑定的业务表
2解析该表字段识别校对/审核/审批/分发/抄送等阶段字段不存在不报错
3进入可视化设计器可点选识别到的阶段字段按顺序生成审批流程并保存发布
2从审批注册中心读取该单据已启用的审批环节
3进入可视化设计器可点选启用环节按顺序生成审批节点也支持手动添加节点
@author GHT
@date 2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮
-->

View File

@@ -36,7 +36,13 @@
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './approvalFlow.data';
import { getApprovalFlowList, deleteApprovalFlow, batchDeleteApprovalFlow, updateApprovalFlowStatus } from './approvalFlow.api';
import {
getApprovalFlowList,
deleteApprovalFlow,
batchDeleteApprovalFlow,
updateApprovalFlowStatus,
getApprovalRegistryStages,
} from './approvalFlow.api';
import ApprovalFlowModal from './ApprovalFlowModal.vue';
import FlowDesign from './components/FlowDesign.vue';
@@ -73,9 +79,17 @@
openModal(true, { isUpdate: true, record });
}
// 打开可视化设计器
function handleDesign(record, readonly = false) {
openDesign(true, { record, readonly });
// 打开可视化设计器(加载审批注册中心启用环节)
async function handleDesign(record, readonly = false) {
let paletteStages: any[] = [];
if (record?.bizTable) {
try {
paletteStages = (await getApprovalRegistryStages(record.bizTable)) || [];
} catch {
paletteStages = [];
}
}
openDesign(true, { record, readonly, paletteStages });
}
function handleDelete(record) {

View File

@@ -16,6 +16,7 @@ enum Api {
deleteBatch = '/xslmes/approvalFlow/deleteBatch',
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
designContext = '/xslmes/approvalFlow/designContext',
registryStages = '/xslmes/approvalFlow/registryStages',
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
bizActions = '/xslmes/approvalFlow/bizActions',
@@ -72,9 +73,12 @@ export const batchDeleteApprovalFlow = (params, handleSuccess) => {
/**
* 获取当前功能页的审批流设计上下文:
* 返回 { routePath, bizTable, bizTableName, stages[], flow }
* stages 为识别到的阶段字段(校对/审核/审批/分发/抄送)flow 为可直接进入设计器的流程记录。
* stages 为审批注册中心已启用环节flow 为可直接进入设计器的流程记录。
*/
export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } });
/** 按业务表查询审批注册中心启用环节(列表页「设计」入口用) */
export const getApprovalRegistryStages = (bizTable: string) => defHttp.get<any[]>({ url: Api.registryStages, params: { bizTable } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作(后端@ApprovalBizAction注解扫描)-----
@@ -84,3 +88,12 @@ export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url
*/
export const getApprovalBizActions = (table: string) => defHttp.get({ url: Api.bizActions, params: { table } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案-----
/** 查询某业务表、某触发时机下已发布的集成方案,供节点配置下拉 */
export const listPublishedIntegrationPlans = (params: { sourceTable: string; triggerPhase: string }) =>
defHttp.get({
url: '/xslmes/mesXslIntegrationPlan/list',
params: { ...params, status: '1', pageNo: 1, pageSize: 200 },
});
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案-----

View File

@@ -11,12 +11,17 @@
<span class="fd-tb-item">审批流<b>{{ record.flowName }}</b></span>
<span class="fd-tb-tip">点击节点可配置点击节点间的+可插入审批人 / 抄送人 / 条件分支</span>
</div>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计审批注册中心启用环节作为候选,点选追加为流程节点----- -->
<div class="fd-body">
<div class="fd-palette" v-if="!readonly && paletteStages.length">
<div class="fd-palette-title">当前页识别到的审批阶段</div>
<div class="fd-palette-tip">点击下方阶段按顺序追加到流程末尾处理人将取自单据对应字段</div>
<div class="fd-palette-list">
<div class="fd-palette" v-if="!readonly">
<div class="fd-palette-title">审批注册中心启用环节</div>
<div class="fd-palette-tip" v-if="paletteStages.length">
点击下方环节按顺序追加到流程末尾处理人取自注册中心配置的人员字段
</div>
<div class="fd-palette-tip fd-palette-empty" v-else>
该单据未在审批注册中心配置启用环节您仍可通过节点间+手动添加审批人节点仅发起审批时生效
</div>
<div class="fd-palette-list" v-if="paletteStages.length">
<div v-for="s in paletteStages" :key="s.stageKey" class="fd-palette-item" :class="'fd-palette-' + s.nodeType" @click="appendStageNode(s)">
<div class="fd-palette-item-name">{{ s.stageName }}</div>
<div class="fd-palette-item-field">{{ s.fieldComment || s.field }}</div>
@@ -34,7 +39,7 @@
</div>
</div>
</div>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计审批注册中心启用环节作为候选,点选追加为流程节点----- -->
</div>
<NodeConfigDrawer ref="drawerRef" :readonly="readonly" />
</BasicModal>
@@ -71,9 +76,10 @@
// 当前审批流绑定的业务表,供节点配置按表查可选回调动作
const bizTableRef = ref('');
provide('approvalBizTable', bizTableRef);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
provide('approvalFlowRoot', root);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批注册中心候选环节----- -->
const paletteStages = ref<StageField[]>([]);
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批注册中心候选环节----- -->
const modalTitle = computed(() => (readonly.value ? '查看审批流' : '设计审批流'));
@@ -108,9 +114,9 @@
readonly.value = !!data?.readonly;
flowCtx.readonly = readonly.value;
bizTableRef.value = data?.record?.bizTable || '';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收审批注册中心候选环节----- -->
paletteStages.value = Array.isArray(data?.paletteStages) ? data.paletteStages : [];
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收审批注册中心候选环节----- -->
const id = data?.record?.id || '';
Object.assign(record, {
id,

View File

@@ -87,48 +87,76 @@
</a-radio-group>
</a-form-item>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(自动识别页面按钮)----- -->
<a-divider style="margin: 16px 0 12px">回调接口审批联动业务</a-divider>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R3新增绑定审批环节配置区分关键节点与纯过路审批节点----- -->
<a-divider style="margin: 16px 0 12px">审批环节绑定</a-divider>
<a-form-item label="绑定审批环节">
<a-select
v-model:value="form.props.stageKey"
:disabled="readonly"
allow-clear
placeholder="未设置(按节点名/单据状态自动推断)"
style="width: 260px"
@change="onStageKeyChange"
>
<a-select-option value="">不绑定纯过路审批不改变单据状态</a-select-option>
<a-select-option value="proofread">校对</a-select-option>
<a-select-option value="audit">审核</a-select-option>
<a-select-option value="approve">批准</a-select-option>
</a-select>
<div style="font-size: 12px; color: #888; margin-top: 4px; line-height: 1.5">
<span v-if="form.props.stageKey == null || form.props.stageKey === undefined">
未设置钉钉回调时按节点名称或单据状态自动匹配旧版兼容模式
</span>
<span v-else-if="form.props.stageKey === ''" style="color: #ff7a00">
纯过路审批此节点通过后不触发任何集成动作不改变单据状态
</span>
<span v-else style="color: #389e0d">
关键节点此节点通过后触发{{ stageKeyLabel(form.props.stageKey) }}环节的集成方案
</span>
</div>
</a-form-item>
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R3新增绑定审批环节配置区分关键节点与纯过路审批节点----- -->
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2节点改绑集成方案,停用 HTTP 回调接口配置----- -->
<a-divider style="margin: 16px 0 12px">集成方案审批联动业务</a-divider>
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
:message="
pageActionOptions.length
? '以下为该业务已标注(@ApprovalBizAction的可选接口。审批到对应时机时系统会以「当前处理人」身份调用所选接口自动带上单据ID。'
: '该业务暂未标注可选接口(在后端 Controller 方法上加 @ApprovalBizAction 注解即可出现在此),也可手动填写接口路径。'
"
message="审批到对应时机时,由「集成方案管理」中已发布方案自动执行业务效果(如环节状态同步)。此处选择便于设计与核对;实际执行按 source_table + trigger_phase + trigger_stage 匹配。"
/>
<div v-for="phase in callbackPhases" :key="phase.key" class="fd-cb-block">
<div class="fd-cb-title">{{ phase.label }}</div>
<div v-for="(a, i) in form.props.callbackActions[phase.key]" :key="i" class="fd-cb-row">
<a-input v-model:value="a.name" placeholder="动作名" :disabled="readonly" style="width: 88px" />
<a-select v-model:value="a.method" :disabled="readonly" style="width: 82px" :options="methodOptions" />
<a-input v-model:value="a.url" placeholder="接口路径 /xxx" :disabled="readonly" style="flex: 1; min-width: 120px" />
<Icon v-if="!readonly" icon="ant-design:minus-circle-outlined" class="fd-cb-del" @click="removeAction(phase.key, i)" />
</div>
<a-space v-if="!readonly" style="margin-top: 4px" :size="6" wrap>
<a-select
v-if="pageActionOptions.length"
style="width: 240px"
placeholder="从页面按钮中选择…"
:options="pageActionOptions"
v-model:value="actionPicker[phase.key]"
show-search
option-filter-prop="label"
@select="(v) => addFromButton(phase.key, v)"
/>
<a-button size="small" @click="addAction(phase.key)">手动添加</a-button>
</a-space>
<a-alert
v-if="isLastApproverNode"
type="warning"
show-icon
style="margin-bottom: 12px"
message="当前为流程最后一个审批节点:「批准」类方案触发时机为「审批通过」,已合并到下方下拉(带 [流程最终通过] 前缀)。"
/>
<div class="fd-ip-block">
<div class="fd-ip-title">{{ isLastApproverNode ? '本节点通过 / 流程最终通过时执行' : '本节点通过时执行' }}</div>
<a-select
:value="primaryPlanValue"
:disabled="readonly"
allow-clear
show-search
option-filter-prop="label"
placeholder="选择已发布的集成方案(可留空,由引擎自动匹配)"
style="width: 100%"
:options="primaryPlanOptions"
@change="onPrimaryPlanChange"
/>
</div>
<div v-if="!isLastApproverNode && planOptions.onApprove.length" class="fd-ip-block">
<div class="fd-ip-title">流程最终通过时执行</div>
<a-alert type="info" show-icon style="margin-bottom: 8px" message="仅流程最后一个审批节点需要配置;中间节点请只配置「本节点通过」。" />
</div>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
<a-alert
type="success"
show-icon
style="margin-top: 4px"
message="驳回 / 撤销 已全局统一:系统会自动执行该业务标注为「驳回时执行(@ApprovalBizAction onReject)」的接口完成回退,无需在此逐节点、逐流程配置。"
style="margin-top: 8px"
message="驳回 / 撤销:在「集成方案管理」配置 trigger_phase=onReject 的方案(如 REGISTRY_STAGE_REVERT无需在节点单独配置。"
/>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2节点改绑集成方案,停用 HTTP 回调接口配置----- -->
</template>
<!-- 抄送人 -->
@@ -200,55 +228,132 @@
</template>
<script lang="ts" setup>
import { computed, ref, inject, watch, nextTick } from 'vue';
import { computed, ref, inject, watch, nextTick, reactive } from 'vue';
import type { Ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
import { ApiSelect } from '/@/components/Form';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { OPERATOR_OPTIONS } from './flowTypes';
import { OPERATOR_OPTIONS, collectApproverNodes } from './flowTypes';
import type { FlowNode } from './flowTypes';
import { useMessage } from '/@/hooks/web/useMessage';
import { getApprovalBizActions } from '../approvalFlow.api';
import { listPublishedIntegrationPlans } from '../approvalFlow.api';
const props = defineProps<{ readonly?: boolean }>();
const emit = defineEmits(['confirm']);
const { createMessage } = useMessage();
// 当前审批流绑定的业务表(由 FlowDesign 注入),据此向后端查可选回调动作
const bizTable = inject<Ref<string>>('approvalBizTable', ref(''));
// 后端 @ApprovalBizAction 标注的可选业务动作
const bizActions = ref<any[]>([]);
const bizActionsTable = ref('');
const flowRoot = inject<Ref<FlowNode | null>>('approvalFlowRoot', ref(null));
const integrationPlansCacheKey = ref('');
async function loadBizActions() {
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const integrationPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
];
const planOptions = reactive<Record<string, { label: string; value: string }[]>>({
onNodeApprove: [],
onApprove: [],
});
const isLastApproverNode = computed(() => {
if (!node.value || node.value.type !== 'approver') return false;
const approvers = collectApproverNodes(flowRoot.value);
return approvers.length > 0 && approvers[approvers.length - 1].id === node.value.id;
});
const primaryPlanOptions = computed(() => {
const opts = (planOptions.onNodeApprove || []).map((o) => ({
label: o.label,
value: `onNodeApprove:${o.value}`,
}));
if (isLastApproverNode.value) {
(planOptions.onApprove || []).forEach((o) => {
opts.push({
label: `[流程最终通过] ${o.label}`,
value: `onApprove:${o.value}`,
});
});
}
return opts;
});
const primaryPlanValue = computed(() => {
const ip = form.value?.props?.integrationPlans;
if (!ip) return undefined;
if (ip.onNodeApprove) return `onNodeApprove:${ip.onNodeApprove}`;
if (isLastApproverNode.value && ip.onApprove) return `onApprove:${ip.onApprove}`;
return undefined;
});
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R3】绑定审批环节辅助函数-----
function stageKeyLabel(key: string): string {
const map: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
return map[key] || key;
}
function onStageKeyChange(val: string | undefined) {
if (!form.value) return;
// allow-clear 触发时 val=undefined表示「未设置」null → 启发式兼容)
// 选了空串选项表示「明确不绑定」("" → 纯过路审批)
form.value.props.stageKey = val === undefined ? undefined : val;
}
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R3】绑定审批环节辅助函数-----
function onPrimaryPlanChange(val?: string) {
if (!form.value) return;
form.value.props.integrationPlans.onNodeApprove = undefined;
form.value.props.integrationPlans.onApprove = undefined;
if (!val) return;
const sep = val.indexOf(':');
if (sep <= 0) return;
const phase = val.slice(0, sep);
const planId = val.slice(sep + 1);
form.value.props.integrationPlans[phase] = planId;
}
async function loadIntegrationPlans() {
const table = bizTable.value || '';
if (!table || bizActionsTable.value === table) {
if (!table) {
planOptions.onNodeApprove = [];
planOptions.onApprove = [];
return;
}
const cacheKey = table;
if (integrationPlansCacheKey.value === cacheKey) {
return;
}
try {
const res = await getApprovalBizActions(table);
bizActions.value = Array.isArray(res) ? res : [];
bizActionsTable.value = table;
} catch (e) {
bizActions.value = [];
for (const phase of integrationPhases) {
const res = await listPublishedIntegrationPlans({ sourceTable: table, triggerPhase: phase.key });
const records = res?.records ?? [];
planOptions[phase.key] = records.map((p: any) => ({
label: formatPlanLabel(p),
value: p.id,
}));
}
integrationPlansCacheKey.value = cacheKey;
} catch (_e) {
planOptions.onNodeApprove = [];
planOptions.onApprove = [];
}
}
// 可选动作下拉项(含真实 url/method
const pageActionOptions = computed(() =>
(bizActions.value || []).map((a) => ({
label: `${a.name}${a.method} ${a.url}`,
value: a.url,
raw: a,
})),
);
function formatPlanLabel(plan: any) {
const stage = plan?.triggerStage ? STAGE_LABELS[plan.triggerStage] || plan.triggerStage : '';
const code = plan?.planCode ? ` [${plan.planCode}]` : '';
return `${plan.planName || plan.planCode || plan.id}${stage ? '' + stage + '' : ''}${code}`;
}
watch(
bizTable,
() => {
loadBizActions();
integrationPlansCacheKey.value = '';
loadIntegrationPlans();
},
{ immediate: true },
);
@@ -256,24 +361,10 @@
const open = ref(false);
const node = ref<FlowNode | null>(null);
const form = ref<any>(null);
// 「从页面按钮中选择」下拉的临时选中值(仅作选择器,选完即清空,避免跨节点残留上次的选项)
const actionPicker = ref<Record<string, any>>({ onNodeApprove: undefined, onApprove: undefined, onReject: undefined });
const operatorOptions = OPERATOR_OPTIONS;
const readonly = computed(() => !!props.readonly);
// 回调接口配置:通过类时机需按节点配置;驳回类(onReject)已全局统一(后端按 @ApprovalBizAction 自动执行),无需在此逐节点维护
const callbackPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
];
const methodOptions = [
{ label: 'POST', value: 'POST' },
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
];
const title = computed(() => {
const map: Record<string, string> = { start: '发起人设置', approver: '审批人设置', cc: '抄送人设置', branch: '条件设置' };
return map[node.value?.type || ''] || '节点设置';
@@ -283,48 +374,19 @@
function openDrawer(n: FlowNode) {
node.value = n;
// 切换节点时清空下拉选择器的残留值
actionPicker.value = { onNodeApprove: undefined, onApprove: undefined, onReject: undefined };
// 编辑副本,确定时回写,避免取消后脏数据
form.value = { name: n.name, props: cloneDeep(n.props) };
// 审批人节点确保回调接口配置结构存在
if (n.type === 'approver') {
const cb = form.value.props.callbackActions || {};
form.value.props.callbackActions = {
onNodeApprove: Array.isArray(cb.onNodeApprove) ? cb.onNodeApprove : [],
onApprove: Array.isArray(cb.onApprove) ? cb.onApprove : [],
onReject: Array.isArray(cb.onReject) ? cb.onReject : [],
const ip = form.value.props.integrationPlans || {};
form.value.props.integrationPlans = {
onNodeApprove: ip.onNodeApprove || undefined,
onApprove: ip.onApprove || undefined,
};
// 清空历史 HTTP 回调配置,避免与集成方案双写
form.value.props.callbackActions = { onNodeApprove: [], onApprove: [], onReject: [] };
}
open.value = true;
}
function addAction(phaseKey: string) {
form.value.props.callbackActions[phaseKey].push({ name: '', method: 'POST', url: '' });
}
function removeAction(phaseKey: string, i: number) {
form.value.props.callbackActions[phaseKey].splice(i, 1);
}
/** 从「已标注的业务动作」中选择一个接口加入回调(按 url 值查回原始动作) */
function addFromButton(phaseKey: string, url: string) {
const opt = pageActionOptions.value.find((o) => o.value === url);
const raw = opt?.raw;
if (!raw || !raw.url) {
return;
}
const list = form.value.props.callbackActions[phaseKey];
// 选完即清空下拉,使其回到 placeholder仅作选择器用
actionPicker.value[phaseKey] = undefined;
if (list.some((a) => a.url === raw.url)) {
createMessage.info('该接口已添加');
return;
}
list.push({ name: raw.name, method: raw.method || 'POST', url: raw.url });
}
/** 切换审批方式时,若切为单人则自动裁剪 userText 至第一个 */
function onMultiModeChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
@@ -332,7 +394,6 @@
}
}
/** userText 变化时,若当前是单人模式则裁剪 */
function onUserTextChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
@@ -355,7 +416,6 @@
function onConfirm() {
if (node.value && form.value) {
// 单人审批最终兜底校验
if (
node.value.type === 'approver' &&
form.value.props.multiMode === 'none' &&
@@ -397,29 +457,17 @@
cursor: pointer;
}
/* 回调接口配置 */
.fd-cb-block {
.fd-ip-block {
margin-bottom: 14px;
padding: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
}
.fd-cb-title {
.fd-ip-title {
font-size: 13px;
font-weight: 500;
color: #595959;
margin-bottom: 8px;
}
.fd-cb-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.fd-cb-del {
color: #ff4d4f;
cursor: pointer;
flex-shrink: 0;
}
</style>

View File

@@ -56,6 +56,14 @@
color: #999;
line-height: 1.5;
margin-bottom: 12px;
&.fd-palette-empty {
color: #d48806;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
padding: 8px 10px;
}
}
.fd-palette-list {

View File

@@ -81,8 +81,8 @@ export function createCcNode(): FlowNode {
};
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
/** 解析出的页面阶段字段 */
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按审批注册中心启用环节生成审批阶段节点-----
/** 审批注册中心启用环节 */
export interface StageField {
stageKey: string;
stageName: string;
@@ -92,9 +92,8 @@ export interface StageField {
}
/**
* 由"当前页字段"生成阶段节点:
* 审批阶段(校对/审核/审批/分发) -> 审批人节点,处理人=取单据该字段中的人员;
* 抄送阶段 -> 抄送节点,抄送人=取单据该字段中的人员。
* 由审批注册中心启用环节生成阶段节点:
* 审批环节(校对/审核/批准) -> 审批人节点,处理人=取注册中心配置的人员字段。
*/
export function createStageNode(stage: StageField): FlowNode {
const fieldLabel = stage.fieldComment || stage.field;
@@ -111,9 +110,10 @@ export function createStageNode(stage: StageField): FlowNode {
node.props.approverType = 'field';
node.props.fieldName = stage.field;
node.props.fieldLabel = fieldLabel;
node.props.stageKey = stage.stageKey;
return node;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按审批注册中心启用环节生成审批阶段节点-----
/** 条件分支节点(默认两条分支:条件 + 其它情况) */
export function createConditionNode(): FlowNode {
@@ -202,6 +202,25 @@ function operatorText(op: string): string {
return OPERATOR_OPTIONS.find((o) => o.value === op)?.label || op;
}
/** 按主流程顺序收集审批人节点(不含条件分支内的审批人) */
export function collectApproverNodes(node: FlowNode | null | undefined): FlowNode[] {
const result: FlowNode[] = [];
let current = node;
while (current) {
if (current.type === 'approver') {
result.push(current);
}
if (current.type === 'condition' && current.conditionNodes?.length) {
const firstBranch = current.conditionNodes[0];
if (firstBranch?.childNode) {
result.push(...collectApproverNodes(firstBranch.childNode));
}
}
current = current.childNode || null;
}
return result;
}
/** 深度遍历每个节点(含分支) */
export function eachNode(node: FlowNode | null | undefined, cb: (n: FlowNode) => void) {
if (!node) return;

View File

@@ -0,0 +1,15 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslApprovalTrace/list',
queryById = '/xslmes/mesXslApprovalTrace/queryById',
queryByBiz = '/xslmes/mesXslApprovalTrace/queryByBiz',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const queryById = (params: { id: string }) => defHttp.get({ url: Api.queryById, params });
/** 按业务表 + 单据ID 查询痕迹(供业务页关联展示) */
export const queryByBiz = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.queryByBiz, params });

View File

@@ -0,0 +1,25 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务表', dataIndex: 'bizTable', width: 200, align: 'left', ellipsis: true },
{ title: '单据ID', dataIndex: 'bizDataId', width: 200, align: 'left', ellipsis: true },
{ title: '校对人', dataIndex: 'proofreadBy', width: 100 },
{ title: '校对时间', dataIndex: 'proofreadTime', width: 165 },
{ title: '审核人', dataIndex: 'auditBy', width: 100 },
{ title: '审核时间', dataIndex: 'auditTime', width: 165 },
{ title: '批准人', dataIndex: 'approveBy', width: 100 },
{ title: '批准时间', dataIndex: 'approveTime', width: 165 },
{ title: '更新时间', dataIndex: 'updateTime', width: 165 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务表', field: 'bizTable', component: 'JInput', colProps: { span: 8 } },
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 8 } },
];
/** 注册中心抽屉内明细列表(已按业务表过滤,不重复展示业务表列) */
export const drawerColumns: BasicColumn[] = columns.filter((col) => col.dataIndex !== 'bizTable');
export const drawerSearchFormSchema: FormSchema[] = [
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 12 } },
];

View File

@@ -0,0 +1,42 @@
<template>
<div>
<BasicTable @register="registerTable" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslApprovalTrace" setup>
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, searchFormSchema } from './MesXslApprovalTrace.data';
import { list } from './MesXslApprovalTrace.api';
const route = useRoute();
const { tableContext } = useListPage({
tableProps: {
title: '审批痕迹',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 0, ifShow: false },
},
});
const [registerTable, { getForm, reload }] = tableContext;
onMounted(async () => {
const bizTable = route.query.bizTable as string;
if (bizTable) {
const form = await getForm();
await form.setFieldsValue({ bizTable });
reload();
}
});
</script>

View File

@@ -0,0 +1,32 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslBizDocRegistry/list',
save = '/xslmes/mesXslBizDocRegistry/add',
edit = '/xslmes/mesXslBizDocRegistry/edit',
deleteOne = '/xslmes/mesXslBizDocRegistry/delete',
deleteBatch = '/xslmes/mesXslBizDocRegistry/deleteBatch',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const saveOrUpdate = (params, isUpdate) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params });
export const deleteOne = (params, handleSuccess) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess());
export const batchDelete = (params, handleSuccess) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除选中数据',
okText: '确认',
cancelText: '取消',
onOk: () =>
defHttp.delete({ url: Api.deleteBatch, params }, { joinParamsToUrl: true }).then(() => handleSuccess()),
});
};

View File

@@ -0,0 +1,134 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
const STAGE_DICT = 'mes_xsl_approval_stage';
function hasStage(values: Recordable, stage: string) {
const raw = values?.enabledStages;
if (!raw) return false;
if (Array.isArray(raw)) return raw.includes(stage);
return String(raw).split(',').includes(stage);
}
export const columns: BasicColumn[] = [
{ title: '业务编码', dataIndex: 'docCode', width: 140, align: 'left' },
{ title: '物理表名', dataIndex: 'tableName', width: 200, align: 'left' },
{ title: '中文名称', dataIndex: 'displayName', width: 140 },
{ title: '启用环节', dataIndex: 'enabledStages_dictText', width: 180, ellipsis: true },
{ title: '启用', dataIndex: 'enabled_dictText', width: 70 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务编码', field: 'docCode', component: 'JInput', colProps: { span: 6 } },
{ label: '表名/中文名', field: 'displayName', component: 'JInput', colProps: { span: 6 } },
{
label: '启用',
field: 'enabled',
component: 'JDictSelectTag',
componentProps: { dictCode: 'yn' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '业务编码',
field: 'docCode',
component: 'Input',
componentProps: { placeholder: '唯一标识,如 mixer_ps_compile' },
dynamicRules: () => [{ required: true, message: '请输入业务编码!' }],
},
{
label: '物理表名',
field: 'tableName',
component: 'Input',
componentProps: { placeholder: '数据库表名,如 mes_xsl_mixer_ps_compile' },
dynamicRules: () => [{ required: true, message: '请输入物理表名!' }],
},
{
label: '中文名称',
field: 'displayName',
component: 'Input',
componentProps: { placeholder: '如 密炼PS编制' },
},
{
label: '启用',
field: 'enabled',
component: 'Switch',
defaultValue: 1,
componentProps: {
checkedValue: 1,
unCheckedValue: 0,
checkedChildren: '是',
unCheckedChildren: '否',
},
},
{
label: '启用环节',
field: 'enabledStages',
component: 'JDictSelectTag',
componentProps: {
dictCode: STAGE_DICT,
mode: 'multiple',
placeholder: '多选:校对 / 审核 / 批准',
},
helpMessage: '勾选后该业务表才允许执行对应环节,并写入审批痕迹明细',
},
{
label: '状态字段',
field: 'statusField',
component: 'Input',
defaultValue: 'status',
componentProps: { placeholder: '默认 status' },
},
{
label: '校对人字段',
field: 'proofreadByField',
component: 'Input',
defaultValue: 'proofread_by',
ifShow: ({ values }) => hasStage(values, 'proofread'),
},
{
label: '校对时间字段',
field: 'proofreadTimeField',
component: 'Input',
defaultValue: 'proofread_time',
ifShow: ({ values }) => hasStage(values, 'proofread'),
},
{
label: '审核人字段',
field: 'auditByField',
component: 'Input',
defaultValue: 'audit_by',
ifShow: ({ values }) => hasStage(values, 'audit'),
},
{
label: '审核时间字段',
field: 'auditTimeField',
component: 'Input',
defaultValue: 'audit_time',
ifShow: ({ values }) => hasStage(values, 'audit'),
},
{
label: '批准人字段',
field: 'approveByField',
component: 'Input',
defaultValue: 'approve_by',
ifShow: ({ values }) => hasStage(values, 'approve'),
},
{
label: '批准时间字段',
field: 'approveTimeField',
component: 'Input',
defaultValue: 'approve_time',
ifShow: ({ values }) => hasStage(values, 'approve'),
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { rows: 3 },
},
];

View File

@@ -0,0 +1,104 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_biz_doc_registry:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
新增
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined" />
删除
</a-menu-item>
</a-menu>
</template>
<a-button v-auth="'xslmes:mes_xsl_biz_doc_registry:delete'">
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_biz_doc_registry:edit' },
{
label: '查看明细',
onClick: handleViewDetail.bind(null, record),
auth: 'xslmes:mes_xsl_biz_doc_registry:trace',
},
{
label: '删除',
auth: 'xslmes:mes_xsl_biz_doc_registry:delete',
popConfirm: {
title: '确认删除该审批注册?',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
},
]"
/>
</template>
</BasicTable>
<MesXslBizDocRegistryModal @register="registerModal" @success="handleSuccess" />
<MesXslApprovalTraceDrawer @register="registerTraceDrawer" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslBizDocRegistry" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useDrawer } from '/@/components/Drawer';
import { useListPage } from '/@/hooks/system/useListPage';
import Icon from '/@/components/Icon';
import MesXslBizDocRegistryModal from './components/MesXslBizDocRegistryModal.vue';
import MesXslApprovalTraceDrawer from './components/MesXslApprovalTraceDrawer.vue';
import { columns, searchFormSchema } from './MesXslBizDocRegistry.data';
import { list, deleteOne, batchDelete } from './MesXslBizDocRegistry.api';
const [registerModal, { openModal }] = useModal();
const [registerTraceDrawer, { openDrawer: openTraceDrawer }] = useDrawer();
const { tableContext } = useListPage({
tableProps: {
title: '审批注册中心',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 220, fixed: 'right' },
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
function handleAdd() {
openModal(true, { isUpdate: false });
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true });
}
function handleViewDetail(record: Recordable) {
openTraceDrawer(true, { record });
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, handleSuccess);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
</script>

View File

@@ -0,0 +1,9 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslIntegrationLog/list',
retry = '/xslmes/mesXslIntegrationLog/retry',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const retry = (id) => defHttp.post({ url: Api.retry, params: { id } }, { joinParamsToUrl: true });

View File

@@ -0,0 +1,30 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '源单表', dataIndex: 'sourceBizTable', width: 180, align: 'left' },
{ title: '源单ID', dataIndex: 'sourceBizId', width: 120 },
{ title: '状态', dataIndex: 'status_dictText', width: 90 },
{ title: '耗时(ms)', dataIndex: 'execTimeMs', width: 90 },
{ title: '重试次数', dataIndex: 'retryCount', width: 80 },
{ title: '错误信息', dataIndex: 'errorMessage', ellipsis: true },
{ title: '幂等键', dataIndex: 'idempotentKey', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '源单表', field: 'sourceBizTable', component: 'JInput', colProps: { span: 6 } },
{ label: '源单ID', field: 'sourceBizId', component: 'JInput', colProps: { span: 6 } },
{
label: '执行状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_log_status' },
colProps: { span: 6 },
},
{
label: '创建时间',
field: 'createTime',
component: 'RangePicker',
colProps: { span: 8 },
},
];

View File

@@ -0,0 +1,87 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #action="{ record }">
<TableAction
:actions="[
{
label: '重试',
icon: 'ant-design:redo-outlined',
auth: 'xslmes:mes_xsl_integration_log:retry',
disabled: record.status === 'success',
tooltip: record.status === 'success' ? '已成功,无需重试' : '重新执行该动作',
popConfirm: {
title: '确认重试该集成动作?',
confirm: handleRetry.bind(null, record),
placement: 'topLeft',
},
},
]"
/>
</template>
<!-- 详情展开 -->
<template #expandedRowRender="{ record }">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="方案ID">{{ record.planId || '—' }}</a-descriptions-item>
<a-descriptions-item label="动作ID">{{ record.actionId || '—' }}</a-descriptions-item>
<a-descriptions-item label="台账ID">{{ record.recordId || '—' }}</a-descriptions-item>
<a-descriptions-item label="幂等键">{{ record.idempotentKey || '—' }}</a-descriptions-item>
<a-descriptions-item label="错误信息" :span="2">
<span style="color: #f5222d; white-space: pre-wrap">{{ record.errorMessage || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="请求快照" :span="2">
<pre style="margin: 0; font-size: 12px; max-height: 100px; overflow: auto">{{ record.requestSnapshot || '—' }}</pre>
</a-descriptions-item>
<a-descriptions-item label="响应快照" :span="2">
<pre style="margin: 0; font-size: 12px; max-height: 100px; overflow: auto">{{ record.responseSnapshot || '—' }}</pre>
</a-descriptions-item>
</a-descriptions>
</template>
</BasicTable>
</div>
</template>
<script lang="ts" name="xslmes-mesXslIntegrationLog" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './MesXslIntegrationLog.data';
import { list, retry } from './MesXslIntegrationLog.api';
const { createMessage } = useMessage();
const { tableContext } = useListPage({
tableProps: {
title: '集成执行日志',
api: list,
columns,
canResize: true,
expandRowByClick: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
labelWidth: 80,
},
actionColumn: { width: 90, fixed: 'right' },
},
});
const [registerTable, { reload }] = tableContext;
async function handleRetry(record: Recordable) {
try {
await retry(record.id);
createMessage.success('重试任务已提交');
reload();
} catch (e: any) {
createMessage.error(e?.message || '重试失败');
}
}
</script>
<style lang="less" scoped>
:deep(.ant-picker-range) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,57 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslIntegrationPlan/list',
save = '/xslmes/mesXslIntegrationPlan/add',
edit = '/xslmes/mesXslIntegrationPlan/edit',
deleteOne = '/xslmes/mesXslIntegrationPlan/delete',
publish = '/xslmes/mesXslIntegrationPlan/publish',
disable = '/xslmes/mesXslIntegrationPlan/disable',
tableColumns = '/xslmes/mesXslIntegrationPlan/tableColumns',
actionList = '/xslmes/mesXslIntegrationPlan/action/listByPlanId',
actionAdd = '/xslmes/mesXslIntegrationPlan/action/add',
actionEdit = '/xslmes/mesXslIntegrationPlan/action/edit',
actionDelete = '/xslmes/mesXslIntegrationPlan/action/delete',
bizDocList = '/xslmes/mesXslBizDocRegistry/list',
registryByTable = '/xslmes/mesXslIntegrationPlan/registryByTable',
previewDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/previewDefaultFromFlow',
generateDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/generateDefaultFromFlow',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const saveOrUpdate = (params, isUpdate) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params });
export const deleteOne = (params, handleSuccess) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess());
export const publishPlan = (id) => defHttp.post({ url: Api.publish, params: { id } }, { joinParamsToUrl: true });
export const disablePlan = (id) => defHttp.post({ url: Api.disable, params: { id } }, { joinParamsToUrl: true });
// 可视化向导专用
export const savePlan = (params) => defHttp.post<any>({ url: Api.save, params });
export const getTableColumns = (tableName: string) => defHttp.get<any[]>({ url: Api.tableColumns, params: { tableName } });
export const listBizDocRegistry = () => defHttp.get<any>({ url: Api.bizDocList, params: { pageNo: 1, pageSize: 200 } });
export const getRegistryByTable = (tableName: string) => defHttp.get<any>({ url: Api.registryByTable, params: { tableName } });
export const getDictItems = (dictCode: string) => defHttp.get<any[]>({ url: `/sys/dict/getDictItems/${dictCode}` });
export const previewDefaultFromFlow = (params: { sourceTable: string; flowId?: string }) =>
defHttp.get<any>({ url: Api.previewDefaultFromFlow, params });
export const generateDefaultFromFlow = (params: {
sourceTable: string;
flowId?: string;
overwriteDraft?: boolean;
nodeBindings?: Array<{ nodeId: string; stage?: string | null }>;
}) => defHttp.post<any>({ url: Api.generateDefaultFromFlow, params });
// 动作管理
export const listActions = (planId) => defHttp.get({ url: Api.actionList, params: { planId } });
export const saveAction = (params) => defHttp.post({ url: Api.actionAdd, params });
export const editAction = (params) => defHttp.put({ url: Api.actionEdit, params });
export const deleteAction = (params, handleSuccess) =>
defHttp.delete({ url: Api.actionDelete, params }, { joinParamsToUrl: true }).then(() => handleSuccess());

View File

@@ -0,0 +1,156 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '方案编码', dataIndex: 'planCode', width: 150, align: 'left' },
{ title: '方案名称', dataIndex: 'planName', width: 180, align: 'left' },
{ title: '源单表', dataIndex: 'sourceTable', width: 200, align: 'left' },
{ title: '触发时机', dataIndex: 'triggerPhase_dictText', width: 110 },
{ title: '绑定环节', dataIndex: 'triggerStage_dictText', width: 90 },
{ title: '执行模式', dataIndex: 'execMode_dictText', width: 100 },
{ title: '状态', dataIndex: 'status_dictText', width: 90 },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '方案编码', field: 'planCode', component: 'JInput', colProps: { span: 6 } },
{ label: '方案名称', field: 'planName', component: 'JInput', colProps: { span: 6 } },
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_plan_status' },
colProps: { span: 6 },
},
{
label: '触发时机',
field: 'triggerPhase',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_trigger_phase' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '方案编码',
field: 'planCode',
component: 'Input',
componentProps: { placeholder: '唯一编码,如 formula_approve_sync' },
dynamicRules: () => [{ required: true, message: '请输入方案编码!' }],
},
{
label: '方案名称',
field: 'planName',
component: 'Input',
componentProps: { placeholder: '如 配合示方审批通过同步ERP' },
dynamicRules: () => [{ required: true, message: '请输入方案名称!' }],
},
{
label: '源单表名',
field: 'sourceTable',
component: 'Input',
componentProps: { placeholder: '触发的业务表,如 mes_xsl_formula_spec' },
dynamicRules: () => [{ required: true, message: '请输入源单表名!' }],
},
{
label: '触发时机',
field: 'triggerPhase',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_trigger_phase', type: 'select' },
dynamicRules: () => [{ required: true, message: '请选择触发时机!' }],
},
{
label: '绑定环节',
field: 'triggerStage',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_stage', type: 'select', placeholder: '节点通过时必选' },
helpMessage: '须为审批注册中心已启用的环节;节点通过时必选,全流程通过默认批准',
},
{
label: '执行模式',
field: 'execMode',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_exec_mode', type: 'select' },
defaultValue: 'async',
},
{
label: '匹配条件',
field: 'matchCondition',
component: 'InputTextArea',
componentProps: { rows: 2, placeholder: '可选留空表示无条件匹配。如status = \'approved\'' },
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { rows: 2 },
},
];
// 动作表格列
export const actionColumns: BasicColumn[] = [
{ title: '动作名称', dataIndex: 'actionName', width: 140, align: 'left' },
{ title: '动作类型', dataIndex: 'actionType_dictText', width: 110 },
{ title: '失败策略', dataIndex: 'onFail_dictText', width: 90 },
{ title: '执行顺序', dataIndex: 'execOrder', width: 80 },
{ title: '启用', dataIndex: 'enabled_dictText', width: 70 },
{ title: 'SQL 模板', dataIndex: 'sqlTemplate', ellipsis: true },
];
// 动作表单
export const actionFormSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '', field: 'planId', component: 'Input', show: false },
{
label: '动作名称',
field: 'actionName',
component: 'Input',
componentProps: { placeholder: '如 更新ERP状态' },
dynamicRules: () => [{ required: true, message: '请输入动作名称!' }],
},
{
label: '动作类型',
field: 'actionType',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_action_type', type: 'select' },
defaultValue: 'SQL_UPDATE',
dynamicRules: () => [{ required: true, message: '请选择动作类型!' }],
},
{
label: 'SQL 模板',
field: 'sqlTemplate',
component: 'InputTextArea',
componentProps: {
rows: 5,
placeholder: 'UPDATE mes_xsl_xxx SET status=\'approved\' WHERE id=#{source.id}',
},
ifShow: ({ values }) => values.actionType === 'SQL_UPDATE',
},
{
label: '执行顺序',
field: 'execOrder',
component: 'InputNumber',
componentProps: { min: 0, max: 999 },
defaultValue: 0,
},
{
label: '失败策略',
field: 'onFail',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_on_fail', type: 'select' },
defaultValue: 'stop',
},
{
label: '幂等键',
field: 'idempotentKey',
component: 'Input',
componentProps: { placeholder: '留空默认使用 台账ID_动作ID' },
},
{
label: '启用',
field: 'enabled',
component: 'Switch',
defaultValue: true,
},
];

View File

@@ -0,0 +1,150 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_integration_plan:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
新增方案
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_integration_plan:edit'"
preIcon="ant-design:thunderbolt-outlined"
style="margin-left: 8px"
@click="handleGenerateDefault"
>
按流程生成默认方案
</a-button>
</template>
<template #action="{ record }">
<TableAction :actions="getTableActions(record)" :dropDownActions="getDropDownActions(record)" />
</template>
</BasicTable>
<MesXslIntegrationPlanModal @register="registerModal" @success="handleSuccess" />
<MesXslIntegrationActionDrawer ref="actionDrawerRef" />
<MesXslIntegrationPlanWizard ref="wizardRef" @success="handleSuccess" />
<GenerateDefaultPlanModal ref="generateModalRef" @success="handleSuccess" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslIntegrationPlan" setup>
import { ref } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import MesXslIntegrationPlanModal from './components/MesXslIntegrationPlanModal.vue';
import MesXslIntegrationActionDrawer from './components/MesXslIntegrationActionDrawer.vue';
import MesXslIntegrationPlanWizard from './MesXslIntegrationPlanWizard.vue';
import GenerateDefaultPlanModal from './components/GenerateDefaultPlanModal.vue';
import { columns, searchFormSchema } from './MesXslIntegrationPlan.data';
import { list, deleteOne, publishPlan, disablePlan } from './MesXslIntegrationPlan.api';
const { createMessage } = useMessage();
const [registerModal, { openModal }] = useModal();
const actionDrawerRef = ref();
const wizardRef = ref();
const generateModalRef = ref();
const { tableContext } = useListPage({
tableProps: {
title: '集成方案管理',
api: list,
columns: [
...columns,
// 状态列用 tag 渲染覆盖默认
],
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 200, fixed: 'right' },
customRow: () => ({}),
},
});
const [registerTable, { reload }] = tableContext;
function handleAdd() {
wizardRef.value?.open();
}
function handleGenerateDefault() {
generateModalRef.value?.open();
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true });
}
function handleManageActions(record: Recordable) {
actionDrawerRef.value?.open(record);
}
async function handlePublish(record: Recordable) {
try {
await publishPlan(record.id);
createMessage.success('发布成功');
reload();
} catch (e: any) {
createMessage.error(e?.message || '发布失败');
}
}
async function handleDisable(record: Recordable) {
try {
await disablePlan(record.id);
createMessage.success('已停用');
reload();
} catch (e: any) {
createMessage.error(e?.message || '停用失败');
}
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, handleSuccess);
}
function handleSuccess() {
reload();
}
function getTableActions(record: Recordable) {
const actions: any[] = [
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_integration_plan:edit', disabled: record.status === '1' },
{ label: '管理动作', icon: 'ant-design:setting-outlined', onClick: handleManageActions.bind(null, record) },
];
if (record.status === '0' || record.status === '2') {
actions.push({
label: '发布',
color: 'success',
auth: 'xslmes:mes_xsl_integration_plan:publish',
onClick: handlePublish.bind(null, record),
});
}
if (record.status === '1') {
actions.push({
label: '停用',
color: 'error',
auth: 'xslmes:mes_xsl_integration_plan:publish',
onClick: handleDisable.bind(null, record),
});
}
return actions;
}
function getDropDownActions(record: Recordable) {
return [
{
label: '删除',
auth: 'xslmes:mes_xsl_integration_plan:delete',
disabled: record.status === '1',
popConfirm: {
title: '确认删除该方案(同时删除所有动作)?',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
},
];
}
</script>

View File

@@ -0,0 +1,466 @@
<template>
<a-drawer
v-model:open="visible"
title="新建集成方案"
width="1120"
:body-style="{ padding: '16px 20px', display: 'flex', flexDirection: 'column', height: 'calc(100vh - 55px)' }"
@close="visible = false"
>
<!-- 基本信息 -->
<a-card size="small" title="基本信息" style="margin-bottom: 14px; flex-shrink: 0">
<a-form ref="planFormRef" :model="planForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="方案名称" name="planName" :rules="[{ required: true, message: '请输入方案名称' }]">
<a-input v-model:value="planForm.planName" placeholder="如 密炼PS审批通过→同步配合示方" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="方案编码" name="planCode" :rules="[{ required: true, message: '请输入方案编码' }]">
<a-input v-model:value="planForm.planCode" placeholder="如 mixer_ps_on_approve英文下划线" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="触发时机" name="triggerPhase" :rules="[{ required: true }]">
<a-radio-group v-model:value="planForm.triggerPhase" @change="onTriggerPhaseChange">
<a-radio value="onApprove">审批通过</a-radio>
<a-radio value="onReject">审批驳回</a-radio>
<a-radio value="onNodeApprove">节点通过</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="执行模式">
<a-radio-group v-model:value="planForm.execMode">
<a-radio value="async">异步推荐</a-radio>
<a-radio value="sync">同步</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16" v-if="planForm.sourceTable">
<a-col :span="24">
<a-form-item
label="绑定环节"
name="triggerStage"
:rules="stageRules"
:help="stageHelpText"
>
<a-radio-group v-model:value="planForm.triggerStage" :disabled="!enabledStageOptions.length">
<a-radio v-if="planForm.triggerPhase === 'onReject'" :value="''">任意环节</a-radio>
<a-radio v-for="opt in enabledStageOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-radio>
</a-radio-group>
<div v-if="!enabledStageOptions.length" style="color: #fa8c16; font-size: 12px; margin-top: 4px">
该单据在审批注册中心未配置启用环节请先到审批注册中心勾选环节
</div>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<!-- 主体左侧选表 + 右侧动作 -->
<a-row :gutter="14" style="flex: 1; min-height: 0">
<!-- 左侧触发业务表 -->
<a-col :span="7" style="height: 100%">
<a-card
size="small"
title="触发业务表"
:body-style="{ padding: '10px', overflowY: 'auto', maxHeight: 'calc(100% - 46px)' }"
style="height: 100%"
:loading="loadingBizDocs"
>
<div style="color: #888; font-size: 12px; margin-bottom: 10px; line-height: 1.5">
选择当哪个业务表的记录完成审批时触发右侧的动作列表
</div>
<div
v-for="doc in bizDocList"
:key="doc.tableName"
:style="{
padding: '10px 12px',
border: '2px solid',
borderColor: planForm.sourceTable === doc.tableName ? '#1677ff' : '#e8e8e8',
borderRadius: '6px',
marginBottom: '8px',
cursor: 'pointer',
background: planForm.sourceTable === doc.tableName ? '#e6f4ff' : 'white',
transition: 'all 0.2s',
}"
@click="selectSourceTable(doc)"
>
<div style="font-weight: 500; color: rgba(0, 0, 0, 0.85); margin-bottom: 2px">
{{ getDocLabel(doc) || doc.tableName }}
</div>
<div style="font-size: 11px; color: #999; font-family: monospace">{{ doc.tableName }}</div>
<div v-if="planForm.sourceTable === doc.tableName && sourceColumns.length" style="font-size: 11px; color: #1677ff; margin-top: 3px">
{{ sourceColumns.length }} 个字段
</div>
<div
v-if="doc.enabledStages_dictText || doc.enabledStages"
style="font-size: 11px; color: #52c41a; margin-top: 3px"
>
环节{{ doc.enabledStages_dictText || formatStages(doc.enabledStages) }}
</div>
</div>
<div v-if="!bizDocList.length && !loadingBizDocs" style="text-align: center; padding: 20px 0; color: #bbb">
<div style="font-size: 24px">📭</div>
<div style="font-size: 12px; margin-top: 6px">暂无注册的业务表<br />请先在审批注册中心中添加</div>
</div>
</a-card>
</a-col>
<!-- 右侧集成动作列表 -->
<a-col :span="17" style="height: 100%">
<a-card
size="small"
:title="`集成动作(触发表:${planForm.sourceTable || '—'}`"
:body-style="{ padding: '12px', overflowY: 'auto', maxHeight: 'calc(100% - 46px)' }"
style="height: 100%"
>
<template #extra>
<a-button
type="primary"
size="small"
:disabled="!planForm.sourceTable"
:title="planForm.sourceTable ? '添加动作' : '请先选择左侧触发业务表'"
@click="openActionEditor()"
>
<template #icon><PlusOutlined /></template>
添加动作
</a-button>
</template>
<!-- 空状态 -->
<div v-if="!actions.length" style="text-align: center; padding: 50px 0; color: #bbb">
<div style="font-size: 40px; margin-bottom: 10px">📋</div>
<div style="font-size: 14px">{{ planForm.sourceTable ? '点击右上角「添加动作」开始配置' : '请先在左侧选择触发业务表' }}</div>
</div>
<!-- 动作卡片列表 -->
<div v-for="(action, idx) in actions" :key="idx" style="border: 1px solid #e8e8e8; border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; background: white">
<div style="display: flex; justify-content: space-between; align-items: flex-start">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<a-tag color="blue" style="font-size: 13px; font-weight: 600">{{ idx + 1 }}</a-tag>
<span style="font-size: 13px; font-weight: 600">{{ getActionIcon(action) }} {{ action.actionName }}</span>
<a-tag :color="action.onFail === 'stop' ? 'orange' : 'default'" size="small">
{{ action.onFail === 'stop' ? '失败终止' : '失败继续' }}
</a-tag>
</div>
<a-space>
<a-button size="small" @click="openActionEditor(action, idx)">编辑</a-button>
<a-popconfirm title="确认删除该动作?" @confirm="actions.splice(idx, 1)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</div>
<!-- 可视化配置摘要 -->
<div v-if="action.actionConfig" style="margin-top: 8px; font-size: 12px">
{{ getVisualSummary(action) }}
</div>
<!-- SQL 预览 -->
<div v-if="action.sqlTemplate" style="margin-top: 8px">
<pre style="font-size: 11px; color: #666; margin: 0; white-space: pre-wrap; background: #f5f7fa; padding: 6px 8px; border-radius: 4px; max-height: 56px; overflow: hidden; font-family: monospace; line-height: 1.5">{{ action.sqlTemplate }}</pre>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 底部操作按钮 -->
<div
style="flex-shrink: 0; margin-top: 14px; padding: 12px 0; border-top: 1px solid #e8e8e8; display: flex; justify-content: flex-end; gap: 8px"
>
<a-button @click="visible = false">取消</a-button>
<a-button :loading="saving" @click="handleSave(false)">保存为草稿</a-button>
<a-button type="primary" :loading="saving" @click="handleSave(true)">保存并发布</a-button>
</div>
<VisualActionEditor ref="actionEditorRef" @success="handleActionSaved" />
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import {
savePlan,
publishPlan,
getTableColumns,
listBizDocRegistry,
getRegistryByTable,
getDictItems,
saveAction,
} from './MesXslIntegrationPlan.api';
import VisualActionEditor from './components/VisualActionEditor.vue';
const STAGE_DICT = 'mes_xsl_approval_stage';
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const visible = ref(false);
const saving = ref(false);
const loadingBizDocs = ref(false);
const planFormRef = ref();
const actionEditorRef = ref();
const bizDocList = ref<any[]>([]);
const sourceColumns = ref<any[]>([]);
const actions = ref<any[]>([]);
const editingIdx = ref(-1);
const planForm = ref({
planCode: '',
planName: '',
sourceTable: '',
registryId: '',
triggerPhase: 'onApprove',
triggerStage: 'approve',
execMode: 'async',
remark: '',
});
const stageDictItems = ref<{ label: string; value: string }[]>([]);
const selectedRegistry = ref<any>(null);
const enabledStageOptions = computed(() => {
const raw = selectedRegistry.value?.enabledStages;
if (!raw) return [];
const enabled = String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const dictMap = new Map(stageDictItems.value.map((d) => [d.value, d.label]));
return enabled.map((v) => ({ value: v, label: dictMap.get(v) || STAGE_LABELS[v] || v }));
});
const stageRules = computed(() => {
if (planForm.value.triggerPhase === 'onNodeApprove') {
return [{ required: true, message: '节点通过时必须选择绑定环节' }];
}
return [];
});
const stageHelpText = computed(() => {
if (!planForm.value.sourceTable) return '请先选择左侧触发业务表';
if (planForm.value.triggerPhase === 'onApprove') return '全流程最终通过时触发,默认绑定「批准」环节';
if (planForm.value.triggerPhase === 'onReject') return '驳回时触发;选「任意环节」表示任一节点驳回均执行';
return '仅当该审批环节通过时触发集成动作';
});
async function loadBizDocs() {
loadingBizDocs.value = true;
try {
const [res, dictRes] = await Promise.all([listBizDocRegistry(), getDictItems(STAGE_DICT)]);
bizDocList.value = (res as any)?.records || (Array.isArray(res) ? res : []);
stageDictItems.value = (dictRes || []).map((d: any) => ({
label: d.text || d.label || d.itemText,
value: d.value || d.itemValue,
}));
} catch {
bizDocList.value = [];
stageDictItems.value = Object.entries(STAGE_LABELS).map(([value, label]) => ({ value, label }));
} finally {
loadingBizDocs.value = false;
}
}
async function selectSourceTable(doc: any) {
planForm.value.sourceTable = doc.tableName;
planForm.value.registryId = doc.id || '';
sourceColumns.value = [];
selectedRegistry.value = doc;
try {
const [cols, registry] = await Promise.all([
getTableColumns(doc.tableName),
getRegistryByTable(doc.tableName).catch(() => doc),
]);
sourceColumns.value = (cols as any) || [];
if (registry) {
selectedRegistry.value = registry;
planForm.value.registryId = registry.id || doc.id || '';
}
} catch {
sourceColumns.value = [];
}
syncDefaultTriggerStage();
}
function formatStages(stages: string) {
return String(stages)
.split(',')
.map((s) => STAGE_LABELS[s.trim()] || s.trim())
.join(' / ');
}
function onTriggerPhaseChange() {
syncDefaultTriggerStage();
}
function syncDefaultTriggerStage() {
const opts = enabledStageOptions.value;
if (!opts.length) {
planForm.value.triggerStage = '';
return;
}
if (planForm.value.triggerPhase === 'onApprove') {
planForm.value.triggerStage = opts.some((o) => o.value === 'approve') ? 'approve' : opts[opts.length - 1].value;
} else if (planForm.value.triggerPhase === 'onReject') {
if (!opts.some((o) => o.value === planForm.value.triggerStage)) {
planForm.value.triggerStage = '';
}
} else if (planForm.value.triggerPhase === 'onNodeApprove') {
if (!opts.some((o) => o.value === planForm.value.triggerStage)) {
planForm.value.triggerStage = opts[0].value;
}
}
}
function getDocLabel(doc: any): string {
return doc?.displayName || doc?.docName || doc?.bizName || '';
}
function getActionIcon(action: any): string {
if (action.actionType === 'REGISTRY_STAGE_SYNC') return '✅';
if (action.actionType === 'REGISTRY_STAGE_REVERT') return '↩️';
if (!action.actionConfig) return '🔧';
try {
const cfg = JSON.parse(action.actionConfig);
if (cfg.visualType === 'REGISTRY_STAGE_SYNC') return '✅';
if (cfg.visualType === 'REGISTRY_STAGE_REVERT') return '↩️';
return cfg.visualType === 'STATUS_MODIFY' ? '📋' : '🔄';
} catch {
return '🔧';
}
}
function getVisualSummary(action: any): string {
if (action.actionType === 'REGISTRY_STAGE_SYNC') {
return '审批注册中心环节同步(自动写状态/操作人/痕迹)';
}
if (action.actionType === 'REGISTRY_STAGE_REVERT') {
return '审批注册中心环节回退(驳回回编制态)';
}
if (!action.actionConfig) return '';
try {
const cfg = JSON.parse(action.actionConfig);
if (cfg.visualType === 'REGISTRY_STAGE_SYNC') {
const stage = cfg.registryStage?.stage || cfg.stage || '?';
return `环节同步 → ${stage},前置=${cfg.registryStage?.expectedFrom || cfg.expectedFrom || '自动'}`;
}
if (cfg.visualType === 'REGISTRY_STAGE_REVERT') {
return `环节回退 → ${cfg.registryStage?.targetStage || cfg.targetStage || 'compile'}`;
}
const lbl = cfg.targetTableLabel || cfg.targetTable || '目标表';
if (cfg.visualType === 'STATUS_MODIFY') {
return `修改【${lbl}】.${cfg.statusConfig?.targetField || '?'} → '${cfg.statusConfig?.newValue || '?'}',关联:${cfg.targetTable}.${cfg.linkCondition?.targetField} = ${planForm.value.sourceTable}.${cfg.linkCondition?.sourceField}`;
}
if (cfg.visualType === 'DATA_SYNC') {
return `数据带入【${lbl}】,${cfg.fieldMappings?.length || 0} 个字段映射,关联:${cfg.targetTable}.${cfg.linkCondition?.targetField} = ${planForm.value.sourceTable}.${cfg.linkCondition?.sourceField}`;
}
} catch {/**/ }
return '';
}
function openActionEditor(action?: any, idx?: number) {
editingIdx.value = idx ?? -1;
actionEditorRef.value?.open({
sourceTable: planForm.value.sourceTable,
sourceColumns: sourceColumns.value,
bizDocList: bizDocList.value,
sourceRegistry: selectedRegistry.value,
action,
execOrder: idx === undefined ? actions.value.length + 1 : action?.execOrder,
});
}
function handleActionSaved(actionData: any) {
if (editingIdx.value >= 0) {
actions.value[editingIdx.value] = actionData;
} else {
actions.value.push(actionData);
}
}
async function handleSave(publish: boolean) {
try {
await planFormRef.value?.validate();
} catch {
return;
}
if (!planForm.value.sourceTable) {
createMessage.warning('请在左侧选择触发业务表');
return;
}
if (!enabledStageOptions.value.length) {
createMessage.warning('该单据未在审批注册中心配置启用环节');
return;
}
if (planForm.value.triggerPhase === 'onNodeApprove' && !planForm.value.triggerStage) {
createMessage.warning('节点通过时必须选择绑定环节');
return;
}
if (!actions.value.length) {
createMessage.warning('请至少添加一个集成动作');
return;
}
saving.value = true;
try {
// 1. 保存方案,返回含 id 的实体
const savedPlan = await savePlan({ ...planForm.value });
const planId = (savedPlan as any)?.id;
if (!planId) {
createMessage.error('保存方案失败无法获取方案ID请重试');
return;
}
// 2. 逐个保存动作enabled 须转 0/1后端字段为 Integer
for (let i = 0; i < actions.value.length; i++) {
const a = actions.value[i];
await saveAction({ ...a, planId, execOrder: i + 1, enabled: a.enabled ? 1 : 0 });
}
// 3. 发布(可选)
if (publish) {
await publishPlan(planId);
}
createMessage.success(publish ? '方案已保存并发布' : '方案已保存为草稿');
emit('success');
visible.value = false;
} catch (e: any) {
createMessage.error(e?.message || '保存失败,请检查配置后重试');
} finally {
saving.value = false;
}
}
function open() {
planForm.value = {
planCode: '',
planName: '',
sourceTable: '',
registryId: '',
triggerPhase: 'onApprove',
triggerStage: 'approve',
execMode: 'async',
remark: '',
};
actions.value = [];
sourceColumns.value = [];
selectedRegistry.value = null;
visible.value = true;
loadBizDocs();
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,332 @@
<template>
<a-modal
v-model:open="visible"
title="按审批流程生成默认方案"
width="900px"
:confirm-loading="generating"
ok-text="确认生成"
cancel-text="取消"
@ok="handleGenerate"
@cancel="visible = false"
>
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }" style="margin-top: 12px">
<a-form-item label="业务单据" required>
<a-select
v-model:value="form.sourceTable"
placeholder="选择审批注册中心已启用的业务表"
show-search
:filter-option="filterDoc"
:options="docOptions"
@change="onTableChange"
/>
</a-form-item>
<a-form-item label="审批流程" required>
<a-select
v-model:value="form.flowId"
placeholder="选择该业务表对应的审批流"
:loading="loadingFlows"
:options="flowOptions"
@change="loadPreview"
/>
</a-form-item>
<a-form-item label="覆盖草稿">
<a-checkbox v-model:checked="form.overwriteDraft">
删除同前缀的草稿方案后重新生成已发布方案不受影响
</a-checkbox>
</a-form-item>
</a-form>
<a-spin :spinning="loadingPreview">
<template v-if="preview">
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
:message="`流程节点 ${preview.flowNodeCount || 0} 个;已配置环节 ${preview.configuredNodeCount || 0} 个;未配置 ${preview.unconfiguredNodeCount || 0} 个`"
:description="`状态字典:${preview.statusDictCode || '-'};驳回回退至:${preview.initialStatusLabel}${preview.initialStatus}`"
/>
<a-alert
v-if="(preview.unconfiguredNodeCount || 0) > 0"
type="warning"
show-icon
style="margin-bottom: 12px"
message="存在未配置或未选择环节的流程节点,这些节点不会生成集成方案。"
/>
<a-table
size="small"
:pagination="false"
:data-source="preview.nodeBindings || []"
:columns="previewColumns"
row-key="nodeId"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'nodeName'">
<span>{{ record.nodeName }}</span>
<a-tag :color="record.stageConfigured ? 'success' : 'warning'" style="margin-left: 6px">
{{ record.configuredText }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'stage'">
<a-select
v-model:value="record.stage"
allow-clear
placeholder="可不选"
style="width: 110px"
:options="stageOptions"
@change="() => onStageChange(record)"
/>
</template>
<template v-else-if="column.dataIndex === 'willGenerate'">
<a-tag v-if="record.willGenerate" color="processing">将生成</a-tag>
<span v-else style="color: #bbb">-</span>
</template>
<template v-else-if="column.dataIndex === 'unconfiguredReason'">
<span style="color: #fa8c16; font-size: 12px">{{ record.unconfiguredReason || '-' }}</span>
</template>
</template>
</a-table>
<div style="margin-top: 8px; font-size: 12px; color: #888">
将生成 {{ planCount }} 个方案 1 个驳回回退方案默认状态为草稿生成后请核对并发布
</div>
</template>
<a-empty v-else-if="!loadingPreview" description="请选择业务表与审批流程后预览" />
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { listBizDocRegistry, previewDefaultFromFlow, generateDefaultFromFlow } from '../MesXslIntegrationPlan.api';
import { getApprovalFlowList } from '/@/views/approval/flow/approvalFlow.api';
const emit = defineEmits<{ success: [] }>();
const { createMessage } = useMessage();
const visible = ref(false);
const generating = ref(false);
const loadingFlows = ref(false);
const loadingPreview = ref(false);
const preview = ref<any>(null);
const docList = ref<any[]>([]);
const flowList = ref<any[]>([]);
const form = ref({
sourceTable: undefined as string | undefined,
flowId: undefined as string | undefined,
overwriteDraft: true,
});
const previewColumns = [
{ title: '序号', dataIndex: 'nodeIndex', width: 52 },
{ title: '流程节点', dataIndex: 'nodeName', width: 200 },
{ title: '识别环节', dataIndex: 'stage', width: 130 },
{ title: '前置状态', dataIndex: 'expectedFromLabel', width: 90 },
{ title: '生成方案', dataIndex: 'willGenerate', width: 88 },
{ title: '未配置原因', dataIndex: 'unconfiguredReason', ellipsis: true },
];
const stageOptions = computed(() =>
(preview.value?.stageOptions || []).map((opt: any) => ({
value: opt.value,
label: opt.label,
})),
);
const planCount = computed(() => {
const bindings = preview.value?.nodeBindings || [];
const configured = bindings.filter((b: any) => b.willGenerate).length;
return configured > 0 ? configured + 1 : 0;
});
const docOptions = computed(() =>
docList.value
.filter((d) => d.enabled === 1)
.map((d) => ({
value: d.tableName,
label: `${d.displayName || d.tableName}${d.tableName}`,
})),
);
const flowOptions = computed(() =>
flowList.value.map((f) => ({
value: f.id,
label: `${f.flowName || f.id}${f.status === '1' ? ' [已发布]' : ''}`,
})),
);
function filterDoc(input: string, option: any) {
return (option?.label || '').toLowerCase().includes(input.toLowerCase());
}
function labelOfStatusChain(chain: any[], value?: string) {
if (!value) return '-';
const hit = (chain || []).find((item) => item.value === value);
return hit?.label || value;
}
function buildUnconfiguredReason(stageMeta: Record<string, any>, stage?: string) {
if (!stage) {
return '未选择审批环节';
}
const meta = stageMeta?.[stage];
if (!meta) {
return '未选择审批环节';
}
if (!meta.enabled) {
return `环节「${meta.label || stage}」未在注册中心启用`;
}
if (!meta.configured) {
return `环节「${meta.label || stage}」未配置操作人字段`;
}
return '环节未完整配置';
}
function resolveExpectedFrom(bindings: any[], index: number, statusChain: any[], initialStatus: string) {
const current = bindings[index];
if (!current?.stage) {
return initialStatus;
}
const stageIdx = (statusChain || []).findIndex((item) => item.value === current.stage);
if (stageIdx > 0) {
return statusChain[stageIdx - 1].value;
}
for (let j = index - 1; j >= 0; j--) {
const prev = bindings[j];
if (prev.stageConfigured && prev.stage) {
return prev.stage;
}
}
return initialStatus;
}
function recalcBindings() {
if (!preview.value?.nodeBindings) {
return;
}
const bindings = preview.value.nodeBindings;
const stageMeta = preview.value.stageMeta || {};
const statusChain = preview.value.statusChain || [];
const initialStatus = preview.value.initialStatus;
bindings.forEach((record: any) => {
const stage = record.stage || undefined;
const meta = stage ? stageMeta[stage] : null;
const configured = !!(stage && meta?.enabled && meta?.configured);
record.stageConfigured = configured;
record.configuredText = configured ? '已配置该环节' : '未配置该环节';
record.stageLabel = stage ? labelOfStatusChain(statusChain, stage) : '-';
record.unconfiguredReason = configured ? undefined : buildUnconfiguredReason(stageMeta, stage);
});
const configuredBindings = bindings.filter((b: any) => b.stageConfigured);
bindings.forEach((record: any) => {
if (!record.stageConfigured) {
record.willGenerate = false;
record.triggerPhase = null;
record.expectedFrom = null;
record.expectedFromLabel = '-';
return;
}
const cfgIdx = configuredBindings.indexOf(record);
record.willGenerate = cfgIdx >= 0;
record.triggerPhase = 'onNodeApprove';
const expectedFrom = resolveExpectedFrom(bindings, bindings.indexOf(record), statusChain, initialStatus);
record.expectedFrom = expectedFrom;
record.expectedFromLabel = labelOfStatusChain(statusChain, expectedFrom);
});
preview.value.configuredNodeCount = configuredBindings.length;
preview.value.unconfiguredNodeCount = bindings.length - configuredBindings.length;
}
function onStageChange(record: any) {
if (record.stage === undefined || record.stage === null || record.stage === '') {
record.stage = undefined;
}
recalcBindings();
}
async function open() {
visible.value = true;
preview.value = null;
form.value = { sourceTable: undefined, flowId: undefined, overwriteDraft: true };
const res = await listBizDocRegistry();
docList.value = res?.records || res || [];
}
async function onTableChange(table: string) {
form.value.flowId = undefined;
preview.value = null;
flowList.value = [];
if (!table) return;
loadingFlows.value = true;
try {
const res = await getApprovalFlowList({ bizTable: table, pageNo: 1, pageSize: 50 });
flowList.value = res?.records || [];
if (flowList.value.length === 1) {
form.value.flowId = flowList.value[0].id;
await loadPreview();
}
} finally {
loadingFlows.value = false;
}
}
async function loadPreview() {
if (!form.value.sourceTable || !form.value.flowId) {
preview.value = null;
return;
}
loadingPreview.value = true;
try {
preview.value = await previewDefaultFromFlow({
sourceTable: form.value.sourceTable,
flowId: form.value.flowId,
});
recalcBindings();
} catch (e: any) {
preview.value = null;
createMessage.error(e?.message || '预览失败');
} finally {
loadingPreview.value = false;
}
}
function buildNodeBindingsPayload() {
return (preview.value?.nodeBindings || []).map((item: any) => ({
nodeId: item.nodeId,
stage: item.stage || null,
}));
}
async function handleGenerate() {
if (!form.value.sourceTable || !form.value.flowId) {
createMessage.warning('请选择业务表和审批流程');
return;
}
recalcBindings();
if ((preview.value?.configuredNodeCount || 0) === 0) {
createMessage.warning('没有已配置环节的流程节点,无法生成方案');
return;
}
generating.value = true;
try {
const res = await generateDefaultFromFlow({
sourceTable: form.value.sourceTable,
flowId: form.value.flowId,
overwriteDraft: form.value.overwriteDraft,
nodeBindings: buildNodeBindingsPayload(),
});
createMessage.success(res?.message || '生成成功');
visible.value = false;
emit('success');
} catch (e: any) {
createMessage.error(e?.message || '生成失败');
} finally {
generating.value = false;
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,49 @@
<template>
<BasicDrawer @register="registerDrawer" :title="drawerTitle" width="960" destroyOnClose>
<BasicTable @register="registerTable" />
</BasicDrawer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { BasicTable, useTable } from '/@/components/Table';
import { drawerColumns, drawerSearchFormSchema } from '../MesXslApprovalTrace.data';
import { list } from '../MesXslApprovalTrace.api';
const registryRecord = ref<Recordable>({});
const drawerTitle = computed(() => {
const record = registryRecord.value;
const name = record.displayName || record.docCode || record.tableName || '';
return name ? `审批明细 — ${name}` : '审批明细';
});
const [registerTable, { reload, setProps }] = useTable({
title: '审批痕迹明细',
api: list,
columns: drawerColumns,
canResize: true,
useSearchForm: true,
formConfig: {
schemas: drawerSearchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
showTableSetting: true,
bordered: true,
showIndexColumn: true,
immediate: false,
actionColumn: { width: 0, ifShow: false },
});
const [registerDrawer, { setDrawerProps }] = useDrawerInner(async (data) => {
registryRecord.value = data?.record || {};
const tableName = registryRecord.value.tableName;
setProps({
searchInfo: tableName ? { bizTable: tableName } : {},
});
setDrawerProps({ confirmLoading: false });
await reload();
});
</script>

View File

@@ -0,0 +1,69 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="640" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslBizDocRegistry.data';
import { saveOrUpdate } from '../MesXslBizDocRegistry.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(false);
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data.record) {
const record = { ...data.record };
// 启用开关:后端为 0/1 整数Switch 需 checkedValue/unCheckedValue 对齐
if (record.enabled === true || record.enabled === '1') {
record.enabled = 1;
} else if (record.enabled === false || record.enabled === '0') {
record.enabled = 0;
}
if (typeof record.enabledStages === 'string') {
record.enabledStages = record.enabledStages.split(',').filter(Boolean);
} else if (!Array.isArray(record.enabledStages)) {
record.enabledStages = [];
}
await setFieldsValue(record);
}
});
const title = computed(() => (unref(isUpdate) ? '编辑审批注册' : '新增审批注册'));
async function handleSubmit() {
try {
const values = await validate();
// 空数组须显式传空串,后端才能清空 enabled_stages
if (Array.isArray(values.enabledStages)) {
values.enabledStages = values.enabledStages.join(',');
} else if (values.enabledStages == null) {
values.enabledStages = '';
}
if (typeof values.enabled === 'boolean') {
values.enabled = values.enabled ? 1 : 0;
} else if (values.enabled !== 0 && values.enabled !== 1) {
values.enabled = values.enabled ? 1 : 0;
}
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, unref(isUpdate));
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<a-drawer v-model:open="visible" :title="`动作管理 — ${planName}`" width="900" destroy-on-close @close="visible = false">
<div style="margin-bottom: 12px">
<a-button type="primary" size="small" @click="handleAddAction">
<template #icon><PlusOutlined /></template>
添加动作
</a-button>
</div>
<a-table :dataSource="actions" :columns="tableColumns" :loading="loading" :pagination="false" row-key="id" size="small" bordered>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">{{ record.enabled ? '启用' : '停用' }}</a-tag>
</template>
<template v-if="column.dataIndex === 'actionType'">
<a-tag color="blue">{{ actionTypeLabel(record.actionType) }}</a-tag>
</template>
<template v-if="column.dataIndex === 'onFail'">
<a-tag :color="record.onFail === 'stop' ? 'orange' : 'default'">{{ record.onFail === 'stop' ? '终止' : '继续' }}</a-tag>
</template>
<template v-if="column.dataIndex === 'summary'">
<span style="font-size: 12px; color: #666">{{ formatActionSummary(record) }}</span>
</template>
<template v-if="column.key === 'operation'">
<a-space>
<a-button size="small" @click="handleEditAction(record)">编辑</a-button>
<a-popconfirm title="确认删除该动作?" @confirm="handleDeleteAction(record)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2动作管理复用可视化编辑器,与向导添加动作一致 -->
<VisualActionEditor ref="visualEditorRef" @success="handleActionSaved" />
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2动作管理复用可视化编辑器 -->
</a-drawer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import {
listActions,
saveAction,
editAction,
deleteAction,
getTableColumns,
listBizDocRegistry,
getRegistryByTable,
} from '../MesXslIntegrationPlan.api';
import VisualActionEditor from './VisualActionEditor.vue';
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const ACTION_TYPE_LABELS: Record<string, string> = {
REGISTRY_STAGE_SYNC: '审批环节同步',
REGISTRY_STAGE_REVERT: '审批环节回退',
SQL_UPDATE: 'SQL更新',
};
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const planId = ref('');
const planName = ref('');
const planRecord = ref<Recordable>({});
const actions = ref<any[]>([]);
const visualEditorRef = ref();
const bizDocList = ref<any[]>([]);
const sourceColumns = ref<any[]>([]);
const sourceRegistry = ref<any>(null);
const tableColumns = [
{ title: '顺序', dataIndex: 'execOrder', width: 60, align: 'center' },
{ title: '动作名称', dataIndex: 'actionName', width: 140 },
{ title: '类型', dataIndex: 'actionType', width: 120 },
{ title: '配置摘要', dataIndex: 'summary', ellipsis: true },
{ title: '失败策略', dataIndex: 'onFail', width: 90 },
{ title: '启用', dataIndex: 'enabled', width: 70 },
{ title: '操作', key: 'operation', width: 130, align: 'center' },
];
function actionTypeLabel(type: string) {
return ACTION_TYPE_LABELS[type] || type || '-';
}
function formatActionSummary(record: Recordable) {
if (record.actionType === 'REGISTRY_STAGE_SYNC' || record.actionType === 'REGISTRY_STAGE_REVERT') {
try {
const cfg = JSON.parse(record.actionConfig || '{}');
if (record.actionType === 'REGISTRY_STAGE_SYNC') {
const stage = cfg.registryStage?.stage || cfg.stage;
const from = cfg.registryStage?.expectedFrom || cfg.expectedFrom;
return `环节→${STAGE_LABELS[stage] || stage || '?'}${from ? `,前置=${from}` : ''}`;
}
const target = cfg.registryStage?.targetStage || cfg.targetStage || 'compile';
return `回退→${target}`;
} catch {
return record.actionConfig || '-';
}
}
return record.sqlTemplate || '-';
}
async function loadEditorContext() {
const sourceTable = planRecord.value.sourceTable;
if (!sourceTable) return;
try {
const [cols, registryRes, docsRes] = await Promise.all([
getTableColumns(sourceTable),
getRegistryByTable(sourceTable),
listBizDocRegistry(),
]);
sourceColumns.value = (cols as any) || [];
sourceRegistry.value = registryRes || null;
bizDocList.value = (docsRes as any)?.records || (Array.isArray(docsRes) ? docsRes : []);
} catch {
sourceColumns.value = [];
sourceRegistry.value = null;
bizDocList.value = [];
}
}
async function open(plan: Recordable) {
planId.value = plan.id;
planName.value = plan.planName;
planRecord.value = plan;
visible.value = true;
await Promise.all([loadActions(), loadEditorContext()]);
}
async function loadActions() {
loading.value = true;
try {
actions.value = (await listActions(planId.value)) || [];
} finally {
loading.value = false;
}
}
function openVisualEditor(action?: Recordable) {
if (!planRecord.value.sourceTable) {
createMessage.warning('方案未配置触发业务表');
return;
}
visualEditorRef.value?.open({
sourceTable: planRecord.value.sourceTable,
sourceColumns: sourceColumns.value,
bizDocList: bizDocList.value,
sourceRegistry: sourceRegistry.value,
action: action
? { ...action, planId: planId.value }
: { planId: planId.value, execOrder: actions.value.length + 1 },
execOrder: action?.execOrder ?? actions.value.length + 1,
});
}
function handleAddAction() {
openVisualEditor();
}
function handleEditAction(record: Recordable) {
openVisualEditor(record);
}
async function handleDeleteAction(record: Recordable) {
await deleteAction({ id: record.id }, loadActions);
}
async function handleActionSaved(actionData: Recordable) {
const payload = {
...actionData,
planId: planId.value,
enabled: actionData.enabled !== false && actionData.enabled !== 0 ? 1 : 0,
};
try {
if (actionData.id) {
await editAction(payload);
} else {
await saveAction(payload);
}
createMessage.success('保存成功');
await loadActions();
} catch (e: any) {
createMessage.error(e?.message || '保存失败');
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,46 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="720" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslIntegrationPlan.data';
import { saveOrUpdate } from '../MesXslIntegrationPlan.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(false);
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data.record) {
await setFieldsValue({ ...data.record });
}
});
const title = computed(() => (unref(isUpdate) ? '编辑集成方案' : '新增集成方案'));
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, unref(isUpdate));
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,753 @@
<template>
<a-modal
v-model:open="visible"
:title="isUpdate ? '编辑动作' : '添加动作'"
width="860px"
:confirm-loading="saving"
ok-text="确认"
cancel-text="取消"
@ok="handleConfirm"
@cancel="visible = false"
>
<a-form ref="formRef" :model="form" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }" style="margin-top: 12px">
<a-row :gutter="16">
<a-col :span="15">
<a-form-item label="动作名称" name="actionName" :rules="[{ required: true, message: '请输入动作名称' }]">
<a-input v-model:value="form.actionName" placeholder="如 配合示方→认定通过" />
</a-form-item>
</a-col>
<a-col :span="9">
<a-form-item label="执行顺序" :label-col="{ span: 7 }" :wrapper-col="{ span: 17 }">
<a-input-number v-model:value="form.execOrder" :min="0" style="width: 90px" />
</a-form-item>
</a-col>
</a-row>
<!-- 动作类型卡片 -->
<div style="margin-bottom: 20px">
<div style="font-weight: 500; margin-bottom: 10px; color: rgba(0, 0, 0, 0.85)">动作类型</div>
<div style="display: flex; gap: 12px">
<div
v-for="t in ACTION_TYPES"
:key="t.value"
:style="{
flex: '1', border: '2px solid',
borderColor: vc.visualType === t.value ? '#1677ff' : t.disabled ? '#f0f0f0' : '#d9d9d9',
borderRadius: '8px', padding: '14px 12px', cursor: t.disabled ? 'not-allowed' : 'pointer',
background: vc.visualType === t.value ? '#e6f4ff' : t.disabled ? '#fafafa' : 'white',
opacity: t.disabled ? 0.5 : 1, transition: 'all 0.2s', userSelect: 'none',
}"
@click="!t.disabled && selectVisualType(t.value)"
>
<div style="font-size: 22px; margin-bottom: 6px">{{ t.icon }}</div>
<div style="font-weight: 600; margin-bottom: 4px; color: rgba(0, 0, 0, 0.85)">{{ t.label }}</div>
<div style="font-size: 12px; color: #888; line-height: 1.4">{{ t.desc }}</div>
</div>
</div>
</div>
<!-- 审批注册中心环节同步无需选目标表无需写 SQL -->
<template v-if="vc.visualType === 'REGISTRY_STAGE_SYNC' || vc.visualType === 'REGISTRY_STAGE_REVERT'">
<a-alert
type="info"
show-icon
style="margin-bottom: 14px"
message="按审批注册中心配置自动更新源单状态、操作人/时间,并双写审批痕迹明细,无需绑定 Java 校对/审核/批准接口。"
/>
<template v-if="vc.registryStage">
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_SYNC'" label="审批环节" required>
<a-select
v-model:value="vc.registryStage.stage"
:options="registryStageOptions"
placeholder="从注册中心启用环节选择"
show-search
option-filter-prop="label"
style="width: 100%"
/>
<div v-if="!registryStageOptions.length" style="font-size: 12px; color: #faad14; margin-top: 4px">
未配置启用环节请先在审批注册中心配置 enabled_stages
</div>
</a-form-item>
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_SYNC'" label="前置状态">
<a-select
v-if="sourceStatusDictItems.length"
v-model:value="vc.registryStage.expectedFrom"
:options="sourceStatusDictItems"
placeholder="留空则按环节自动推断"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.registryStage.expectedFrom"
placeholder="未解析到状态字典,可手填 compile / proofread / audit"
/>
<div style="font-size: 12px; color: #888; margin-top: 4px">
取自触发表{{ sourceStatusFieldName }}字段字典{{ sourceStatusDictCode ? `${sourceStatusDictCode}` : '' }}留空则自动推断仅当前状态等于此前置值时才执行
</div>
</a-form-item>
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_REVERT'" label="回退目标">
<a-select
v-if="sourceStatusDictItems.length"
v-model:value="vc.registryStage.targetStage"
:options="sourceStatusDictItems"
placeholder="默认 compile编制态"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
/>
<a-input v-else v-model:value="vc.registryStage.targetStage" placeholder="默认 compile编制态" />
</a-form-item>
</template>
</template>
<!-- 操作目标表 -->
<a-form-item v-if="vc.visualType === 'STATUS_MODIFY' || vc.visualType === 'DATA_SYNC'" label="操作目标表">
<div style="display: flex; gap: 8px; align-items: center">
<a-select
v-model:value="vc.targetTable"
placeholder="选择要被操作的业务表"
show-search
:filter-option="filterBizDoc"
@change="onTargetTableChange"
style="flex: 1"
>
<a-select-option v-for="doc in bizDocList" :key="doc.tableName" :value="doc.tableName">
<span>{{ getDocLabel(doc) }}</span>
<span style="color: #bbb; margin-left: 8px; font-size: 12px">{{ doc.tableName }}</span>
</a-select-option>
</a-select>
<a-spin v-if="loadingTargetCols" size="small" />
<span v-else-if="targetColumns.length" style="color: #888; font-size: 12px; white-space: nowrap">
{{ targetColumns.length }} 个字段
</span>
</div>
</a-form-item>
<template v-if="vc.targetTable">
<!-- 关联条件 -->
<div style="background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 6px; padding: 14px; margin-bottom: 16px">
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
关联条件
<span style="font-weight: 400; color: #888; font-size: 12px; margin-left: 6px">通过哪个字段定位目标表记录</span>
</div>
<div style="display: flex; align-items: flex-end; gap: 12px">
<div style="flex: 1">
<div style="font-size: 12px; color: #888; margin-bottom: 4px">触发表字段{{ sourceTable }}</div>
<a-select v-model:value="vc.linkCondition.sourceField" :options="sourceFieldOpts" placeholder="触发表关联字段" show-search style="width: 100%" />
</div>
<div style="font-size: 15px; color: #1677ff; padding-bottom: 6px; flex-shrink: 0; font-weight: 500"> 等于 </div>
<div style="flex: 1">
<div style="font-size: 12px; color: #888; margin-bottom: 4px">目标表字段{{ vc.targetTable }}</div>
<a-select v-model:value="vc.linkCondition.targetField" :options="targetFieldOpts" placeholder="目标表关联字段" show-search style="width: 100%" />
</div>
</div>
</div>
<!-- ============ 状态修改全新设计 ============ -->
<template v-if="vc.visualType === 'STATUS_MODIFY'">
<!-- 触发表节点识别可选 -->
<div style="background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 12px 14px; margin-bottom: 14px">
<div style="font-size: 12px; font-weight: 600; color: #d48806; margin-bottom: 8px">
🎯 触发表节点识别可选
<span style="font-weight: 400; color: #888; margin-left: 6px">仅在 onNodeApprove 时需要用于区分哪个节点触发了此动作</span>
</div>
<div style="display: flex; align-items: flex-end; gap: 8px">
<span style="font-size: 12px; color: #888; padding-bottom: 5px; white-space: nowrap"> {{ sourceTable }} </span>
<div style="flex: 1">
<a-select
v-model:value="vc.statusConfig!.srcConditionField"
:options="sourceFieldOpts"
placeholder="字段名,如 status"
allow-clear
show-search
style="width: 100%"
@change="onSrcConditionFieldChange"
/>
</div>
<span style="font-size: 14px; color: #d48806; font-weight: 600; padding-bottom: 5px"></span>
<div style="flex: 1">
<a-select
v-if="srcConditionDictCode && vc.statusConfig!.srcConditionField"
v-model:value="vc.statusConfig!.srcConditionValue"
:options="srcConditionDictItems"
placeholder="选择状态值"
allow-clear
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.srcConditionValue"
:placeholder="vc.statusConfig!.srcConditionField ? '如 compile' : '选择左侧字段后填写'"
:disabled="!vc.statusConfig!.srcConditionField"
/>
</div>
</div>
</div>
<!-- 目标表状态变更字段选择 + fromto 箭头 -->
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
目标表状态变更
</div>
<a-form-item label="修改字段" :label-col="{ span: 5 }" :wrapper-col="{ span: 14 }">
<a-select
v-model:value="vc.statusConfig!.targetField"
:options="targetFieldOpts"
placeholder="选择要更新的字段(如 status"
show-search
@change="onTargetStatusFieldChange"
/>
</a-form-item>
<!-- from to 箭头区 -->
<div v-if="vc.statusConfig!.targetField"
style="display: flex; align-items: stretch; gap: 0; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; margin-bottom: 12px; margin-left: 20%">
<!-- 前置状态 -->
<div style="flex: 1; padding: 14px 16px; text-align: center">
<div style="font-size: 11px; color: #888; margin-bottom: 6px; line-height: 1.4">
前置状态可选<br>
<span style="color: #aaa">仅当字段当前值为此值时才执行</span>
</div>
<a-select
v-if="targetStatusDictCode"
v-model:value="vc.statusConfig!.fromValue"
:options="[{ value: '', label: '不限(留空)' }, ...targetStatusDictItems]"
placeholder="不限(留空)"
allow-clear
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.fromValue"
placeholder="不限(留空则不检查)"
allow-clear
/>
</div>
<!-- 箭头分隔 -->
<div style="display: flex; align-items: center; padding: 0 10px; background: #e8e8e8; flex-shrink: 0">
<div style="text-align: center; color: #1677ff">
<div style="font-size: 22px; line-height: 1"></div>
<div style="font-size: 10px; color: #888; margin-top: 2px">改为</div>
</div>
</div>
<!-- 新状态 -->
<div style="flex: 1; padding: 14px 16px; text-align: center; background: #e6f4ff">
<div style="font-size: 11px; color: #1677ff; margin-bottom: 6px; line-height: 1.4">
新状态 *<br>
<span style="color: #888">执行后字段将被改为此值</span>
</div>
<a-select
v-if="targetStatusDictCode"
v-model:value="vc.statusConfig!.newValue"
:options="targetStatusDictItems"
placeholder="请选择新状态"
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.newValue"
placeholder="如 approve"
/>
</div>
</div>
<div v-if="vc.statusConfig!.targetField" style="margin-left: 20%; margin-bottom: 12px">
<a-checkbox v-model:checked="vc.statusConfig!.addUpdateTime">同时更新 update_time = NOW()</a-checkbox>
</div>
</template>
<!-- ============ 数据带入 ============ -->
<template v-if="vc.visualType === 'DATA_SYNC'">
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
字段映射
<span style="font-weight: 400; color: #888; font-size: 12px; margin-left: 6px">目标字段 数据来源</span>
</div>
<div
v-for="(m, idx) in vc.fieldMappings"
:key="idx"
style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px"
>
<a-select v-model:value="m.targetField" :options="targetFieldOpts" placeholder="目标字段" show-search style="width: 160px" />
<span style="color: #1677ff; flex-shrink: 0; font-weight: 600"></span>
<a-select v-model:value="m.sourceType" style="width: 110px">
<a-select-option value="source_field">触发表字段</a-select-option>
<a-select-option value="constant">固定值</a-select-option>
<a-select-option value="expression">SQL表达式</a-select-option>
</a-select>
<a-select v-if="m.sourceType === 'source_field'" v-model:value="m.sourceValue" :options="sourceFieldOpts" placeholder="来源字段" show-search style="flex: 1" />
<a-input v-else v-model:value="m.sourceValue" :placeholder="getMappingPlaceholder(m.sourceType)" style="flex: 1" />
<a-button size="small" danger type="text" @click="vc.fieldMappings!.splice(idx, 1)">删除</a-button>
</div>
<a-button type="dashed" size="small" @click="addMappingRow" style="width: 100%; margin-top: 4px">
+ 添加字段映射
</a-button>
</template>
<!-- SQL 预览 -->
<div v-if="previewSql" style="margin-top: 16px; background: #f0f5ff; border: 1px solid #d6e4ff; padding: 12px; border-radius: 6px">
<div style="font-size: 12px; color: #1677ff; font-weight: 500; margin-bottom: 6px">生成的 SQL预览</div>
<pre style="margin: 0; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; color: #333; line-height: 1.6">{{ previewSql }}</pre>
</div>
</template>
<a-form-item label="失败策略" style="margin-top: 16px">
<a-radio-group v-model:value="form.onFail">
<a-radio value="stop">失败后终止后续动作</a-radio>
<a-radio value="continue">失败后继续执行</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { getTableColumns, getDictItems } from '../MesXslIntegrationPlan.api';
interface ColMeta { columnName: string; comment: string; dataType: string; columnKey: string; }
interface FieldMapping { targetField: string; sourceType: 'source_field' | 'constant' | 'expression'; sourceValue: string; }
interface StatusConfig {
targetField: string;
fromValue: string;
newValue: string;
addUpdateTime: boolean;
srcConditionField: string;
srcConditionValue: string;
}
interface RegistryStageConfig {
stage?: string;
expectedFrom?: string;
targetStage?: string;
}
interface VisualConfig {
visualType: 'STATUS_MODIFY' | 'DATA_SYNC' | 'REGISTRY_STAGE_SYNC' | 'REGISTRY_STAGE_REVERT';
targetTable: string;
targetTableLabel: string;
linkCondition: { sourceField: string; targetField: string };
statusConfig: StatusConfig;
fieldMappings: FieldMapping[];
registryStage?: RegistryStageConfig;
}
const emit = defineEmits<{ success: [action: any] }>();
const { createMessage } = useMessage();
/** 触发表 status 字段未带字典注释时的兜底映射 */
const SOURCE_TABLE_STATUS_DICT: Record<string, string> = {
mes_xsl_mixer_ps_compile: 'xslmes_mixer_ps_status',
mes_xsl_formula_spec: 'xslmes_formula_spec_status',
};
const ACTION_TYPES = [
{ value: 'REGISTRY_STAGE_SYNC', icon: '✅', label: '审批环节同步', desc: '按注册中心更新源单状态+操作人+痕迹', disabled: false },
{ value: 'REGISTRY_STAGE_REVERT', icon: '↩️', label: '审批环节回退', desc: '驳回时回退源单并清空痕迹', disabled: false },
{ value: 'STATUS_MODIFY', icon: '📋', label: '状态修改', desc: '手写SQL修改目标表状态', disabled: false },
{ value: 'DATA_SYNC', icon: '🔄', label: '数据带入', desc: '将触发表字段映射到目标表', disabled: false },
{ value: 'CREATE_DOC', icon: '', label: '下推生成单据', desc: '在目标表创建新记录Phase 1', disabled: true },
];
const visible = ref(false);
const saving = ref(false);
const isUpdate = ref(false);
const formRef = ref();
const loadingTargetCols = ref(false);
const sourceTable = ref('');
const sourceColumns = ref<ColMeta[]>([]);
const bizDocList = ref<any[]>([]);
const sourceRegistry = ref<any>(null);
/** 审批环节:注册中心 enabled_stages + 业务 status 字典标签(不写死校对/审核/批准) */
const registryStageOptions = computed(() => {
const raw = sourceRegistry.value?.enabledStages;
if (!raw) return [];
const dictLabelMap = Object.fromEntries(sourceStatusDictItems.value.map((i) => [i.value, i.label]));
return String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((v) => ({ value: v, label: dictLabelMap[v] || v }));
});
const targetColumns = ref<ColMeta[]>([]);
// 字典相关
const dictCache = ref<Record<string, any[]>>({});
const targetStatusDictCode = ref('');
const targetStatusDictItems = ref<any[]>([]);
const srcConditionDictCode = ref('');
const srcConditionDictItems = ref<any[]>([]);
const sourceStatusDictCode = ref('');
const sourceStatusDictItems = ref<any[]>([]);
const sourceStatusFieldName = computed(
() => sourceRegistry.value?.statusField || 'status',
);
const defaultStatusConfig = (): StatusConfig => ({
targetField: '', fromValue: '', newValue: '', addUpdateTime: true,
srcConditionField: '', srcConditionValue: '',
});
const defaultRegistryStage = (): RegistryStageConfig => ({
stage: '',
expectedFrom: '',
targetStage: '',
});
const defaultVc = (): VisualConfig => ({
visualType: 'REGISTRY_STAGE_SYNC',
targetTable: '', targetTableLabel: '',
linkCondition: { sourceField: '', targetField: '' },
statusConfig: defaultStatusConfig(),
fieldMappings: [],
registryStage: defaultRegistryStage(),
});
/** 兼容 Flyway 扁平格式stage/expectedFrom 在顶层与向导嵌套格式registryStage 对象) */
function normalizeParsedConfig(parsed: any, actionType?: string): VisualConfig {
const base = defaultVc();
if (!parsed || typeof parsed !== 'object') {
if (actionType === 'REGISTRY_STAGE_REVERT') {
base.visualType = 'REGISTRY_STAGE_REVERT';
} else if (actionType === 'REGISTRY_STAGE_SYNC') {
base.visualType = 'REGISTRY_STAGE_SYNC';
}
return base;
}
const visualType = (parsed.visualType || actionType || base.visualType) as VisualConfig['visualType'];
const merged: VisualConfig = {
...base,
...parsed,
visualType,
linkCondition: { ...base.linkCondition, ...(parsed.linkCondition || {}) },
statusConfig: { ...defaultStatusConfig(), ...(parsed.statusConfig || {}) },
fieldMappings: Array.isArray(parsed.fieldMappings) ? parsed.fieldMappings : [],
registryStage: {
...defaultRegistryStage(),
...(parsed.registryStage || {}),
},
};
// Flyway 预置:{"visualType":"REGISTRY_STAGE_SYNC","stage":"proofread","expectedFrom":"compile"}
if (parsed.stage) merged.registryStage!.stage = parsed.stage;
if (parsed.expectedFrom !== undefined && parsed.expectedFrom !== null) {
merged.registryStage!.expectedFrom = parsed.expectedFrom;
}
if (parsed.targetStage) merged.registryStage!.targetStage = parsed.targetStage;
return merged;
}
const defaultForm = () => ({ id: '', actionName: '', execOrder: 0, onFail: 'stop', enabled: true });
const form = ref(defaultForm());
const vc = ref(defaultVc());
const sourceFieldOpts = computed(() =>
sourceColumns.value.map((c) => ({
value: c.columnName,
label: c.comment ? `${c.columnName}${c.comment}` : c.columnName,
}))
);
const targetFieldOpts = computed(() =>
targetColumns.value.map((c) => ({
value: c.columnName,
label: c.comment ? `${c.columnName}${c.comment}` : c.columnName,
}))
);
const previewSql = computed(() => buildSql(vc.value));
// 监听目标状态字段变化 → 加载字典
watch(
() => vc.value.statusConfig?.targetField,
async (field) => {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
const dc = extractDictCode(targetColumns.value.find((c) => c.columnName === field)?.comment || '');
targetStatusDictCode.value = dc || '';
targetStatusDictItems.value = dc ? await loadDict(dc) : [];
}
);
// 审批环节变化时,前置状态留空则填入默认推断值
watch(
() => vc.value.registryStage?.stage,
(stage) => {
if (!vc.value.registryStage || !stage) return;
if (!vc.value.registryStage.expectedFrom) {
vc.value.registryStage.expectedFrom = defaultExpectedFromForStage(stage);
}
},
);
// 监听触发表节点字段变化 → 加载字典
watch(
() => vc.value.statusConfig?.srcConditionField,
async (field) => {
if (!field) { srcConditionDictCode.value = ''; srcConditionDictItems.value = []; return; }
const dc = extractDictCode(sourceColumns.value.find((c) => c.columnName === field)?.comment || '');
srcConditionDictCode.value = dc || '';
srcConditionDictItems.value = dc ? await loadDict(dc) : [];
}
);
function extractDictCode(comment: string): string | null {
if (!comment) return null;
// 匹配 "字典xslmes_xxx" 或 "字典:xslmes_xxx" 或 "字典 xslmes_xxx"
const m = comment.match(/字典[:\s]?([a-zA-Z][a-zA-Z0-9_]*)/);
return m ? m[1] : null;
}
async function loadDict(dictCode: string): Promise<any[]> {
if (dictCache.value[dictCode]) return dictCache.value[dictCode];
try {
const items = await getDictItems(dictCode);
const opts = ((items as any) || []).map((it: any) => ({
value: it.value,
label: it.title || it.text || it.value,
}));
dictCache.value[dictCode] = opts;
return opts;
} catch {
return [];
}
}
/** 从触发表 status 字段解析字典,供「前置状态」「回退目标」下拉 */
async function loadSourceStatusDict() {
const field = sourceStatusFieldName.value;
const col = sourceColumns.value.find((c) => c.columnName === field);
let dictCode = extractDictCode(col?.comment || '');
if (!dictCode && sourceTable.value) {
dictCode = SOURCE_TABLE_STATUS_DICT[sourceTable.value] || '';
}
sourceStatusDictCode.value = dictCode || '';
sourceStatusDictItems.value = dictCode ? await loadDict(dictCode) : [];
}
/** 按 status 字典顺序推断前置状态(字典第一项为驳回回退初始态) */
function defaultExpectedFromForStage(stage?: string): string {
if (!stage) return '';
const items = sourceStatusDictItems.value;
if (items.length) {
const idx = items.findIndex((i) => i.value === stage);
if (idx > 0) return items[idx - 1].value;
}
const opts = registryStageOptions.value;
const sidx = opts.findIndex((o) => o.value === stage);
if (sidx > 0) return opts[sidx - 1].value;
return items.length ? items[0].value : '';
}
function defaultRevertTargetStage(): string {
const items = sourceStatusDictItems.value;
if (!items.length) return 'compile';
const enabled = new Set(registryStageOptions.value.map((o) => o.value));
const firstStageIdx = items.findIndex((i) => enabled.has(i.value));
if (firstStageIdx > 0) return items[firstStageIdx - 1].value;
const nonStage = items.find((i) => !enabled.has(i.value));
return nonStage?.value || items[0].value;
}
function buildSql(config: VisualConfig): string {
const { targetTable, linkCondition, visualType, statusConfig, fieldMappings } = config;
if (!targetTable || !linkCondition.sourceField || !linkCondition.targetField) return '';
const baseWhere = `${linkCondition.targetField}=#{source.${linkCondition.sourceField}}`;
if (visualType === 'STATUS_MODIFY') {
const s = statusConfig;
if (!s.targetField || !s.newValue) return '';
const sets = [`${s.targetField}='${s.newValue}'`];
if (s.addUpdateTime) sets.push('update_time=NOW()');
const conditions = [baseWhere];
if (s.fromValue) conditions.push(`${s.targetField}='${s.fromValue}'`);
if (s.srcConditionField && s.srcConditionValue) {
conditions.push(`#{source.${s.srcConditionField}}='${s.srcConditionValue}'`);
}
return `UPDATE ${targetTable} SET ${sets.join(', ')} WHERE ${conditions.join(' AND ')}`;
}
if (visualType === 'DATA_SYNC') {
const filled = fieldMappings.filter((m) => m.targetField && m.sourceValue);
if (!filled.length) return '';
const sets = filled.map((m) => {
if (m.sourceType === 'source_field') return `${m.targetField}=#{source.${m.sourceValue}}`;
if (m.sourceType === 'expression') return `${m.targetField}=${m.sourceValue}`;
return `${m.targetField}='${m.sourceValue}'`;
});
sets.push('update_time=NOW()');
return `UPDATE ${targetTable} SET ${sets.join(', ')} WHERE ${baseWhere}`;
}
return '';
}
function selectVisualType(type: VisualConfig['visualType']) {
vc.value.visualType = type;
if ((type === 'REGISTRY_STAGE_SYNC' || type === 'REGISTRY_STAGE_REVERT') && !vc.value.registryStage) {
vc.value.registryStage = defaultRegistryStage();
}
if (type === 'REGISTRY_STAGE_SYNC' && registryStageOptions.value.length && !vc.value.registryStage?.stage) {
vc.value.registryStage!.stage = registryStageOptions.value[0].value;
vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage);
}
if (type === 'REGISTRY_STAGE_REVERT' && !vc.value.registryStage?.targetStage) {
vc.value.registryStage!.targetStage = defaultRevertTargetStage();
}
}
function getDocLabel(doc: any): string {
return doc?.displayName || doc?.docName || doc?.bizName || doc?.tableName || '';
}
function filterBizDoc(input: string, option: any): boolean {
return (option.value?.toLowerCase() || '').includes(input.toLowerCase());
}
async function onTargetTableChange(tableName: string) {
if (!tableName) { targetColumns.value = []; return; }
loadingTargetCols.value = true;
try {
const cols = await getTableColumns(tableName);
targetColumns.value = (cols as any) || [];
const doc = bizDocList.value.find((d) => d.tableName === tableName);
vc.value.targetTableLabel = getDocLabel(doc);
// 重置状态字段的字典缓存
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
} catch {
targetColumns.value = [];
} finally {
loadingTargetCols.value = false;
}
}
async function onTargetStatusFieldChange(field: string) {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
// 清空旧值,防止值不匹配新字典
vc.value.statusConfig!.fromValue = '';
vc.value.statusConfig!.newValue = '';
}
async function onSrcConditionFieldChange(field: string) {
if (!field) { srcConditionDictCode.value = ''; srcConditionDictItems.value = []; return; }
vc.value.statusConfig!.srcConditionValue = '';
}
function getMappingPlaceholder(sourceType: string): string {
return sourceType === 'expression' ? 'NOW()、REPLACE(UUID(),\'-\',\'\') 等SQL表达式' : '固定值字符串';
}
function addMappingRow() {
if (!vc.value.fieldMappings) vc.value.fieldMappings = [];
vc.value.fieldMappings.push({ targetField: '', sourceType: 'source_field', sourceValue: '' });
}
async function handleConfirm() {
try { await formRef.value?.validate(); } catch { return; }
if (vc.value.visualType === 'REGISTRY_STAGE_SYNC') {
if (!vc.value.registryStage?.stage) {
createMessage.warning('请选择审批环节');
return;
}
emit('success', {
...form.value,
actionType: 'REGISTRY_STAGE_SYNC',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
return;
}
if (vc.value.visualType === 'REGISTRY_STAGE_REVERT') {
emit('success', {
...form.value,
actionType: 'REGISTRY_STAGE_REVERT',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
return;
}
if (!vc.value.targetTable) { createMessage.warning('请选择操作目标表'); return; }
if (!vc.value.linkCondition.sourceField || !vc.value.linkCondition.targetField) { createMessage.warning('请配置关联条件'); return; }
const sql = buildSql(vc.value);
if (!sql) { createMessage.warning('配置不完整,请检查字段设置'); return; }
emit('success', {
...form.value,
actionType: 'SQL_UPDATE',
sqlTemplate: sql,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
}
async function open(opts: { sourceTable: string; sourceColumns: ColMeta[]; bizDocList: any[]; sourceRegistry?: any; action?: any; execOrder?: number }) {
sourceTable.value = opts.sourceTable;
sourceColumns.value = opts.sourceColumns || [];
bizDocList.value = opts.bizDocList || [];
sourceRegistry.value = opts.sourceRegistry || null;
targetColumns.value = [];
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
srcConditionDictCode.value = '';
srcConditionDictItems.value = [];
sourceStatusDictCode.value = '';
sourceStatusDictItems.value = [];
if (opts.action) {
isUpdate.value = true;
const a = opts.action;
form.value = {
id: a.id || '',
actionName: a.actionName || '',
execOrder: a.execOrder ?? 0,
onFail: a.onFail || 'stop',
enabled: a.enabled !== false && a.enabled !== 0,
};
if (a.actionConfig) {
try {
const parsed = JSON.parse(a.actionConfig);
vc.value = normalizeParsedConfig(parsed, a.actionType);
if (vc.value.targetTable) {
onTargetTableChange(vc.value.targetTable);
}
} catch {
vc.value = normalizeParsedConfig(null, a.actionType);
}
} else {
vc.value = normalizeParsedConfig(null, a.actionType);
}
} else {
isUpdate.value = false;
form.value = { ...defaultForm(), execOrder: opts.execOrder ?? 0 };
vc.value = defaultVc();
}
await loadSourceStatusDict();
if (!isUpdate.value) {
if (registryStageOptions.value.length && !vc.value.registryStage?.stage) {
vc.value.registryStage!.stage = registryStageOptions.value[0].value;
vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage);
}
if (!vc.value.registryStage?.targetStage) {
vc.value.registryStage!.targetStage = defaultRevertTargetStage();
}
}
visible.value = true;
}
defineExpose({ open });
</script>

View File

@@ -9,9 +9,11 @@ enum Api {
importExcel = '/xslmes/mesXslMixerPsCompile/importExcel',
exportXls = '/xslmes/mesXslMixerPsCompile/exportXls',
queryById = '/xslmes/mesXslMixerPsCompile/queryById',
proofread = '/xslmes/mesXslMixerPsCompile/proofread',
audit = '/xslmes/mesXslMixerPsCompile/audit',
approve = '/xslmes/mesXslMixerPsCompile/approve',
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】校对/审核/批准接口停用,改由审批流+集成方案驱动
// proofread = '/xslmes/mesXslMixerPsCompile/proofread',
// audit = '/xslmes/mesXslMixerPsCompile/audit',
// approve = '/xslmes/mesXslMixerPsCompile/approve',
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】
}
export const list = (params) => defHttp.get({ url: Api.list, params });
@@ -32,8 +34,8 @@ export const saveOrUpdate = (params, isUpdate) => {
export const getExportUrl = Api.exportXls;
export const getImportUrl = Api.importExcel;
export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true });
export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】校对/审核/批准接口停用,保留代码备后期恢复
// export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true });
// export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
// export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】

View File

@@ -10,30 +10,8 @@
>
新增
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:proofread'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:check-circle-outlined"
@click="handleProofread"
>
校对
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:audit'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:audit-outlined"
@click="handleAudit"
>
审核
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:approve'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:safety-certificate-outlined"
@click="handleApprove"
>
批准
</a-button>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2校对/审核/批准改由审批流+集成方案驱动列表不再提供手工操作入口 -->
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2校对/审核/批准改由审批流+集成方案驱动 -->
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_mixer_ps_compile:exportXls'"
@@ -86,7 +64,6 @@
<script lang="ts" name="xslmes-mesXslMixerPsCompile" setup>
import { computed, reactive } from 'vue';
import { Modal } from 'ant-design-vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
@@ -100,9 +77,6 @@
batchDelete,
getExportUrl,
getImportUrl,
proofread,
audit,
approve,
} from './MesXslMixerPsCompile.api';
const { createMessage } = useMessage();
@@ -171,38 +145,6 @@
openModal(true, { record, isUpdate: true, showFooter: true });
}
function handleStatusAction(action: 'proofread' | 'audit' | 'approve', label: string) {
if (selectedRowKeys.value.length === 0) {
createMessage.warning('请先选择要' + label + '的记录');
return;
}
Modal.confirm({
title: '确认' + label,
content: `确定对选中的 ${selectedRowKeys.value.length} 条记录执行${label}吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
const ids = selectedRowKeys.value.join(',');
const fn = action === 'proofread' ? proofread : action === 'audit' ? audit : approve;
await fn({ ids });
createMessage.success(label + '成功');
handleSuccess();
},
});
}
function handleProofread() {
handleStatusAction('proofread', '校对');
}
function handleAudit() {
handleStatusAction('audit', '审核');
}
function handleApprove() {
handleStatusAction('approve', '批准');
}
function handleDetail(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: false });
}